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:
|
---|
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 |
|
---|
364 | if __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)
|
---|