source: dassldapsync/dassldapsync.py@ 1259

Last change on this file since 1259 was 1259, checked in by joergs, on May 3, 2022 at 7:53:07 PM

adapted to SLES 15 and Python 3. Encoding problems with Python 2

Fixing sisabling deleting

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