├── README.rst ├── ldap2json.conf.sample └── ldap2json.py /README.rst: -------------------------------------------------------------------------------- 1 | ldap2json 2 | ========= 3 | 4 | A simple proxy that turns HTTP GET requests into LDAP queries and 5 | then returns JSON-encoded results. 6 | 7 | Requirements 8 | ============ 9 | 10 | ldap2json requires the bottle_ framework, the ldap_ module, the configobj_ 11 | module, the memcache_ module, and a recent version of Python (where 12 | "recent" means "has ``argparse``"). 13 | 14 | .. _bottle: http://bottlepy.org/ 15 | .. _ldap: http://www.python-ldap.org/ 16 | .. _configobj: http://www.voidspace.org.uk/python/configobj.html 17 | .. _memcache: http://www.tummy.com/Community/software/python-memcached/ 18 | 19 | Running ldap2json 20 | ================= 21 | 22 | Running ldap2json from the command line:: 23 | 24 | ./ldap2json.py [ -f configfile ] 25 | 26 | Return values 27 | ============= 28 | 29 | If a search returns an empty result, ldap2json will return a 404 status 30 | code to the caller. 31 | 32 | Otherwise, the return value is a list of *[DN, attribute_dictionary]* 33 | tuples, where *DN* is the distinguished name of the record and 34 | *attribute_dictionary* is a key/value dictionary of attributes. The values 35 | of the attribute dictionary will *always* be lists, even if attributes are 36 | single-valued. 37 | 38 | Configuration 39 | ============== 40 | 41 | ldap2json uses a simple INI-style configuration file. 42 | 43 | Global settings 44 | --------------- 45 | 46 | The global section of the config file may contain values for the following: 47 | 48 | - ``host`` -- Bind address for the web application. 49 | - ``port`` -- Port on which to listen. 50 | - ``debug`` -- Enable some debugging output if true. This will also cause 51 | ``bottle`` to reload the server if the source files change. 52 | 53 | ldap 54 | ---- 55 | 56 | The ``ldap`` section may contain two values: 57 | 58 | - ``uris`` -- a common-separated list of ``ldap://`` URIs specifying the 59 | endpoint for queries. If a server is unavailable, ldap2json will try the 60 | next one in sequence until it is able to connect. 61 | - ``basedn`` -- the base DN to use for searches. 62 | 63 | An example `ldap` section might look like this:: 64 | 65 | [ldap] 66 | 67 | uris = ldap://ldap1.example.com, ldap://ldap2.example.com 68 | basedn = "ou=people, dc=example, dc=com" 69 | 70 | Note that due to my use of the `configobj` module, strings containing 71 | commas must be quoted if you do not want them converted into a list. 72 | 73 | memcache 74 | -------- 75 | 76 | ldap2json will use memcache, if it's available, for caching results. The 77 | ``memcache`` section may contain values for the following: 78 | 79 | - ``servers`` -- a comma-separated list of memcache ``host:port`` servers. 80 | - ``lifetime`` -- the lifetime of items added to the cache. 81 | 82 | An example ``memcache`` section might look like this:: 83 | 84 | [memcache] 85 | 86 | servers = 127.0.0.1:11211 87 | lifetime = 600 88 | 89 | An example 90 | ========== 91 | 92 | Assuming that the server is running on ``localhost`` port ``8080``, the 93 | following:: 94 | 95 | $ curl http://localhost:8080/ldap?cn=alice* 96 | 97 | Might return something like this:: 98 | 99 | [ 100 | [ 101 | "uid=alice,ou=people,o=Example Organization,c=US", 102 | { 103 | "telephoneNumber": [ 104 | "+1-617-555-1212" 105 | ], 106 | "description": [ 107 | "employee" 108 | ], 109 | "title": [ 110 | "Ninja" 111 | ], 112 | "sn": [ 113 | "Person" 114 | ], 115 | "mail": [ 116 | "alice@example.com" 117 | ], 118 | "givenName": [ 119 | "Alice" 120 | ], 121 | "cn": [ 122 | "Alice Person" 123 | ] 124 | } 125 | ] 126 | ] 127 | 128 | -------------------------------------------------------------------------------- /ldap2json.conf.sample: -------------------------------------------------------------------------------- 1 | #host = 127.0.0.1 2 | #port = 8080 3 | 4 | #[ldap] 5 | # 6 | #uri = ldap://localhost 7 | #basedn = "ou=people, dc=example, dc=com" 8 | 9 | #[memcache] 10 | # 11 | #servers = 127.0.0.1:11211 12 | #lifetime = 600 13 | 14 | -------------------------------------------------------------------------------- /ldap2json.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | '''ldap2json acts as a proxy between HTTP GET requests and an LDAP 4 | directory. Results are returned to the caller using JSON.''' 5 | 6 | import os 7 | import sys 8 | import argparse 9 | import ldap 10 | import configobj 11 | import pprint 12 | import urllib 13 | import json 14 | import memcache 15 | import logging 16 | import itertools 17 | import time 18 | 19 | from bottle import route,run,request,response,HTTPError 20 | 21 | directory = None 22 | cache = None 23 | config = None 24 | 25 | class LDAPDirectory (object): 26 | '''A simple wrapper for LDAP connections that exposes a simplified 27 | search interface. At the moment this class only supports anonymous 28 | binds.''' 29 | 30 | def __init__ (self, uris, 31 | basedn='', 32 | scope=ldap.SCOPE_SUBTREE, 33 | debug=False, 34 | maxwait=120, 35 | ): 36 | 37 | self.uris = itertools.cycle(uris) 38 | self.maxwait = maxwait 39 | 40 | self.basedn = basedn 41 | self.scope = scope 42 | self.debug = debug 43 | 44 | self.connect() 45 | 46 | def connect(self): 47 | uri = self.uris.next() 48 | logging.info('Connecting to %s' % uri) 49 | self.dir = ldap.initialize(uri) 50 | 51 | def search(self, **kwargs): 52 | '''Turns kwargs into an LDAP search filter, executes the search, 53 | and returns the results. The keys in kwargs are ANDed together; 54 | only results meeting *all* criteria will be returned. 55 | 56 | If the connection to the LDAP server has been lost, search will try 57 | to reconnect with exponential backoff. The wait time between 58 | reconnection attempts will grow no large than self.maxwait.''' 59 | 60 | if not kwargs: 61 | kwargs = { 'objectclass': '*' } 62 | 63 | filter = self.build_filter(**kwargs) 64 | tries = 0 65 | 66 | while True: 67 | tries += 1 68 | 69 | try: 70 | res = self.dir.search_s( 71 | self.basedn, 72 | self.scope, 73 | filterstr=filter) 74 | return res 75 | except ldap.SERVER_DOWN: 76 | interval = max(1, min(self.maxwait, (tries-1)*2)) 77 | logging.error('Lost connection to LDAP server: ' 78 | 'reconnecting in %d seconds.' % interval) 79 | time.sleep(interval) 80 | self.connect() 81 | 82 | def build_filter(self, **kwargs): 83 | '''Transform a dictionary into an LDAP search filter.''' 84 | 85 | filter = [] 86 | for k,v in sorted(kwargs.items(), key=lambda x: x[0]): 87 | filter.append('(%s=%s)' % (k,v)) 88 | 89 | if len(filter) > 1: 90 | return '(&%s)' % ''.join(filter) 91 | else: 92 | return filter[0] 93 | 94 | class Cache (object): 95 | '''This is a very simple wrapper over memcache.Client that 96 | lets us specify a default lifetime for cache objects.''' 97 | 98 | def __init__ (self, servers, lifetime=600): 99 | self.lifetime = lifetime 100 | self.cache = memcache.Client(servers) 101 | 102 | def set(self, k, v): 103 | self.cache.set(k, v, time=self.lifetime) 104 | 105 | def get(self, k): 106 | return self.cache.get(k) 107 | 108 | @route('/ldap') 109 | def ldapsearch(): 110 | '''This method is where web clients interact with ldap2json. Any 111 | request parameters are turned into an LDAP filter, and results are JSON 112 | encoded and returned to the caller.''' 113 | 114 | global directory 115 | global cache 116 | global config 117 | 118 | callback = None 119 | 120 | # This supports JSONP requests, which require that the JSON 121 | # data be wrapped in a function call specified by the 122 | # callback parameter. 123 | if 'callback' in request.GET: 124 | callback = request.GET['callback'] 125 | del request.GET['callback'] 126 | 127 | # jquery adds this to JSONP requests to prevent caching. 128 | if '_' in request.GET: 129 | del request.GET['_'] 130 | 131 | key = urllib.quote('/ldap/%s/%s' % ( 132 | directory.basedn, 133 | request.urlparts.query, 134 | )) 135 | 136 | res = cache.get(key) 137 | 138 | if res is None: 139 | res = directory.search(**request.GET) 140 | cache.set(key, res) 141 | 142 | if not res: 143 | raise HTTPError(404) 144 | 145 | response.content_type = 'application/json' 146 | text = json.dumps(res, indent=2) 147 | 148 | # wrap JSON data in function call for JSON responses. 149 | if callback: 150 | text = '%s(%s)' % (callback, text) 151 | 152 | return text 153 | 154 | def parse_args(): 155 | p = argparse.ArgumentParser() 156 | p.add_argument('-d', '--debug', action='store_true', 157 | default=None) 158 | p.add_argument('-f', '--config', 159 | default='ldap2json.conf') 160 | return p.parse_args() 161 | 162 | def init_memcache(): 163 | global config 164 | global cache 165 | 166 | # Extract server list from config file. 167 | servers = config.get('memcache', {}).get( 168 | 'servers', '127.0.0.1:11211') 169 | lifetime = config.get('memcache', {}).get('lifetime', 600) 170 | 171 | # Make sure we have a Python list of servers. 172 | if isinstance(servers, (str, unicode)): 173 | servers = [servers] 174 | 175 | # Make sure we have an integer. 176 | lifetime = int(lifetime) 177 | 178 | assert lifetime > 0 179 | assert isinstance(servers, list) 180 | 181 | if config.get('debug'): 182 | print >>sys.stderr, 'using memcache servers: %s' % ( 183 | servers) 184 | 185 | cache = Cache(servers, lifetime=lifetime) 186 | 187 | def init_directory(): 188 | global directory 189 | global config 190 | 191 | uris = config.get('ldap', {}).get( 'uris', ['ldap://localhost']) 192 | basedn = config.get('ldap', {}).get( 'basedn', '') 193 | 194 | # Make sure we have a list of uris. 195 | if isinstance(uris, (str, unicode)): 196 | uris = [uris] 197 | 198 | directory = LDAPDirectory( 199 | uris, 200 | basedn=basedn, 201 | debug=config.get('debug'), 202 | ) 203 | 204 | def init_logging(): 205 | logging.basicConfig(level=logging.INFO, 206 | datefmt='%Y-%m-%d %H:%M:%S', 207 | format='%(asctime)s %(name)s [%(levelname)s]: %(message)s', 208 | ) 209 | 210 | def main(): 211 | global directory 212 | global cache 213 | global config 214 | 215 | opts = parse_args() 216 | 217 | config = configobj.ConfigObj(opts.config) 218 | 219 | # Only override config file "debug" setting if --debug 220 | # was explicitly passed on the command line. 221 | if opts.debug is not None: 222 | config['debug'] = opts.debug 223 | 224 | if config.get('debug'): 225 | print >>sys.stderr, 'CONFIG:', pprint.pformat(dict(config)) 226 | 227 | init_logging() 228 | init_memcache() 229 | init_directory() 230 | 231 | run( 232 | host=config.get('host', '127.0.0.1'), 233 | port=config.get('port', 8080), 234 | reloader=config.get('debug', False), 235 | ) 236 | 237 | if __name__ == '__main__': 238 | main() 239 | 240 | --------------------------------------------------------------------------------