0001 #! /usr/bin/env python 0002 """An RFC 2821 smtp proxy. 0003 0004 Usage: %(program)s [options] [localhost:localport [remotehost:remoteport]] 0005 0006 Options: 0007 0008 --nosetuid 0009 -n 0010 This program generally tries to setuid `nobody', unless this flag is 0011 set. The setuid call will fail if this program is not run as root (in 0012 which case, use this flag). 0013 0014 --version 0015 -V 0016 Print the version number and exit. 0017 0018 --class classname 0019 -c classname 0020 Use `classname' as the concrete SMTP proxy class. Uses `PureProxy' by 0021 default. 0022 0023 --debug 0024 -d 0025 Turn on debugging prints. 0026 0027 --help 0028 -h 0029 Print this message and exit. 0030 0031 Version: %(__version__)s 0032 0033 If localhost is not given then `localhost' is used, and if localport is not 0034 given then 8025 is used. If remotehost is not given then `localhost' is used, 0035 and if remoteport is not given, then 25 is used. 0036 """ 0037 0038 0039 # Overview: 0040 # 0041 # This file implements the minimal SMTP protocol as defined in RFC 821. It 0042 # has a hierarchy of classes which implement the backend functionality for the 0043 # smtpd. A number of classes are provided: 0044 # 0045 # SMTPServer - the base class for the backend. Raises NotImplementedError 0046 # if you try to use it. 0047 # 0048 # DebuggingServer - simply prints each message it receives on stdout. 0049 # 0050 # PureProxy - Proxies all messages to a real smtpd which does final 0051 # delivery. One known problem with this class is that it doesn't handle 0052 # SMTP errors from the backend server at all. This should be fixed 0053 # (contributions are welcome!). 0054 # 0055 # MailmanProxy - An experimental hack to work with GNU Mailman 0056 # <www.list.org>. Using this server as your real incoming smtpd, your 0057 # mailhost will automatically recognize and accept mail destined to Mailman 0058 # lists when those lists are created. Every message not destined for a list 0059 # gets forwarded to a real backend smtpd, as with PureProxy. Again, errors 0060 # are not handled correctly yet. 0061 # 0062 # Please note that this script requires Python 2.0 0063 # 0064 # Author: Barry Warsaw <barry@python.org> 0065 # 0066 # TODO: 0067 # 0068 # - support mailbox delivery 0069 # - alias files 0070 # - ESMTP 0071 # - handle error codes from the backend smtpd 0072 0073 import sys 0074 import os 0075 import errno 0076 import getopt 0077 import time 0078 import socket 0079 import asyncore 0080 import asynchat 0081 0082 __all__ = ["SMTPServer","DebuggingServer","PureProxy","MailmanProxy"] 0083 0084 program = sys.argv[0] 0085 __version__ = 'Python SMTP proxy version 0.2' 0086 0087 0088 class Devnull: 0089 def write(self, msg): pass 0090 def flush(self): pass 0091 0092 0093 DEBUGSTREAM = Devnull() 0094 NEWLINE = '\n' 0095 EMPTYSTRING = '' 0096 COMMASPACE = ', ' 0097 0098 0099 0100 def usage(code, msg=''): 0101 print >> sys.stderr, __doc__ % globals() 0102 if msg: 0103 print >> sys.stderr, msg 0104 sys.exit(code) 0105 0106 0107 0108 class SMTPChannel(asynchat.async_chat): 0109 COMMAND = 0 0110 DATA = 1 0111 0112 def __init__(self, server, conn, addr): 0113 asynchat.async_chat.__init__(self, conn) 0114 self.__server = server 0115 self.__conn = conn 0116 self.__addr = addr 0117 self.__line = [] 0118 self.__state = self.COMMAND 0119 self.__greeting = 0 0120 self.__mailfrom = None 0121 self.__rcpttos = [] 0122 self.__data = '' 0123 self.__fqdn = socket.getfqdn() 0124 self.__peer = conn.getpeername() 0125 print >> DEBUGSTREAM, 'Peer:', repr(self.__peer) 0126 self.push('220 %s %s' % (self.__fqdn, __version__)) 0127 self.set_terminator('\r\n') 0128 0129 # Overrides base class for convenience 0130 def push(self, msg): 0131 asynchat.async_chat.push(self, msg + '\r\n') 0132 0133 # Implementation of base class abstract method 0134 def collect_incoming_data(self, data): 0135 self.__line.append(data) 0136 0137 # Implementation of base class abstract method 0138 def found_terminator(self): 0139 line = EMPTYSTRING.join(self.__line) 0140 print >> DEBUGSTREAM, 'Data:', repr(line) 0141 self.__line = [] 0142 if self.__state == self.COMMAND: 0143 if not line: 0144 self.push('500 Error: bad syntax') 0145 return 0146 method = None 0147 i = line.find(' ') 0148 if i < 0: 0149 command = line.upper() 0150 arg = None 0151 else: 0152 command = line[:i].upper() 0153 arg = line[i+1:].strip() 0154 method = getattr(self, 'smtp_' + command, None) 0155 if not method: 0156 self.push('502 Error: command "%s" not implemented' % command) 0157 return 0158 method(arg) 0159 return 0160 else: 0161 if self.__state != self.DATA: 0162 self.push('451 Internal confusion') 0163 return 0164 # Remove extraneous carriage returns and de-transparency according 0165 # to RFC 821, Section 4.5.2. 0166 data = [] 0167 for text in line.split('\r\n'): 0168 if text and text[0] == '.': 0169 data.append(text[1:]) 0170 else: 0171 data.append(text) 0172 self.__data = NEWLINE.join(data) 0173 status = self.__server.process_message(self.__peer, 0174 self.__mailfrom, 0175 self.__rcpttos, 0176 self.__data) 0177 self.__rcpttos = [] 0178 self.__mailfrom = None 0179 self.__state = self.COMMAND 0180 self.set_terminator('\r\n') 0181 if not status: 0182 self.push('250 Ok') 0183 else: 0184 self.push(status) 0185 0186 # SMTP and ESMTP commands 0187 def smtp_HELO(self, arg): 0188 if not arg: 0189 self.push('501 Syntax: HELO hostname') 0190 return 0191 if self.__greeting: 0192 self.push('503 Duplicate HELO/EHLO') 0193 else: 0194 self.__greeting = arg 0195 self.push('250 %s' % self.__fqdn) 0196 0197 def smtp_NOOP(self, arg): 0198 if arg: 0199 self.push('501 Syntax: NOOP') 0200 else: 0201 self.push('250 Ok') 0202 0203 def smtp_QUIT(self, arg): 0204 # args is ignored 0205 self.push('221 Bye') 0206 self.close_when_done() 0207 0208 # factored 0209 def __getaddr(self, keyword, arg): 0210 address = None 0211 keylen = len(keyword) 0212 if arg[:keylen].upper() == keyword: 0213 address = arg[keylen:].strip() 0214 if not address: 0215 pass 0216 elif address[0] == '<' and address[-1] == '>' and address != '<>': 0217 # Addresses can be in the form <person@dom.com> but watch out 0218 # for null address, e.g. <> 0219 address = address[1:-1] 0220 return address 0221 0222 def smtp_MAIL(self, arg): 0223 print >> DEBUGSTREAM, '===> MAIL', arg 0224 address = self.__getaddr('FROM:', arg) 0225 if not address: 0226 self.push('501 Syntax: MAIL FROM:<address>') 0227 return 0228 if self.__mailfrom: 0229 self.push('503 Error: nested MAIL command') 0230 return 0231 self.__mailfrom = address 0232 print >> DEBUGSTREAM, 'sender:', self.__mailfrom 0233 self.push('250 Ok') 0234 0235 def smtp_RCPT(self, arg): 0236 print >> DEBUGSTREAM, '===> RCPT', arg 0237 if not self.__mailfrom: 0238 self.push('503 Error: need MAIL command') 0239 return 0240 address = self.__getaddr('TO:', arg) 0241 if not address: 0242 self.push('501 Syntax: RCPT TO: <address>') 0243 return 0244 self.__rcpttos.append(address) 0245 print >> DEBUGSTREAM, 'recips:', self.__rcpttos 0246 self.push('250 Ok') 0247 0248 def smtp_RSET(self, arg): 0249 if arg: 0250 self.push('501 Syntax: RSET') 0251 return 0252 # Resets the sender, recipients, and data, but not the greeting 0253 self.__mailfrom = None 0254 self.__rcpttos = [] 0255 self.__data = '' 0256 self.__state = self.COMMAND 0257 self.push('250 Ok') 0258 0259 def smtp_DATA(self, arg): 0260 if not self.__rcpttos: 0261 self.push('503 Error: need RCPT command') 0262 return 0263 if arg: 0264 self.push('501 Syntax: DATA') 0265 return 0266 self.__state = self.DATA 0267 self.set_terminator('\r\n.\r\n') 0268 self.push('354 End data with <CR><LF>.<CR><LF>') 0269 0270 0271 0272 class SMTPServer(asyncore.dispatcher): 0273 def __init__(self, localaddr, remoteaddr): 0274 self._localaddr = localaddr 0275 self._remoteaddr = remoteaddr 0276 asyncore.dispatcher.__init__(self) 0277 self.create_socket(socket.AF_INET, socket.SOCK_STREAM) 0278 # try to re-use a server port if possible 0279 self.set_reuse_addr() 0280 self.bind(localaddr) 0281 self.listen(5) 0282 print >> DEBUGSTREAM, \ 0283 '%s started at %s\n\tLocal addr: %s\n\tRemote addr:%s' % ( 0284 self.__class__.__name__, time.ctime(time.time()), 0285 localaddr, remoteaddr) 0286 0287 def handle_accept(self): 0288 conn, addr = self.accept() 0289 print >> DEBUGSTREAM, 'Incoming connection from %s' % repr(addr) 0290 channel = SMTPChannel(self, conn, addr) 0291 0292 # API for "doing something useful with the message" 0293 def process_message(self, peer, mailfrom, rcpttos, data): 0294 """Override this abstract method to handle messages from the client. 0295 0296 peer is a tuple containing (ipaddr, port) of the client that made the 0297 socket connection to our smtp port. 0298 0299 mailfrom is the raw address the client claims the message is coming 0300 from. 0301 0302 rcpttos is a list of raw addresses the client wishes to deliver the 0303 message to. 0304 0305 data is a string containing the entire full text of the message, 0306 headers (if supplied) and all. It has been `de-transparencied' 0307 according to RFC 821, Section 4.5.2. In other words, a line 0308 containing a `.' followed by other text has had the leading dot 0309 removed. 0310 0311 This function should return None, for a normal `250 Ok' response; 0312 otherwise it returns the desired response string in RFC 821 format. 0313 0314 """ 0315 raise NotImplementedError 0316 0317 0318 0319 class DebuggingServer(SMTPServer): 0320 # Do something with the gathered message 0321 def process_message(self, peer, mailfrom, rcpttos, data): 0322 inheaders = 1 0323 lines = data.split('\n') 0324 print '---------- MESSAGE FOLLOWS ----------' 0325 for line in lines: 0326 # headers first 0327 if inheaders and not line: 0328 print 'X-Peer:', peer[0] 0329 inheaders = 0 0330 print line 0331 print '------------ END MESSAGE ------------' 0332 0333 0334 0335 class PureProxy(SMTPServer): 0336 def process_message(self, peer, mailfrom, rcpttos, data): 0337 lines = data.split('\n') 0338 # Look for the last header 0339 i = 0 0340 for line in lines: 0341 if not line: 0342 break 0343 i += 1 0344 lines.insert(i, 'X-Peer: %s' % peer[0]) 0345 data = NEWLINE.join(lines) 0346 refused = self._deliver(mailfrom, rcpttos, data) 0347 # TBD: what to do with refused addresses? 0348 print >> DEBUGSTREAM, 'we got some refusals:', refused 0349 0350 def _deliver(self, mailfrom, rcpttos, data): 0351 import smtplib 0352 refused = {} 0353 try: 0354 s = smtplib.SMTP() 0355 s.connect(self._remoteaddr[0], self._remoteaddr[1]) 0356 try: 0357 refused = s.sendmail(mailfrom, rcpttos, data) 0358 finally: 0359 s.quit() 0360 except smtplib.SMTPRecipientsRefused, e: 0361 print >> DEBUGSTREAM, 'got SMTPRecipientsRefused' 0362 refused = e.recipients 0363 except (socket.error, smtplib.SMTPException), e: 0364 print >> DEBUGSTREAM, 'got', e.__class__ 0365 # All recipients were refused. If the exception had an associated 0366 # error code, use it. Otherwise,fake it with a non-triggering 0367 # exception code. 0368 errcode = getattr(e, 'smtp_code', -1) 0369 errmsg = getattr(e, 'smtp_error', 'ignore') 0370 for r in rcpttos: 0371 refused[r] = (errcode, errmsg) 0372 return refused 0373 0374 0375 0376 class MailmanProxy(PureProxy): 0377 def process_message(self, peer, mailfrom, rcpttos, data): 0378 from cStringIO import StringIO 0379 from Mailman import Utils 0380 from Mailman import Message 0381 from Mailman import MailList 0382 # If the message is to a Mailman mailing list, then we'll invoke the 0383 # Mailman script directly, without going through the real smtpd. 0384 # Otherwise we'll forward it to the local proxy for disposition. 0385 listnames = [] 0386 for rcpt in rcpttos: 0387 local = rcpt.lower().split('@')[0] 0388 # We allow the following variations on the theme 0389 # listname 0390 # listname-admin 0391 # listname-owner 0392 # listname-request 0393 # listname-join 0394 # listname-leave 0395 parts = local.split('-') 0396 if len(parts) > 2: 0397 continue 0398 listname = parts[0] 0399 if len(parts) == 2: 0400 command = parts[1] 0401 else: 0402 command = '' 0403 if not Utils.list_exists(listname) or command not in ( 0404 '', 'admin', 'owner', 'request', 'join', 'leave'): 0405 continue 0406 listnames.append((rcpt, listname, command)) 0407 # Remove all list recipients from rcpttos and forward what we're not 0408 # going to take care of ourselves. Linear removal should be fine 0409 # since we don't expect a large number of recipients. 0410 for rcpt, listname, command in listnames: 0411 rcpttos.remove(rcpt) 0412 # If there's any non-list destined recipients left, 0413 print >> DEBUGSTREAM, 'forwarding recips:', ' '.join(rcpttos) 0414 if rcpttos: 0415 refused = self._deliver(mailfrom, rcpttos, data) 0416 # TBD: what to do with refused addresses? 0417 print >> DEBUGSTREAM, 'we got refusals:', refused 0418 # Now deliver directly to the list commands 0419 mlists = {} 0420 s = StringIO(data) 0421 msg = Message.Message(s) 0422 # These headers are required for the proper execution of Mailman. All 0423 # MTAs in existance seem to add these if the original message doesn't 0424 # have them. 0425 if not msg.getheader('from'): 0426 msg['From'] = mailfrom 0427 if not msg.getheader('date'): 0428 msg['Date'] = time.ctime(time.time()) 0429 for rcpt, listname, command in listnames: 0430 print >> DEBUGSTREAM, 'sending message to', rcpt 0431 mlist = mlists.get(listname) 0432 if not mlist: 0433 mlist = MailList.MailList(listname, lock=0) 0434 mlists[listname] = mlist 0435 # dispatch on the type of command 0436 if command == '': 0437 # post 0438 msg.Enqueue(mlist, tolist=1) 0439 elif command == 'admin': 0440 msg.Enqueue(mlist, toadmin=1) 0441 elif command == 'owner': 0442 msg.Enqueue(mlist, toowner=1) 0443 elif command == 'request': 0444 msg.Enqueue(mlist, torequest=1) 0445 elif command in ('join', 'leave'): 0446 # TBD: this is a hack! 0447 if command == 'join': 0448 msg['Subject'] = 'subscribe' 0449 else: 0450 msg['Subject'] = 'unsubscribe' 0451 msg.Enqueue(mlist, torequest=1) 0452 0453 0454 0455 class Options: 0456 setuid = 1 0457 classname = 'PureProxy' 0458 0459 0460 0461 def parseargs(): 0462 global DEBUGSTREAM 0463 try: 0464 opts, args = getopt.getopt( 0465 sys.argv[1:], 'nVhc:d', 0466 ['class=', 'nosetuid', 'version', 'help', 'debug']) 0467 except getopt.error, e: 0468 usage(1, e) 0469 0470 options = Options() 0471 for opt, arg in opts: 0472 if opt in ('-h', '--help'): 0473 usage(0) 0474 elif opt in ('-V', '--version'): 0475 print >> sys.stderr, __version__ 0476 sys.exit(0) 0477 elif opt in ('-n', '--nosetuid'): 0478 options.setuid = 0 0479 elif opt in ('-c', '--class'): 0480 options.classname = arg 0481 elif opt in ('-d', '--debug'): 0482 DEBUGSTREAM = sys.stderr 0483 0484 # parse the rest of the arguments 0485 if len(args) < 1: 0486 localspec = 'localhost:8025' 0487 remotespec = 'localhost:25' 0488 elif len(args) < 2: 0489 localspec = args[0] 0490 remotespec = 'localhost:25' 0491 elif len(args) < 3: 0492 localspec = args[0] 0493 remotespec = args[1] 0494 else: 0495 usage(1, 'Invalid arguments: %s' % COMMASPACE.join(args)) 0496 0497 # split into host/port pairs 0498 i = localspec.find(':') 0499 if i < 0: 0500 usage(1, 'Bad local spec: %s' % localspec) 0501 options.localhost = localspec[:i] 0502 try: 0503 options.localport = int(localspec[i+1:]) 0504 except ValueError: 0505 usage(1, 'Bad local port: %s' % localspec) 0506 i = remotespec.find(':') 0507 if i < 0: 0508 usage(1, 'Bad remote spec: %s' % remotespec) 0509 options.remotehost = remotespec[:i] 0510 try: 0511 options.remoteport = int(remotespec[i+1:]) 0512 except ValueError: 0513 usage(1, 'Bad remote port: %s' % remotespec) 0514 return options 0515 0516 0517 0518 if __name__ == '__main__': 0519 options = parseargs() 0520 # Become nobody 0521 if options.setuid: 0522 try: 0523 import pwd 0524 except ImportError: 0525 print >> sys.stderr, \ 0526 'Cannot import module "pwd"; try running with -n option.' 0527 sys.exit(1) 0528 nobody = pwd.getpwnam('nobody')[2] 0529 try: 0530 os.setuid(nobody) 0531 except OSError, e: 0532 if e.errno != errno.EPERM: raise 0533 print >> sys.stderr, \ 0534 'Cannot setuid "nobody"; try running with -n option.' 0535 sys.exit(1) 0536 classname = options.classname 0537 if "." in classname: 0538 lastdot = classname.rfind(".") 0539 mod = __import__(classname[:lastdot], globals(), locals(), [""]) 0540 classname = classname[lastdot+1:] 0541 else: 0542 import __main__ as mod 0543 class_ = getattr(mod, classname) 0544 proxy = class_((options.localhost, options.localport), 0545 (options.remotehost, options.remoteport)) 0546 try: 0547 asyncore.loop() 0548 except KeyboardInterrupt: 0549 pass 0550
Generated by PyXR 0.9.4