1 | #!/usr/bin/python
|
---|
2 | # vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4
|
---|
3 |
|
---|
4 | import argparse
|
---|
5 | import ConfigParser
|
---|
6 | import datetime
|
---|
7 | import dateutil.parser
|
---|
8 | import dateutil.tz
|
---|
9 | import ldap
|
---|
10 | import ldap.modlist
|
---|
11 | import ldif
|
---|
12 | import logging
|
---|
13 | import os
|
---|
14 | import sys
|
---|
15 |
|
---|
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
|
---|
23 |
|
---|
24 |
|
---|
25 | class 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 |
|
---|
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
|
---|
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 |
|
---|
65 | def 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 |
|
---|
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))
|
---|
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 |
|
---|
84 | class 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 |
|
---|
360 | if __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)
|
---|