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