diff --git a/Lib/smtpd.py b/Lib/smtpd.py new file mode 100755 index 00000000000..21c0114079e --- /dev/null +++ b/Lib/smtpd.py @@ -0,0 +1,531 @@ +#! /usr/bin/env python +"""An RFC 821 smtp proxy. + +Usage: %(program)s [options] localhost:port remotehost:port + +Options: + + --nosetuid + -n + This program generally tries to setuid `nobody', unless this flag is + set. The setuid call will fail if this program is not run as root (in + which case, use this flag). + + --version + -V + Print the version number and exit. + + --class classname + -c classname + Use `classname' as the concrete SMTP proxy class. Uses `SMTPProxy' by + default. + + --debug + -d + Turn on debugging prints. + + --help + -h + Print this message and exit. + +Version: %(__version__)s + +""" + +# Overview: +# +# This file implements the minimal SMTP protocol as defined in RFC 821. It +# has a hierarchy of classes which implement the backend functionality for the +# smtpd. A number of classes are provided: +# +# SMTPServer - the base class for the backend. Raises an UnimplementedError +# if you try to use it. +# +# DebuggingServer - simply prints each message it receives on stdout. +# +# PureProxy - Proxies all messages to a real smtpd which does final +# delivery. One known problem with this class is that it doesn't handle +# SMTP errors from the backend server at all. This should be fixed +# (contributions are welcome!). +# +# MailmanProxy - An experimental hack to work with GNU Mailman +# . Using this server as your real incoming smtpd, your +# mailhost will automatically recognize and accept mail destined to Mailman +# lists when those lists are created. Every message not destined for a list +# gets forwarded to a real backend smtpd, as with PureProxy. Again, errors +# are not handled correctly yet. +# +# Please note that this script requires Python 2.0 +# +# Author: Barry Warsaw +# +# TODO: +# +# - support mailbox delivery +# - alias files +# - ESMTP +# - handle error codes from the backend smtpd + +import sys +import os +import errno +import getopt +import time +import socket +import asyncore +import asynchat + + +program = sys.argv[0] +__version__ = 'Python SMTP proxy version 0.2' + + +class Devnull: + def write(self, msg): pass + def flush(self): pass + + +DEBUGSTREAM = Devnull() +NEWLINE = '\n' +EMPTYSTRING = '' + + + +def usage(code, msg=''): + print >> sys.stderr, __doc__ % globals() + if msg: + print >> sys.stderr, msg + sys.exit(code) + + + +class SMTPChannel(asynchat.async_chat): + COMMAND = 0 + DATA = 1 + + def __init__(self, server, conn, addr): + asynchat.async_chat.__init__(self, conn) + self.__server = server + self.__conn = conn + self.__addr = addr + self.__line = [] + self.__state = self.COMMAND + self.__greeting = 0 + self.__mailfrom = None + self.__rcpttos = [] + self.__data = '' + self.__fqdn = socket.gethostbyaddr( + socket.gethostbyname(socket.gethostname()))[0] + self.__peer = conn.getpeername() + print >> DEBUGSTREAM, 'Peer:', repr(self.__peer) + self.push('220 %s %s' % (self.__fqdn, __version__)) + self.set_terminator('\r\n') + + # Overrides base class for convenience + def push(self, msg): + asynchat.async_chat.push(self, msg + '\r\n') + + # Implementation of base class abstract method + def collect_incoming_data(self, data): + self.__line.append(data) + + # Implementation of base class abstract method + def found_terminator(self): + line = EMPTYSTRING.join(self.__line) + self.__line = [] + if self.__state == self.COMMAND: + if not line: + self.push('500 Error: bad syntax') + return + method = None + i = line.find(' ') + if i < 0: + command = line.upper() + arg = None + else: + command = line[:i].upper() + arg = line[i+1:].strip() + method = getattr(self, 'smtp_' + command, None) + if not method: + self.push('502 Error: command "%s" not implemented' % command) + return + method(arg) + return + else: + if self.__state <> self.DATA: + self.push('451 Internal confusion') + return + # Remove extraneous carriage returns and de-transparency according + # to RFC 821, Section 4.5.2. + data = [] + for text in line.split('\r\n'): + if text and text[0] == '.': + data.append(text[1:]) + else: + data.append(text) + self.__data = NEWLINE.join(data) + status = self.__server.process_message(self.__peer, + self.__mailfrom, + self.__rcpttos, + self.__data) + self.__rcpttos = [] + self.__mailfrom = None + self.__state = self.COMMAND + self.set_terminator('\r\n') + if not status: + self.push('250 Ok') + else: + self.push(status) + + # SMTP and ESMTP commands + def smtp_HELO(self, arg): + if not arg: + self.push('501 Syntax: HELO hostname') + return + if self.__greeting: + self.push('503 Duplicate HELO/EHLO') + else: + self.__greeting = arg + self.push('250 %s' % self.__fqdn) + + def smtp_NOOP(self, arg): + if arg: + self.push('501 Syntax: NOOP') + else: + self.push('250 Ok') + + def smtp_QUIT(self, arg): + # args is ignored + self.push('221 Bye') + self.close_when_done() + + # factored + def __getaddr(self, keyword, arg): + address = None + keylen = len(keyword) + if arg[:keylen].upper() == keyword: + address = arg[keylen:].strip() + if address[0] == '<' and address[-1] == '>' and address <> '<>': + # Addresses can be in the form but watch out + # for null address, e.g. <> + address = address[1:-1] + return address + + def smtp_MAIL(self, arg): + print >> DEBUGSTREAM, '===> MAIL', arg + address = self.__getaddr('FROM:', arg) + if not address: + self.push('501 Syntax: MAIL FROM:
') + return + if self.__mailfrom: + self.push('503 Error: nested MAIL command') + return + self.__mailfrom = address + print >> DEBUGSTREAM, 'sender:', self.__mailfrom + self.push('250 Ok') + + def smtp_RCPT(self, arg): + print >> DEBUGSTREAM, '===> RCPT', arg + if not self.__mailfrom: + self.push('503 Error: need MAIL command') + return + address = self.__getaddr('TO:', arg) + if not address: + self.push('501 Syntax: RCPT TO:
') + return + if address.lower().startswith('stimpy'): + self.push('503 You suck %s' % address) + return + self.__rcpttos.append(address) + print >> DEBUGSTREAM, 'recips:', self.__rcpttos + self.push('250 Ok') + + def smtp_RSET(self, arg): + if arg: + self.push('501 Syntax: RSET') + return + # Resets the sender, recipients, and data, but not the greeting + self.__mailfrom = None + self.__rcpttos = [] + self.__data = '' + self.__state = self.COMMAND + self.push('250 Ok') + + def smtp_DATA(self, arg): + if not self.__rcpttos: + self.push('503 Error: need RCPT command') + return + if arg: + self.push('501 Syntax: DATA') + return + self.__state = self.DATA + self.set_terminator('\r\n.\r\n') + self.push('354 End data with .') + + + +class SMTPServer(asyncore.dispatcher): + def __init__(self, localaddr, remoteaddr): + self._localaddr = localaddr + self._remoteaddr = remoteaddr + asyncore.dispatcher.__init__(self) + self.create_socket(socket.AF_INET, socket.SOCK_STREAM) + # try to re-use a server port if possible + self.socket.setsockopt( + socket.SOL_SOCKET, socket.SO_REUSEADDR, + self.socket.getsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR) | 1) + self.bind(localaddr) + self.listen(5) + print '%s started at %s\n\tLocal addr: %s\n\tRemote addr:%s' % ( + self.__class__.__name__, time.ctime(time.time()), + localaddr, remoteaddr) + + def handle_accept(self): + conn, addr = self.accept() + print >> DEBUGSTREAM, 'Incoming connection from %s' % repr(addr) + channel = SMTPChannel(self, conn, addr) + + # API for "doing something useful with the message" + def process_message(self, peer, mailfrom, rcpttos, data): + """Override this abstract method to handle messages from the client. + + peer is a tuple containing (ipaddr, port) of the client that made the + socket connection to our smtp port. + + mailfrom is the raw address the client claims the message is coming + from. + + rcpttos is a list of raw addresses the client wishes to deliver the + message to. + + data is a string containing the entire full text of the message, + headers (if supplied) and all. It has been `de-transparencied' + according to RFC 821, Section 4.5.2. In other words, a line + containing a `.' followed by other text has had the leading dot + removed. + + This function should return None, for a normal `250 Ok' response; + otherwise it returns the desired response string in RFC 821 format. + + """ + raise UnimplementedError + + +class DebuggingServer(SMTPServer): + # Do something with the gathered message + def process_message(self, peer, mailfrom, rcpttos, data): + inheaders = 1 + lines = data.split('\n') + print '---------- MESSAGE FOLLOWS ----------' + for line in lines: + # headers first + if inheaders and not line: + print 'X-Peer:', peer[0] + inheaders = 0 + print line + print '------------ END MESSAGE ------------' + + + +class PureProxy(SMTPServer): + def process_message(self, peer, mailfrom, rcpttos, data): + lines = data.split('\n') + # Look for the last header + i = 0 + for line in lines: + if not line: + break + i += 1 + lines.insert(i, 'X-Peer: %s' % peer[0]) + data = NEWLINE.join(lines) + refused = self._deliver(mailfrom, rcpttos, data) + # TBD: what to do with refused addresses? + print >> DEBUGSTREAM, 'we got some refusals' + + def _deliver(self, mailfrom, rcpttos, data): + import smtplib + refused = {} + try: + s = smtplib.SMTP() + s.connect(self._remoteaddr[0], self._remoteaddr[1]) + try: + refused = s.sendmail(mailfrom, rcpttos, data) + finally: + s.quit() + except smtplib.SMTPRecipientsRefused, e: + print >> DEBUGSTREAM, 'got SMTPRecipientsRefused' + refused = e.recipients + except (socket.error, smtplib.SMTPException), e: + print >> DEBUGSTREAM, 'got', e.__class__ + # All recipients were refused. If the exception had an associated + # error code, use it. Otherwise,fake it with a non-triggering + # exception code. + errcode = getattr(e, 'smtp_code', -1) + errmsg = getattr(e, 'smtp_error', 'ignore') + for r in rcpttos: + refused[r] = (errcode, errmsg) + return refused + + + +class MailmanProxy(PureProxy): + def process_message(self, peer, mailfrom, rcpttos, data): + from cStringIO import StringIO + import paths + from Mailman import Utils + from Mailman import Message + from Mailman import MailList + # If the message is to a Mailman mailing list, then we'll invoke the + # Mailman script directly, without going through the real smtpd. + # Otherwise we'll forward it to the local proxy for disposition. + listnames = [] + for rcpt in rcpttos: + local = rcpt.lower().split('@')[0] + # We allow the following variations on the theme + # listname + # listname-admin + # listname-owner + # listname-request + # listname-join + # listname-leave + parts = local.split('-') + if len(parts) > 2: + continue + listname = parts[0] + if len(parts) == 2: + command = parts[1] + else: + command = '' + if not Utils.list_exists(listname) or command not in ( + '', 'admin', 'owner', 'request', 'join', 'leave'): + continue + listnames.append((rcpt, listname, command)) + # Remove all list recipients from rcpttos and forward what we're not + # going to take care of ourselves. Linear removal should be fine + # since we don't expect a large number of recipients. + for rcpt, listname, command in listnames: + rcpttos.remove(rcpt) + # If there's any non-list destined recipients left, + print >> DEBUGSTREAM, 'forwarding recips:', ' '.join(rcpttos) + if rcpttos: + refused = self._deliver(mailfrom, rcpttos, data) + # TBD: what to do with refused addresses? + print >> DEBUGSTREAM, 'we got refusals' + # Now deliver directly to the list commands + mlists = {} + s = StringIO(data) + msg = Message.Message(s) + # These headers are required for the proper execution of Mailman. All + # MTAs in existance seem to add these if the original message doesn't + # have them. + if not msg.getheader('from'): + msg['From'] = mailfrom + if not msg.getheader('date'): + msg['Date'] = time.ctime(time.time()) + for rcpt, listname, command in listnames: + print >> DEBUGSTREAM, 'sending message to', rcpt + mlist = mlists.get(listname) + if not mlist: + mlist = MailList.MailList(listname, lock=0) + mlists[listname] = mlist + # dispatch on the type of command + if command == '': + # post + msg.Enqueue(mlist, tolist=1) + elif command == 'admin': + msg.Enqueue(mlist, toadmin=1) + elif command == 'owner': + msg.Enqueue(mlist, toowner=1) + elif command == 'request': + msg.Enqueue(mlist, torequest=1) + elif command in ('join', 'leave'): + # TBD: this is a hack! + if command == 'join': + msg['Subject'] = 'subscribe' + else: + msg['Subject'] = 'unsubscribe' + msg.Enqueue(mlist, torequest=1) + + + +class Options: + setuid = 1 + classname = 'PureProxy' + + +def parseargs(): + global DEBUGSTREAM + try: + opts, args = getopt.getopt( + sys.argv[1:], 'nVhc:d', + ['class=', 'nosetuid', 'version', 'help', 'debug']) + except getopt.error, e: + usage(1, e) + + options = Options() + for opt, arg in opts: + if opt in ('-h', '--help'): + usage(0) + elif opt in ('-V', '--version'): + print >> sys.stderr, __version__ + sys.exit(0) + elif opt in ('-n', '--nosetuid'): + options.setuid = 0 + elif opt in ('-c', '--class'): + options.classname = arg + elif opt in ('-d', '--debug'): + DEBUGSTREAM = sys.stderr + + # parse the rest of the arguments + try: + localspec = args[0] + remotespec = args[1] + except IndexError: + usage(1, 'Not enough arguments') + # split into host/port pairs + i = localspec.find(':') + if i < 0: + usage(1, 'Bad local spec: "%s"' % localspec) + options.localhost = localspec[:i] + try: + options.localport = int(localspec[i+1:]) + except ValueError: + usage(1, 'Bad local port: "%s"' % localspec) + i = remotespec.find(':') + if i < 0: + usage(1, 'Bad remote spec: "%s"' % remotespec) + options.remotehost = remotespec[:i] + try: + options.remoteport = int(remotespec[i+1:]) + except ValueError: + usage(1, 'Bad remote port: "%s"' % remotespec) + return options + + + +if __name__ == '__main__': + options = parseargs() + # Become nobody + if options.setuid: + try: + import pwd + except ImportError: + print >> sys.stderr, \ + 'Cannot import module "pwd"; try running with -n option.' + sys.exit(1) + nobody = pwd.getpwnam('nobody')[2] + try: + os.setuid(nobody) + except OSError, e: + if e.errno <> errno.EPERM: raise + print >> sys.stderr, \ + 'Cannot setuid "nobody"; try running with -n option.' + sys.exit(1) + import __main__ + class_ = getattr(__main__, options.classname) + proxy = class_((options.localhost, options.localport), + (options.remotehost, options.remoteport)) + try: + asyncore.loop() + except KeyboardInterrupt: + pass