source: dassldapsync/dassldapsync.py@ 1220

Last change on this file since 1220 was 1220, checked in by joergs, on Nov 15, 2016 at 11:28:19 AM

cleanup

  • Property svn:executable set to *
File size: 14.9 KB
RevLine 
[1218]1#!/usr/bin/python
2# vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4
3
[1219]4import argparse
5import ConfigParser
6import datetime
7import dateutil.parser
8import dateutil.tz
[1218]9import ldap
[1219]10import ldap.modlist
[1218]11import ldif
[1219]12import logging
[1218]13import os
14import sys
15
[1219]16def 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]25class 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
37class 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
65def 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]74def 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]84class 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]360if __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)
Note: See TracBrowser for help on using the repository browser.