|
1 #! /usr/bin/env python |
|
2 """An RFC 2821 smtp proxy. |
|
3 |
|
4 Usage: %(program)s [options] [localhost:localport [remotehost:remoteport]] |
|
5 |
|
6 Options: |
|
7 |
|
8 --nosetuid |
|
9 -n |
|
10 This program generally tries to setuid `nobody', unless this flag is |
|
11 set. The setuid call will fail if this program is not run as root (in |
|
12 which case, use this flag). |
|
13 |
|
14 --version |
|
15 -V |
|
16 Print the version number and exit. |
|
17 |
|
18 --class classname |
|
19 -c classname |
|
20 Use `classname' as the concrete SMTP proxy class. Uses `PureProxy' by |
|
21 default. |
|
22 |
|
23 --debug |
|
24 -d |
|
25 Turn on debugging prints. |
|
26 |
|
27 --help |
|
28 -h |
|
29 Print this message and exit. |
|
30 |
|
31 Version: %(__version__)s |
|
32 |
|
33 If localhost is not given then `localhost' is used, and if localport is not |
|
34 given then 8025 is used. If remotehost is not given then `localhost' is used, |
|
35 and if remoteport is not given, then 25 is used. |
|
36 """ |
|
37 |
|
38 |
|
39 # Overview: |
|
40 # |
|
41 # This file implements the minimal SMTP protocol as defined in RFC 821. It |
|
42 # has a hierarchy of classes which implement the backend functionality for the |
|
43 # smtpd. A number of classes are provided: |
|
44 # |
|
45 # SMTPServer - the base class for the backend. Raises NotImplementedError |
|
46 # if you try to use it. |
|
47 # |
|
48 # DebuggingServer - simply prints each message it receives on stdout. |
|
49 # |
|
50 # PureProxy - Proxies all messages to a real smtpd which does final |
|
51 # delivery. One known problem with this class is that it doesn't handle |
|
52 # SMTP errors from the backend server at all. This should be fixed |
|
53 # (contributions are welcome!). |
|
54 # |
|
55 # MailmanProxy - An experimental hack to work with GNU Mailman |
|
56 # <www.list.org>. Using this server as your real incoming smtpd, your |
|
57 # mailhost will automatically recognize and accept mail destined to Mailman |
|
58 # lists when those lists are created. Every message not destined for a list |
|
59 # gets forwarded to a real backend smtpd, as with PureProxy. Again, errors |
|
60 # are not handled correctly yet. |
|
61 # |
|
62 # Please note that this script requires Python 2.0 |
|
63 # |
|
64 # Author: Barry Warsaw <barry@python.org> |
|
65 # |
|
66 # TODO: |
|
67 # |
|
68 # - support mailbox delivery |
|
69 # - alias files |
|
70 # - ESMTP |
|
71 # - handle error codes from the backend smtpd |
|
72 |
|
73 import sys |
|
74 import os |
|
75 import errno |
|
76 import getopt |
|
77 import time |
|
78 import socket |
|
79 import asyncore |
|
80 import asynchat |
|
81 |
|
82 __all__ = ["SMTPServer","DebuggingServer","PureProxy","MailmanProxy"] |
|
83 |
|
84 program = sys.argv[0] |
|
85 __version__ = 'Python SMTP proxy version 0.2' |
|
86 |
|
87 |
|
88 class Devnull: |
|
89 def write(self, msg): pass |
|
90 def flush(self): pass |
|
91 |
|
92 |
|
93 DEBUGSTREAM = Devnull() |
|
94 NEWLINE = '\n' |
|
95 EMPTYSTRING = '' |
|
96 COMMASPACE = ', ' |
|
97 |
|
98 |
|
99 |
|
100 def usage(code, msg=''): |
|
101 print >> sys.stderr, __doc__ % globals() |
|
102 if msg: |
|
103 print >> sys.stderr, msg |
|
104 sys.exit(code) |
|
105 |
|
106 |
|
107 |
|
108 class SMTPChannel(asynchat.async_chat): |
|
109 COMMAND = 0 |
|
110 DATA = 1 |
|
111 |
|
112 def __init__(self, server, conn, addr): |
|
113 asynchat.async_chat.__init__(self, conn) |
|
114 self.__server = server |
|
115 self.__conn = conn |
|
116 self.__addr = addr |
|
117 self.__line = [] |
|
118 self.__state = self.COMMAND |
|
119 self.__greeting = 0 |
|
120 self.__mailfrom = None |
|
121 self.__rcpttos = [] |
|
122 self.__data = '' |
|
123 self.__fqdn = socket.getfqdn() |
|
124 self.__peer = conn.getpeername() |
|
125 print >> DEBUGSTREAM, 'Peer:', repr(self.__peer) |
|
126 self.push('220 %s %s' % (self.__fqdn, __version__)) |
|
127 self.set_terminator('\r\n') |
|
128 |
|
129 # Overrides base class for convenience |
|
130 def push(self, msg): |
|
131 asynchat.async_chat.push(self, msg + '\r\n') |
|
132 |
|
133 # Implementation of base class abstract method |
|
134 def collect_incoming_data(self, data): |
|
135 self.__line.append(data) |
|
136 |
|
137 # Implementation of base class abstract method |
|
138 def found_terminator(self): |
|
139 line = EMPTYSTRING.join(self.__line) |
|
140 print >> DEBUGSTREAM, 'Data:', repr(line) |
|
141 self.__line = [] |
|
142 if self.__state == self.COMMAND: |
|
143 if not line: |
|
144 self.push('500 Error: bad syntax') |
|
145 return |
|
146 method = None |
|
147 i = line.find(' ') |
|
148 if i < 0: |
|
149 command = line.upper() |
|
150 arg = None |
|
151 else: |
|
152 command = line[:i].upper() |
|
153 arg = line[i+1:].strip() |
|
154 method = getattr(self, 'smtp_' + command, None) |
|
155 if not method: |
|
156 self.push('502 Error: command "%s" not implemented' % command) |
|
157 return |
|
158 method(arg) |
|
159 return |
|
160 else: |
|
161 if self.__state != self.DATA: |
|
162 self.push('451 Internal confusion') |
|
163 return |
|
164 # Remove extraneous carriage returns and de-transparency according |
|
165 # to RFC 821, Section 4.5.2. |
|
166 data = [] |
|
167 for text in line.split('\r\n'): |
|
168 if text and text[0] == '.': |
|
169 data.append(text[1:]) |
|
170 else: |
|
171 data.append(text) |
|
172 self.__data = NEWLINE.join(data) |
|
173 status = self.__server.process_message(self.__peer, |
|
174 self.__mailfrom, |
|
175 self.__rcpttos, |
|
176 self.__data) |
|
177 self.__rcpttos = [] |
|
178 self.__mailfrom = None |
|
179 self.__state = self.COMMAND |
|
180 self.set_terminator('\r\n') |
|
181 if not status: |
|
182 self.push('250 Ok') |
|
183 else: |
|
184 self.push(status) |
|
185 |
|
186 # SMTP and ESMTP commands |
|
187 def smtp_HELO(self, arg): |
|
188 if not arg: |
|
189 self.push('501 Syntax: HELO hostname') |
|
190 return |
|
191 if self.__greeting: |
|
192 self.push('503 Duplicate HELO/EHLO') |
|
193 else: |
|
194 self.__greeting = arg |
|
195 self.push('250 %s' % self.__fqdn) |
|
196 |
|
197 def smtp_NOOP(self, arg): |
|
198 if arg: |
|
199 self.push('501 Syntax: NOOP') |
|
200 else: |
|
201 self.push('250 Ok') |
|
202 |
|
203 def smtp_QUIT(self, arg): |
|
204 # args is ignored |
|
205 self.push('221 Bye') |
|
206 self.close_when_done() |
|
207 |
|
208 # factored |
|
209 def __getaddr(self, keyword, arg): |
|
210 address = None |
|
211 keylen = len(keyword) |
|
212 if arg[:keylen].upper() == keyword: |
|
213 address = arg[keylen:].strip() |
|
214 if not address: |
|
215 pass |
|
216 elif address[0] == '<' and address[-1] == '>' and address != '<>': |
|
217 # Addresses can be in the form <person@dom.com> but watch out |
|
218 # for null address, e.g. <> |
|
219 address = address[1:-1] |
|
220 return address |
|
221 |
|
222 def smtp_MAIL(self, arg): |
|
223 print >> DEBUGSTREAM, '===> MAIL', arg |
|
224 address = self.__getaddr('FROM:', arg) if arg else None |
|
225 if not address: |
|
226 self.push('501 Syntax: MAIL FROM:<address>') |
|
227 return |
|
228 if self.__mailfrom: |
|
229 self.push('503 Error: nested MAIL command') |
|
230 return |
|
231 self.__mailfrom = address |
|
232 print >> DEBUGSTREAM, 'sender:', self.__mailfrom |
|
233 self.push('250 Ok') |
|
234 |
|
235 def smtp_RCPT(self, arg): |
|
236 print >> DEBUGSTREAM, '===> RCPT', arg |
|
237 if not self.__mailfrom: |
|
238 self.push('503 Error: need MAIL command') |
|
239 return |
|
240 address = self.__getaddr('TO:', arg) if arg else None |
|
241 if not address: |
|
242 self.push('501 Syntax: RCPT TO: <address>') |
|
243 return |
|
244 self.__rcpttos.append(address) |
|
245 print >> DEBUGSTREAM, 'recips:', self.__rcpttos |
|
246 self.push('250 Ok') |
|
247 |
|
248 def smtp_RSET(self, arg): |
|
249 if arg: |
|
250 self.push('501 Syntax: RSET') |
|
251 return |
|
252 # Resets the sender, recipients, and data, but not the greeting |
|
253 self.__mailfrom = None |
|
254 self.__rcpttos = [] |
|
255 self.__data = '' |
|
256 self.__state = self.COMMAND |
|
257 self.push('250 Ok') |
|
258 |
|
259 def smtp_DATA(self, arg): |
|
260 if not self.__rcpttos: |
|
261 self.push('503 Error: need RCPT command') |
|
262 return |
|
263 if arg: |
|
264 self.push('501 Syntax: DATA') |
|
265 return |
|
266 self.__state = self.DATA |
|
267 self.set_terminator('\r\n.\r\n') |
|
268 self.push('354 End data with <CR><LF>.<CR><LF>') |
|
269 |
|
270 |
|
271 |
|
272 class SMTPServer(asyncore.dispatcher): |
|
273 def __init__(self, localaddr, remoteaddr): |
|
274 self._localaddr = localaddr |
|
275 self._remoteaddr = remoteaddr |
|
276 asyncore.dispatcher.__init__(self) |
|
277 self.create_socket(socket.AF_INET, socket.SOCK_STREAM) |
|
278 # try to re-use a server port if possible |
|
279 self.set_reuse_addr() |
|
280 self.bind(localaddr) |
|
281 self.listen(5) |
|
282 print >> DEBUGSTREAM, \ |
|
283 '%s started at %s\n\tLocal addr: %s\n\tRemote addr:%s' % ( |
|
284 self.__class__.__name__, time.ctime(time.time()), |
|
285 localaddr, remoteaddr) |
|
286 |
|
287 def handle_accept(self): |
|
288 conn, addr = self.accept() |
|
289 print >> DEBUGSTREAM, 'Incoming connection from %s' % repr(addr) |
|
290 channel = SMTPChannel(self, conn, addr) |
|
291 |
|
292 # API for "doing something useful with the message" |
|
293 def process_message(self, peer, mailfrom, rcpttos, data): |
|
294 """Override this abstract method to handle messages from the client. |
|
295 |
|
296 peer is a tuple containing (ipaddr, port) of the client that made the |
|
297 socket connection to our smtp port. |
|
298 |
|
299 mailfrom is the raw address the client claims the message is coming |
|
300 from. |
|
301 |
|
302 rcpttos is a list of raw addresses the client wishes to deliver the |
|
303 message to. |
|
304 |
|
305 data is a string containing the entire full text of the message, |
|
306 headers (if supplied) and all. It has been `de-transparencied' |
|
307 according to RFC 821, Section 4.5.2. In other words, a line |
|
308 containing a `.' followed by other text has had the leading dot |
|
309 removed. |
|
310 |
|
311 This function should return None, for a normal `250 Ok' response; |
|
312 otherwise it returns the desired response string in RFC 821 format. |
|
313 |
|
314 """ |
|
315 raise NotImplementedError |
|
316 |
|
317 |
|
318 |
|
319 class DebuggingServer(SMTPServer): |
|
320 # Do something with the gathered message |
|
321 def process_message(self, peer, mailfrom, rcpttos, data): |
|
322 inheaders = 1 |
|
323 lines = data.split('\n') |
|
324 print '---------- MESSAGE FOLLOWS ----------' |
|
325 for line in lines: |
|
326 # headers first |
|
327 if inheaders and not line: |
|
328 print 'X-Peer:', peer[0] |
|
329 inheaders = 0 |
|
330 print line |
|
331 print '------------ END MESSAGE ------------' |
|
332 |
|
333 |
|
334 |
|
335 class PureProxy(SMTPServer): |
|
336 def process_message(self, peer, mailfrom, rcpttos, data): |
|
337 lines = data.split('\n') |
|
338 # Look for the last header |
|
339 i = 0 |
|
340 for line in lines: |
|
341 if not line: |
|
342 break |
|
343 i += 1 |
|
344 lines.insert(i, 'X-Peer: %s' % peer[0]) |
|
345 data = NEWLINE.join(lines) |
|
346 refused = self._deliver(mailfrom, rcpttos, data) |
|
347 # TBD: what to do with refused addresses? |
|
348 print >> DEBUGSTREAM, 'we got some refusals:', refused |
|
349 |
|
350 def _deliver(self, mailfrom, rcpttos, data): |
|
351 import smtplib |
|
352 refused = {} |
|
353 try: |
|
354 s = smtplib.SMTP() |
|
355 s.connect(self._remoteaddr[0], self._remoteaddr[1]) |
|
356 try: |
|
357 refused = s.sendmail(mailfrom, rcpttos, data) |
|
358 finally: |
|
359 s.quit() |
|
360 except smtplib.SMTPRecipientsRefused, e: |
|
361 print >> DEBUGSTREAM, 'got SMTPRecipientsRefused' |
|
362 refused = e.recipients |
|
363 except (socket.error, smtplib.SMTPException), e: |
|
364 print >> DEBUGSTREAM, 'got', e.__class__ |
|
365 # All recipients were refused. If the exception had an associated |
|
366 # error code, use it. Otherwise,fake it with a non-triggering |
|
367 # exception code. |
|
368 errcode = getattr(e, 'smtp_code', -1) |
|
369 errmsg = getattr(e, 'smtp_error', 'ignore') |
|
370 for r in rcpttos: |
|
371 refused[r] = (errcode, errmsg) |
|
372 return refused |
|
373 |
|
374 |
|
375 |
|
376 class MailmanProxy(PureProxy): |
|
377 def process_message(self, peer, mailfrom, rcpttos, data): |
|
378 from cStringIO import StringIO |
|
379 from Mailman import Utils |
|
380 from Mailman import Message |
|
381 from Mailman import MailList |
|
382 # If the message is to a Mailman mailing list, then we'll invoke the |
|
383 # Mailman script directly, without going through the real smtpd. |
|
384 # Otherwise we'll forward it to the local proxy for disposition. |
|
385 listnames = [] |
|
386 for rcpt in rcpttos: |
|
387 local = rcpt.lower().split('@')[0] |
|
388 # We allow the following variations on the theme |
|
389 # listname |
|
390 # listname-admin |
|
391 # listname-owner |
|
392 # listname-request |
|
393 # listname-join |
|
394 # listname-leave |
|
395 parts = local.split('-') |
|
396 if len(parts) > 2: |
|
397 continue |
|
398 listname = parts[0] |
|
399 if len(parts) == 2: |
|
400 command = parts[1] |
|
401 else: |
|
402 command = '' |
|
403 if not Utils.list_exists(listname) or command not in ( |
|
404 '', 'admin', 'owner', 'request', 'join', 'leave'): |
|
405 continue |
|
406 listnames.append((rcpt, listname, command)) |
|
407 # Remove all list recipients from rcpttos and forward what we're not |
|
408 # going to take care of ourselves. Linear removal should be fine |
|
409 # since we don't expect a large number of recipients. |
|
410 for rcpt, listname, command in listnames: |
|
411 rcpttos.remove(rcpt) |
|
412 # If there's any non-list destined recipients left, |
|
413 print >> DEBUGSTREAM, 'forwarding recips:', ' '.join(rcpttos) |
|
414 if rcpttos: |
|
415 refused = self._deliver(mailfrom, rcpttos, data) |
|
416 # TBD: what to do with refused addresses? |
|
417 print >> DEBUGSTREAM, 'we got refusals:', refused |
|
418 # Now deliver directly to the list commands |
|
419 mlists = {} |
|
420 s = StringIO(data) |
|
421 msg = Message.Message(s) |
|
422 # These headers are required for the proper execution of Mailman. All |
|
423 # MTAs in existance seem to add these if the original message doesn't |
|
424 # have them. |
|
425 if not msg.getheader('from'): |
|
426 msg['From'] = mailfrom |
|
427 if not msg.getheader('date'): |
|
428 msg['Date'] = time.ctime(time.time()) |
|
429 for rcpt, listname, command in listnames: |
|
430 print >> DEBUGSTREAM, 'sending message to', rcpt |
|
431 mlist = mlists.get(listname) |
|
432 if not mlist: |
|
433 mlist = MailList.MailList(listname, lock=0) |
|
434 mlists[listname] = mlist |
|
435 # dispatch on the type of command |
|
436 if command == '': |
|
437 # post |
|
438 msg.Enqueue(mlist, tolist=1) |
|
439 elif command == 'admin': |
|
440 msg.Enqueue(mlist, toadmin=1) |
|
441 elif command == 'owner': |
|
442 msg.Enqueue(mlist, toowner=1) |
|
443 elif command == 'request': |
|
444 msg.Enqueue(mlist, torequest=1) |
|
445 elif command in ('join', 'leave'): |
|
446 # TBD: this is a hack! |
|
447 if command == 'join': |
|
448 msg['Subject'] = 'subscribe' |
|
449 else: |
|
450 msg['Subject'] = 'unsubscribe' |
|
451 msg.Enqueue(mlist, torequest=1) |
|
452 |
|
453 |
|
454 |
|
455 class Options: |
|
456 setuid = 1 |
|
457 classname = 'PureProxy' |
|
458 |
|
459 |
|
460 |
|
461 def parseargs(): |
|
462 global DEBUGSTREAM |
|
463 try: |
|
464 opts, args = getopt.getopt( |
|
465 sys.argv[1:], 'nVhc:d', |
|
466 ['class=', 'nosetuid', 'version', 'help', 'debug']) |
|
467 except getopt.error, e: |
|
468 usage(1, e) |
|
469 |
|
470 options = Options() |
|
471 for opt, arg in opts: |
|
472 if opt in ('-h', '--help'): |
|
473 usage(0) |
|
474 elif opt in ('-V', '--version'): |
|
475 print >> sys.stderr, __version__ |
|
476 sys.exit(0) |
|
477 elif opt in ('-n', '--nosetuid'): |
|
478 options.setuid = 0 |
|
479 elif opt in ('-c', '--class'): |
|
480 options.classname = arg |
|
481 elif opt in ('-d', '--debug'): |
|
482 DEBUGSTREAM = sys.stderr |
|
483 |
|
484 # parse the rest of the arguments |
|
485 if len(args) < 1: |
|
486 localspec = 'localhost:8025' |
|
487 remotespec = 'localhost:25' |
|
488 elif len(args) < 2: |
|
489 localspec = args[0] |
|
490 remotespec = 'localhost:25' |
|
491 elif len(args) < 3: |
|
492 localspec = args[0] |
|
493 remotespec = args[1] |
|
494 else: |
|
495 usage(1, 'Invalid arguments: %s' % COMMASPACE.join(args)) |
|
496 |
|
497 # split into host/port pairs |
|
498 i = localspec.find(':') |
|
499 if i < 0: |
|
500 usage(1, 'Bad local spec: %s' % localspec) |
|
501 options.localhost = localspec[:i] |
|
502 try: |
|
503 options.localport = int(localspec[i+1:]) |
|
504 except ValueError: |
|
505 usage(1, 'Bad local port: %s' % localspec) |
|
506 i = remotespec.find(':') |
|
507 if i < 0: |
|
508 usage(1, 'Bad remote spec: %s' % remotespec) |
|
509 options.remotehost = remotespec[:i] |
|
510 try: |
|
511 options.remoteport = int(remotespec[i+1:]) |
|
512 except ValueError: |
|
513 usage(1, 'Bad remote port: %s' % remotespec) |
|
514 return options |
|
515 |
|
516 |
|
517 |
|
518 if __name__ == '__main__': |
|
519 options = parseargs() |
|
520 # Become nobody |
|
521 if options.setuid: |
|
522 try: |
|
523 import pwd |
|
524 except ImportError: |
|
525 print >> sys.stderr, \ |
|
526 'Cannot import module "pwd"; try running with -n option.' |
|
527 sys.exit(1) |
|
528 nobody = pwd.getpwnam('nobody')[2] |
|
529 try: |
|
530 os.setuid(nobody) |
|
531 except OSError, e: |
|
532 if e.errno != errno.EPERM: raise |
|
533 print >> sys.stderr, \ |
|
534 'Cannot setuid "nobody"; try running with -n option.' |
|
535 sys.exit(1) |
|
536 classname = options.classname |
|
537 if "." in classname: |
|
538 lastdot = classname.rfind(".") |
|
539 mod = __import__(classname[:lastdot], globals(), locals(), [""]) |
|
540 classname = classname[lastdot+1:] |
|
541 else: |
|
542 import __main__ as mod |
|
543 class_ = getattr(mod, classname) |
|
544 proxy = class_((options.localhost, options.localport), |
|
545 (options.remotehost, options.remoteport)) |
|
546 try: |
|
547 asyncore.loop() |
|
548 except KeyboardInterrupt: |
|
549 pass |