]> code.delx.au - offlineimap/blob - offlineimap/imapserver.py
Add support for ssl client certificates
[offlineimap] / offlineimap / imapserver.py
1 # IMAP server support
2 # Copyright (C) 2002 - 2007 John Goerzen
3 # <jgoerzen@complete.org>
4 #
5 # This program is free software; you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License as published by
7 # the Free Software Foundation; either version 2 of the License, or
8 # (at your option) any later version.
9 #
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # GNU General Public License for more details.
14 #
15 # You should have received a copy of the GNU General Public License
16 # along with this program; if not, write to the Free Software
17 # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
18
19 import imaplib
20 from offlineimap import imaplibutil, imaputil, threadutil
21 from offlineimap.ui import UIBase
22 from threading import *
23 import thread, hmac, os
24 import base64
25
26 try:
27 # do we have a recent pykerberos?
28 have_gss = False
29 import kerberos
30 if 'authGSSClientWrap' in dir(kerberos):
31 have_gss = True
32 except ImportError:
33 pass
34
35 class UsefulIMAPMixIn:
36 def getstate(self):
37 return self.state
38 def getselectedfolder(self):
39 if self.getstate() == 'SELECTED':
40 return self.selectedfolder
41 return None
42
43 def select(self, mailbox='INBOX', readonly=None, force = 0):
44 if (not force) and self.getselectedfolder() == mailbox \
45 and self.is_readonly == readonly:
46 # No change; return.
47 return
48 result = self.__class__.__bases__[1].select(self, mailbox, readonly)
49 if result[0] != 'OK':
50 raise ValueError, "Error from select: %s" % str(result)
51 if self.getstate() == 'SELECTED':
52 self.selectedfolder = mailbox
53 else:
54 self.selectedfolder = None
55
56 def _mesg(self, s, secs=None):
57 imaplibutil.new_mesg(self, s, secs)
58
59 class UsefulIMAP4(UsefulIMAPMixIn, imaplib.IMAP4):
60 def open(self, host = '', port = imaplib.IMAP4_PORT):
61 imaplibutil.new_open(self, host, port)
62
63 class UsefulIMAP4_SSL(UsefulIMAPMixIn, imaplibutil.WrappedIMAP4_SSL):
64 def open(self, host = '', port = imaplib.IMAP4_SSL_PORT):
65 imaplibutil.new_open_ssl(self, host, port)
66
67 class UsefulIMAP4_Tunnel(UsefulIMAPMixIn, imaplibutil.IMAP4_Tunnel): pass
68
69 class IMAPServer:
70 GSS_STATE_STEP = 0
71 GSS_STATE_WRAP = 1
72 def __init__(self, config, reposname,
73 username = None, password = None, hostname = None,
74 port = None, ssl = 1, maxconnections = 1, tunnel = None,
75 reference = '""', sslclientcert = None, sslclientkey = None):
76 self.reposname = reposname
77 self.config = config
78 self.username = username
79 self.password = password
80 self.passworderror = None
81 self.goodpassword = None
82 self.hostname = hostname
83 self.tunnel = tunnel
84 self.port = port
85 self.usessl = ssl
86 self.sslclientcert = sslclientcert
87 self.sslclientkey = sslclientkey
88 self.delim = None
89 self.root = None
90 if port == None:
91 if ssl:
92 self.port = 993
93 else:
94 self.port = 143
95 self.maxconnections = maxconnections
96 self.availableconnections = []
97 self.assignedconnections = []
98 self.lastowner = {}
99 self.semaphore = BoundedSemaphore(self.maxconnections)
100 self.connectionlock = Lock()
101 self.reference = reference
102 self.gss_step = self.GSS_STATE_STEP
103 self.gss_vc = None
104 self.gssapi = False
105
106 def getpassword(self):
107 if self.goodpassword != None:
108 return self.goodpassword
109
110 if self.password != None and self.passworderror == None:
111 return self.password
112
113 self.password = UIBase.getglobalui().getpass(self.reposname,
114 self.config,
115 self.passworderror)
116 self.passworderror = None
117
118 return self.password
119
120 def getdelim(self):
121 """Returns this server's folder delimiter. Can only be called
122 after one or more calls to acquireconnection."""
123 return self.delim
124
125 def getroot(self):
126 """Returns this server's folder root. Can only be called after one
127 or more calls to acquireconnection."""
128 return self.root
129
130
131 def releaseconnection(self, connection):
132 """Releases a connection, returning it to the pool."""
133 self.connectionlock.acquire()
134 self.assignedconnections.remove(connection)
135 self.availableconnections.append(connection)
136 self.connectionlock.release()
137 self.semaphore.release()
138
139 def md5handler(self, response):
140 ui = UIBase.getglobalui()
141 challenge = response.strip()
142 ui.debug('imap', 'md5handler: got challenge %s' % challenge)
143
144 passwd = self.getpassword()
145 retval = self.username + ' ' + hmac.new(passwd, challenge).hexdigest()
146 ui.debug('imap', 'md5handler: returning %s' % retval)
147 return retval
148
149 def plainauth(self, imapobj):
150 UIBase.getglobalui().debug('imap',
151 'Attempting plain authentication')
152 imapobj.login(self.username, self.getpassword())
153
154 def gssauth(self, response):
155 data = base64.b64encode(response)
156 try:
157 if self.gss_step == self.GSS_STATE_STEP:
158 if not self.gss_vc:
159 rc, self.gss_vc = kerberos.authGSSClientInit('imap@' +
160 self.hostname)
161 response = kerberos.authGSSClientResponse(self.gss_vc)
162 rc = kerberos.authGSSClientStep(self.gss_vc, data)
163 if rc != kerberos.AUTH_GSS_CONTINUE:
164 self.gss_step = self.GSS_STATE_WRAP
165 elif self.gss_step == self.GSS_STATE_WRAP:
166 rc = kerberos.authGSSClientUnwrap(self.gss_vc, data)
167 response = kerberos.authGSSClientResponse(self.gss_vc)
168 rc = kerberos.authGSSClientWrap(self.gss_vc, response,
169 self.username)
170 response = kerberos.authGSSClientResponse(self.gss_vc)
171 except kerberos.GSSError, err:
172 # Kerberos errored out on us, respond with None to cancel the
173 # authentication
174 UIBase.getglobalui().debug('imap',
175 '%s: %s' % (err[0][0], err[1][0]))
176 return None
177
178 if not response:
179 response = ''
180 return base64.b64decode(response)
181
182 def acquireconnection(self):
183 """Fetches a connection from the pool, making sure to create a new one
184 if needed, to obey the maximum connection limits, etc.
185 Opens a connection to the server and returns an appropriate
186 object."""
187
188 self.semaphore.acquire()
189 self.connectionlock.acquire()
190 imapobj = None
191
192 if len(self.availableconnections): # One is available.
193 # Try to find one that previously belonged to this thread
194 # as an optimization. Start from the back since that's where
195 # they're popped on.
196 threadid = thread.get_ident()
197 imapobj = None
198 for i in range(len(self.availableconnections) - 1, -1, -1):
199 tryobj = self.availableconnections[i]
200 if self.lastowner[tryobj] == threadid:
201 imapobj = tryobj
202 del(self.availableconnections[i])
203 break
204 if not imapobj:
205 imapobj = self.availableconnections[0]
206 del(self.availableconnections[0])
207 self.assignedconnections.append(imapobj)
208 self.lastowner[imapobj] = thread.get_ident()
209 self.connectionlock.release()
210 return imapobj
211
212 self.connectionlock.release() # Release until need to modify data
213
214 success = 0
215 while not success:
216 # Generate a new connection.
217 if self.tunnel:
218 UIBase.getglobalui().connecting('tunnel', self.tunnel)
219 imapobj = UsefulIMAP4_Tunnel(self.tunnel)
220 success = 1
221 elif self.usessl:
222 UIBase.getglobalui().connecting(self.hostname, self.port)
223 imapobj = UsefulIMAP4_SSL(self.hostname, self.port,
224 self.sslclientkey, self.sslclientcert)
225 else:
226 UIBase.getglobalui().connecting(self.hostname, self.port)
227 imapobj = UsefulIMAP4(self.hostname, self.port)
228
229 imapobj.mustquote = imaplibutil.mustquote
230
231 if not self.tunnel:
232 try:
233 # Try GSSAPI and continue if it fails
234 if 'AUTH=GSSAPI' in imapobj.capabilities and have_gss:
235 UIBase.getglobalui().debug('imap',
236 'Attempting GSSAPI authentication')
237 try:
238 imapobj.authenticate('GSSAPI', self.gssauth)
239 except imapobj.error, val:
240 UIBase.getglobalui().debug('imap',
241 'GSSAPI Authentication failed')
242 else:
243 self.gssapi = True
244 self.password = None
245
246 if not self.gssapi:
247 if 'AUTH=CRAM-MD5' in imapobj.capabilities:
248 UIBase.getglobalui().debug('imap',
249 'Attempting CRAM-MD5 authentication')
250 try:
251 imapobj.authenticate('CRAM-MD5', self.md5handler)
252 except imapobj.error, val:
253 self.plainauth(imapobj)
254 else:
255 self.plainauth(imapobj)
256 # Would bail by here if there was a failure.
257 success = 1
258 self.goodpassword = self.password
259 except imapobj.error, val:
260 self.passworderror = str(val)
261 self.password = None
262
263 if self.delim == None:
264 listres = imapobj.list(self.reference, '""')[1]
265 if listres == [None] or listres == None:
266 # Some buggy IMAP servers do not respond well to LIST "" ""
267 # Work around them.
268 listres = imapobj.list(self.reference, '"*"')[1]
269 self.delim, self.root = \
270 imaputil.imapsplit(listres[0])[1:]
271 self.delim = imaputil.dequote(self.delim)
272 self.root = imaputil.dequote(self.root)
273
274 self.connectionlock.acquire()
275 self.assignedconnections.append(imapobj)
276 self.lastowner[imapobj] = thread.get_ident()
277 self.connectionlock.release()
278 return imapobj
279
280 def connectionwait(self):
281 """Waits until there is a connection available. Note that between
282 the time that a connection becomes available and the time it is
283 requested, another thread may have grabbed it. This function is
284 mainly present as a way to avoid spawning thousands of threads
285 to copy messages, then have them all wait for 3 available connections.
286 It's OK if we have maxconnections + 1 or 2 threads, which is what
287 this will help us do."""
288 threadutil.semaphorewait(self.semaphore)
289
290 def close(self):
291 # Make sure I own all the semaphores. Let the threads finish
292 # their stuff. This is a blocking method.
293 self.connectionlock.acquire()
294 threadutil.semaphorereset(self.semaphore, self.maxconnections)
295 for imapobj in self.assignedconnections + self.availableconnections:
296 imapobj.logout()
297 self.assignedconnections = []
298 self.availableconnections = []
299 self.lastowner = {}
300 self.connectionlock.release()
301
302 def keepalive(self, timeout, event):
303 """Sends a NOOP to each connection recorded. It will wait a maximum
304 of timeout seconds between doing this, and will continue to do so
305 until the Event object as passed is true. This method is expected
306 to be invoked in a separate thread, which should be join()'d after
307 the event is set."""
308 ui = UIBase.getglobalui()
309 ui.debug('imap', 'keepalive thread started')
310 while 1:
311 ui.debug('imap', 'keepalive: top of loop')
312 event.wait(timeout)
313 ui.debug('imap', 'keepalive: after wait')
314 if event.isSet():
315 ui.debug('imap', 'keepalive: event is set; exiting')
316 return
317 ui.debug('imap', 'keepalive: acquiring connectionlock')
318 self.connectionlock.acquire()
319 numconnections = len(self.assignedconnections) + \
320 len(self.availableconnections)
321 self.connectionlock.release()
322 ui.debug('imap', 'keepalive: connectionlock released')
323 threads = []
324 imapobjs = []
325
326 for i in range(numconnections):
327 ui.debug('imap', 'keepalive: processing connection %d of %d' % (i, numconnections))
328 imapobj = self.acquireconnection()
329 ui.debug('imap', 'keepalive: connection %d acquired' % i)
330 imapobjs.append(imapobj)
331 thr = threadutil.ExitNotifyThread(target = imapobj.noop)
332 thr.setDaemon(1)
333 thr.start()
334 threads.append(thr)
335 ui.debug('imap', 'keepalive: thread started')
336
337 ui.debug('imap', 'keepalive: joining threads')
338
339 for thr in threads:
340 # Make sure all the commands have completed.
341 thr.join()
342
343 ui.debug('imap', 'keepalive: releasing connections')
344
345 for imapobj in imapobjs:
346 self.releaseconnection(imapobj)
347
348 ui.debug('imap', 'keepalive: bottom of loop')
349
350 class ConfigedIMAPServer(IMAPServer):
351 """This class is designed for easier initialization given a ConfigParser
352 object and an account name. The passwordhash is used if
353 passwords for certain accounts are known. If the password for this
354 account is listed, it will be obtained from there."""
355 def __init__(self, repository, passwordhash = {}):
356 """Initialize the object. If the account is not a tunnel,
357 the password is required."""
358 self.repos = repository
359 self.config = self.repos.getconfig()
360 usetunnel = self.repos.getpreauthtunnel()
361 if not usetunnel:
362 host = self.repos.gethost()
363 user = self.repos.getuser()
364 port = self.repos.getport()
365 ssl = self.repos.getssl()
366 sslclientcert = self.repos.getsslclientcert()
367 sslclientkey = self.repos.getsslclientkey()
368 reference = self.repos.getreference()
369 server = None
370 password = None
371
372 if repository.getname() in passwordhash:
373 password = passwordhash[repository.getname()]
374
375 # Connect to the remote server.
376 if usetunnel:
377 IMAPServer.__init__(self, self.config, self.repos.getname(),
378 tunnel = usetunnel,
379 reference = reference,
380 maxconnections = self.repos.getmaxconnections())
381 else:
382 if not password:
383 password = self.repos.getpassword()
384 IMAPServer.__init__(self, self.config, self.repos.getname(),
385 user, password, host, port, ssl,
386 self.repos.getmaxconnections(),
387 reference = reference,
388 sslclientcert = sslclientcert,
389 sslclientkey = sslclientkey)