Changeset 1219 for dassldapsync


Ignore:
Timestamp:
11/08/16 17:39:27 (5 years ago)
Author:
joergs
Message:

code cleanup, without changing the functionality

(at least hopefully. Tested normal synchronisation between 2 LDAP
servers (delete=true, create=true) but without further rewritting of
LDAP entries.

Location:
dassldapsync
Files:
2 edited

Legend:

Unmodified
Added
Removed
  • dassldapsync/dassldapsync.conf

    r1218 r1219  
    33baseDn=dc=example,dc=com
    44
     5#[source]
     6#server=ldapserver.example.com
     7#baseDn=dc=example,dc=com
     8#bindDn=cn=admin,dc=example,dc=com
     9#bindPassword=secret
     10#filter=(objectclass=*)
     11#attributes=*
     12#starttls=true
     13#pwd_max_days=0
     14
    515[destination]
    6 server=ldapserver
     16server=ldapserver.example.com
    717bindDn=cn=admin,dc=example,dc=com
    818bindPassword=secret
    919baseDn=dc=example,dc=com
     20#attributes=
    1021delete=true
    1122create=true
    1223starttls=false
     24#rdn=
  • dassldapsync/dassldapsync.py

    r1218 r1219  
    22# vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4
    33
     4import argparse
     5import ConfigParser
     6import datetime
     7import dateutil.parser
     8import dateutil.tz
    49import ldap
     10import ldap.modlist
    511import ldif
    6 import ldap.modlist
    7 import ConfigParser
     12import logging
    813import os
    914import sys
    1015
    11 import dateutil.parser
    12 import dateutil.tz
    13 import datetime
     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
    1424
    1525class Options:
     
    1828        self.starttls=False
    1929        self.updateonly=False
     30        self.filter=None
    2031        self.attrfilter=None
    2132        self.exclude=None
    2233        self.renameattr=None
    2334        self.renamecommand=None
    24         self.verbose=False
    25 
    26 def notify_created(dn):
    27     print "notify_created",dn
    28 
    29 def notify_modified(dn):
    30     print "notify_modified",dn
    31 
    32 def notify_deleted(dn):
    33     print "notify_deleted",dn
    34 
    35 def notify_renamed(dn,newdn,uid,newuid,options):
    36     print "notify_renamed",dn,newdn
    37     subprocess.check_call("%s %s %s %s %s" % (options.renamecommand,dn,newdn,uid,newuid),shell=True)
     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
    3864
    3965def readLDIFSource(path):
     66    logger = logging.getLogger()
     67    logger.info("reading LDAP objects from file {}".format(path))
    4068    with open(path,'r') as f:
    4169        parser = ldif.LDIFRecordList(f)
     
    4472    return result
    4573
    46 def readLdapSource(server,binddn,bindpw,basedn,filter,starttls=False):
     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))
    4777    con = ldap.open(server,port=389)
    4878    if starttls:
    4979        con.start_tls_s()
    5080    con.simple_bind_s(binddn,bindpw)
    51     results=con.search_s(basedn,ldap.SCOPE_SUBTREE,filter,None)
     81    results=con.search_s(basedn,ldap.SCOPE_SUBTREE,filter,attrs)
    5282    return results
    5383
    54 def syncLdapDestination(searchresult,destserver,destbinddn,destbindpw,srcbasedn,destbasedn,destrdn,options=Options()):
    55 
    56     attrmap=ldap.cidict.cidict({
     84class 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({
    5799        })
    58     classmap={
     100        self.classmap={
    59101        }
    60102
    61     junk_attrs = [ "memberof", "modifiersname", "modifytimestamp", "entryuuid", "entrycsn", "contextcsn", "creatorsname", "createtimestamp", "structuralobjectclass", "pwdchangedtime", "pwdfailuretime" ]
    62     update_objects=[]
    63 
    64     if len(searchresult)==0:
    65         print "empty source, aborting"
    66         return
    67 
    68     for r in searchresult:
    69         dn=r[0]
    70 
    71         d=ldap.cidict.cidict(r[1])
    72         objectclasses=d["objectclass"]
    73 
    74         newObjectclasses=[]
    75         for o in objectclasses:
    76             if o.lower() in classmap:
    77                     new_oc = classmap[o.lower()]
    78                     if not new_oc in newObjectclasses:
    79                         newObjectclasses.append(new_oc)
     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))
    80202            else:
    81                 #pass
    82                 if not o in newObjectclasses:
    83                     newObjectclasses.append(o)
    84 
    85         d["objectclass"]=newObjectclasses
    86 
    87         rpath = dn[:-len(srcbasedn)]
    88         # print "dn:",dn,"src:",srcbasedn,"rpath:",rpath,"dest:",destbasedn
    89 
    90         for a in d.keys():
    91             attr=a
    92             if attrmap.has_key(a.lower()):
    93                 attr=attrmap[attr].lower()
    94                 if attr.lower()!=a.lower():
    95                     # print "# ",a," -> ",attr
    96                     values=d[a]
    97                     del d[a]
    98                     d[attr]=values
    99             else:
    100                 # del d[a]
    101                 continue
    102 
    103         dn=rpath+destbasedn
    104 
    105         update_objects.append((dn,d))
    106 
    107     con = ldap.open(destserver,port=389)
    108     if options.starttls:
    109         con.start_tls_s()
    110     con.simple_bind_s(destbinddn,destbindpw)
    111 
    112     exist=0
    113     failed=0
    114     good=0
    115     deleted=0
    116     existing=[]
    117     tzutc = dateutil.tz.gettz('UTC')
    118     now = datetime.datetime.now(tzutc)
    119     max_age = datetime.timedelta(days=pwd_max_days)
    120 
    121     for o in update_objects:
    122         dn,entry=o
    123         try:
    124             result = None
    125             if options.renameattr and entry.has_key(options.renameattr):
    126                 result = con.search_s(destbasedn,ldap.SCOPE_SUB,"%s=%s" % (options.renameattr,entry[options.renameattr][0]))
    127                 if result != None and len(result)>0:
    128                     existingDn, existingEntry = result[0]
    129                     if existingDn.lower() != dn.lower():
    130                         con.modrdn_s(existingDn,dn)
    131                         notify_renamed(existingDn,dn,existingEntry[options.renameattr][0],entry[options.renameattr][0],options)
    132                         continue
    133 
    134             result=con.search_s(dn,ldap.SCOPE_BASE,"objectclass=*")
    135             destDn,destEntry=result[0]
    136 
    137             if options.exclude!=None and destDn.lower().endswith(options.exclude):
    138                 continue
    139 
    140             # hack for syncing accounts locked by password policy
    141             do_unlock = False
    142             if pwd_max_days>0 and entry.has_key('pwdChangedTime'):
    143                 # print "pwdChangedTime set for",dn
    144                 pwdChange = entry['pwdChangedTime'][0]
    145                 d = dateutil.parser.parse(pwdChange)
    146                 if (now-d)>max_age:
    147                     if dn.startswith('cn=haydar aldetest'):
    148                         entry['pwdAccountLockedTime']=[ '000001010000Z' ]
    149                         print "locking",dn,pwdChange
    150                 else:
    151                     result = con.search_s(dn,ldap.SCOPE_BASE,"objectclass=*", \
    152                        attrlist = [ 'pwdAccountLockedTime' ])
    153                     tmp_dn, tmp_entry = result[0]
    154                     if tmp_entry.has_key('pwdAccountLockedTime'):
    155                         print "unlocking",dn,pwdChange
    156                         do_unlock = True       
    157  
    158             mod_attrs=ldap.modlist.modifyModlist(destEntry,entry)
    159 
    160             # hack for unlocking, see above
    161             if do_unlock:
    162                 mod_attrs.append( (ldap.MOD_DELETE,'pwdAccountLockedTime',None) )
    163 
    164             if options.attrfilter!=None:
    165                 mod_attrs=[ a for a in mod_attrs if a[1] in options.attrfilter]
    166 
    167             if junk_attrs!=None:
    168                 mod_attrs=[ a for a in mod_attrs if a[1].lower() not in junk_attrs]
    169 
    170             if mod_attrs!=[]:
    171                 exist=exist+1
    172                 #if options.verbose:
    173                 #       print dn, "already exists"
    174                 try:
    175                         # print dn,destEntry['objectClass'],entry['objectClass']
    176                         con.modify_s(dn,mod_attrs)
    177                 except:
    178                         print "error",dn,mod_attrs
    179                 notify_modified(dn)
    180             else:
    181                 pass
    182                 # print "no changes, not modified"
    183 
    184         except ldap.NO_SUCH_OBJECT:
    185             if options.updateonly==True:
    186                 continue
    187 
     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)
    188230            try:
    189                 con.add_s(dn,ldap.modlist.addModlist(entry,junk_attrs))
    190                 notify_created(dn)
    191                 if options.verbose:
    192                     print dn,"created"
    193                 good=good+1
    194             except (ldap.OBJECT_CLASS_VIOLATION,ldap.NO_SUCH_OBJECT):
    195                 print dn, "failed"
    196                 failed=failed+1
    197 
    198     if options.delete==True and options.updateonly==False:
    199         result=con.search_s(destbasedn,ldap.SCOPE_SUBTREE,filter)
    200         existing=[ x[0].lower() for x in result ]
     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 ]
    201285
    202286        morituri=existing
    203287
    204         if destbasedn.lower() in existing:
    205             morituri.remove(destbasedn.lower())
     288        if self.destbasedn.lower() in existing:
     289            morituri.remove(self.destbasedn.lower())
    206290
    207291        for o in update_objects:
     
    210294                morituri.remove(dn.lower())
    211295        for dn in morituri:
    212             if options.exclude != None and dn.lower().endswith(options.exclude):
    213                 # print "ignoring",dn
    214                 continue
    215 
    216             try:
    217                 con.delete_s(dn)
    218             except:
    219                 print "failed to delete",dn
    220 
    221             notify_deleted(dn)
    222             if options.verbose:
    223                 print dn,"deleted"
    224             deleted=deleted+1
     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()
    225328 
    226     con.unbind()
    227     print good,"entries created,",exist,"updated,",deleted,"deleted,",failed,"failed."
     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
    228362
    229363
    230364if __name__ == "__main__":
    231     conffile="ldapsync.conf"
    232     filter = None
     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
    233373    exclude = None
    234     if len(sys.argv)>1:
    235         conffile=sys.argv[1]
    236 
    237     config=ConfigParser.ConfigParser()
     374
     375    config=ConfigParserDefaults()
    238376    config.read(conffile)
    239377
     
    245383
    246384    basedn = config.get("source","baseDn")
     385    filter = config.get0("source","filter", None)
     386    attrs = config.get0("source", "attributes", None)
    247387
    248388    if srcfile==None:
     
    250390        admindn = config.get("source","bindDn")
    251391        adminpw = config.get("source","bindPassword")
    252         filter = config.get("source","filter")
    253392        starttls = config.getboolean("source","starttls")
    254 
    255     if filter==None:
    256         filter = '(objectClass=*)'
    257 
    258     options = Options()
    259 
    260     try:
    261         options.exclude = config.get("destination","excludesubtree").lower()
    262     except:
    263         pass
    264393
    265394    destsrv = config.get("destination","server")
     
    268397    destbasedn = config.get("destination","baseDn")
    269398    destdelete = config.getboolean("destination","delete")
    270     rdn = config.get("destination","rdn")
    271 
    272399    try:
    273         options.updateonly = not config.getboolean("destination","create")
     400        rdn = config.get("destination","rdn")
     401        logger.warning("setting rdn is currently ignored")
    274402    except:
    275         options.updateonly = False
    276     options.starttls = config.getboolean("destination","starttls")
     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   
    277419    try:
    278420        options.attrfilter = config.get("destination","attributes").split(",")
     
    280422        options.attrfilter = None
    281423
    282     try:
    283         options.renameattr = config.get("destination","detectRename")
    284     except:
    285         options.renameattr = None
    286 
    287     try:
    288         options.renamecommand = config.get("destination","detectRename")
    289     except:
    290         options.renamecommand = None
    291 
    292424    if srcfile:
    293         result = readLDIFSource(srcfile)
     425        objects = readLDIFSource(srcfile)
    294426    else:
    295         result = readLdapSource(srv,admindn,adminpw,basedn,filter,starttls)
    296 
    297     try:
    298         pwd_max_days = int(config.get("source","pwd_max_days"))
    299     except:
    300         pwd_max_days = 0
    301 
    302     syncLdapDestination(result,destsrv,destadmindn,destadminpw,basedn,destbasedn,rdn,options)
     427        objects = readLdapSource(srv,admindn,adminpw,basedn,filter,attrs,starttls)
     428
     429    ldapsync = LdapSync(destsrv,destadmindn,destadminpw,basedn,destbasedn,options)
     430    ldapsync.sync(objects)
Note: See TracChangeset for help on using the changeset viewer.