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
Line 
1#!/usr/bin/python
2# vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4
3
4import argparse
5import ConfigParser
6import datetime
7import dateutil.parser
8import dateutil.tz
9import ldap
10import ldap.modlist
11import ldif
12import logging
13import os
14import sys
15
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
23
24
25class Options:
26 def __init__(self):
27 self.delete=True
28 self.starttls=False
29 self.updateonly=False
30 self.filter=None
31 self.attrfilter=None
32 self.exclude=None
33 self.renameattr=None
34 self.renamecommand=None
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
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
64
65def readLDIFSource(path):
66 logger = logging.getLogger()
67 logger.info("reading LDAP objects from file {}".format(path))
68 with open(path,'r') as f:
69 parser = ldif.LDIFRecordList(f)
70 parser.parse()
71 result = parser.all_records
72 return result
73
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))
77 con = ldap.open(server,port=389)
78 if starttls:
79 con.start_tls_s()
80 con.simple_bind_s(binddn,bindpw)
81 results=con.search_s(basedn,ldap.SCOPE_SUBTREE,filter,attrs)
82 return results
83
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
95
96 self.con = None
97
98 self.attrmap=ldap.cidict.cidict({
99 })
100 self.classmap={
101 }
102
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))
202 else:
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)
230 try:
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 ]
285
286 morituri=existing
287
288 if self.destbasedn.lower() in existing:
289 morituri.remove(self.destbasedn.lower())
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:
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()
328
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
362
363
364if __name__ == "__main__":
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
373 exclude = None
374
375 config=ConfigParserDefaults()
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")
385 filter = config.get0("source","filter", None)
386 attrs = config.get0("source", "attributes", None)
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:
400 rdn = config.get("destination","rdn")
401 logger.warning("setting rdn is currently ignored")
402 except:
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
419 try:
420 options.attrfilter = config.get("destination","attributes").split(",")
421 except:
422 options.attrfilter = None
423
424 if srcfile:
425 objects = readLDIFSource(srcfile)
426 else:
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 TracBrowser for help on using the repository browser.