3 # Copyright (C) 2008-2013 W. Trevor King
4 # Copyright (C) 2012-2013 Wade Berrier
5 # Copyright (C) 2012 Niels de Vos
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU General Public License for more details.
17 # You should have received a copy of the GNU General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
20 "LDAP address searches for Mutt"
22 import ConfigParser as _configparser
23 import hashlib as _hashlib
25 import logging as _logging
26 import os.path as _os_path
27 import pickle as _pickle
31 import ldap.sasl as _ldap_sasl
37 LOG = _logging.getLogger('mutt-ldap')
38 LOG.addHandler(_logging.StreamHandler())
39 LOG.setLevel(_logging.ERROR)
41 CONFIG = _configparser.SafeConfigParser()
42 CONFIG.add_section('connection')
43 CONFIG.set('connection', 'server', 'domaincontroller.yourdomain.com')
44 CONFIG.set('connection', 'port', '389') # set to 636 for default over SSL
45 CONFIG.set('connection', 'ssl', 'no')
46 CONFIG.set('connection', 'starttls', 'no')
47 CONFIG.set('connection', 'basedn', 'ou=x co.,dc=example,dc=net')
48 CONFIG.add_section('auth')
49 CONFIG.set('auth', 'user', '')
50 CONFIG.set('auth', 'password', '')
51 CONFIG.set('auth', 'gssapi', 'no')
52 CONFIG.add_section('query')
53 CONFIG.set('query', 'filter', '') # only match entries according to this filter
54 CONFIG.set('query', 'search-fields', 'cn displayName uid mail') # fields to wildcard search
55 CONFIG.add_section('results')
56 CONFIG.set('results', 'optional-column', '') # mutt can display one optional column
57 CONFIG.add_section('cache')
58 CONFIG.set('cache', 'enable', 'yes') # enable caching by default
59 CONFIG.set('cache', 'path', '~/.mutt-ldap.cache') # cache results here
60 CONFIG.set('cache', 'fields', '') # fields to cache (if empty, setup in the main block)
61 CONFIG.set('cache', 'longevity-days', '14') # TODO: cache results for 14 days by default
62 CONFIG.add_section('system')
63 # HACK: Python 2.x support, see http://bugs.python.org/issue2128
64 CONFIG.set('system', 'argv-encoding', 'utf-8')
66 CONFIG.read(_os_path.expanduser('~/.mutt-ldap.rc'))
69 class LDAPConnection (object):
70 """Wrap an LDAP connection supporting the 'with' statement
72 See PEP 343 for details.
74 def __init__(self, config=None):
78 self.connection = None
84 def __exit__(self, type, value, traceback):
88 if self.connection is not None:
89 raise RuntimeError('already connected to the LDAP server')
91 if self.config.getboolean('connection', 'ssl'):
93 url = '{0}://{1}:{2}'.format(
95 self.config.get('connection', 'server'),
96 self.config.get('connection', 'port'))
97 LOG.info(u'connect to LDAP server at {0}'.format(url))
98 self.connection = _ldap.initialize(url)
99 if (self.config.getboolean('connection', 'starttls') and
101 self.connection.start_tls_s()
102 if self.config.getboolean('auth', 'gssapi'):
103 sasl = _ldap_sasl.gssapi()
104 self.connection.sasl_interactive_bind_s('', sasl)
106 self.connection.bind(
107 self.config.get('auth', 'user'),
108 self.config.get('auth', 'password'),
112 if self.connection is None:
113 raise RuntimeError('not connected to an LDAP server')
114 LOG.info(u'unbind from LDAP server')
115 self.connection.unbind()
116 self.connection = None
118 def search(self, query):
119 if self.connection is None:
120 raise RuntimeError('connect to the LDAP server before searching')
124 fields = self.config.get('query', 'search-fields').split()
125 filterstr = u'(|{0})'.format(
126 u' '.join([u'({0}=*{1}{2})'.format(field, query, post) for
128 query_filter = self.config.get('query', 'filter')
130 filterstr = u'(&({0}){1})'.format(query_filter, filterstr)
131 LOG.info(u'search for {0}'.format(filterstr))
132 msg_id = self.connection.search(
133 self.config.get('connection', 'basedn'),
135 filterstr.encode('utf-8'))
137 while res_type != _ldap.RES_SEARCH_RESULT:
139 res_type, res_data = self.connection.result(
140 msg_id, all=False, timeout=0)
141 except _ldap.ADMINLIMIT_EXCEEDED as e:
142 LOG.warn(u'could not handle query results: {0}'.format(e))
145 # use `yield from res_data` in Python >= 3.3, see PEP 380
146 for entry in res_data:
150 class CachedLDAPConnection (LDAPConnection):
151 _cache_version = '{0}.0'.format(__version__)
154 # delay LDAP connection until we actually need it
159 super(CachedLDAPConnection, self).unbind()
163 def search(self, query):
164 cache_hit, entries = self._cache_lookup(query=query)
166 LOG.info(u'return cached entries for {0}'.format(query))
167 # use `yield from res_data` in Python >= 3.3, see PEP 380
168 for entry in entries:
171 if self.connection is None:
172 super(CachedLDAPConnection, self).connect()
174 keys = self.config.get('cache', 'fields').split()
175 for entry in super(CachedLDAPConnection, self).search(query=query):
177 # use dict comprehensions in Python >= 2.7, see PEP 274
179 [(key, data[key]) for key in keys if key in data])
180 entries.append((cn, cached_data))
182 self._cache_store(query=query, entries=entries)
184 def _load_cache(self):
185 path = _os_path.expanduser(self.config.get('cache', 'path'))
186 LOG.info(u'load cache from {0}'.format(path))
189 data = _json.load(open(path, 'rb'))
190 except IOError as e: # probably "No such file"
191 LOG.warn(u'error reading cache: {0}'.format(e))
192 except (ValueError, KeyError) as e: # probably a corrupt cache file
193 LOG.warn(u'error parsing cache: {0}'.format(e))
195 version = data.get('version', None)
196 if version == self._cache_version:
197 self._cache = data.get('queries', {})
199 LOG.debug(u'drop outdated local cache {0} != {1}'.format(
200 version, self._cache_version))
203 def _save_cache(self):
204 path = _os_path.expanduser(self.config.get('cache', 'path'))
205 LOG.info(u'save cache to {0}'.format(path))
207 'queries': self._cache,
208 'version': self._cache_version,
210 with open(path, 'wb') as f:
211 _json.dump(data, f, indent=2, separators=(',', ': '))
212 f.write('\n'.encode('utf-8'))
214 def _cache_store(self, query, entries):
215 self._cache[self._cache_key(query=query)] = {
217 'time': _time.time(),
220 def _cache_lookup(self, query):
221 data = self._cache.get(self._cache_key(query=query), None)
224 return (True, data['entries'])
226 def _cache_key(self, query):
227 return str((self._config_id(), query))
229 def _config_id(self):
230 """Return a unique ID representing the current configuration
232 config_string = _pickle.dumps(self.config)
233 return _hashlib.sha1(config_string).hexdigest()
235 def _cull_cache(self):
236 cull_days = self.config.getint('cache', 'longevity-days')
237 day_seconds = 24*60*60
238 expire = _time.time() - cull_days * day_seconds
239 for key in list(self._cache.keys()): # cull the cache
240 if self._cache[key]['time'] < expire:
241 LOG.debug('cull entry from cache: {0}'.format(key))
245 def _decode_query_data(obj):
246 if isinstance(obj, unicode): # e.g. cached JSON data
248 return unicode(obj, 'utf-8')
250 def format_columns(address, data):
251 yield _decode_query_data(address)
252 yield _decode_query_data(data.get('displayName', data['cn'])[-1])
253 optional_column = CONFIG.get('results', 'optional-column')
254 if optional_column in data:
255 yield _decode_query_data(data[optional_column][-1])
257 def format_entry(entry):
260 for m in data['mail']:
261 # http://www.mutt.org/doc/manual/manual-4.html#ss4.5
262 # Describes the format mutt expects: address\tname
263 yield u'\t'.join(format_columns(m, data))
266 if __name__ == '__main__':
269 # HACK: convert sys.argv to Unicode (not needed in Python 3)
270 argv_encoding = CONFIG.get('system', 'argv-encoding')
271 sys.argv = [unicode(arg, argv_encoding) for arg in sys.argv]
273 if len(sys.argv) < 2:
274 sys.stderr.write(u'{0}: no search string given\n'.format(sys.argv[0]))
277 query = u' '.join(sys.argv[1:])
279 if CONFIG.getboolean('cache', 'enable'):
280 connection_class = CachedLDAPConnection
281 if not CONFIG.get('cache', 'fields'):
282 # setup a reasonable default
283 fields = ['mail', 'cn', 'displayName'] # used by format_entry()
284 optional_column = CONFIG.get('results', 'optional-column')
286 fields.append(optional_column)
287 CONFIG.set('cache', 'fields', ' '.join(fields))
289 connection_class = LDAPConnection
292 with connection_class() as connection:
293 entries = connection.search(query=query)
294 for entry in entries:
295 addresses.extend(format_entry(entry))
296 print(u'{0} addresses found:'.format(len(addresses)))
297 print(u'\n'.join(addresses))