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