#!/usr/bin/env python # -*- coding: utf-8 -*- # # LC_LDAP.PY-- LightWeight CransLdap # # Copyright (C) 2010 Cr@ns # Author: Antoine Durand-Gasselin # 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 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 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 }, }