source: dassldapsync/dassldapsync.py@ 1219

Last change on this file since 1219 was 1219, checked in by joergs, on Nov 8, 2016 at 5:39:27 PM

code cleanup, without changing the functionality

(at least hopefully. Tested normal synchronisation between 2 LDAP
servers (delete=true, create=true) but without further rewritting of
LDAP entries.

  • 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
[1219]84class 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
[1218]95
[1219]96 self.con = None
97
98 self.attrmap=ldap.cidict.cidict({
[1218]99 })
[1219]100 self.classmap={
[1218]101 }
102
[1219]103 self.junk_attrs = [ "memberof", "modifiersname", "modifytimestamp", "entryuuid", "entrycsn", "contextcsn", "creatorsname", "createtimestamp", "structuralobjectclass", "pwdchangedtime", "pwdfailuretime" ]
[1218]104
[1219]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)
[1218]112
[1219]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
[1218]122
[1219]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
[1218]129
[1219]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")
[1218]135
[1219]136 update_objects=[]
137
138 for r in searchresult:
139 dn = self.__adapt_dn(r[0])
140 d=ldap.cidict.cidict(r[1])
[1218]141
[1219]142 if self.__is_dn_included(dn):
143 objectclasses=d["objectclass"]
[1218]144
[1219]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))
[1218]202 else:
[1219]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
[1218]212
213
214
[1219]215 def __syncLdapDestination(self, update_objects):
216
217 result = {
218 'add': { 'ok': [], 'failed': [] },
219 'update': { 'ok': [], 'failed': [] },
220 }
[1218]221
[1219]222 tzutc = dateutil.tz.gettz('UTC')
223 now = datetime.datetime.now(tzutc)
224 max_age = datetime.timedelta(days=self.options.pwd_max_days)
[1218]225
[1219]226 logger.debug("writing data to destination LDAP")
227 for o in update_objects:
228 dn,entry=o
229 #logger.debug(dn)
230 try:
231 destDn,destEntry=self.__get_dest_entry(dn, entry)
[1218]232
[1219]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)
[1218]237
[1219]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) )
[1218]242
[1219]243 if self.options.attrfilter is not None:
244 mod_attrs=[ a for a in mod_attrs if a[1].lower() in options.attrfilter]
[1218]245
[1219]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]
[1218]248
[1219]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)
[1218]257
[1219]258 except ldap.NO_SUCH_OBJECT:
259 if options.updateonly==True:
260 continue
[1218]261
[1219]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
[1218]270
[1219]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 """
[1218]278
[1219]279 result = {
280 'delete': { 'ok': [], 'failed': [] },
281 }
[1218]282
[1219]283 searchresult=self.con.search_s(self.destbasedn,ldap.SCOPE_SUBTREE,self.options.filter)
284 existing=[ x[0].lower() for x in searchresult ]
[1218]285
286 morituri=existing
287
[1219]288 if self.destbasedn.lower() in existing:
289 morituri.remove(self.destbasedn.lower())
[1218]290
291 for o in update_objects:
292 dn,entry=o
293 if dn.lower() in existing:
294 morituri.remove(dn.lower())
295 for dn in morituri:
[1219]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
[1218]306
[1219]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 }
[1218]316
[1219]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()
[1218]328
[1219]329 self.__log_summary(result, True)
[1218]330
331
[1219]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
362
363
[1218]364if __name__ == "__main__":
[1219]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
[1218]373 exclude = None
374
[1219]375 config=ConfigParserDefaults()
[1218]376 config.read(conffile)
377
378 srcfile = None
379 try:
380 srcfile = config.get("source","file")
381 except:
382 pass
383
384 basedn = config.get("source","baseDn")
[1219]385 filter = config.get0("source","filter", None)
386 attrs = config.get0("source", "attributes", None)
[1218]387
388 if srcfile==None:
389 srv = config.get("source","server")
390 admindn = config.get("source","bindDn")
391 adminpw = config.get("source","bindPassword")
392 starttls = config.getboolean("source","starttls")
393
394 destsrv = config.get("destination","server")
395 destadmindn = config.get("destination","bindDn")
396 destadminpw = config.get("destination","bindPassword")
397 destbasedn = config.get("destination","baseDn")
398 destdelete = config.getboolean("destination","delete")
399 try:
[1219]400 rdn = config.get("destination","rdn")
401 logger.warning("setting rdn is currently ignored")
[1218]402 except:
[1219]403 pass
[1218]404
[1219]405 options = Options()
[1218]406 try:
[1219]407 options.exclude = config.get("destination","excludesubtree").lower()
[1218]408 except:
[1219]409 pass
[1218]410
[1219]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
[1218]419 try:
[1219]420 options.attrfilter = config.get("destination","attributes").split(",")
[1218]421 except:
[1219]422 options.attrfilter = None
[1218]423
424 if srcfile:
[1219]425 objects = readLDIFSource(srcfile)
[1218]426 else:
[1219]427 objects = readLdapSource(srv,admindn,adminpw,basedn,filter,attrs,starttls)
[1218]428
[1219]429 ldapsync = LdapSync(destsrv,destadmindn,destadminpw,basedn,destbasedn,options)
430 ldapsync.sync(objects)
Note: See TracBrowser for help on using the repository browser.