1#!/usr/bin/env python
2"""
3This script implements a syncrepl consumer which syncs data from an OpenLDAP
4server to a local (shelve) database.
5
6Notes:
7
8The bound user needs read access to the attributes entryDN and entryCSN.
9"""
10
11# Import modules from Python standard lib
12import logging
13import shelve
14import signal
15import sys
16import time
17
18# Import the python-ldap modules
19import ldap
20import ldapurl
21# Import specific classes from python-ldap
22from ldap.ldapobject import ReconnectLDAPObject
23from ldap.syncrepl import SyncreplConsumer
24
25logger = logging.getLogger('syncrepl')
26logger.setLevel(logging.DEBUG)
27logger.addHandler(logging.StreamHandler())
28
29# Global state
30watcher_running = True
31ldap_connection = False
32
33
34class SyncReplClient(ReconnectLDAPObject, SyncreplConsumer):
35    """
36    Syncrepl Consumer Client
37    """
38
39    def __init__(self, db_path, *args, **kwargs):
40        # Initialise the LDAP Connection first
41        ldap.ldapobject.ReconnectLDAPObject.__init__(self, *args, **kwargs)
42        # Now prepare the data store
43        if db_path:
44            self.__data = shelve.open(db_path, 'c')
45        else:
46            self.__data = {}
47        # We need this for later internal use
48        self.__presentUUIDs = {}
49
50    def close_db(self):
51        # Close the data store properly to avoid corruption
52        self.__data.close()
53
54    def syncrepl_get_cookie(self):
55        if 'cookie' in self.__data:
56            return self.__data['cookie']
57
58    def syncrepl_set_cookie(self,cookie):
59        self.__data['cookie'] = cookie
60
61    def syncrepl_entry(self, dn, attributes, uuid):
62        logger.debug('dn=%r attributes=%r uuid=%r', dn, attributes, uuid)
63        # First we determine the type of change we have here
64        # (and store away the previous data for later if needed)
65        previous_attributes = {}
66        if uuid in self.__data:
67            change_type = 'modify'
68            previous_attributes = self.__data[uuid]
69        else:
70            change_type = 'add'
71        # Now we store our knowledge of the existence of this entry
72        # (including the DN as an attribute for convenience)
73        attributes['dn'] = dn
74        self.__data[uuid] = attributes
75        # Debugging
76        logger.debug('Detected %s of entry %r', change_type, dn)
77        # If we have a cookie then this is not our first time being run,
78        # so it must be a change
79        if 'ldap_cookie' in self.__data:
80            self.perform_application_sync(dn, attributes, previous_attributes)
81
82    def syncrepl_delete(self,uuids):
83        # Make sure we know about the UUID being deleted, just in case...
84        uuids = [uuid for uuid in uuids if uuid in self.__data]
85        # Delete all the UUID values we know of
86        for uuid in uuids:
87            logger.debug('Detected deletion of entry %r', self.__data[uuid]['dn'])
88            del self.__data[uuid]
89
90    def syncrepl_present(self,uuids,refreshDeletes=False):
91        # If we have not been given any UUID values,
92        # then we have recieved all the present controls...
93        if uuids is None:
94            # We only do things if refreshDeletes is false as the syncrepl
95            # extension will call syncrepl_delete instead when it detects a
96            # delete notice
97            if refreshDeletes is False:
98                deletedEntries = [
99                    uuid
100                    for uuid in self.__data.keys()
101                    if uuid not in self.__presentUUIDs and uuid != 'ldap_cookie'
102                ]
103                self.syncrepl_delete( deletedEntries )
104            # Phase is now completed, reset the list
105            self.__presentUUIDs = {}
106        else:
107            # Note down all the UUIDs we have been sent
108            for uuid in uuids:
109                    self.__presentUUIDs[uuid] = True
110
111    def syncrepl_refreshdone(self):
112        logger.info('Initial synchronization is now done, persist phase begins')
113
114    def perform_application_sync(self,dn,attributes,previous_attributes):
115        logger.info('Performing application sync for %r', dn)
116        return True
117
118
119# Shutdown handler
120def commenceShutdown(signum, stack):
121    # Declare the needed global variables
122    global watcher_running, ldap_connection
123    logger.warn('Shutting down!')
124
125    # We are no longer running
126    watcher_running = False
127
128    # Tear down the server connection
129    if ldap_connection:
130        ldap_connection.close_db()
131        ldap_connection.unbind_s()
132        del ldap_connection
133
134    # Shutdown
135    sys.exit(0)
136
137# Time to actually begin execution
138# Install our signal handlers
139signal.signal(signal.SIGTERM, commenceShutdown)
140signal.signal(signal.SIGINT, commenceShutdown)
141
142
143try:
144    ldap_url = ldapurl.LDAPUrl(sys.argv[1])
145    database_path = sys.argv[2]
146except IndexError,e:
147    print (
148        'Usage:\n'
149        '{script_name} <LDAP URL> <pathname of database>\n'
150        '{script_name} "ldap://127.0.0.1/cn=users,dc=test'
151         '?*'
152         '?sub'
153         '?(objectClass=*)'
154         '?bindname=uid=admin%2ccn=users%2cdc=test,'
155         'X-BINDPW=password" db.shelve'
156    ).format(script_name=sys.argv[0])
157    sys.exit(1)
158except ValueError as e:
159    print('Error parsing command-line arguments:',str(e))
160    sys.exit(1)
161
162while watcher_running:
163    logger.info('Connecting to %s now...', ldap_url.initializeUrl())
164    # Prepare the LDAP server connection (triggers the connection as well)
165    ldap_connection = SyncReplClient(database_path, ldap_url.initializeUrl())
166
167    # Now we login to the LDAP server
168    try:
169        ldap_connection.simple_bind_s(ldap_url.who, ldap_url.cred)
170    except ldap.INVALID_CREDENTIALS as err:
171        logger.error('Login to LDAP server failed: %s', err)
172        sys.exit(1)
173    except ldap.SERVER_DOWN:
174        logger.warn('LDAP server is down, going to retry.')
175        time.sleep(5)
176        continue
177
178    # Commence the syncing
179    logger.debug('Commencing sync process')
180    ldap_search = ldap_connection.syncrepl_search(
181        ldap_url.dn or '',
182        ldap_url.scope or ldap.SCOPE_SUBTREE,
183        mode = 'refreshAndPersist',
184        attrlist=ldap_url.attrs,
185        filterstr = ldap_url.filterstr or '(objectClass=*)'
186    )
187
188    try:
189        while ldap_connection.syncrepl_poll( all = 1, msgid = ldap_search):
190            pass
191    except KeyboardInterrupt:
192        # User asked to exit
193        commenceShutdown(None, None)
194    except Exception as err:
195        # Handle any exception
196        if watcher_running:
197            logger.exception('Unhandled exception, going to retry: %s', err)
198            logger.info('Going to retry after 5 secs')
199            time.sleep(5)
200