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
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(object):
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 self.classmap={}
100
101 self.junk_attrs = [ "memberof", "modifiersname", "modifytimestamp", "entryuuid", "entrycsn", "contextcsn", "creatorsname", "createtimestamp", "structuralobjectclass", "pwdchangedtime", "pwdfailuretime" ]
102
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)
110
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
120
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
127
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")
133
134 update_objects=[]
135
136 for r in searchresult:
137 dn = self.__adapt_dn(r[0])
138 d=ldap.cidict.cidict(r[1])
139
140 if self.__is_dn_included(dn):
141 objectclasses=d["objectclass"]
142
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))
200 else:
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
210
211
212
213 def __syncLdapDestination(self, update_objects):
214
215 result = {
216 'add': { 'ok': [], 'failed': [] },
217 'update': { 'ok': [], 'failed': [] },
218 }
219
220 tzutc = dateutil.tz.gettz('UTC')
221 now = datetime.datetime.now(tzutc)
222 max_age = datetime.timedelta(days=self.options.pwd_max_days)
223
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)
230
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)
235
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) )
240
241 if self.options.attrfilter is not None:
242 mod_attrs=[ a for a in mod_attrs if a[1].lower() in options.attrfilter]
243
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]
246
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)
255
256 except ldap.NO_SUCH_OBJECT:
257 if options.updateonly==True:
258 continue
259
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)
264 except (ldap.OBJECT_CLASS_VIOLATION, ldap.NO_SUCH_OBJECT, ldap.CONSTRAINT_VIOLATION):
265 self.logger.warning("failed to add {}".format(dn))
266 result['add']['failed'].append(dn)
267 return result
268
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 """
276
277 result = {
278 'delete': { 'ok': [], 'failed': [] },
279 }
280
281 searchresult=self.con.search_s(self.destbasedn,ldap.SCOPE_SUBTREE,self.options.filter)
282 existing=[ x[0].lower() for x in searchresult ]
283
284 morituri=existing
285
286 if self.destbasedn.lower() in existing:
287 morituri.remove(self.destbasedn.lower())
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:
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
304
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 }
314
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()
326
327 self.__log_summary(result, True)
328
329
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
360if __name__ == "__main__":
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
369 exclude = None
370
371 config=ConfigParserDefaults()
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")
381 filter = config.get0("source","filter", None)
382 attrs = config.get0("source", "attributes", None)
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:
396 rdn = config.get("destination","rdn")
397 logger.warning("setting rdn is currently ignored")
398 except:
399 pass
400
401 options = Options()
402 try:
403 options.exclude = config.get("destination","excludesubtree").lower()
404 except:
405 pass
406
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
415 try:
416 options.attrfilter = config.get("destination","attributes").split(",")
417 except:
418 options.attrfilter = None
419
420 if srcfile:
421 objects = readLDIFSource(srcfile)
422 else:
423 objects = readLdapSource(srv,admindn,adminpw,basedn,filter,attrs,starttls)
424
425 ldapsync = LdapSync(destsrv,destadmindn,destadminpw,basedn,destbasedn,options)
426 ldapsync.sync(objects)
Note: See TracBrowser for help on using the repository browser.