#!/usr/bin/python # vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4 import argparse import ConfigParser import datetime import dateutil.parser import dateutil.tz import ldap import ldap.modlist import ldif import logging import os import sys def getArguments(): configfile = '/etc/dassldapsync.conf' parser = argparse.ArgumentParser(description='Synchronize the content of two LDAP servers.' ) parser.add_argument('-d', '--debug', action='store_true', help="enable debug output") parser.add_argument('configfile', default=configfile, help="Configuration file [default: {}]".format(configfile)) args = parser.parse_args() return args class Options: def __init__(self): self.delete=True self.starttls=False self.updateonly=False self.filter=None self.attrfilter=None self.exclude=None self.renameattr=None self.renamecommand=None self.pwd_max_days=0 class ConfigParserDefaults(ConfigParser.ConfigParser, object): def get(self, section, option, default = None): try: result = super(self.__class__, self).get(section, option) except ConfigParser.NoOptionError: if default is None: raise else: result = default return result def get0(self, section, option, default = None): try: result = super(self.__class__, self).get(section, option) except ConfigParser.NoOptionError: result = default return result def getboolean(self, section, option, default = None): try: result = super(self.__class__, self).getboolean(section, option) except ConfigParser.NoOptionError: if default is None: raise else: result = default return result def readLDIFSource(path): logger = logging.getLogger() logger.info("reading LDAP objects from file {}".format(path)) with open(path,'r') as f: parser = ldif.LDIFRecordList(f) parser.parse() result = parser.all_records return result def readLdapSource(server,binddn,bindpw,basedn,filter,attrs=None,starttls=False): logger = logging.getLogger() logger.info("reading LDAP objects from server {}".format(server)) con = ldap.open(server,port=389) if starttls: con.start_tls_s() con.simple_bind_s(binddn,bindpw) results=con.search_s(basedn,ldap.SCOPE_SUBTREE,filter,attrs) return results class LdapSync(object): def __init__(self, destserver,destbinddn,destbindpw,srcbasedn,destbasedn,options=Options()): self.logger = logging.getLogger() self.destserver = destserver self.destbasedn = destbasedn self.destbinddn = destbinddn self.destbindpw = destbindpw self.options = options self.srcbasedn = srcbasedn self.con = None self.attrmap=ldap.cidict.cidict({}) self.classmap={} self.junk_attrs = [ "memberof", "modifiersname", "modifytimestamp", "entryuuid", "entrycsn", "contextcsn", "creatorsname", "createtimestamp", "structuralobjectclass", "pwdchangedtime", "pwdfailuretime" ] def __ldap_connect(self): if self.con is None: self.logger.info("connect to destination LDAP server {}".format(self.destserver)) self.con = ldap.open(self.destserver,port=389) if self.options.starttls: self.con.start_tls_s() self.con.simple_bind_s(self.destbinddn,self.destbindpw) def __adapt_dn(self, dn): # move LDAP object to dest base if self.srcbasedn != self.destbasedn: dn_old = dn rpath = dn[:-len(srcbasedn)] dn=rpath+self.destbasedn self.logger.debug("moved {} to {}".format(dn_old, dn)) # print "dn:",dn,"src:",srcbasedn,"rpath:",rpath,"dest:",destbasedn return dn def __is_dn_included(self, dn): if self.options.exclude is None: return True if dn.lower().endswith(self.options.exclude): return False return True def __adapt_source_ldap_objects(self, searchresult): """ Do configured modification to the source LDAP objects. """ self.logger.debug("modifying LDAP objects retrieved from source LDAP") update_objects=[] for r in searchresult: dn = self.__adapt_dn(r[0]) d=ldap.cidict.cidict(r[1]) if self.__is_dn_included(dn): objectclasses=d["objectclass"] newObjectclasses=[] for o in objectclasses: if o.lower() in self.classmap: new_oc = self.classmap[o.lower()] if not new_oc in newObjectclasses: newObjectclasses.append(new_oc) else: #pass if not o in newObjectclasses: newObjectclasses.append(o) d["objectclass"]=newObjectclasses for a in d.keys(): attr=a if self.attrmap.has_key(a.lower()): attr=self.attrmap[attr].lower() if attr.lower()!=a.lower(): # print "# ",a," -> ",attr values=d[a] del d[a] d[attr]=values update_objects.append((dn,d)) return update_objects def __get_dest_entry(self, dn, entry): """ In the destination LDAP, the objects should be named according to options.renameattr. """ existingDestDn = None existingDestEntry = None if self.options.renameattr and entry.has_key(self.options.renameattr): searchresult = self.con.search_s(self.destbasedn,ldap.SCOPE_SUB,"%s=%s" % (self.options.renameattr,entry[self.options.renameattr][0])) if searchresult != None and len(searchresult)>0: existingDestDn, existingDestEntry = searchresult[0] if existingDestDn.lower() != dn.lower(): self.con.modrdn_s(existingDestDn,dn) notify_renamed(existingDestDn, dn, existingDestEntry[self.options.renameattr][0],entry[self.options.renameattr][0],options) if existingDestDn is None: searchresult=self.con.search_s(dn,ldap.SCOPE_BASE,"objectclass=*") existingDestDn, existingDestEntry = searchresult[0] return (existingDestDn, existingDestEntry) def __handle_pwdAccountLockedTime(self, dn, entry, now, max_age): # hack for syncing accounts locked by password policy do_unlock = False if self.options.pwd_max_days>0 and entry.has_key('pwdChangedTime'): # print "pwdChangedTime set for",dn pwdChange = entry['pwdChangedTime'][0] d = dateutil.parser.parse(pwdChange) if (now-d)>max_age: entry['pwdAccountLockedTime']=['000001010000Z'] self.logger.info( "locking {} {}".format(dn, pwdChange)) else: # pwdAccountLockedTime is a operational attribute, # and therefore not part of entry. # Do extra search to retrieve attribute. searchresult = self.con.search_s(dn,ldap.SCOPE_BASE,"objectclass=*", \ attrlist = ['pwdAccountLockedTime']) tmp_dn, tmp_entry = searchresult[0] if tmp_entry.has_key('pwdAccountLockedTime'): do_unlock = True return do_unlock def __syncLdapDestination(self, update_objects): result = { 'add': { 'ok': [], 'failed': [] }, 'update': { 'ok': [], 'failed': [] }, } tzutc = dateutil.tz.gettz('UTC') now = datetime.datetime.now(tzutc) max_age = datetime.timedelta(days=self.options.pwd_max_days) logger.debug("writing data to destination LDAP") for o in update_objects: dn,entry=o #logger.debug(dn) try: destDn,destEntry=self.__get_dest_entry(dn, entry) # hack for syncing accounts locked by password policy do_unlock = self.__handle_pwdAccountLockedTime(dn, entry, now, max_age) mod_attrs=ldap.modlist.modifyModlist(destEntry,entry) # hack for unlocking, see above if do_unlock: self.logger.info( "unlocking {} {}".format(dn,pwdChange)) mod_attrs.append( (ldap.MOD_DELETE,'pwdAccountLockedTime',None) ) if self.options.attrfilter is not None: mod_attrs=[ a for a in mod_attrs if a[1].lower() in options.attrfilter] if self.junk_attrs is not None: mod_attrs=[ a for a in mod_attrs if a[1].lower() not in self.junk_attrs] if mod_attrs: try: self.con.modify_s(dn,mod_attrs) result['update']['ok'].append(dn) self.notify_modified(dn) except: self.logger.error("failed to modify {} ({})".format(dn,mod_attrs)) result['update']['failed'].append(dn) except ldap.NO_SUCH_OBJECT: if options.updateonly==True: continue try: self.con.add_s(dn,ldap.modlist.addModlist(entry,self.junk_attrs)) self.notify_created(dn) result['add']['ok'].append(dn) except (ldap.OBJECT_CLASS_VIOLATION, ldap.NO_SUCH_OBJECT, ldap.CONSTRAINT_VIOLATION): self.logger.warning("failed to add {}".format(dn)) result['add']['failed'].append(dn) return result def __deleteDestLdapObjects(self, update_objects): """ Remove all LDAP objects in destination LDAP server that did not come from the source LDAP objects and are not excluded. """ result = { 'delete': { 'ok': [], 'failed': [] }, } searchresult=self.con.search_s(self.destbasedn,ldap.SCOPE_SUBTREE,self.options.filter) existing=[ x[0].lower() for x in searchresult ] morituri=existing if self.destbasedn.lower() in existing: morituri.remove(self.destbasedn.lower()) for o in update_objects: dn,entry=o if dn.lower() in existing: morituri.remove(dn.lower()) for dn in morituri: if self.__is_dn_included(dn): try: self.con.delete_s(dn) self.notify_deleted(dn) result['delete']['ok'].append(dn) except: self.logger.error("failed to delete {}".format(dn)) result['delete']['failed'].append(dn) return result def sync(self, searchresult): """ Synchronize entries from searchresult to destination LDAP server. """ result = { 'add': { 'ok': [], 'failed': [] }, 'update': { 'ok': [], 'failed': [] }, 'delete': { 'ok': [], 'failed': [] }, } if len(searchresult)==0: self.logger.error("empty source, aborting") return self.__ldap_connect() update_objects = self.__adapt_source_ldap_objects(searchresult) result = self.__syncLdapDestination(update_objects) if self.options.delete==True and self.options.updateonly==False: result.update(self.__deleteDestLdapObjects(update_objects)) self.con.unbind() self.__log_summary(result, True) def __log_summary(self, result, show_failed = True, show_ok = False): for action in result.keys(): ok = len(result[action]['ok']) failed = len(result[action]['failed']) print "{} (ok: {}, failed: {}):".format(action, ok, failed) if show_ok and ok > 0: print "succeeded:" print "\n".join(result[action]['ok']) if show_failed and failed > 0: print "failed:" print "\n".join(result[action]['failed']) def notify_created(self, dn): print "created",dn def notify_modified(self, dn): print "modified",dn def notify_deleted(self, dn): print "deleted",dn def notify_renamed(self, dn, newdn, uid, newuid, options): print "renamed",dn,newdn subprocess.check_call("%s %s %s %s %s" % (options.renamecommand,dn,newdn,uid,newuid),shell=True) if __name__ == "__main__": logging.basicConfig(format='%(levelname)s %(module)s.%(funcName)s: %(message)s', level=logging.INFO) logger = logging.getLogger() args=getArguments() if args.debug: logger.setLevel(logging.DEBUG) conffile=args.configfile exclude = None config=ConfigParserDefaults() config.read(conffile) srcfile = None try: srcfile = config.get("source","file") except: pass basedn = config.get("source","baseDn") filter = config.get0("source","filter", None) attrs = config.get0("source", "attributes", None) if srcfile==None: srv = config.get("source","server") admindn = config.get("source","bindDn") adminpw = config.get("source","bindPassword") starttls = config.getboolean("source","starttls") destsrv = config.get("destination","server") destadmindn = config.get("destination","bindDn") destadminpw = config.get("destination","bindPassword") destbasedn = config.get("destination","baseDn") destdelete = config.getboolean("destination","delete") try: rdn = config.get("destination","rdn") logger.warning("setting rdn is currently ignored") except: pass options = Options() try: options.exclude = config.get("destination","excludesubtree").lower() except: pass options.updateonly = not config.getboolean("destination","create", False) options.starttls = config.getboolean("destination","starttls", False) options.renameattr = config.get0("destination","detectRename", None) options.renamecommand = config.get0("destination","detectRename", None) options.pwd_max_days = int(config.get("source","pwd_max_days",0)) options.filter = filter try: options.attrfilter = config.get("destination","attributes").split(",") except: options.attrfilter = None if srcfile: objects = readLDIFSource(srcfile) else: objects = readLdapSource(srv,admindn,adminpw,basedn,filter,attrs,starttls) ldapsync = LdapSync(destsrv,destadmindn,destadminpw,basedn,destbasedn,options) ldapsync.sync(objects)