lc_ldap/lc_ldap.py
2010-06-26 14:33:36 +02:00

683 lines
26 KiB
Python

#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# LC_LDAP.PY-- LightWeight CransLdap
#
# Copyright (C) 2010 Cr@ns <roots@crans.org>
# Author: Antoine Durand-Gasselin <adg@crans.org>
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
# * Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# * Neither the name of the Cr@ns nor the names of its contributors may
# be used to endorse or promote products derived from this software
# without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY
# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
from __future__ import with_statement
import os, sys, ldap, ldap.modlist, re, netaddr, datetime, copy, time
sys.path.append('/usr/scripts/gestion')
import config, crans_utils
uri = 'ldapi:///' #'ldap://ldap.adm.crans.org/'
base_dn = 'ou=data,dc=crans,dc=org'
base_lock = 'ou=lock,dc=crans,dc=org'
def is_actif(sanction):
"""Retourne True ou False suivant si la sanction fournie (chaîne
venant de blacklist) est active ou non
"""
bl = sanction.split('$')
now = time.time()
debut = int(bl[0])
if bl[1] == '-':
fin = now + 1
else:
fin = int(bl[1])
return debut < now and fin > now
def uldif_to_ldif(uldif):
"""Prend en argument un dico ldif, et vérifie que toutes les
valeurs sont bien des unicodes, les converti alors en chaînes
utf-8, renvoie un ldif"""
ldif = {}
for attr, vals in uldif.items():
ldif[attr] = [ unicode.encode(val, 'utf-8') for val in uldif[attr] ]
return ldif
def ldif_to_uldif(ldif):
'Prend en argument un dico ldif, et décode toutes les chaînes en utf-8'
uldif = {}
for attr, vals in ldif.items():
uldif[attr] = [ unicode(val, 'utf-8') for val in ldif[attr] ]
return uldif
class lc_ldap(ldap.ldapobject.LDAPObject):
def __init__(self, dn=None, user=None, cred=None, uri=uri):
"""Initialise la connexion ldap,
- En authentifiant avec dn et cred s'ils sont précisés
- Si dn n'est pas précisé, mais que user est précisé, récupère
le dn associé à l'uid user, et effectue l'authentification
avec ce dn et cred
- Sinon effectue une authentification anonyme
"""
ldap.ldapobject.LDAPObject.__init__(self, uri)
if user and not re.match('[a-z_][a-z0-9_-]*', user):
raise ValueError('Invalid user name: %s' % user)
if user and not dn:
self.simple_bind_s(base_dn)
res = self.search_s('uid=%s' % user)
if len(res) < 1:
raise ldap.INVALID_CREDENTIALS({'desc': 'No such user: %s' %s })
elif len(res) > 1:
raise ldap.INVALID_CREDENTIALS({'desc': 'Too many matches: uid=%s' %s })
else:
dn = res[0][0]
if dn:
self.conn = self.bind_s(dn, cred)
else:
self.conn = self.simple_bind_s()
def search(self, filter, mode='ro', dn= base_dn, scope= 2, sizelimit=400):
res = self.search_ext_s(dn, scope, filter, sizelimit=sizelimit)
return [ CransLdapObject(self, r[0], mode=mode) for r in res ]
def allMachines(self):
"""Renvoie la liste de toutes les machines,
Conçue pour s'éxécuter le plus rapidement possible"""
res = {}
machines = []
for dn, attrs in self.search_s(base_dn, scope=2):
res[dn] = attrs
for dn, attrs in res.items():
if dn.startswith('mid='):
m = CransLdapObject(self, dn, ldif = attrs)
parent_dn = dn.split(',', 1)[1]
m._proprio = CransLdapObject(self, parent_dn, res[parent_dn])
machines.append(m)
return machines
def newMachine(self, parent, realm, uldif):
"""Crée une nouvelle machine"""
raise NotImplementedError()
def newAdherent(self, uldif):
"""Crée un nouvel adhérent"""
aid = uldif.setdefault('aid', [ unicode(self._find_id('aid')) ])
# XXX - autres tests
return self._create_entity('aid=%s,%s' % (aid[0], base_dn), uldif)
def newClub(self, uldif):
"""Crée un nouveau club"""
raise NotImplementedError()
def newFacture(self, uldif):
"""Crée une nouvelle facture"""
raise NotImplementedError()
def _create_entity(self, dn, uldif):
'''Crée une nouvelle entité ldap en dn, avec attributs ldif:
uniquement en unicode'''
lock = CransLock(self)
for item in ['aid', 'uid', 'chbre', 'mailAlias', 'canonicalAlias',
'fid', 'cid', 'mid', 'macAddress', 'host', 'hostAlias' ]:
for val in uldif.get(item, []):
lock.add(item, val)
uldif['historique'] = [ self._hist('Création')]
ldif = uldif_to_ldif(uldif)
modlist = ldap.modlist.addModlist(ldif)
with lock:
print dn, modlist
self.add_s(dn, modlist)
return CransLdapObject(self, dn, mode='w')
def _find_id(self, attr, plage = xrange(1, 32000)):
'''Trouve un <attr>id libre dans plage'''
res = self.search_s(base_dn, 2, '%s=*' % attr, attrlist = [attr])
nonfree = [ int(r[1].get(attr)[0]) for r in res if r[1].get(attr) ]
nonfree.sort()
for id in plage:
if nonfree and nonfree[0] <= id:
while nonfree and nonfree[0] <= id:
nonfree = nonfree[1:]
else:
break
else:
raise EnvironmentError(u'Aucun %s libre dans la plage [%d, %d]' %
(attr, plage[0], id))
return id
def _hist(self, msg):
now = datetime.datetime.now().strftime('%Y-%m-%d %H:%M : ')
return unicode(now) + msg
# ? def reconnect(self, conn=None):
class CransLock:
def __init__(self, conn):
self.conn = conn
self.locks = {}
self._active = []
def __enter__(self):
self.lock()
def __exit__(self, *args):
# XXX - connecter correctement les tracebacks.
print "exiting with exception", args
self.release()
return True
def add(self, item, valeur):
'''rajoute un lock, après avoir vérifié qu'il peut être posé'''
try:
locked = self._islocked(item, valeur)
if locked:
raise EnvironmentError(u'Object déjà locké', locked)
except ldap.NO_SUCH_OBJECT:
pass
locked_values = self.locks.get(item, [])
if valeur not in locked_values:
locked_values.append(valeur)
self.locks[item] = locked_values
def remove(self, item, valeur):
'''Enlève un lock'''
self.locks[item].remove(valeur)
def lock(self):
'''Essaie de prendre tous les verrous'''
items = self.locks.items()
items.sort()
try:
for item, valeurs in items:
for valeur in valeurs:
self._lockitem(item, valeur)
except Exception, e:
# XXX - connecter proprement les traceback
self.release()
raise e
def release(self):
'''Relâche tous les verrous'''
exceptions = []
print "releasing", self._active
for item in self._active[:]:
try:
self._releaseitem(item)
except Exception, e:
exceptions.append(e)
if len(exceptions) == 1:
# XXX - connecter proprement les tracebacks
raise exceptions[0]
elif len(exceptions) > 1:
raise Exception(exceptions)
def _islocked(self, item, valeur):
# XXX - return self.conn.search_s(base_dn, 2, '%s=%s' % (item, valeur))
return self.conn.search_s('%s=%s,%s' % (item, valeur, base_lock), 0)
def _lockitem(self, item, valeur):
u"""
Lock un item avec la valeur valeur, les items possibles
peuvent être :
aid $ chbre $ mail $ mailAlias $ canonicalAlias $
mid $ macAddress $ host $ hostAlias $ ipHostNumber
Retourne le dn du lock
"""
valeur = valeur.encode('utf-8')
lock_dn = '%s=%s,%s' % (item, valeur, base_lock)
lockid = '%s-%s' % ('localhost', os.getpid())
modlist = ldap.modlist.addModlist({ 'objectClass': 'lock',
'lockid': lockid,
item: valeur })
print "locking", lock_dn
try:
self.conn.add_s(lock_dn, modlist)
except ldap.ALREADY_EXISTS:
# # Pas de chance, le lock est déja pris
# try:
# res = self.conn.search_s(lock_dn, 2, 'objectClass=lock')[0]
# l = res[1]['lockid'][0]
# except: l = '%s-1' % hostname
# if l != lockid:
# # C'est locké par un autre process que le notre
# # il tourne encore ?
# if l.split('-')[0] == hostname and os.system('ps %s > /dev/null 2>&1' % l.split('-')[1] ):
# # Il ne tourne plus
# self._releaseitem(res[0]) # delock
# return self._lockitem(item, valeur) # relock
raise EnvironmentError(u'Objet (%s=%s) locké, patienter.' % (item, valeur), l)
self._active.append(lock_dn)
return lock_dn
def _releaseitem(self, lockdn):
u"""Destruction d'un lock"""
# Mettre des verifs ?
print "releasing", lockdn
self._active.remove(lockdn)
self.conn.delete_s(lockdn)
class CransLdapObject:
mode = 'ro'
attrs = None # Contient un dico uldif qui doit représenter ce qui
# est dans la base
_modifs = None # C'est là qu'on met les modifications
def __init__(self, conn, dn, mode='ro', ldif = None):
'''Créé une instance d'un objet Crans (machine, adhérent,
etc...) à ce dn, si ldif est précisé, n'effectue pas de
recherche dans la base ldap.
'''
if not isinstance(conn, lc_ldap):
raise TypeError(u"conn doit être une instance de lc_ldap")
self.conn = conn
if ldif:
self.dn = dn
# /!\ attention, on a pas un uldif (rapidité...)
self.attrs = ldif
self.__class__ = eval(self.attrs['objectClass'][0])
elif dn == base_dn:
self.__class__ = AssociationCrans
else:
self.mode = mode
res = conn.search_s(dn, 0)
if not res:
raise ValueError ('objet inexistant: %s' % dn)
self.dn, self.attrs = res[0]
self.attrs = ldif_to_uldif(self.attrs)
self.__class__ = eval(self.attrs['objectClass'][0])
self._modifs = copy.deepcopy(self.attrs)
def save(self):
"Enregistre les modifications"
if self.mode != 'w':
raise EnvironmentError(u"Objet en lecture seule, réessayer en lecture/écriture")
# Vérifications et Historique
histo = self._gen_hist(self._modifs)
self._modifs['historique'] += histo
# unicode -> utf-8
ldif = uldif_to_ldif(self._modifs)
orig_ldif = uldif_to_ldif(self.attrs)
# modifications
modlist = ldap.modlist.modifyModlist(orig_ldif, ldif)
self.conn.modify_s(self.dn, modlist)
# Vérification des modifications
self.attrs = ldif_to_uldif(self.conn.search_s(self.dn, 0)[0][1])
if self.attrs != self._modifs:
raise EnvironmentError(u"Les modifications apportées à l'objet %s n'ont pas été correctement sauvegardées\nexpected = %s, found = %s" % (self.dn, self._modifs, self.attrs))
def _gen_hist(self, modifs):
"""Vérifie la correction des modifs et genère l'historique des
modifications apportées"""
histo = []
for field in self.ufields:
if len(modifs.get(field, [])) != 1:
raise ValueError('%s doit avoir exactement une valeur' % field)
for field in self.ofields:
if len(modifs.get(field, [])) > 1:
raise ValueError('%s doit avoir au maximum une valeur' % field)
if modifs.get(field, []) != self.attrs.get(field, []):
if modifs.get(field, []) == []:
msg = u"[%s] %s -> RESET" % (field, self.attrs[field][0])
elif self.attrs.get(field, []) == []:
msg = u"[%s] := %s"(field, modifs[field][0])
else:
msg = u"[%s] %s -> %s" % (field, self.attrs[field][0], modifs[field][0])
histo.append(self.conn._hist(msg))
for field in self.xfields + self.ufields:
if modifs.get(field, []) != self.attrs.get(field, []):
msg = u"[%s] %s -> %s" % (field, u'; '.join(self.attrs[field]), u'; '.join(modifs[field]))
histo.append(self.conn._hist(msg))
for field in self.mfields:
oldvals = self.attrs.get(field, [])
newvals = modifs.get(field, [])
olds = set(oldvals)
news = set(newvals)
if oldvals == newvals:
continue
elif olds != news:
adds = ''.join([ '+' + val for val in news - olds])
diff = ''.join([ '-' + val for val in olds - news])
msg = u'[%s]%s%s' % ( field, adds, diff)
elif olds == news and len(oldvals) == len(newvals) == len(olds):
msg = u"[%s].shuffle()" % field
else:
raise ValueError(u"Les valeurs pour %s : %s -> %s ne semblent pas différentes" % (field, oldvals, newvals))
histo.append(self.conn._hist(msg))
return histo
def blacklist_actif(self):
u"""Vérifie si l'instance courante est blacklistée.
Retourne les sanctions en cours (liste).
Retourne une liste vide si aucune sanction en cours.
"""
return self.blacklist_all()[0].keys()
def blacklist_all(self):
u"""Vérifie si l'instance courante est blacklistée ou a été
blacklistée. Retourne les sanctions en cours sous la forme
d'un couple de deux dictionnaires (l'un pour les sanctions
actives, l'autre pour les inactive), chacun ayant comme
clef la sanction et comme valeur une liste de couple de
dates (en secondes depuis epoch) correspondant aux
différentes périodes de sanctions.
ex: {'upload': [(1143336210, 1143509010), ...]}
"""
bl_liste = self.attrs.get('blacklist', [])
if isinstance(self, machine): # Blist du propriétaire
bl_liste += proprio().blacklist()
actifs = {}; inactifs = {}
for sanction in bl_liste:
f = sanction.split('$')
if is_actif(sanction):
actifs.setdefault(f[2], []).append((f[0], f[1]))
else:
inactifs.setdefault(f[2], []).append((f[0], f[1]))
return (actifs, inactifs)
def blacklist(self, new=None):
u"""
Blacklistage de la ou de toutes la machines du propriétaire
* new est une liste de 4 termes :
[debut_sanction, fin_sanction, sanction, commentaire]
* début et fin sont le nombre de secondes depuis epoch
* pour un début ou fin immédiate mettre now
* pour une fin indéterminée mettre '-'
Les données sont stockées dans la base sous la forme :
debut$fin$sanction$commentaire
Pour modifier une entrée donner un tuple de deux termes :
(index dans blacklist à modifier, nouvelle liste),
l'index étant celui dans la liste retournée par blacklist().
"""
Blist = self.attrs.setdefault('blacklist', [])[:]
if new == None:
return Blist
if type(new) == tuple:
# Modification
index = new[0]
new = new[1]
if new == '':
Blist.pop(index)
return Blist
else:
index = -1
if type(new) != list or len(new) != 4:
raise TypeError
# Verification que les dates sont OK
if new[0] == 'now':
debut = new[0] = int(time.time())
else:
try: debut = new[0] = int(new[0])
except: raise ValueError(u'Date de début blacklist invalide')
if new[1] == 'now':
fin = new[1] = int(time.time())
elif new[1] == '-':
fin = -1
else:
try: fin = new[1] = int(new[1])
except: raise ValueError(u'Date de fin blacklist invalide')
if debut == fin:
raise ValueError(u'Dates de début et de fin identiques')
elif fin != -1 and debut > fin:
raise ValueError(u'Date de fin avant date de début')
# On dépasse la fin de sanction d'1min pour être sûr qu'elle est périmée.
fin = fin + 60
new_c = u'$'.join(map(str, new))
if index != -1:
Blist[index] = new_c
else:
Blist.append(new_c)
if Blist != self._modifs.get('blacklist'):
self._modifs['blacklist'] = Blist
if not hasattr(self, "_blacklist_restart"):
self._blacklist_restart = {}
restart = self._blacklist_restart.setdefault(new[2], [])
if debut not in restart:
restart.append(debut)
if fin != -1 and fin not in restart:
restart.append(fin)
return Blist
def __getattribute__(self, attr):
if self.__dict__.has_key(attr):
return self.__dict__[attr]
else:
if attr in self.ufields + self.ofields + self.mfields + self.xfields:
return _getormod_ldapattr(self, attr)
def get_ldapattr(self, attr):
"""Renvoie un attribut ldap de l'objet self"""
attrs = self._modifs.get(attr, self.attrs.get(attr,[]))
if len(attrs) == 1:
return attrs[0]
else: return attrs
def mod_ldapattr(self, attr, newVal, oldVal = None):
"""Modifie l'attribut attr ayant la valeur oldVal en newVal. Si
l'attribut attr n'a qu'une seule valeur, il n'est pas nécessaire
de préciser oldVal."""
assert isinstance(newVal, unicode)
attrs = self._modifs.get(attr, self.attrs[attr])[:]
if oldVal: # and oldVal in attrs:
attrs.remove(oldVal)
attrs.append(newVal)
self._modifs[attr] = attrs
elif len(attrs) == 1:
self._modifs[attr] = [newVal]
else:
raise ValueError(u"%s has multiple values, must specify oldVal")
def del_ldapattr(self, attr, val):
"""Supprime la valeur val de l'attribut attr"""
self._modifs.setdefault(attr, self.attrs.get(attr, [])[:]) .remove(newVal)
def set_ldapattr(self, attr, newVals):
"""Définit les nouvelles valeurs d'un attribut"""
if not isinstance(newVals, list):
newVals = [newVals]
for val in newVals: assert isinstance(val, unicode)
self._modifs[attr] = newVals
def add_ldapattr(self, attr, newVal):
"""Rajoute la valeur val à l'attribut attr"""
assert isinstance(newVal, unicode)
self._modifs.setdefault(attr, self.attrs.get(attr, [])[:]).append(newVal)
class proprio(CransLdapObject):
ufields = [ 'nom', 'chbre' ]
mfields = [ 'paiement', 'info', 'blacklist', 'controle']
ofields = []; xfields = []
_machines = None
def machines(self):
if self._machines == None:
self._machines = self.conn.search_s('mid=*', dn = self.dn, scope = 1)
for m in machines:
m._proprio = self
return self._machines
class machine(CransLdapObject):
_proprio = None
ufields = ['mid', 'macAddress', 'host', 'midType']
ofields = []
mfields = ['info', 'blacklist', 'hostAlias', 'exempt',
'portTCPout', 'portTCPin', 'portUDPout', 'portUDPin']
xfields = ['ipHostNumber']
def proprio(self):
parent_dn = self.dn.split(',', 1)[1]
self._proprio = CransLdapObject(self.conn, parent_dn, self.mode)
return self._proprio
class AssociationCrans(proprio): pass
class adherent(proprio):
ufields = proprio.ufields + ['aid', 'prenom', 'tel', 'mail', 'mailInvalide']
ofields = proprio.ofields + ['charteMA', 'adherentPayant', 'typeAdhesion',
'canonicalAlias', 'solde', 'contourneGreylist',
'rewriteMailHeaders', 'derniereConnexion',
'homepageAlias']
mfields = proprio.mfields + ['carteEtudiant', 'mailAlias', 'droits' ]
xfields = ['etudes', 'postalAddress']
class club(proprio):
ufields = ['cid', 'responsable']
mfields = ['imprimeurClub']
class machineFixe(machine): pass
class machineWifi(machine):
ufields = machine.ufields + ['ipsec']
class machineCrans(machine):
ufields = machine.ufields + ['prise']
ofields = machine.ofields + ['nombrePrises']
class borneWifi(machine):
ufields = machine.ufields + ['canal', 'puissane', 'hotspot',
'prise', 'positionBorne', 'nvram']
class facture(CransLdapObject):
ufields = ['fid', 'modePaiement', 'recuPaiement']
class service(CransLdapObject): pass
class lock(CransLdapObject): pass
MODIFIABLE_ATTRS = [ 'tel', 'chbre', 'mailAlias', 'loginShell' ]
CRANS_ATTRIBUTES = {
'nom' : { 'attr' : 'nom',
'hname' : 'Nom',
'isunique' : True },
'prenom' : { 'attr' : 'prenom',
'hname' : u'Prénom',
'isunique' : True },
'tel' : { 'attr' : 'tel',
'hname' : 'Téléphone',
'isunique' : True },
'paiement' : { 'attr' : 'paiement',
'hname' : u'Années de cotisations',
'isunique' : False },
'carteEtudiant' : { 'atttr' : 'carteEtudiant',
'hname' : u'Carte fournie pour les années',
'isunique' : False },
'mailAlias' : { 'attr' : 'mailAlias',
'hname' : 'Alias mail',
'isunique' : False },
'canonicalAlias' : { 'attr' : 'canonicalAlias',
'hname' : 'Alias mail canonique',
'isunique' : True },
'etudes' : { 'attr' : 'etudes',
'hname' : u'Études suivies',
'isunique' : False },
'chbre' : { 'attr' : 'chbre',
'hname' : 'Chambre',
'isunique' : True },
'droits' : { 'attr' : 'droits',
'hname' : 'Droits',
'isunique' : False },
'solde' : { 'attr' : 'solde',
'hname' : "Solde sur le compte d'impression",
'isunique' : True },
'mid' : { 'attr' : 'mid',
'hname' : 'Identifiant de machine',
'isunique' : True },
'hostAlias' : { 'attr' : 'hostAlias',
'hname' : 'Alias de nom de machine',
'isunique' : False },
'ipsec' : { 'attr' : 'ipsec',
'hname' : 'Clef wifi',
'isunique' : True },
'puissance' : { 'attr' : 'puissance',
'hname' : u"Puissance d'émission de la borne wifi",
'isunique' : True },
'canal' : { 'attr' : 'canal',
'hname' : u"Canal d'émission de la borne wifi",
'isunique' : True },
'portTCPout' : { 'attr' : 'portTCPout',
'hname' : u"Port TCP ouvert vers l'extérieur",
'isunique' : False },
'portTCPin' : { 'attr' : 'portTCPin',
'hname' : u"Port TCP ouvert depuis l'extérieur",
'isunique' : False },
'portUDPout' : { 'attr' : 'portUDPout',
'hname' : u"Port UDP ouvert vers l'extérieur",
'isunique' : False },
'portUDPin' : { 'attr' : 'portUDPin',
'hname' : u"Port UDP ouvert depuis l'extérieur",
'isunique' : False },
'prise' : { 'attr' : 'prise',
'hname' : 'Prise sur laquelle est branchée la machine',
'isunique' : True },
'cid' : { 'attr' : 'cid',
'hname' : 'Identifiant de club',
'isunique' : True },
'responsable' : { 'attr' : 'responsable',
'hname' : 'Responsable du club',
'isunique' : True },
'blacklist' : {'attr' : 'blacklist',
'hname' : 'Historique des sanctions',
'isunique' : False },
'historique' : { 'attr' : 'historique',
'hname' : 'Historique des modifications',
'isunique' : False },
}