Changeset 1219 for dassldapsync
- Timestamp:
- Nov 8, 2016, 5:39:27 PM (8 years ago)
- Location:
- dassldapsync
- Files:
-
- 2 edited
Legend:
- Unmodified
- Added
- Removed
-
dassldapsync/dassldapsync.conf
r1218 r1219 3 3 baseDn=dc=example,dc=com 4 4 5 #[source] 6 #server=ldapserver.example.com 7 #baseDn=dc=example,dc=com 8 #bindDn=cn=admin,dc=example,dc=com 9 #bindPassword=secret 10 #filter=(objectclass=*) 11 #attributes=* 12 #starttls=true 13 #pwd_max_days=0 14 5 15 [destination] 6 server=ldapserver 16 server=ldapserver.example.com 7 17 bindDn=cn=admin,dc=example,dc=com 8 18 bindPassword=secret 9 19 baseDn=dc=example,dc=com 20 #attributes= 10 21 delete=true 11 22 create=true 12 23 starttls=false 24 #rdn= -
dassldapsync/dassldapsync.py
r1218 r1219 2 2 # vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4 3 3 4 import argparse 5 import ConfigParser 6 import datetime 7 import dateutil.parser 8 import dateutil.tz 4 9 import ldap 10 import ldap.modlist 5 11 import ldif 6 import ldap.modlist 7 import ConfigParser 12 import logging 8 13 import os 9 14 import sys 10 15 11 import dateutil.parser 12 import dateutil.tz 13 import datetime 16 def getArguments(): 17 configfile = '/etc/dassldapsync.conf' 18 parser = argparse.ArgumentParser(description='Synchronize the content of two LDAP servers.' ) 19 parser.add_argument('-d', '--debug', action='store_true', help="enable debug output") 20 parser.add_argument('configfile', default=configfile, help="Configuration file [default: {}]".format(configfile)) 21 args = parser.parse_args() 22 return args 23 14 24 15 25 class Options: … … 18 28 self.starttls=False 19 29 self.updateonly=False 30 self.filter=None 20 31 self.attrfilter=None 21 32 self.exclude=None 22 33 self.renameattr=None 23 34 self.renamecommand=None 24 self.verbose=False 25 26 def notify_created(dn): 27 print "notify_created",dn 28 29 def notify_modified(dn): 30 print "notify_modified",dn 31 32 def notify_deleted(dn): 33 print "notify_deleted",dn 34 35 def notify_renamed(dn,newdn,uid,newuid,options): 36 print "notify_renamed",dn,newdn 37 subprocess.check_call("%s %s %s %s %s" % (options.renamecommand,dn,newdn,uid,newuid),shell=True) 35 self.pwd_max_days=0 36 37 class ConfigParserDefaults(ConfigParser.ConfigParser, object): 38 def get(self, section, option, default = None): 39 try: 40 result = super(self.__class__, self).get(section, option) 41 except ConfigParser.NoOptionError: 42 if default is None: 43 raise 44 else: 45 result = default 46 return result 47 48 def get0(self, section, option, default = None): 49 try: 50 result = super(self.__class__, self).get(section, option) 51 except ConfigParser.NoOptionError: 52 result = default 53 return result 54 55 def getboolean(self, section, option, default = None): 56 try: 57 result = super(self.__class__, self).getboolean(section, option) 58 except ConfigParser.NoOptionError: 59 if default is None: 60 raise 61 else: 62 result = default 63 return result 38 64 39 65 def readLDIFSource(path): 66 logger = logging.getLogger() 67 logger.info("reading LDAP objects from file {}".format(path)) 40 68 with open(path,'r') as f: 41 69 parser = ldif.LDIFRecordList(f) … … 44 72 return result 45 73 46 def readLdapSource(server,binddn,bindpw,basedn,filter,starttls=False): 74 def readLdapSource(server,binddn,bindpw,basedn,filter,attrs=None,starttls=False): 75 logger = logging.getLogger() 76 logger.info("reading LDAP objects from server {}".format(server)) 47 77 con = ldap.open(server,port=389) 48 78 if starttls: 49 79 con.start_tls_s() 50 80 con.simple_bind_s(binddn,bindpw) 51 results=con.search_s(basedn,ldap.SCOPE_SUBTREE,filter, None)81 results=con.search_s(basedn,ldap.SCOPE_SUBTREE,filter,attrs) 52 82 return results 53 83 54 def syncLdapDestination(searchresult,destserver,destbinddn,destbindpw,srcbasedn,destbasedn,destrdn,options=Options()): 55 56 attrmap=ldap.cidict.cidict({ 84 class LdapSync: 85 def __init__(self, destserver,destbinddn,destbindpw,srcbasedn,destbasedn,options=Options()): 86 self.logger = logging.getLogger() 87 88 self.destserver = destserver 89 self.destbasedn = destbasedn 90 self.destbinddn = destbinddn 91 self.destbindpw = destbindpw 92 self.options = options 93 94 self.srcbasedn = srcbasedn 95 96 self.con = None 97 98 self.attrmap=ldap.cidict.cidict({ 57 99 }) 58 classmap={100 self.classmap={ 59 101 } 60 102 61 junk_attrs = [ "memberof", "modifiersname", "modifytimestamp", "entryuuid", "entrycsn", "contextcsn", "creatorsname", "createtimestamp", "structuralobjectclass", "pwdchangedtime", "pwdfailuretime" ] 62 update_objects=[] 63 64 if len(searchresult)==0: 65 print "empty source, aborting" 66 return 67 68 for r in searchresult: 69 dn=r[0] 70 71 d=ldap.cidict.cidict(r[1]) 72 objectclasses=d["objectclass"] 73 74 newObjectclasses=[] 75 for o in objectclasses: 76 if o.lower() in classmap: 77 new_oc = classmap[o.lower()] 78 if not new_oc in newObjectclasses: 79 newObjectclasses.append(new_oc) 103 self.junk_attrs = [ "memberof", "modifiersname", "modifytimestamp", "entryuuid", "entrycsn", "contextcsn", "creatorsname", "createtimestamp", "structuralobjectclass", "pwdchangedtime", "pwdfailuretime" ] 104 105 def __ldap_connect(self): 106 if self.con is None: 107 self.logger.info("connect to destination LDAP server {}".format(self.destserver)) 108 self.con = ldap.open(self.destserver,port=389) 109 if self.options.starttls: 110 self.con.start_tls_s() 111 self.con.simple_bind_s(self.destbinddn,self.destbindpw) 112 113 def __adapt_dn(self, dn): 114 # move LDAP object to dest base 115 if self.srcbasedn != self.destbasedn: 116 dn_old = dn 117 rpath = dn[:-len(srcbasedn)] 118 dn=rpath+self.destbasedn 119 self.logger.debug("moved {} to {}".format(dn_old, dn)) 120 # print "dn:",dn,"src:",srcbasedn,"rpath:",rpath,"dest:",destbasedn 121 return dn 122 123 def __is_dn_included(self, dn): 124 if self.options.exclude is None: 125 return True 126 if dn.lower().endswith(self.options.exclude): 127 return False 128 return True 129 130 def __adapt_source_ldap_objects(self, searchresult): 131 """ 132 Do configured modification to the source LDAP objects. 133 """ 134 self.logger.debug("modifying LDAP objects retrieved from source LDAP") 135 136 update_objects=[] 137 138 for r in searchresult: 139 dn = self.__adapt_dn(r[0]) 140 d=ldap.cidict.cidict(r[1]) 141 142 if self.__is_dn_included(dn): 143 objectclasses=d["objectclass"] 144 145 newObjectclasses=[] 146 for o in objectclasses: 147 if o.lower() in self.classmap: 148 new_oc = self.classmap[o.lower()] 149 if not new_oc in newObjectclasses: 150 newObjectclasses.append(new_oc) 151 else: 152 #pass 153 if not o in newObjectclasses: 154 newObjectclasses.append(o) 155 156 d["objectclass"]=newObjectclasses 157 158 for a in d.keys(): 159 attr=a 160 if self.attrmap.has_key(a.lower()): 161 attr=self.attrmap[attr].lower() 162 if attr.lower()!=a.lower(): 163 # print "# ",a," -> ",attr 164 values=d[a] 165 del d[a] 166 d[attr]=values 167 168 update_objects.append((dn,d)) 169 return update_objects 170 171 172 def __get_dest_entry(self, dn, entry): 173 """ 174 In the destination LDAP, the objects should be named 175 according to options.renameattr. 176 """ 177 existingDestDn = None 178 existingDestEntry = None 179 if self.options.renameattr and entry.has_key(self.options.renameattr): 180 searchresult = self.con.search_s(self.destbasedn,ldap.SCOPE_SUB,"%s=%s" % (self.options.renameattr,entry[self.options.renameattr][0])) 181 if searchresult != None and len(searchresult)>0: 182 existingDestDn, existingDestEntry = searchresult[0] 183 if existingDestDn.lower() != dn.lower(): 184 self.con.modrdn_s(existingDestDn,dn) 185 notify_renamed(existingDestDn, dn, existingDestEntry[self.options.renameattr][0],entry[self.options.renameattr][0],options) 186 if existingDestDn is None: 187 searchresult=self.con.search_s(dn,ldap.SCOPE_BASE,"objectclass=*") 188 existingDestDn, existingDestEntry = searchresult[0] 189 return (existingDestDn, existingDestEntry) 190 191 192 def __handle_pwdAccountLockedTime(self, dn, entry, now, max_age): 193 # hack for syncing accounts locked by password policy 194 do_unlock = False 195 if self.options.pwd_max_days>0 and entry.has_key('pwdChangedTime'): 196 # print "pwdChangedTime set for",dn 197 pwdChange = entry['pwdChangedTime'][0] 198 d = dateutil.parser.parse(pwdChange) 199 if (now-d)>max_age: 200 entry['pwdAccountLockedTime']=['000001010000Z'] 201 self.logger.info( "locking {} {}".format(dn, pwdChange)) 80 202 else: 81 #pass 82 if not o in newObjectclasses: 83 newObjectclasses.append(o) 84 85 d["objectclass"]=newObjectclasses 86 87 rpath = dn[:-len(srcbasedn)] 88 # print "dn:",dn,"src:",srcbasedn,"rpath:",rpath,"dest:",destbasedn 89 90 for a in d.keys(): 91 attr=a 92 if attrmap.has_key(a.lower()): 93 attr=attrmap[attr].lower() 94 if attr.lower()!=a.lower(): 95 # print "# ",a," -> ",attr 96 values=d[a] 97 del d[a] 98 d[attr]=values 99 else: 100 # del d[a] 101 continue 102 103 dn=rpath+destbasedn 104 105 update_objects.append((dn,d)) 106 107 con = ldap.open(destserver,port=389) 108 if options.starttls: 109 con.start_tls_s() 110 con.simple_bind_s(destbinddn,destbindpw) 111 112 exist=0 113 failed=0 114 good=0 115 deleted=0 116 existing=[] 117 tzutc = dateutil.tz.gettz('UTC') 118 now = datetime.datetime.now(tzutc) 119 max_age = datetime.timedelta(days=pwd_max_days) 120 121 for o in update_objects: 122 dn,entry=o 123 try: 124 result = None 125 if options.renameattr and entry.has_key(options.renameattr): 126 result = con.search_s(destbasedn,ldap.SCOPE_SUB,"%s=%s" % (options.renameattr,entry[options.renameattr][0])) 127 if result != None and len(result)>0: 128 existingDn, existingEntry = result[0] 129 if existingDn.lower() != dn.lower(): 130 con.modrdn_s(existingDn,dn) 131 notify_renamed(existingDn,dn,existingEntry[options.renameattr][0],entry[options.renameattr][0],options) 132 continue 133 134 result=con.search_s(dn,ldap.SCOPE_BASE,"objectclass=*") 135 destDn,destEntry=result[0] 136 137 if options.exclude!=None and destDn.lower().endswith(options.exclude): 138 continue 139 140 # hack for syncing accounts locked by password policy 141 do_unlock = False 142 if pwd_max_days>0 and entry.has_key('pwdChangedTime'): 143 # print "pwdChangedTime set for",dn 144 pwdChange = entry['pwdChangedTime'][0] 145 d = dateutil.parser.parse(pwdChange) 146 if (now-d)>max_age: 147 if dn.startswith('cn=haydar aldetest'): 148 entry['pwdAccountLockedTime']=[ '000001010000Z' ] 149 print "locking",dn,pwdChange 150 else: 151 result = con.search_s(dn,ldap.SCOPE_BASE,"objectclass=*", \ 152 attrlist = [ 'pwdAccountLockedTime' ]) 153 tmp_dn, tmp_entry = result[0] 154 if tmp_entry.has_key('pwdAccountLockedTime'): 155 print "unlocking",dn,pwdChange 156 do_unlock = True 157 158 mod_attrs=ldap.modlist.modifyModlist(destEntry,entry) 159 160 # hack for unlocking, see above 161 if do_unlock: 162 mod_attrs.append( (ldap.MOD_DELETE,'pwdAccountLockedTime',None) ) 163 164 if options.attrfilter!=None: 165 mod_attrs=[ a for a in mod_attrs if a[1] in options.attrfilter] 166 167 if junk_attrs!=None: 168 mod_attrs=[ a for a in mod_attrs if a[1].lower() not in junk_attrs] 169 170 if mod_attrs!=[]: 171 exist=exist+1 172 #if options.verbose: 173 # print dn, "already exists" 174 try: 175 # print dn,destEntry['objectClass'],entry['objectClass'] 176 con.modify_s(dn,mod_attrs) 177 except: 178 print "error",dn,mod_attrs 179 notify_modified(dn) 180 else: 181 pass 182 # print "no changes, not modified" 183 184 except ldap.NO_SUCH_OBJECT: 185 if options.updateonly==True: 186 continue 187 203 # pwdAccountLockedTime is a operational attribute, 204 # and therefore not part of entry. 205 # Do extra search to retrieve attribute. 206 searchresult = self.con.search_s(dn,ldap.SCOPE_BASE,"objectclass=*", \ 207 attrlist = ['pwdAccountLockedTime']) 208 tmp_dn, tmp_entry = searchresult[0] 209 if tmp_entry.has_key('pwdAccountLockedTime'): 210 do_unlock = True 211 return do_unlock 212 213 214 215 def __syncLdapDestination(self, update_objects): 216 217 result = { 218 'add': { 'ok': [], 'failed': [] }, 219 'update': { 'ok': [], 'failed': [] }, 220 } 221 222 tzutc = dateutil.tz.gettz('UTC') 223 now = datetime.datetime.now(tzutc) 224 max_age = datetime.timedelta(days=self.options.pwd_max_days) 225 226 logger.debug("writing data to destination LDAP") 227 for o in update_objects: 228 dn,entry=o 229 #logger.debug(dn) 188 230 try: 189 con.add_s(dn,ldap.modlist.addModlist(entry,junk_attrs)) 190 notify_created(dn) 191 if options.verbose: 192 print dn,"created" 193 good=good+1 194 except (ldap.OBJECT_CLASS_VIOLATION,ldap.NO_SUCH_OBJECT): 195 print dn, "failed" 196 failed=failed+1 197 198 if options.delete==True and options.updateonly==False: 199 result=con.search_s(destbasedn,ldap.SCOPE_SUBTREE,filter) 200 existing=[ x[0].lower() for x in result ] 231 destDn,destEntry=self.__get_dest_entry(dn, entry) 232 233 # hack for syncing accounts locked by password policy 234 do_unlock = self.__handle_pwdAccountLockedTime(dn, entry, now, max_age) 235 236 mod_attrs=ldap.modlist.modifyModlist(destEntry,entry) 237 238 # hack for unlocking, see above 239 if do_unlock: 240 self.logger.info( "unlocking {} {}".format(dn,pwdChange)) 241 mod_attrs.append( (ldap.MOD_DELETE,'pwdAccountLockedTime',None) ) 242 243 if self.options.attrfilter is not None: 244 mod_attrs=[ a for a in mod_attrs if a[1].lower() in options.attrfilter] 245 246 if self.junk_attrs is not None: 247 mod_attrs=[ a for a in mod_attrs if a[1].lower() not in self.junk_attrs] 248 249 if mod_attrs: 250 try: 251 self.con.modify_s(dn,mod_attrs) 252 result['update']['ok'].append(dn) 253 self.notify_modified(dn) 254 except: 255 self.logger.error("failed to modify {} ({})".format(dn,mod_attrs)) 256 result['update']['failed'].append(dn) 257 258 except ldap.NO_SUCH_OBJECT: 259 if options.updateonly==True: 260 continue 261 262 try: 263 self.con.add_s(dn,ldap.modlist.addModlist(entry,self.junk_attrs)) 264 self.notify_created(dn) 265 result['add']['ok'].append(dn) 266 except (ldap.OBJECT_CLASS_VIOLATION,ldap.NO_SUCH_OBJECT): 267 self.logger.warning("failed to add {}".format(dn)) 268 result['add']['failed'].append(dn) 269 return result 270 271 272 def __deleteDestLdapObjects(self, update_objects): 273 """ 274 Remove all LDAP objects in destination LDAP server 275 that did not come from the source LDAP objects 276 and are not excluded. 277 """ 278 279 result = { 280 'delete': { 'ok': [], 'failed': [] }, 281 } 282 283 searchresult=self.con.search_s(self.destbasedn,ldap.SCOPE_SUBTREE,self.options.filter) 284 existing=[ x[0].lower() for x in searchresult ] 201 285 202 286 morituri=existing 203 287 204 if destbasedn.lower() in existing:205 morituri.remove( destbasedn.lower())288 if self.destbasedn.lower() in existing: 289 morituri.remove(self.destbasedn.lower()) 206 290 207 291 for o in update_objects: … … 210 294 morituri.remove(dn.lower()) 211 295 for dn in morituri: 212 if options.exclude != None and dn.lower().endswith(options.exclude): 213 # print "ignoring",dn 214 continue 215 216 try: 217 con.delete_s(dn) 218 except: 219 print "failed to delete",dn 220 221 notify_deleted(dn) 222 if options.verbose: 223 print dn,"deleted" 224 deleted=deleted+1 296 if self.__is_dn_included(dn): 297 try: 298 self.con.delete_s(dn) 299 self.notify_deleted(dn) 300 result['delete']['ok'].append(dn) 301 except: 302 self.logger.error("failed to delete {}".format(dn)) 303 result['delete']['failed'].append(dn) 304 return result 305 306 307 def sync(self, searchresult): 308 """ 309 Synchronize entries from searchresult to destination LDAP server. 310 """ 311 result = { 312 'add': { 'ok': [], 'failed': [] }, 313 'update': { 'ok': [], 'failed': [] }, 314 'delete': { 'ok': [], 'failed': [] }, 315 } 316 317 if len(searchresult)==0: 318 self.logger.error("empty source, aborting") 319 return 320 321 self.__ldap_connect() 322 323 update_objects = self.__adapt_source_ldap_objects(searchresult) 324 result = self.__syncLdapDestination(update_objects) 325 if self.options.delete==True and self.options.updateonly==False: 326 result.update(self.__deleteDestLdapObjects(update_objects)) 327 self.con.unbind() 225 328 226 con.unbind() 227 print good,"entries created,",exist,"updated,",deleted,"deleted,",failed,"failed." 329 self.__log_summary(result, True) 330 331 332 333 def __log_summary(self, result, show_failed = True, show_ok = False): 334 for action in result.keys(): 335 ok = len(result[action]['ok']) 336 failed = len(result[action]['failed']) 337 print "{} (ok: {}, failed: {}):".format(action, ok, failed) 338 339 if show_ok and ok > 0: 340 print "succeeded:" 341 print "\n".join(result[action]['ok']) 342 343 if show_failed and failed > 0: 344 print "failed:" 345 print "\n".join(result[action]['failed']) 346 347 348 def notify_created(self, dn): 349 print "created",dn 350 351 def notify_modified(self, dn): 352 print "modified",dn 353 354 def notify_deleted(self, dn): 355 print "deleted",dn 356 357 def notify_renamed(self, dn, newdn, uid, newuid, options): 358 print "renamed",dn,newdn 359 subprocess.check_call("%s %s %s %s %s" % (options.renamecommand,dn,newdn,uid,newuid),shell=True) 360 361 228 362 229 363 230 364 if __name__ == "__main__": 231 conffile="ldapsync.conf" 232 filter = None 365 logging.basicConfig(format='%(levelname)s %(module)s.%(funcName)s: %(message)s', level=logging.INFO) 366 logger = logging.getLogger() 367 368 args=getArguments() 369 if args.debug: 370 logger.setLevel(logging.DEBUG) 371 conffile=args.configfile 372 233 373 exclude = None 234 if len(sys.argv)>1: 235 conffile=sys.argv[1] 236 237 config=ConfigParser.ConfigParser() 374 375 config=ConfigParserDefaults() 238 376 config.read(conffile) 239 377 … … 245 383 246 384 basedn = config.get("source","baseDn") 385 filter = config.get0("source","filter", None) 386 attrs = config.get0("source", "attributes", None) 247 387 248 388 if srcfile==None: … … 250 390 admindn = config.get("source","bindDn") 251 391 adminpw = config.get("source","bindPassword") 252 filter = config.get("source","filter")253 392 starttls = config.getboolean("source","starttls") 254 255 if filter==None:256 filter = '(objectClass=*)'257 258 options = Options()259 260 try:261 options.exclude = config.get("destination","excludesubtree").lower()262 except:263 pass264 393 265 394 destsrv = config.get("destination","server") … … 268 397 destbasedn = config.get("destination","baseDn") 269 398 destdelete = config.getboolean("destination","delete") 270 rdn = config.get("destination","rdn")271 272 399 try: 273 options.updateonly = not config.getboolean("destination","create") 400 rdn = config.get("destination","rdn") 401 logger.warning("setting rdn is currently ignored") 274 402 except: 275 options.updateonly = False 276 options.starttls = config.getboolean("destination","starttls") 403 pass 404 405 options = Options() 406 try: 407 options.exclude = config.get("destination","excludesubtree").lower() 408 except: 409 pass 410 411 options.updateonly = not config.getboolean("destination","create", False) 412 options.starttls = config.getboolean("destination","starttls", False) 413 options.renameattr = config.get0("destination","detectRename", None) 414 options.renamecommand = config.get0("destination","detectRename", None) 415 options.pwd_max_days = int(config.get("source","pwd_max_days",0)) 416 options.filter = filter 417 418 277 419 try: 278 420 options.attrfilter = config.get("destination","attributes").split(",") … … 280 422 options.attrfilter = None 281 423 282 try:283 options.renameattr = config.get("destination","detectRename")284 except:285 options.renameattr = None286 287 try:288 options.renamecommand = config.get("destination","detectRename")289 except:290 options.renamecommand = None291 292 424 if srcfile: 293 result= readLDIFSource(srcfile)425 objects = readLDIFSource(srcfile) 294 426 else: 295 result = readLdapSource(srv,admindn,adminpw,basedn,filter,starttls) 296 297 try: 298 pwd_max_days = int(config.get("source","pwd_max_days")) 299 except: 300 pwd_max_days = 0 301 302 syncLdapDestination(result,destsrv,destadmindn,destadminpw,basedn,destbasedn,rdn,options) 427 objects = readLdapSource(srv,admindn,adminpw,basedn,filter,attrs,starttls) 428 429 ldapsync = LdapSync(destsrv,destadmindn,destadminpw,basedn,destbasedn,options) 430 ldapsync.sync(objects)
Note:
See TracChangeset
for help on using the changeset viewer.