source: dassldapsync/dassldapsync.py@ 1260

Last change on this file since 1260 was 1260, checked in by joergs, on May 3, 2022 at 7:55:52 PM

adapted a bit for Python 2, but still not runnable.

  • Property svn:executable set to *
File size: 23.2 KB
Line 
1#!/usr/bin/python
2# vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4
3
4from __future__ import print_function
5
6# core modules
7import argparse
8try:
9 from configparser import ConfigParser
10except ImportError:
11 from ConfigParser import ConfigParser
12import logging
13from pprint import pprint
14import signal
15import subprocess
16import sys
17import time
18
19# external modules
20import datetime
21import dateutil.parser
22import dateutil.tz
23import ldap
24from ldap.ldapobject import ReconnectLDAPObject
25import ldap.modlist
26from ldap.syncrepl import SyncreplConsumer
27from ldapurl import LDAPUrl
28import ldif
29
30
31
32def getArguments():
33 configfile = '/etc/dassldapsync.conf'
34 parser = argparse.ArgumentParser(description='Synchronize the content of two LDAP servers.')
35 parser.add_argument('-d', '--debug', action='store_true', help="enable debug output")
36 parser.add_argument('configfile', default=configfile,
37 help="Configuration file [default: {}]".format(configfile))
38 return parser.parse_args()
39
40
41class Options(object):
42 def __init__(self):
43 self.delete = True
44 self.starttls = False
45 self.updateonly = False
46 self.filter = None
47 self.attrlist = None
48 self.exclude = None
49 self.renameattr = None
50 self.renamecommand = None
51 self.pwd_max_days = 0
52
53def readLDIFSource(path):
54 logger = logging.getLogger()
55 logger.info("reading LDAP objects from file {}".format(path))
56 with open(path, 'r') as f:
57 parser = ldif.LDIFRecordList(f)
58 parser.parse()
59 result = parser.all_records
60 return result
61
62def readLdapSource(server, binddn, bindpw, basedn, filterstr, attrlist=None, starttls=False):
63 logger = logging.getLogger()
64 logger.info("reading LDAP objects from server {}".format(server))
65 ldapurl = LDAPUrl(hostport="{}:389".format(self.server))
66 con = ldap.initialize(ldapurl. initializeUrl())
67 if starttls:
68 con.start_tls_s()
69 con.simple_bind_s(binddn, bindpw)
70 results = con.search_s(basedn, ldap.SCOPE_SUBTREE, filterstr, attrlist)
71 return results
72
73class LdapSync(object):
74 def __init__(self, destserver,
75 destbinddn, destbindpw,
76 srcbasedn, destbasedn, options=Options()):
77 self.logger = logging.getLogger()
78
79 self.destserver = destserver
80 self.destbasedn = destbasedn
81 self.destbinddn = destbinddn
82 self.destbindpw = destbindpw
83 self.options = options
84
85 self.srcbasedn = srcbasedn
86
87 self.con = None
88
89 self.attrmap = ldap.cidict.cidict({})
90 self.classmap = {}
91
92 #self.junk_objectclasses = [ b"sambaidmapentry" ]
93 #"sambasid",
94 self.junk_objectclasses = []
95 self.junk_attrs = ["authzto",
96 "creatorsname", "createtimestamp", "contextcsn",
97 "entrycsn", "entryuuid",
98 "memberof", "modifiersname", "modifytimestamp",
99 "pwdaccountlockedtime", "pwdchangedtime", "pwdfailuretime",
100 "structuralobjectclass"]
101
102 self.reset_result()
103
104
105 def reset_result(self):
106 self.result = {
107 'add': {'ok': [], 'failed': []},
108 'update': {'ok': [], 'failed': []},
109 'delete': {'ok': [], 'failed': []},
110 }
111
112
113 def _dest_ldap_connect(self):
114 if self.con is None:
115 self.logger.info("connect to destination LDAP server {}".format(self.destserver))
116 ldapurl = LDAPUrl(hostport="{}:389".format(self.destserver))
117 self.con = ldap.initialize(ldapurl. initializeUrl())
118 if self.options.starttls:
119 self.con.start_tls_s()
120 self.con.simple_bind_s(self.destbinddn, self.destbindpw)
121
122 def __adapt_dn(self, dn):
123 # move LDAP object to dest base
124 if self.srcbasedn != self.destbasedn:
125 dn_old = dn
126 rpath = dn[:-len(self.srcbasedn)]
127 dn = rpath+self.destbasedn
128 self.logger.debug("moved {} to {}".format(dn_old, dn))
129 # print "dn:",dn,"src:",srcbasedn,"rpath:",rpath,"dest:",destbasedn
130 return dn
131
132 def __is_dn_included(self, dn):
133 if self.options.exclude is None:
134 return True
135 if dn.lower().endswith(self.options.exclude):
136 return False
137 return True
138
139 def __adapt_source_ldap_objects(self, searchresult):
140 """
141 Do configured modification to the source LDAP objects.
142 """
143 self.logger.debug("modifying LDAP objects retrieved from source LDAP")
144
145 update_objects = []
146
147 for r in searchresult:
148 dn = self.__adapt_dn(r[0])
149 d = ldap.cidict.cidict(r[1])
150
151 if self.__is_dn_included(dn):
152 objectclasses = d["objectclass"]
153
154 newObjectclasses = []
155 for o in objectclasses:
156 if o.lower() in self.classmap:
157 new_oc = self.classmap[o.lower()]
158 if new_oc not in newObjectclasses:
159 newObjectclasses.append(new_oc)
160 else:
161 if o not in newObjectclasses:
162 newObjectclasses.append(o)
163
164 d["objectclass"] = newObjectclasses
165
166 for a in d.keys():
167 attr = a
168 if self.attrmap.has_key(a.lower()):
169 attr = self.attrmap[attr].lower()
170 if attr.lower() != a.lower():
171 values = d[a]
172 del d[a]
173 d[attr] = values
174
175 update_objects.append((dn, d))
176 return update_objects
177
178
179 def _get_dest_entry(self, dn, entry):
180 """
181 In the destination LDAP, the objects should be named
182 according to options.renameattr.
183 """
184 attrlist = self.options.attrlist
185
186 existingDestDn = None
187 existingDestEntry = None
188 if self.options.renameattr and entry.has_key(self.options.renameattr):
189 searchresult = self.con.search_s(
190 self.destbasedn,
191 ldap.SCOPE_SUBTREE,
192 '%s=%s' % (self.options.renameattr, entry[self.options.renameattr][0]), attrlist)
193 if searchresult is not None and len(searchresult) > 0:
194 existingDestDn, existingDestEntry = searchresult[0]
195 if existingDestDn.lower() != dn.lower():
196 self.con.modrdn_s(existingDestDn, dn)
197 self.notify_renamed(existingDestDn, dn,
198 existingDestEntry[self.options.renameattr][0],
199 entry[self.options.renameattr][0],
200 options)
201 if existingDestDn is None:
202 searchresult = self.con.search_s(dn, ldap.SCOPE_BASE, 'objectclass=*', attrlist)
203 existingDestDn, existingDestEntry = searchresult[0]
204 return (existingDestDn, existingDestEntry)
205
206
207 def __handle_pwdAccountLockedTime(self, dn, entry, now, max_age):
208 # hack for syncing accounts locked by password policy
209 do_unlock = False
210 if self.options.pwd_max_days > 0 and entry.has_key('pwdChangedTime'):
211 # print "pwdChangedTime set for",dn
212 pwdChange = entry['pwdChangedTime'][0]
213 d = dateutil.parser.parse(pwdChange)
214 if (now-d) > max_age:
215 entry['pwdAccountLockedTime'] = ['000001010000Z']
216 self.logger.info("locking {} {}".format(dn, pwdChange))
217 else:
218 # pwdAccountLockedTime is a operational attribute,
219 # and therefore not part of entry.
220 # Do extra search to retrieve attribute.
221 searchresult = self.con.search_s(
222 dn, ldap.SCOPE_BASE,
223 "objectclass=*", attrlist=['pwdAccountLockedTime'])
224 tmp_dn, tmp_entry = searchresult[0]
225 if tmp_entry.has_key('pwdAccountLockedTime'):
226 do_unlock = True
227 return do_unlock
228
229
230 def _syncLdapObject(self, srcDn, srcAttributes):
231 tzutc = dateutil.tz.gettz('UTC')
232 now = datetime.datetime.now(tzutc)
233 max_age = datetime.timedelta(days=self.options.pwd_max_days)
234
235 objectClasses = srcAttributes['objectClass']
236 srcAttributes['objectClass'] = [oc for oc in objectClasses if oc.lower() not in self.junk_objectclasses]
237
238 try:
239 destDn, destAttributes = self._get_dest_entry(srcDn, srcAttributes)
240
241 # hack for syncing accounts locked by password policy
242 do_unlock = self.__handle_pwdAccountLockedTime(srcDn, srcAttributes, now, max_age)
243
244 mod_attrs = ldap.modlist.modifyModlist(destAttributes, srcAttributes)
245
246 # hack for unlocking, see above
247 if do_unlock:
248 self.logger.info("unlocking {} {}".format(destDn, 'pwdAccountLockedTime'))
249 mod_attrs.append((ldap.MOD_DELETE, 'pwdAccountLockedTime', None))
250
251 if self.options.attrlist is not None:
252 mod_attrs = [a for a in mod_attrs if a[1].lower() in self.options.attrlist]
253
254 if self.junk_attrs is not None:
255 mod_attrs = [a for a in mod_attrs if a[1].lower() not in self.junk_attrs]
256
257 if mod_attrs:
258 try:
259 self.logger.debug('mod_attrs: ' + str(mod_attrs))
260 self.con.modify_s(srcDn, mod_attrs)
261 self.notify_modified(srcDn)
262 except:
263 self.logger.exception('modify failed')
264 self.notify_modified(srcDn, False)
265 else:
266 self.notify_unchanged(srcDn)
267
268 except ldap.NO_SUCH_OBJECT:
269 if not self.options.updateonly:
270 try:
271 entry = ldap.modlist.addModlist(srcAttributes, self.junk_attrs)
272 self.con.add_s(srcDn, entry)
273 self.notify_created(srcDn)
274 except (ldap.OBJECT_CLASS_VIOLATION,
275 ldap.NO_SUCH_OBJECT,
276 ldap.CONSTRAINT_VIOLATION) as e:
277 #print(e)
278 self.notify_created(srcDn, False)
279
280
281 def __syncLdapDestination(self, update_objects):
282
283 logger.debug("writing data to destination LDAP")
284 for obj in update_objects:
285 dn, entry = obj
286 self._syncLdapObject(dn, entry)
287
288
289 def __deleteDestLdapObjects(self, update_objects):
290 """
291 Remove all LDAP objects in destination LDAP server
292 that did not come from the source LDAP objects
293 and are not excluded.
294 """
295
296 searchresult = self.con.search_s(self.destbasedn, ldap.SCOPE_SUBTREE, self.options.filter)
297 existing = [x[0].lower() for x in searchresult]
298
299 morituri = existing
300
301 if self.destbasedn.lower() in existing:
302 morituri.remove(self.destbasedn.lower())
303
304 for obj in update_objects:
305 dn, entry = obj
306 if dn.lower() in existing:
307 morituri.remove(dn.lower())
308 for dn in morituri:
309 if self.__is_dn_included(dn):
310 try:
311 self.con.delete_s(dn)
312 self.notify_deleted(dn)
313 except:
314 self.notify_deleted(dn, False)
315
316
317 def sync(self, searchresult):
318 """
319 Synchronize entries from searchresult to destination LDAP server.
320 """
321 if len(searchresult) == 0:
322 self.logger.error("empty source, aborting")
323 return
324
325 self._dest_ldap_connect()
326
327 update_objects = self.__adapt_source_ldap_objects(searchresult)
328 self.__syncLdapDestination(update_objects)
329 if self.options.delete and not self.options.updateonly:
330 self.__deleteDestLdapObjects(update_objects)
331 self.con.unbind()
332
333 self.__log_summary(True)
334
335
336 def __log_summary(self, show_failed=True, show_ok=False):
337 result = self.result
338 for action in result.keys():
339 ok = len(result[action]['ok'])
340 failed = len(result[action]['failed'])
341 print("{} (ok: {}, failed: {}):".format(action, ok, failed))
342
343 if show_ok and ok > 0:
344 print("succeeded:")
345 print("\n".join(result[action]['ok']))
346
347 if show_failed and failed > 0:
348 print("failed:")
349 print("\n".join(result[action]['failed']))
350
351 def get_short_dn(self, dn):
352 return dn.lower().replace(',' + self.srcbasedn.lower(), '')
353
354 def notify_unchanged(self, dn):
355 logger.debug(u'{} unchanged'.format(self.get_short_dn(dn)))
356
357 def notify_created(self, dn, ok=True):
358 if ok:
359 logger.debug(u'{} created'.format(self.get_short_dn(dn)))
360 self.result['add']['ok'].append(dn)
361 else:
362 self.logger.warning(u"failed to add {}".format(dn))
363 self.result['add']['failed'].append(dn)
364
365 def notify_modified(self, dn, ok=True):
366 if ok:
367 logger.debug(u'{} modified'.format(self.get_short_dn(dn)))
368 self.result['update']['ok'].append(dn)
369 else:
370 self.logger.error(u"failed to modify {}".format(dn))
371 self.result['update']['failed'].append(dn)
372
373 def notify_deleted(self, dn, ok=True):
374 if ok:
375 logger.debug(u'{} deleted'.format(self.get_short_dn(dn)))
376 self.result['delete']['ok'].append(dn)
377 else:
378 self.logger.error(u"failed to delete {}".format(dn))
379 self.result['delete']['failed'].append(dn)
380
381 def notify_renamed(self, dn, newdn, uid, newuid, options):
382 print(u"renamed {} -> {}".format(dn, newdn))
383 subprocess.check_call(
384 "%s %s %s %s %s" % (options.renamecommand, dn, newdn, uid, newuid),
385 shell=True)
386
387
388
389class SyncReplConsumer(ReconnectLDAPObject, SyncreplConsumer):
390 """
391 Syncrepl Consumer interface
392 """
393
394 def __init__(self, dest, syncrepl_entry_callback, *args, **kwargs):
395 self.logger = logging.getLogger()
396 # Initialise the LDAP Connection first
397 ldap.ldapobject.ReconnectLDAPObject.__init__(self, *args, **kwargs)
398 # We need this for later internal use
399 self.__presentUUIDs = dict()
400 self.cookie = None
401 self.dest_ldap = dest
402 self.syncrepl_entry_callback = syncrepl_entry_callback
403
404 def syncrepl_get_cookie(self):
405 return self.cookie
406
407 def syncrepl_set_cookie(self, cookie):
408 self.cookie = cookie
409
410 def syncrepl_entry(self, dn, attributes, uuid):
411 # First we determine the type of change we have here
412 # (and store away the previous data for later if needed)
413 if uuid in self.__presentUUIDs:
414 change_type = 'modify'
415 else:
416 change_type = 'add'
417 # Now we store our knowledge of the existence of this entry
418 self.__presentUUIDs[uuid] = dn
419 # Debugging
420 logger.debug('{}: {} ({})'.format(dn, change_type, ",".join(attributes.keys())))
421 # If we have a cookie then this is not our first time being run,
422 # so it must be a change
423 if self.cookie is not None:
424 self.syncrepl_entry_callback(dn, attributes)
425
426
427 def syncrepl_delete(self, uuids):
428 """ syncrepl_delete """
429 # Make sure we know about the UUID being deleted, just in case...
430 uuids = [uuid for uuid in uuids if uuid in self.__presentUUIDs]
431 # Delete all the UUID values we know of
432 for uuid in uuids:
433 logger.debug('detected deletion of entry {} ({})', uuid, self.__presentUUIDs[uuid])
434 del self.__presentUUIDs[uuid]
435
436 def syncrepl_present(self, uuids, refreshDeletes=False):
437 """ called on initial sync """
438 if uuids is not None:
439 self.logger.debug('uuids: {}'.format(','.join(uuids)))
440 # If we have not been given any UUID values,
441 # then we have recieved all the present controls...
442 if uuids is None:
443 # We only do things if refreshDeletes is false as the syncrepl
444 # extension will call syncrepl_delete instead when it detects a
445 # delete notice
446 if not refreshDeletes:
447 deletedEntries = [
448 uuid for uuid in self.__presentUUIDs
449 ]
450 self.syncrepl_delete(deletedEntries)
451 # Phase is now completed, reset the list
452 self.__presentUUIDs = {}
453 else:
454 # Note down all the UUIDs we have been sent
455 for uuid in uuids:
456 self.__presentUUIDs[uuid] = True
457
458
459 def syncrepl_refreshdone(self):
460 self.logger.info('Initial synchronization is now done, persist phase begins')
461 #self.logger.debug('UUIDs:\n' + '\n'.join(self.__presentUUIDs))
462
463
464
465class LdapSyncRepl(LdapSync):
466 def __init__(self, destsrv,
467 destadmindn, destadminpw,
468 basedn, destbasedn,
469 options=Options(), source_ldap_url_obj=None):
470 # Install our signal handlers
471 signal.signal(signal.SIGTERM, self.shutdown)
472 self.watcher_running = False
473 self.source_ldap_url_obj = source_ldap_url_obj
474 self.ldap_credentials = False
475 self.source_ldap_connection = None
476 super(LdapSyncRepl, self).__init__(destsrv,
477 destadmindn, destadminpw,
478 basedn, destbasedn, options)
479
480
481 def sync(self):
482 self._dest_ldap_connect()
483 self.watcher_running = True
484 while self.watcher_running:
485 self.logger.info('Connecting to source LDAP server')
486 # Prepare the LDAP server connection (triggers the connection as well)
487 self.source_ldap_connection = SyncReplConsumer(self.con,
488 self.perform_application_sync_callback,
489 self.source_ldap_url_obj.initializeUrl())
490
491 if self.source_ldap_url_obj.who and self.source_ldap_url_obj.cred:
492 self.ldap_credentials = True
493 # Now we login to the LDAP server
494 try:
495 self.source_ldap_connection.simple_bind_s(
496 self.source_ldap_url_obj.who, self.source_ldap_url_obj.cred)
497 except ldap.INVALID_CREDENTIALS as e:
498 print('Login to LDAP server failed: ', str(e))
499 sys.exit(1)
500 except ldap.SERVER_DOWN:
501 print('LDAP server is down, going to retry.')
502 time.sleep(5)
503 continue
504
505 # Commence the syncing
506 self.logger.info('Staring sync process')
507 ldap_search = self.source_ldap_connection.syncrepl_search(
508 self.source_ldap_url_obj.dn or '',
509 self.source_ldap_url_obj.scope or ldap.SCOPE_SUBTREE,
510 mode='refreshAndPersist',
511 attrlist=self.source_ldap_url_obj.attrs,
512 filterstr=self.source_ldap_url_obj.filterstr or '(objectClass=*)'
513 )
514
515 try:
516 while self.source_ldap_connection.syncrepl_poll(all=1, msgid=ldap_search):
517 print(".", end="")
518 except KeyboardInterrupt:
519 # User asked to exit
520 print("aborted\n")
521 self.shutdown(None, None)
522 except Exception as e:
523 # Handle any exception
524 if self.watcher_running:
525 self.logger.exception('Encountered a problem, going to retry.')
526 time.sleep(5)
527
528 def perform_application_sync_callback(self, dn, attributes):
529 logger.debug('{}: src: {}'.format(dn, str(attributes)))
530 try:
531 self._syncLdapObject(dn, attributes)
532 except ldap.NO_SUCH_OBJECT:
533 self.logger.info("SKIPPED: {} object does not exist on target".format(dn))
534 return False
535 return True
536
537 def shutdown(self, signum, stack):
538 # Declare the needed global variables
539 self.logger.info('Shutting down!')
540
541 # We are no longer running
542 self.watcher_running = False
543
544def get_ldap_url_obj(self, configsection):
545 baseurl = 'ldap://{server}:389/{basedn}'.format(server=configsection.get('server'), basedn=configsection.get('basedn'))
546 attrs = None
547 if configsection.get('attributes') is not None:
548 attrs = configsection.get('attributes').split(',')
549 return LDAPUrl(
550 baseurl,
551 dn=configsection.get('baseDn'),
552 who=configsection.get('bindDn'),
553 cred=configsection.get('basePassword'),
554 filterstr=configsection.get('filter'),
555 attrs=attrs
556 )
557
558
559if __name__ == "__main__":
560 logging.basicConfig(format='%(levelname)s %(module)s.%(funcName)s: %(message)s', level=logging.INFO)
561 logger = logging.getLogger()
562
563 args = getArguments()
564 if args.debug:
565 logger.setLevel(logging.DEBUG)
566 conffile = args.configfile
567
568 exclude = None
569
570 config = ConfigParser()
571 config.read(conffile)
572
573 srcfile = None
574 try:
575 srcfile = config.get("source", "file")
576 except:
577 pass
578
579 basedn = config.get("source", "baseDn")
580 filterstr = config.get("source", "filter", fallback=None)
581
582 if srcfile is None:
583 srv = config.get("source", "server")
584 admindn = config.get("source", "bindDn")
585 adminpw = config.get("source", "bindPassword")
586 starttls = config.getboolean("source", "starttls")
587
588 destsrv = config.get("destination", "server")
589 destadmindn = config.get("destination", "bindDn")
590 destadminpw = config.get("destination", "bindPassword")
591 destbasedn = config.get("destination", "baseDn")
592 try:
593 rdn = config.get("destination", "rdn")
594 logger.warning("setting rdn is currently ignored")
595 except:
596 pass
597
598 options = Options()
599 try:
600 options.exclude = config.get("destination", "excludesubtree").lower()
601 except:
602 pass
603
604 options.updateonly = not config.getboolean("destination", "create", fallback=False)
605 options.delete = config.getboolean("destination", "delete", fallback=False)
606 options.starttls = config.getboolean("destination", "starttls", fallback=False)
607 options.renameattr = config.get("destination", "detectRename", fallback=None)
608 options.renamecommand = config.get("destination", "detectRename", fallback=None)
609 options.pwd_max_days = int(config.get("source", "pwd_max_days", fallback=0))
610 options.filter = filterstr
611
612 # Set source.attrlist as global option.
613 # If source would use less attributes than dest,
614 # all attributes not retrieved from source would be deleted from dest
615 try:
616 options.attrlist = config.get("source", "attributes").split(",")
617 except:
618 options.attrlist = None
619
620 if config.get('source', 'mode', fallback=None) == 'syncrepl':
621 ldapsync = LdapSyncRepl(
622 destsrv, destadmindn, destadminpw, basedn, destbasedn,
623 options,
624 source_ldap_url_obj=get_ldap_url_obj(config['source']))
625 ldapsync.sync()
626 else:
627 if srcfile:
628 objects = readLDIFSource(srcfile)
629 else:
630 objects = readLdapSource(srv, admindn, adminpw,
631 basedn, filterstr, options.attrlist, starttls)
632
633 ldapsync = LdapSync(destsrv, destadmindn, destadminpw, basedn, destbasedn, options)
634 ldapsync.sync(objects)
Note: See TracBrowser for help on using the repository browser.