home *** CD-ROM | disk | FTP | other *** search
/ Freelog 33 / Freelog033.iso / Progr / Python-2.2.1.exe / SMTPD.PY < prev    next >
Encoding:
Python Source  |  2001-11-04  |  18.0 KB  |  547 lines

  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 `SMTPProxy' 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@digicool.com>
  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)
  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)
  241.         if not address:
  242.             self.push('501 Syntax: RCPT TO: <address>')
  243.             return
  244.         if address.lower().startswith('stimpy'):
  245.             self.push('503 You suck %s' % address)
  246.             return
  247.         self.__rcpttos.append(address)
  248.         print >> DEBUGSTREAM, 'recips:', self.__rcpttos
  249.         self.push('250 Ok')
  250.  
  251.     def smtp_RSET(self, arg):
  252.         if arg:
  253.             self.push('501 Syntax: RSET')
  254.             return
  255.         # Resets the sender, recipients, and data, but not the greeting
  256.         self.__mailfrom = None
  257.         self.__rcpttos = []
  258.         self.__data = ''
  259.         self.__state = self.COMMAND
  260.         self.push('250 Ok')
  261.  
  262.     def smtp_DATA(self, arg):
  263.         if not self.__rcpttos:
  264.             self.push('503 Error: need RCPT command')
  265.             return
  266.         if arg:
  267.             self.push('501 Syntax: DATA')
  268.             return
  269.         self.__state = self.DATA
  270.         self.set_terminator('\r\n.\r\n')
  271.         self.push('354 End data with <CR><LF>.<CR><LF>')
  272.  
  273.  
  274.  
  275. class SMTPServer(asyncore.dispatcher):
  276.     def __init__(self, localaddr, remoteaddr):
  277.         self._localaddr = localaddr
  278.         self._remoteaddr = remoteaddr
  279.         asyncore.dispatcher.__init__(self)
  280.         self.create_socket(socket.AF_INET, socket.SOCK_STREAM)
  281.         # try to re-use a server port if possible
  282.         self.set_reuse_addr()
  283.         self.bind(localaddr)
  284.         self.listen(5)
  285.         print >> DEBUGSTREAM, \
  286.               '%s started at %s\n\tLocal addr: %s\n\tRemote addr:%s' % (
  287.             self.__class__.__name__, time.ctime(time.time()),
  288.             localaddr, remoteaddr)
  289.  
  290.     def handle_accept(self):
  291.         conn, addr = self.accept()
  292.         print >> DEBUGSTREAM, 'Incoming connection from %s' % repr(addr)
  293.         channel = SMTPChannel(self, conn, addr)
  294.  
  295.     # API for "doing something useful with the message"
  296.     def process_message(self, peer, mailfrom, rcpttos, data):
  297.         """Override this abstract method to handle messages from the client.
  298.  
  299.         peer is a tuple containing (ipaddr, port) of the client that made the
  300.         socket connection to our smtp port.
  301.  
  302.         mailfrom is the raw address the client claims the message is coming
  303.         from.
  304.  
  305.         rcpttos is a list of raw addresses the client wishes to deliver the
  306.         message to.
  307.  
  308.         data is a string containing the entire full text of the message,
  309.         headers (if supplied) and all.  It has been `de-transparencied'
  310.         according to RFC 821, Section 4.5.2.  In other words, a line
  311.         containing a `.' followed by other text has had the leading dot
  312.         removed.
  313.  
  314.         This function should return None, for a normal `250 Ok' response;
  315.         otherwise it returns the desired response string in RFC 821 format.
  316.  
  317.         """
  318.         raise NotImplementedError
  319.  
  320.  
  321.  
  322. class DebuggingServer(SMTPServer):
  323.     # Do something with the gathered message
  324.     def process_message(self, peer, mailfrom, rcpttos, data):
  325.         inheaders = 1
  326.         lines = data.split('\n')
  327.         print '---------- MESSAGE FOLLOWS ----------'
  328.         for line in lines:
  329.             # headers first
  330.             if inheaders and not line:
  331.                 print 'X-Peer:', peer[0]
  332.                 inheaders = 0
  333.             print line
  334.         print '------------ END MESSAGE ------------'
  335.  
  336.  
  337.  
  338. class PureProxy(SMTPServer):
  339.     def process_message(self, peer, mailfrom, rcpttos, data):
  340.         lines = data.split('\n')
  341.         # Look for the last header
  342.         i = 0
  343.         for line in lines:
  344.             if not line:
  345.                 break
  346.             i += 1
  347.         lines.insert(i, 'X-Peer: %s' % peer[0])
  348.         data = NEWLINE.join(lines)
  349.         refused = self._deliver(mailfrom, rcpttos, data)
  350.         # TBD: what to do with refused addresses?
  351.         print >> DEBUGSTREAM, 'we got some refusals'
  352.  
  353.     def _deliver(self, mailfrom, rcpttos, data):
  354.         import smtplib
  355.         refused = {}
  356.         try:
  357.             s = smtplib.SMTP()
  358.             s.connect(self._remoteaddr[0], self._remoteaddr[1])
  359.             try:
  360.                 refused = s.sendmail(mailfrom, rcpttos, data)
  361.             finally:
  362.                 s.quit()
  363.         except smtplib.SMTPRecipientsRefused, e:
  364.             print >> DEBUGSTREAM, 'got SMTPRecipientsRefused'
  365.             refused = e.recipients
  366.         except (socket.error, smtplib.SMTPException), e:
  367.             print >> DEBUGSTREAM, 'got', e.__class__
  368.             # All recipients were refused.  If the exception had an associated
  369.             # error code, use it.  Otherwise,fake it with a non-triggering
  370.             # exception code.
  371.             errcode = getattr(e, 'smtp_code', -1)
  372.             errmsg = getattr(e, 'smtp_error', 'ignore')
  373.             for r in rcpttos:
  374.                 refused[r] = (errcode, errmsg)
  375.         return refused
  376.  
  377.  
  378.  
  379. class MailmanProxy(PureProxy):
  380.     def process_message(self, peer, mailfrom, rcpttos, data):
  381.         from cStringIO import StringIO
  382.         from Mailman import Utils
  383.         from Mailman import Message
  384.         from Mailman import MailList
  385.         # If the message is to a Mailman mailing list, then we'll invoke the
  386.         # Mailman script directly, without going through the real smtpd.
  387.         # Otherwise we'll forward it to the local proxy for disposition.
  388.         listnames = []
  389.         for rcpt in rcpttos:
  390.             local = rcpt.lower().split('@')[0]
  391.             # We allow the following variations on the theme
  392.             #   listname
  393.             #   listname-admin
  394.             #   listname-owner
  395.             #   listname-request
  396.             #   listname-join
  397.             #   listname-leave
  398.             parts = local.split('-')
  399.             if len(parts) > 2:
  400.                 continue
  401.             listname = parts[0]
  402.             if len(parts) == 2:
  403.                 command = parts[1]
  404.             else:
  405.                 command = ''
  406.             if not Utils.list_exists(listname) or command not in (
  407.                     '', 'admin', 'owner', 'request', 'join', 'leave'):
  408.                 continue
  409.             listnames.append((rcpt, listname, command))
  410.         # Remove all list recipients from rcpttos and forward what we're not
  411.         # going to take care of ourselves.  Linear removal should be fine
  412.         # since we don't expect a large number of recipients.
  413.         for rcpt, listname, command in listnames:
  414.             rcpttos.remove(rcpt)
  415.         # If there's any non-list destined recipients left,
  416.         print >> DEBUGSTREAM, 'forwarding recips:', ' '.join(rcpttos)
  417.         if rcpttos:
  418.             refused = self._deliver(mailfrom, rcpttos, data)
  419.             # TBD: what to do with refused addresses?
  420.             print >> DEBUGSTREAM, 'we got refusals'
  421.         # Now deliver directly to the list commands
  422.         mlists = {}
  423.         s = StringIO(data)
  424.         msg = Message.Message(s)
  425.         # These headers are required for the proper execution of Mailman.  All
  426.         # MTAs in existance seem to add these if the original message doesn't
  427.         # have them.
  428.         if not msg.getheader('from'):
  429.             msg['From'] = mailfrom
  430.         if not msg.getheader('date'):
  431.             msg['Date'] = time.ctime(time.time())
  432.         for rcpt, listname, command in listnames:
  433.             print >> DEBUGSTREAM, 'sending message to', rcpt
  434.             mlist = mlists.get(listname)
  435.             if not mlist:
  436.                 mlist = MailList.MailList(listname, lock=0)
  437.                 mlists[listname] = mlist
  438.             # dispatch on the type of command
  439.             if command == '':
  440.                 # post
  441.                 msg.Enqueue(mlist, tolist=1)
  442.             elif command == 'admin':
  443.                 msg.Enqueue(mlist, toadmin=1)
  444.             elif command == 'owner':
  445.                 msg.Enqueue(mlist, toowner=1)
  446.             elif command == 'request':
  447.                 msg.Enqueue(mlist, torequest=1)
  448.             elif command in ('join', 'leave'):
  449.                 # TBD: this is a hack!
  450.                 if command == 'join':
  451.                     msg['Subject'] = 'subscribe'
  452.                 else:
  453.                     msg['Subject'] = 'unsubscribe'
  454.                 msg.Enqueue(mlist, torequest=1)
  455.  
  456.  
  457.  
  458. class Options:
  459.     setuid = 1
  460.     classname = 'PureProxy'
  461.  
  462.  
  463.  
  464. def parseargs():
  465.     global DEBUGSTREAM
  466.     try:
  467.         opts, args = getopt.getopt(
  468.             sys.argv[1:], 'nVhc:d',
  469.             ['class=', 'nosetuid', 'version', 'help', 'debug'])
  470.     except getopt.error, e:
  471.         usage(1, e)
  472.  
  473.     options = Options()
  474.     for opt, arg in opts:
  475.         if opt in ('-h', '--help'):
  476.             usage(0)
  477.         elif opt in ('-V', '--version'):
  478.             print >> sys.stderr, __version__
  479.             sys.exit(0)
  480.         elif opt in ('-n', '--nosetuid'):
  481.             options.setuid = 0
  482.         elif opt in ('-c', '--class'):
  483.             options.classname = arg
  484.         elif opt in ('-d', '--debug'):
  485.             DEBUGSTREAM = sys.stderr
  486.  
  487.     # parse the rest of the arguments
  488.     if len(args) < 1:
  489.         localspec = 'localhost:8025'
  490.         remotespec = 'localhost:25'
  491.     elif len(args) < 2:
  492.         localspec = args[0]
  493.         remotespec = 'localhost:25'
  494.     elif len(args) < 3:
  495.         localspec = args[0]
  496.         remotespec = args[1]
  497.     else:
  498.         usage(1, 'Invalid arguments: %s' % COMMASPACE.join(args))
  499.  
  500.     # split into host/port pairs
  501.     i = localspec.find(':')
  502.     if i < 0:
  503.         usage(1, 'Bad local spec: %s' % localspec)
  504.     options.localhost = localspec[:i]
  505.     try:
  506.         options.localport = int(localspec[i+1:])
  507.     except ValueError:
  508.         usage(1, 'Bad local port: %s' % localspec)
  509.     i = remotespec.find(':')
  510.     if i < 0:
  511.         usage(1, 'Bad remote spec: %s' % remotespec)
  512.     options.remotehost = remotespec[:i]
  513.     try:
  514.         options.remoteport = int(remotespec[i+1:])
  515.     except ValueError:
  516.         usage(1, 'Bad remote port: %s' % remotespec)
  517.     return options
  518.  
  519.  
  520.  
  521. if __name__ == '__main__':
  522.     options = parseargs()
  523.     # Become nobody
  524.     if options.setuid:
  525.         try:
  526.             import pwd
  527.         except ImportError:
  528.             print >> sys.stderr, \
  529.                   'Cannot import module "pwd"; try running with -n option.'
  530.             sys.exit(1)
  531.         nobody = pwd.getpwnam('nobody')[2]
  532.         try:
  533.             os.setuid(nobody)
  534.         except OSError, e:
  535.             if e.errno != errno.EPERM: raise
  536.             print >> sys.stderr, \
  537.                   'Cannot setuid "nobody"; try running with -n option.'
  538.             sys.exit(1)
  539.     import __main__
  540.     class_ = getattr(__main__, options.classname)
  541.     proxy = class_((options.localhost, options.localport),
  542.                    (options.remotehost, options.remoteport))
  543.     try:
  544.         asyncore.loop()
  545.     except KeyboardInterrupt:
  546.         pass
  547.