#!/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, re, netaddr, datetime, copy, time, random from ldap.modlist import addModlist, modifyModlist from Levenshtein import jaro sys.path.append('/usr/scripts/gestion') import config, crans_utils from attributs import attrify, blacklist from ldap_locks import CransLock uri = 'ldapi:///' #'ldap://ldap.adm.crans.org/' base_dn = 'ou=data,dc=crans,dc=org' log_dn = "cn=log" # Champs à ignorer dans l'historique HIST_IGNORE_FIELDS = ["modifiersName", "entryCSN", "modifyTimestamp"] def ldif_to_uldif(ldif): uldif = {} for attr, vals in ldif.items(): uldif[attr] = [ unicode(val, 'utf-8') for val in vals ] return uldif def ldif_to_cldif(ldif, conn, check_ctxt = True): """Transforme un dictionnaire renvoyé par python-ldap, en un dictionnaire dont les valeurs sont des instances de Attr Lorsqu'on récupère le ldif de la base ldap, on n'a pas besoin de faire de tests... """ cldif = {} for attr, vals in ldif.items(): cldif[attr] = [ attrify(val, attr, ldif, conn, check_ctxt) for val in vals] return cldif def cldif_to_ldif(cldif): ldif = {} for attr, vals in cldif.items(): ldif[attr] = [ str(val) for val in vals ] return ldif def lc_ldap_test(): """Binding LDAP à la base de tests""" return lc_ldap(dn='cn=admin,dc=crans,dc=org', cred='75bdb64f32') 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' % user }) elif len(res) > 1: raise ldap.INVALID_CREDENTIALS({'desc': 'Too many matches: uid=%s' % user }) else: dn = res[0][0] if dn: self.conn = self.bind_s(dn, cred) self.dn = dn else: self.conn = self.simple_bind_s() self.dn = None def search(self, filterstr, mode='ro', dn= base_dn, scope= 2, sizelimit=400): res = self.search_ext_s(dn, scope, filterstr, sizelimit=sizelimit) return [ new_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 = new_cransldapobject(self, dn, ldif = attrs) parent_dn = dn.split(',', 1)[1] m._proprio = new_cransldapobject(self, parent_dn, res[parent_dn]) machines.append(m) return machines def newMachine(self, parent, realm, uldif): """Crée une nouvelle machine: realm peut être: fil, fil-v6, wifi, wifi-v6, adm, gratuit, personnel-ens, special""" #adm, serveurs, bornes, wifi, adherents, gratuit ou personnel-ens""" owner = self.search('objectClass=*', dn=parent, scope=0)[0] if realm in ["adm", "serveurs"]: uldif['objectClass'] = [u'machineCrans'] assert isinstance(owner, AssociationCrans) # XXX - Vérifier les droits elif realm == "bornes": uldif['objectClass'] = [u'borneWifi'] assert isinstance(owner, AssociationCrans) # XXX - Vérifier les droits elif realm in ["wifi", "wifi-v6"]: uldif['objectClass'] = [u'machineWifi'] assert isinstance(owner, adherent) or isinstance(owner, club) # XXX - Vérifier les droits (owner.type_connexion) elif realm in ["fil", "fil-v6", "gratuit", "personnel-ens"]: uldif['objectClass'] = [u'machineFixe'] assert isinstance(owner, adherent) or isinstance(owner, club) # XXX - Vérifier les droits else: raise ValueError("Realm inconnu: %s" % realm) if realm == 'fil': plage = xrange(256, 2047) else: plage = xrange( *(config.mid[realm])) mid = uldif.setdefault('mid', [ unicode(self._find_id('mid', plage)) ]) uldif['ipHostNumber'] = [ unicode(crans_utils.ip_of_mid(int (mid[0]))) ] return self._create_entity('mid=%s,%s' % (mid[0], parent), uldif) def newAdherent(self, uldif): """Crée un nouvel adhérent""" aid = uldif.setdefault('aid', [ unicode(self._find_id('aid')) ]) uldif['objectClass'] = [u'adherent'] 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''' cldif = ldif_to_cldif(uldif, self) #lock = CransLock(self) # for item in ['aid', 'uid', 'chbre', 'mailAlias', 'canonicalAlias', # 'fid', 'cid', 'mid', 'macAddress', 'host', 'hostAlias' ]: # for val in cldif.get(item, []): # lock.add(item, val) ldif = cldif_to_ldif(cldif) modlist = addModlist(ldif) #with lock: # print dn, modlist # self.add_s(dn, modlist) return new_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 i in plage: if nonfree and nonfree[0] <= i: while nonfree and nonfree[0] <= i: nonfree = nonfree[1:] else: break else: raise EnvironmentError('Aucun %s libre dans la plage [%d, %d]' % (attr, plage[0], i)) return i # def _hist(self, msg): # now = datetime.datetime.now().strftime('%Y-%m-%d %H:%M : ') # return unicode(now) + msg # ? def reconnect(self, conn=None): def new_cransldapobject(conn, dn, mode='ro', ldif = None): """Crée un objet CransLdap en utilisant la classe correspondant à l'objectClass du ldif""" classe = None if ldif: classe = globals()[ldif['objectClass'][0]] elif dn == base_dn: classe = AssociationCrans else: res = conn.search_s(dn, 0) if not res: raise ValueError ('objet inexistant: %s' % dn) _, attrs = res[0] classe = globals()[attrs['objectClass'][0]] return classe(conn, dn, mode, ldif) class CransLdapObject(object): """Classe de base des objets CransLdap""" def __init__(self, conn, dn, mode='ro', ldif = None): ''' Créée 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. ''' self.mode = mode self.attrs = None # Contient un dico uldif qui doit représenter ce qui # est dans la base self._modifs = None # C'est là qu'on met les modifications if not isinstance(conn, lc_ldap): raise TypeError("conn doit être une instance de lc_ldap") self.conn = conn self.dn = dn if ldif: # Vous précisez un ldif, l'objet est 'ro' self.mode = 'ro' self.attrs = ldif elif dn != base_dn: 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) if mode in ['w', 'rw']: self._modifs = copy.deepcopy(self.attrs) self.attrs = ldif_to_cldif(self.attrs, conn, check_ctxt = False) ### Vérification que `λv. str(Attr(v))` est bien une projection oldif = res[0][1] nldif = cldif_to_ldif(self.attrs) for attr, vals in oldif.items(): if nldif[attr] != vals: for v in nldif[attr]: if v in vals: vals.remove(v) nvals = [nldif[attr][v.index(v)] for v in vals ] raise EnvironmentError("λv. str(Attr(v)) n'est peut-être pas une projection:", attr, nvals, vals) # self._modifs = copy.deepcopy(self.attrs) def save(self): "Vérifie que self._modifs contient des valeurs correctes et enregistre les modifications" if self.mode not in ['w', 'rw']: raise EnvironmentError("Objet en lecture seule, réessayer en lecture/écriture") # Vérifications et Historique #histo = self._gen_hist(self._modifs) #self._modifs['historique'] += histo # On récupère la liste des modifications modlist = self.get_modlist() 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("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 get_modlist(self): """Renvoie le dico des modifs""" # unicode -> utf-8 ldif = cldif_to_ldif(self._modifs) orig_ldif = cldif_to_ldif(self.attrs) return modifyModlist(orig_ldif, ldif) def get(self, attr, default): try: return self[attr] except KeyError: return default def __getitem__(self, attr): if self.mode in [ 'w', 'rw' ]: return [ unicode(v) for v in self._modifs[attr] ] else: return [ unicode(v) for v in self.attrs[attr] ] def __setitem__(self, attr, values): if not isinstance(values, list): values = [ values ] self._modifs[attr] = values self._modifs[attr] = [ attrify(val, attr, self._modifs, self.conn) for val in values ] def search_historique(self, ign_fields=HIST_IGNORE_FIELDS): u"""Récupère l'historique l'argument optionnel ign_fields contient la liste des champs à ignorer, HIST_IGNORE_FIELDS par défaut Renvoie une liste de lignes de texte.""" res = self.conn.search_s(log_dn, 2, 'reqDN=%s' % self.dn) res.sort(key=(lambda a: a[1]['reqEnd'][0])) out = [] for cn, attrs in res: date = crans_utils.format_ldap_time(attrs['reqEnd'][0]) if attrs['reqType'][0] == 'delete': out.append("%s : [%s] Suppression" % (date, attrs['reqAuthzID'][0])) elif attrs['reqType'][0] == 'modify': fields = {} for mod in attrs['reqMod']: field, change = mod.split(':', 1) if field not in ign_fields: if field in fields: fields[field].append(change) else: fields[field] = [change] mod_list = [] for field in fields: mods = fields[field] mod_list.append("%s %s" %(field, ", ".join(mods))) if mod_list != []: out.append("%s : [%s] %s" % (date, attrs['reqAuthzID'][0], " ; ".join(mod_list))) return out def blacklist(self, sanction, commentaire, debut=time.time(), fin = '-'): u""" Blacklistage de la ou de toutes la machines du propriétaire * debut 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 """ if debut == 'now': debut = time.time() if fin == 'now': fin = time.time() bl = blacklist(u'%s$%s$%s$%s' % (sanction, commentaire, debut, fin), {}, self.conn, False) self._modifs.setdefault('blacklist', []).append(bl) class proprio(CransLdapObject): ufields = [ 'nom', 'chbre' ] mfields = [ 'paiement', 'info', 'blacklist', 'controle'] ofields = [] xfields = [] def __init__(self, conn, dn, mode='ro', ldif = None): super(proprio, self).__init__(conn, dn, mode, ldif) self._machines = [] def machines(self): if not self._machines: self._machines = self.conn.search('mid=*', dn = self.dn, scope = 1) for m in self._machines: m._proprio = self return self._machines class machine(CransLdapObject): ufields = ['mid', 'macAddress', 'host', 'midType'] ofields = [] mfields = ['info', 'blacklist', 'hostAlias', 'exempt', 'portTCPout', 'portTCPin', 'portUDPout', 'portUDPin'] xfields = ['ipHostNumber'] def __init__(self, conn, dn, mode='ro', ldif = None): super(machine, self).__init__(conn, dn, mode, ldif) self._proprio = None def proprio(self): parent_dn = self.dn.split(',', 1)[1] if not self._proprio: self._proprio = new_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'] def compte(self,login = None, uidNumber=0, hash_pass = '', shell=config.login_shell): """Renvoie le nom du compte crans, s'il n'existe pas, et que uid est précisé, le crée""" if u'posixAccount' in self.attrs['objectClass']: return self.attrs['uid'][0] elif login: fn = crans_utils.strip_accents(unicode(self.attrs['prenom'][0]).capitalize()) ln = crans_utils.strip_accents(unicode(self.attrs['nom'][0]).capitalize()) login = crans_utils.strip_accents(login).lower() if jaro(ln.lower(), login) < 0.75 and jaro(fn.lower() + ' ' + ln.lower(), login) < 0.75: raise ValueError("Le login est trop différent du nom", login, self.attrs['nom'][0]) if not re.match('^[a-z][-a-z]{1,15}$', login): raise ValueError("Le login a entre 2 et 16 lettres, il peut contenir (pas au début) des - ") if crans_utils.mailexist(login): raise ValueError("Login existant ou correspondant à un alias mail.") home = u'/home/' + login if os.path.exists(home): raise ValueError('Création du compte impossible : home existant') if os.path.exists("/var/mail/" + login): raise ValueError('Création du compte impossible : /var/mail/%s existant' % login) self._modifs['homeDirectory'] = [home] self._modifs['mail'] = [login] self._modifs['uid' ] = [login] calias = crans_utils.strip_spaces(fn) + u'.' + crans_utils.strip_spaces(ln) if crans_utils.mailexist(calias): calias = login self._modifs['canonicalAlias'] = [calias] self._modifs['objectClass'] = [u'adherent', u'cransAccount', u'posixAccount', u'shadowAccount'] self._modifs['cn'] = [ fn + u' ' + ln ] self._modifs['loginShell'] = [unicode(shell)] self._modifs['userPassword'] = [unicode(hash_pass)] if uidNumber: if self.conn.search('(uidNumber=%s)' % uidNumber): raise ValueError(u'uidNumber pris') else: pool_uid = range(1001, 9999) random.shuffle(pool_uid) while len(pool_uid) > 0: uidNumber = pool_uid.pop() # On choisit un uid if not self.conn.search('(uidNumber=%s)' % uidNumber): break if not len(pool_uid): raise ValueError("Plus d'uid disponibles !") ## try: ## self.lock('uidNumber', str(uidNumber)) ## except: ## # Quelqu'un nous a piqué l'uid que l'on venait de choisir ! ## return self.compte(login, uidNumber, hash_pass, shell) self._modifs['uidNumber'] = [unicode(uidNumber)] self._modifs['gidNumber'] = [unicode(config.gid)] self._modifs['gecos'] = [self._modifs['cn'][0] + u',,,'] self.save() else: raise EnvironmentError("L'adhérent n'a pas de compte crans") 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' ]