#!/usr/pkg/bin/python2.7
#
# Copyright (C) 2001-2007 Jason R. Mastaler <jason@mastaler.com>
#
# This file is part of TMDA.
#
# TMDA is free software; you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.  A copy of this license should
# be included in the file COPYING.
#
# TMDA is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
# for more details.
#
# You should have received a copy of the GNU General Public License
# along with TMDA; if not, write to the Free Software Foundation, Inc.,
# 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA

# Based on code from Python's (undocumented) smtpd module
# Copyright (C) 2001,2002 Python Software Foundation.


from optparse import OptionGroup, OptionParser

import os
import signal
import socket
import sys
import asynchat
import asyncore
import base64
import hmac
import imaplib
import md5
import popen2
import poplib
import random
import time

try:
    import paths
except ImportError:
    # Prepend /usr/lib/python2.x/site-packages/TMDA/pythonlib
    sitedir = os.path.join(sys.prefix, 'lib', 'python'+sys.version[:3],
                           'site-packages', 'TMDA', 'pythonlib')
    sys.path.insert(0, sitedir)


from TMDA import Util
from TMDA import Version

# Classes

class Devnull:
    def write(self, msg): pass
    def flush(self): pass


class SMTPSession(asynchat.async_chat):
    COMMAND = 0
    DATA = 1
    AUTH = 2

    ac_in_buffer_size = 16384

    def __init__(self, conn, process_msg_func):
        # Base class __init__ calls

        if opts.ssl or opts.tls:
            TMDATLSAsyncDispatcherMixIn.__init__(self, conn, asynchat.async_chat)
            self.tlsConnection.ignoreAbruptClose = True
        asynchat.async_chat.__init__(self, conn)

        # Save our own __init__ parameters

        self.__conn = conn
        self.__process_msg_func = process_msg_func

        # Initialize object state

        self.set_terminator('\r\n')
        self.init_static_state()

        # Debug tracing

        print >> DEBUGSTREAM, 'Incoming connection from:', repr(self.__peer)
        print >> DEBUGSTREAM, 'Incoming connection to:', repr(self._local)

        # Start SSL session, or perform plain-text signon

        if opts.ssl:
            self.do_ssl_handshake()
        else:
            self.handle_connect()

    def init_static_state(self):
        """Initialize 'static state' - that state which is associated
        solely with the object, or the physical network connection.
        In particular, this state is not flushed by STARTTLS."""

        # If we're running under tcpserver, then it sets up a bunch of
        # environment variables that give socket address information.
        # We always use this, rather than e.g. calling getsockname on
        # conn, because the tcpserver socket might not be passed directly
        # to tmda-ofmipd. For example, stunnel might terminate the socket,
        # decrypt the data and send it here over a pipe...
        # Note: Whilst tcpserver does provide these variables, the
        # xinetd/stunnel combination does not.
        if opts.one_session and os.environ.has_key('TCPREMOTEIP'):
            self.__peerip = os.environ['TCPREMOTEIP']
            self.__peername = os.environ.get('TCPREMOTEHOST', None)
            if not self.__peername:
                self.__peername = socket.getfqdn(self.__peerip)
            self.__peerport = os.environ['TCPREMOTEPORT']
            self.__peer = (self.__peerip, self.__peerport)

            self._localip = os.environ['TCPLOCALIP']
            self._localname = os.environ.get('TCPLOCALHOST', None)
            if not self._localname:
                self._localname = socket.getfqdn(self._localip)
            self._localport = os.environ['TCPLOCALPORT']
            self._local = (self._localip, self._localport)
        else:
            # xinetd (or stunnel?) does at least provide REMOTE_HOST.
            if opts.one_session and os.environ.has_key('REMOTE_HOST'):
                self.__peerip = os.environ['REMOTE_HOST']
                self.__peerport = ''
                self.__peer = (self.__peerip, self.__peerport)
            else:
                self.__peer = self.__conn.getpeername()
                self.__peerip = self.__peer[0]
                self.__peerport = self.__peer[1]
            self.__peername = socket.getfqdn(self.__peerip)
            self._local = self.__conn.getsockname()
            self._localip = self._local[0]
            self._localname = socket.getfqdn(self._localip)
            self._localport = self._local[1]

        # Set the TCPLOCALIP environment variable to support
        # VPopMail's reverse IP domain mapping.
        os.environ['TCPLOCALIP'] = self._localip

        self._sent_signon = False

        # SSL/TLS/STARTTLS
        self.__can_starttls = opts.tls

    def init_dynamic_state(self):
        """Initialize 'dynamic state' - that state which must be flushed
        when a STARTLS command is issued, according to the RFC."""

        # SMTP AUTH
        self.__smtpauth = 0
        self.__auth_resp1 = None
        self.__auth_resp2 = None
        self.__auth_username = None
        self.__auth_password = None
        self.__auth_sasl = None
        self.__sasl_types = ['login', 'cram-md5', 'plain']
        # Remove CRAM-MD5 from the published SASL types if using the
        # `--authprog' or `--remoteauth' options. See FAQ 5.8.
        if remoteauth['enable'] or opts.authprog:
            self.__sasl_types.remove('cram-md5')
        self.__auth_cram_md5_ticket = '<%s.%s@%s>' % (random.randrange(10000),
                                                      int(time.time()), FQDN)
        self.__line = []
        self.__state = self.COMMAND
        self.__mailfrom = None
        self.__rcpttos = []
        self.__data = ''

    def do_ssl_handshake(self):
        self.__can_starttls = False
        self.tlsMixinSetActive()
        self.setServerHandshakeOp(certChain=opts.ssl_cert_value,
                                  privateKey=opts.ssl_key_value)

    def handle_connect(self):
        self.init_dynamic_state()
        if not self._sent_signon:
            self._sent_signon = True
            self.push('220 %s ESMTP tmda-ofmipd' % FQDN)

    # 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)
        if opts.debug:
            print >> DEBUGSTREAM, 'Data:', repr(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()
            if self.__can_starttls and not opts.tls == 'optional':
               valid_cmds = ['NOOP', 'EHLO', 'STARTTLS', 'QUIT']
               if not (command in valid_cmds):
                   self.push('530 Must issue a STARTTLS command first')
                   return
            method = getattr(self, 'smtp_' + command, None)
            if not method:
                self.push('502 Error: command "%s" not implemented' % command)
                return
            method(arg)
            return
        elif self.__state == self.DATA:
            # Remove extraneous carriage returns and de-transparency according
            # to RFC 2821, 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)

            if not opts.throttlescript or not os.system("%s %s" % (opts.throttlescript,
                self.__auth_username)):
                try:
                    status = self.__process_msg_func(self.__peer,
                                                     self.__mailfrom,
                                                     self.__rcpttos,
                                                     self.__data,
                                                     self.__auth_username)
                except:
                    print >>DEBUGSTREAM, "process_message raised an exception:"
                    import traceback
                    traceback.print_exc(DEBUGSTREAM)
                    raise
            else:
                status = self.push('450 Outgoing mail quota exceeded')

            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)
        elif self.__state == self.AUTH:
            if line == '*':
                # client canceled the authentication attempt
                self.push('501 AUTH exchange cancelled')
                self.auth_reset_state()
                return
            if not self.__auth_resp1:
                self.__auth_resp1 = line
            else:
                self.__auth_resp2 = line
            self.auth_challenge()
        else:
            self.push('451 Internal confusion')
            return

    # factored
    def __getaddr(self, keyword, arg):
        address = None
        keylen = len(keyword)
        if arg[:keylen].upper() == keyword:
            address = arg[keylen:].strip()
            if not address:
                pass
            elif address[0] == '<' and address[-1] == '>' and address <> '<>':
                # Addresses can be in the form <person@dom.com> but watch out
                # for null address, e.g. <>
                address = address[1:-1]
        return address

    # Authentication methods

    def verify_login(self, b64username, b64password):
        """The LOGIN SMTP authentication method is an undocumented,
        unstandardized Microsoft invention.  Needed to support MS
        Outlook clients."""
        try:
            username = b64_decode(b64username)
            password = b64_decode(b64password)
        except:
            return 501
        self.__auth_username = username.lower()
        self.__auth_password = password
        if remoteauth['enable']:
            # Try first with the remote auth
            if run_remoteauth(username, password, self._localip):
                return 1
        if opts.authprog:
            # Then with the authprog
            if run_authprog(username, password) == 0:
                return 1
        # Now can we fall back on the authfile
        if (not opts.fallback) and (remoteauth['enable'] or opts.authprog):
            return 0
        authdict = authfile2dict(opts.authfile)
        if authdict.get(username.lower(), 0) <> password:
            return 0
        else:
            return 1

    def verify_plain(self, response):
        """PLAIN is described in RFC 2595."""
        try:
            response = b64_decode(response)
        except:
            return 501
        try:
            username, username, password = response.split('\0')
        except ValueError:
            return 0
        self.__auth_username = username.lower()
        self.__auth_password = password
        if remoteauth['enable']:
            # Try first with the remote auth
            if run_remoteauth(username, password, self._localip):
                return 1
        if opts.authprog:
            # Then with the authprog
            if run_authprog(username, password) == 0:
                return 1
        # Now can we fall back on the authfile
        if (not opts.fallback) and (remoteauth['enable'] or opts.authprog):
            return 0
        authdict = authfile2dict(opts.authfile)
        if authdict.get(username.lower(), 0) <> password:
            return 0
        else:
            return 1

    def verify_cram_md5(self, response, ticket):
        """CRAM-MD5 is described in RFC 2195."""
        try:
            response = b64_decode(response)
        except:
            return 501
        try:
            username, hexdigest = response.split()
        except ValueError:
            return 0
        authdict = authfile2dict(opts.authfile)
        password = authdict.get(username.lower(), 0)
        self.__auth_username = username.lower()
        self.__auth_password = password
        if password == 0:
            return 0
        newhexdigest = hmac.HMAC(password, ticket, digestmod=md5).hexdigest()
        if newhexdigest <> hexdigest:
            return 0
        else:
            return 1

    def auth_reset_state(self):
        """As per RFC 2554, the SMTP state is reset if the AUTH fails,
        and once it succeeds."""
        self.__auth_sasl = None
        self.__auth_resp1 = None
        self.__auth_resp2 = None
        self.__state = self.COMMAND

    def auth_notify_required(self):
        """Send a 530 reply.  RFC 2554 says this response may be
        returned by any command other than AUTH, EHLO, HELO, NOOP,
        RSET, or QUIT. It indicates that server policy requires
        authentication in order to perform the requested action."""
        self.push('530 Error: Authentication required')
        
    def auth_notify_fail(self, failcode=0):
        if failcode == 501:
            # base64 decoding failed
            self.push('501 malformed AUTH input')
        else:
            self.push('535 AUTH failed')
        print >> DEBUGSTREAM, 'Auth: ', 'failed for user', \
              "'%s'" % self.__auth_username
        self.__smtpauth = 0

    def auth_notify_succeed(self):
        self.push('235 AUTH successful')
        print >> DEBUGSTREAM, 'Auth: ', 'succeeded for user', \
              "'%s'" % self.__auth_username
        os.environ['LOGIN'] = self.__auth_username
        self.__smtpauth = 1

    def auth_verify(self, sasl=None):
        if sasl is None:
            sasl = self.__auth_sasl
        verify = 0
        if sasl == 'plain':
            verify = self.verify_plain(self.__auth_resp1)
        elif sasl == 'cram-md5':
            verify = self.verify_cram_md5(self.__auth_resp1,
                                          self.__auth_cram_md5_ticket)
        elif sasl == 'login':
            verify =  self.verify_login(self.__auth_resp1,
                                        self.__auth_resp2)
        if verify == 1:
            self.auth_notify_succeed()
        else:
            self.auth_notify_fail(verify)
        self.auth_reset_state()
            
    def auth_challenge(self):
        line = EMPTYSTRING.join(self.__line)
        if not self.__auth_resp1:
            # No initial response, issue first server challenge
            if self.__auth_sasl == 'plain':
                self.push('334 ')
            elif self.__auth_sasl == 'cram-md5':
                self.push('334 ' + b64_encode(self.__auth_cram_md5_ticket))
            elif self.__auth_sasl == 'login':
                self.push('334 VXNlcm5hbWU6')
            return
        if self.__auth_resp1 and not self.__auth_resp2:
            # Client sent an initial response
            if self.__auth_sasl == 'plain':
                # Perform authentication
                self.auth_verify()
            elif self.__auth_sasl == 'cram-md5':
                # Perform authentication
                self.auth_verify()
            elif self.__auth_sasl == 'login':
                # Issue second server challenge
                self.push('334 UGFzc3dvcmQ6')
            return
        if self.__auth_resp1 and self.__auth_resp2:
            # Client sent a second response (only if AUTH=LOGIN),
            # perform authentication
            self.auth_verify()
            return

    # ESMTP/SMTP commands

    def smtp_EHLO(self, arg):
        if not arg:
            self.push('501 Syntax: EHLO hostname')
            return

        responses = []
        responses.append('%s' % FQDN)
        if not self.__can_starttls or opts.tls == 'optional':
            responses.append('AUTH %s' %
                (' '.join(map(lambda s: s.upper(), self.__sasl_types))))
        if self.__can_starttls:
            responses.append('STARTTLS')
        for r in responses[:-1]:
            self.push('250-' + r)
        self.push('250 ' + responses[-1])

        # Put a Received header string in the environment for tmda-inject
        # to add later.
        rh = []
        rh.append('from %s' % (arg))
        if ((arg.lower() <> self.__peername.lower()) and
            (self.__peername.lower() <> self.__peerip)):
            rh.append('(%s [%s])' % (self.__peername, self.__peerip))
        else:
            rh.append('(%s)' % (self.__peerip))
        if opts.ssl:
            rh.append('(using SMTP over TLS)')
        if opts.tls and not self.__can_starttls:
            rh.append('(using STARTTLS)')
        rh.append('by %s (tmda-ofmipd) with ESMTP;' % (FQDN))
        rh.append(Util.make_date())
        os.environ['TMDA_OFMIPD_RECEIVED'] = ' '.join(rh)

    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()

    def smtp_MAIL(self, arg):
        # Authentication required first
        if not self.__smtpauth:
            self.auth_notify_required()
            return
        print >> DEBUGSTREAM, '===> MAIL', arg
        address = self.__getaddr('FROM:', arg)
        if not address:
            self.push('501 Syntax: MAIL FROM:<address>')
            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: <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 <CR><LF>.<CR><LF>')

    def smtp_AUTH(self, arg):
        """RFC 2554 - SMTP Service Extension for Authentication"""
        if self.__smtpauth:
            # After an successful AUTH, no more AUTH commands may be
            # issued in the same session.
            self.push('503 Duplicate AUTH')
            return
        if arg:
            args = arg.split()
            if len(args) == 2:
                self.__auth_sasl = args[0]
                self.__auth_resp1 = args[1]
            else:
                self.__auth_sasl = args[0]
        if self.__auth_sasl:
            self.__auth_sasl = self.__auth_sasl.lower()
        if not arg or self.__auth_sasl not in self.__sasl_types:
            self.push('504 AUTH type unimplemented')
            return
        self.__state = self.AUTH
        self.auth_challenge()

    def smtp_STARTTLS(self, arg):
        """RFC 3207 - SMTP Service Extension for Secure SMTP over Transport Layer Security"""
        if not self.__can_starttls:
            # Not TLS mode, or we have already done STARTTLS
            self.push('503 Duplicate or disallowed STARTTLS')
            return
        if arg:
            self.push('501 Syntax error (no parameters allowed)')
            return
        self.push('220 Ready to start TLS')
        self.do_ssl_handshake()


class SMTPServer(asyncore.dispatcher):
    """Run an SMTP server daemon - accept new socket connections and
    process SMTP sessions on each connection."""
    def __init__(self, localaddr, process_msg_func):
        self._localaddr = localaddr
        self._process_msg_func = process_msg_func
        asyncore.dispatcher.__init__(self)
        self.create_socket(socket.AF_INET, socket.SOCK_STREAM)
        # try to re-use a server port if possible
        self.set_reuse_addr()
        self.bind(localaddr)
        self.listen(5)
        print >> DEBUGSTREAM, \
              'tmda-ofmipd started at %s\n\tListening on %s:%d' % \
              (Util.make_date(), localaddr[0], localaddr[1])

    def readable(self):
        if len(asyncore.socket_map) > opts.connections:
            # too many simultaneous connections
            return 0
        else:
            return 1

    def handle_accept(self):
        conn = self.accept()[0]
        SMTPSession(conn, self._process_msg_func)


# Utility functions

def warning(msg='', exit=1):
    delimiter = '*' * 70
    if msg:
        msg = Util.wraptext(msg)
        print >> sys.stderr, '\n', delimiter, '\n', msg, '\n', delimiter, '\n'
    if exit:
        sys.exit()


def pipecmd(command, *strings):
    popen2._cleanup()
    cmd = popen2.Popen3(command, 1, bufsize=-1)
    cmdout, cmdin, cmderr = cmd.fromchild, cmd.tochild, cmd.childerr
    if strings:
        # Write to the tochild file object.
        for s in strings:
            cmdin.write(s)
        cmdin.flush()
        cmdin.close()
    # Read from the childerr object; command will block until exit.
    err = cmderr.read().strip()
    cmderr.close()
    # Read from the fromchild object.
    out = cmdout.read().strip()
    cmdout.close()
    # Get exit status from the wait() member function.
    return cmd.wait()


def run_authprog(username, password):
    """authprog should return 0 for auth ok, and a positive integer in
    case of a problem."""
    print >> DEBUGSTREAM, "Trying authprog method"
    cmd = "/bin/sh -c 'exec %s 3<&0'" % (opts.authprog,)
    return pipecmd(cmd, '%s\0%s\0' % (username, password))


def run_remoteauth(username, password, localip):
    """Authenticate username/password combination against a remote
    resource.  Return 1 upon successful authentication, and 0
    otherwise."""
    authhost = remoteauth['host']
    authport = remoteauth['port']
    if authhost == '0.0.0.0':
        ipauthmap = ipauthmap2dict(ipauthmapfile)
        if len(ipauthmap) == 0:
            authhost = localip
        else:
            authdata = ipauthmap.get(localip, '127.0.0.1').split(':')
            authhost = authdata[0]
            if len(authdata) > 1:
                authport = authdata[1]
            else:
                authport = remoteauth['port']
    print >> DEBUGSTREAM, "trying %s authentication for %s@%s:%s" % \
          (remoteauth['proto'], username, authhost, authport)
    if remoteauth['proto'] == 'imap':
        M = imaplib.IMAP4(authhost, int(authport))
        try:
            M.login(username, password)
            M.logout()
            return 1
        except:
            print >> DEBUGSTREAM, "imap authentication for %s@%s failed" % \
                  (username, authhost)
            return 0
    elif remoteauth['proto'] == 'imaps':
        M = imaplib.IMAP4_SSL(authhost, int(authport))
        try:
            M.login(username, password)
            M.logout()
            return 1
        except:
            print >> DEBUGSTREAM, "imaps authentication for %s@%s failed" % \
                  (username, authhost)
            return 0
    elif remoteauth['proto'] in ('pop3', 'apop'):
        M = poplib.POP3(authhost, int(authport))
        try:
            if remoteauth['proto'] == 'pop3':
                M.user(username)
                M.pass_(password)
                M.quit()
                return 1
            else:
                M.apop(username, password)
                M.quit()
                return 1
        except:
            print >> DEBUGSTREAM, "%s authentication for %s@%s failed" % \
                  (remoteauth['proto'], username, authhost)
            return 0
    elif remoteauth['proto'] == 'ldap':
        import ldap
        try:
            M = ldap.initialize("ldap://%s:%s" % (authhost, authport))
            M.simple_bind_s(remoteauth['dn'] % username, password)
            M.unbind_s()
            return 1
        except:
            print >> DEBUGSTREAM, "ldap authentication for %s@%s failed" % \
                  (username, authhost)
            return 0
    # proto not implemented
    print >> DEBUGSTREAM, "Error: protocol %s not implemented" % \
            remoteauth['proto']
    return 0


def authfile2dict(authfile):
    """Iterate over a tmda-ofmipd authentication file, and return a
    dictionary containing username:password pairs.  Username is
    returned in lowercase."""
    authdict = {}
    fp = file(authfile, 'r')
    for line in fp:
        line = line.strip()
        if line == '':
            continue
        else:
            fields = line.split(':', 1)
            authdict[fields[0].lower().strip()] = fields[1].strip()
    fp.close()
    return authdict


def ipauthmap2dict(ipauthmapfile):
    """Iterate 'ipauthmapfile' (IP1:IP2:port) and return a dictionary
    containing IP1 -> IP2:port hashes."""
    ipauthmap = {}
    try:
        fp = file(ipauthmapfile, 'r')
        for line in fp:
            line = line.strip()
            if line == '':
                continue
            ipdata = line.split(':', 1)
            ipauthmap[ipdata[0].strip()] = ipdata[1].strip()
        fp.close()
    except IOError:
        pass
    return ipauthmap


def b64_encode(s):
    """base64 encoding without the trailing newline."""
    return base64.encodestring(s)[:-1]


def b64_decode(s):
    """base64 decoding."""
    return base64.decodestring(s)


def process_message_fail(peer, mailfrom, rcpttos, data, auth_username):
    """Debug class which prevents the mail from actually being accepted."""
    raise "Test Exception"


def process_message_vdomain(peer, mailfrom, rcpttos, data, auth_username):
    """This proxy is used only for virtual domain support in a qmail +
    (VPopMail or VMailMgr) environment.  It needs to behave differently from
    the standard TMDA proxy in that authenticated users are not system
    (/usr/pkg/etc/tmda/passwd) users."""
    # Set up partial tmda-inject command line.
    execdir = os.path.dirname(os.path.abspath(program))
    inject_cmd = [os.path.join(execdir, 'tmda-inject')] + rcpttos
    userinfo = auth_username.split('@', 1)
    user = userinfo[0]
    if len(userinfo) > 1:
        domain = userinfo[1]
    else:
        domain = ''
    # If running as uid 0, fork in preparation for running the tmda-inject
    # process and change UID and GID to the virtual domain user.  This is
    # for VMailMgr, where each virtual domain is a system (/usr/pkg/etc/tmda/passwd)
    # user.
    if running_as_root:
        pid = os.fork()
        if pid <> 0:
            rpid, status = os.wait()
            # Did tmda-inject succeed?
            if status <> 0:
                raise IOError, 'tmda-inject failed!'
            return
        else:
            # The 'prepend' is the system user in charge of this virtual
            # domain.
            prepend = Util.getvdomainprepend(auth_username, 
                                             opts.vdomainspath)
            if not prepend:
                err = 'Error: "%s" is not a virtual domain' % (domain,)
                print >> DEBUGSTREAM, err
                os._exit(-1)
            os.seteuid(0)
            os.setgid(Util.getgid(prepend))
            os.setgroups(Util.getgrouplist(prepend))
            os.setuid(Util.getuid(prepend))
            # For VMailMgr's utilities.
            os.environ['HOME'] = Util.gethomedir(prepend)
    # From here on, we're either in the child (pid == 0) or we're not
    # running as root, so we haven't forked.
    vhomedir = Util.getvuserhomedir(user, domain, opts.vhomescript)
    print >> DEBUGSTREAM, 'vuser homedir: "%s"' % (vhomedir,)
    # This is so "~" will work in the .tmda/* files.
    os.environ['HOME'] = vhomedir
    # change inject_cmd to pass the message through if
    # --pure-proxy was specified and the .tmda/config file is
    # missing.
    if opts.pure_proxy and not os.path.exists(os.path.join
                                         (vhomedir, '.tmda', 'config')):
        sendmail_program = os.environ.get('TMDA_SENDMAIL_PROGRAM') \
                           or '/usr/sbin/sendmail'
        inject_cmd = [sendmail_program, '-f', mailfrom, '-i', '--'] + rcpttos
    try:
        Util.pipecmd(inject_cmd, data)
    except Exception, err:
        print >> DEBUGSTREAM, 'Error:', err
        if running_as_root:
            os._exit(-1)
    if running_as_root:
        # Should never get here!
        os._exit(0)


def process_message_sysuser(peer, mailfrom, rcpttos, data, auth_username):
    """Using this server for outgoing smtpd, the authenticated user
    will have his mail tagged using his TMDA config file."""
    if opts.configdir is None:
        # ~user/.tmda/
        tmda_configdir = os.path.join(os.path.expanduser
                                      ('~' + auth_username), '.tmda')
    else:
        tmda_configdir = os.path.join(os.path.expanduser
                                      (opts.configdir), auth_username)
    tmda_configfile = os.path.join(tmda_configdir, 'config')
    if opts.pure_proxy and not os.path.exists(tmda_configfile):
        sendmail_program = os.environ.get('TMDA_SENDMAIL_PROGRAM') \
                           or '/usr/sbin/sendmail'
        inject_cmd = [sendmail_program, '-f', mailfrom, '-i', '--'] + rcpttos
    else:
        execdir = os.path.dirname(os.path.abspath(program))
        inject_path = os.path.join(execdir, 'tmda-inject')
        inject_cmd = [inject_path, '-c', tmda_configfile] + rcpttos

    # This is so "~" will always work in the .tmda/* files.
    os.environ['HOME'] = Util.gethomedir(auth_username)
    # If running as uid 0, fork the tmda-inject process, and
    # then change UID and GID to the authenticated user.
    if running_as_root:
        pid = os.fork()
        if pid == 0:
            os.seteuid(0)
            os.setgid(Util.getgid(auth_username))
            os.setgroups(Util.getgrouplist(auth_username))
            os.setuid(Util.getuid(auth_username))
            try:
                Util.pipecmd(inject_cmd, data)
            except Exception, err:
                print >> DEBUGSTREAM, 'Error:', err
                os._exit(-1)
            os._exit(0)
        else:
            rpid, status = os.wait()
            # Did tmda-inject succeed?
            if status <> 0:
                raise IOError, 'tmda-inject failed!'
    else:
        # no need to fork
        Util.pipecmd(inject_cmd, data)


def create_smtp_session_from_stdin(process_msg_func):
    conn = socket.fromfd(0, socket.AF_INET, socket.SOCK_STREAM)
    SMTPSession(conn, process_msg_func)


def sig_handler(sig_num, frame):
    sys.exit()


# Main code begins


# Constants

remoteauth = { 'proto': None,
               'host': 'localhost',
               'port': None,
               'dn': '',
               'enable': 0,
               }
defaultauthports = { 'imap': 143,
                     'imaps': 993,
                     'apop': 110,
                     'pop3': 110,
                     'ldap': 389,
                     # 'pop3s': 995,
                     }

NEWLINE = '\n'
EMPTYSTRING = ''


# Runtime global variables

program = sys.argv[0]

__version__ = Version.TMDA

FQDN = socket.getfqdn()
if FQDN == 'localhost':
    FQDN = socket.gethostname()

if os.getuid() == 0:
    running_as_root = True
else:
    running_as_root = False


# Option parsing

opt_desc = \
"""An authenticated ofmip proxy for TMDA that allows you to 'tag' your
mail client's outgoing mail through SMTP.  For more information,
including setup and usage instructions, see
http://wiki.tmda.net/TmdaOfmipdHowto""" 

parser = OptionParser(description=opt_desc, version=Version.TMDA)

parser.add_option("-V", 
                  action="store_true", default=False, dest="full_version",
                  help="show full TMDA version information and exit.")

# option groups
gengroup = OptionGroup(parser, "General")
congroup = OptionGroup(parser, "Connection")
authgroup = OptionGroup(parser, "Authentication")
virtgroup = OptionGroup(parser, "Virtual Domains")
    
# general
gengroup.add_option("-d", "--debug",
                    action="store_true", default=False, dest="debug",
                    help="Turn on debugging prints.")

gengroup.add_option("-L", "--log",
                    action="store_true", default=False, dest="log",
                    help= \
"""Turn on logging prints.
This option logs everything that -d logs, except for the raw SMTP protocol
data. Hence, it is useful if you want to leave logging enabled permanently,
but don't want your logs bloated with AUTH data and/or the content of large
attachments.""")

gengroup.add_option("-b", "--background",
                    action="store_false", dest="foreground",
                    help="Detach and run in the background (default).")

gengroup.add_option("-f", "--foreground",
                    action="store_true", default=False, dest="foreground",
                    help="Don't detach; run in the foreground.")

gengroup.add_option("-u", "--username",
                    dest="username",
                    help= \
"""The username that this program should run under.  The default is to
run as the user who starts the program unless that is root, in which
case an attempt to seteuid user 'tofmipd' will be made.  Use this
option to override these defaults.""")

gengroup.add_option("-c", "--configdir",
                    metavar="DIR", dest="configdir",
                    help= \
"""DIR is the base directory to search for the authenticated user's TMDA
configuration file in.  This might be useful if you wish to maintain
TMDA files outside the user's home directory.
'username/config' will be appended to form the path; e.g, `-c
/var/tmda' will have tmda-ofmipd search for `/var/tmda/bobby/config'.
If this option is not used, `~user/.tmda/config' will be assumed, but
see the --vhome-script option for qmail virtual domain users.""")

# connection
congroup.add_option("-p", "--proxyport",
                    default="%s:%s" % (FQDN, 8025), metavar="HOST:PORT", 
                    dest="proxyport", help= \
"""The HOST:PORT to listen for incoming connections on.  The default is
FQDN:8025 (i.e, port 8025 on the fully qualified domain name for the
local host).  Use '0.0.0.0:PORT' to listen on all available
interfaces.""")

congroup.add_option("-C", "--connections",
                    type="int", default="20", metavar="NUM", dest="connections",
                    help= \
"""Do not handle more than NUM simultaneous connections. If there are NUM
active connections, defer acceptance of new connections until one
finishes. NUM must be a positive integer. Default: 20""")

congroup.add_option("-1", "--one-session",
                    action="store_true", default=False, dest="one_session",
                    help= \
"""Don't bind to a port and accept new connections; Process a single SMTP
session on stdin (used both for input & output).  This is useful when
started from tcpserver or stunnel.""")

congroup.add_option("-P", "--pure-proxy",
                    action="store_true", default=False, dest="pure_proxy",
                    help= \
"""Proxy the message straight through to the mail transport system
unaltered if the user's TMDA config file is missing.  The
/usr/sbin/sendmail program on the system is used to inject the
message.  You can override this by setting $TMDA_SENDMAIL_PROGRAM in
the environment.  This option might be useful when serving a mixed
environment of TMDA and non-TMDA users.""")

congroup.add_option("-t", "--throttle-script",
                    metavar="/PATH/TO/SCRIPT", dest="throttlescript",
                    help= \
"""Full pathname of a script which can meter how much mail any user
sends.  The script is passed a login name whenever a user tries to
send mail.  If the script returns a 0, the message is allowed.  For
any other value, the message is rejected.""")

congroup.add_option("", "--ssl",
                    action="store_true", default=False, dest="ssl",
                    help= \
"""Enable SSL encryption. This mode immediately initiates the SSL/TLS
protocol as soon as a connection is made. This mode is not support
for the STARTTLS command. This configuration is typically run on
port 465 (smtps).""")

congroup.add_option("", "--tls",
                    type="choice", default=None, dest="tls",
                    choices=['optional', 'on'],
                    help= \
"""Enable TLS mode. Valid options are optional and on. With this option
enabled, the STARTTLS SMTP command may be used to upgrade the plain-text
connection to SSL/TLS. In 'optional' mode, AUTH is allowed either before
or after STARTTLS. In 'on' mode, clients are forced to STARTTLS prior to
AUTH, to ensure that plain-text AUTH commands are protected. This
configuration is typically run on port 587 (submission).""")

congroup.add_option("", "--ssl-cert",
                    metavar="/PATH/TO/FILE", default=None, dest="ssl_cert",
                    help= \
"""Location of the SSL/TLS certificate key file.""")

congroup.add_option("", "--ssl-key",
                    metavar="/PATH/TO/FILE", default=None, dest="ssl_key",
                    help= \
"""Location of the SSL/TLS private key file.""")

# authentication
authgroup.add_option("-R", "--remoteauth",
                     metavar="PROTO://HOST[:PORT][/DN]", dest="remoteauth",
                     help= \
"""Protocol and host to check username and password. PROTO can be one of
the following: 'imap' (IMAP4 server), 'imaps' (IMAP4 server over SSL),
'pop3' (POP3 server), 'apop' (POP3 server with APOP authentication),
'ldap' (LDAP server).  Optional :PORT defaults to the standard port for
the specified protocol (143 for imap, 993 for imaps, 110 for
pop3/apop, and 389 for ldap).  /DN is mandatory for ldap and should
contain a '%%s' identifying the username. Examples: '-R
imaps://myimapserver.net', '-R pop3://mypopserver.net:2110', '-R
ldap://example.com/cn=%%s,dc=host,dc=com'""")

authgroup.add_option("-A", "--authprog",
                     metavar="PROGRAM", dest="authprog",
                     help= \
"""A checkpassword compatible command used to check username/password.
Examples: '-A "/usr/sbin/checkpassword-pam -s id -- /bin/true"',
'-A "/usr/local/vpopmail/bin/vchkpw /usr/bin/true"'.
The program must be able to receive the username/password pair on
descriptor 3 and in the following format: `username\\0password\\0'
Any program claiming to be checkpassword-compatible should be able to
do this.  If you can tell the program to accept input on another
descriptor, such as stdin, don't.  It won't work, because TMDA follows
the standard (http://cr.yp.to/checkpwd/interface.html) exactly.
Also, checkpassword-type programs expect to find the name of another
program to run on their command line.  For tmda-ofmipd's purpose,
/bin/true is perfectly fine.
Note the position of the quotes in the Examples, which cause the the
whole string following the -A to be passed as a single argument.""")

authgroup.add_option("-a", "--authfile",
                     metavar="FILE", dest="authfile",
                     help= \
"""Path to the file holding authentication information for this proxy.
Default location is /usr/pkg/etc/tmda/tofmipd if running as root/tofmipd, otherwise
~user/.tmda/tofmipd.  Use this option to override these defaults.""")

authgroup.add_option("-F", "--fallback",
                     action="store_true", default=False, dest="fallback",
                     help= \
"""When used with -R or -A, fallback to authenticate against the authfile
if remote authentication fails.  Note: this flag has no effect on -R
to -A fallback. If you specify both -R and -A methods, then authprog
will be tried after remoteauth has failed.""")

# virtual domains
virtgroup.add_option("-S", "--vhome-script",
                     metavar="/PATH/TO/SCRIPT", dest="vhomescript",
                     help= \
"""Full pathname of a script that prints a virtual email user's home
directory on standard output.  tmda-ofmipd will read that and use it
to build the path to the user's config file instead of '~user/.tmda'.
The script must take two arguments, the user name and the domain, on
its command line.  This option is for use only with the VPopMail and
VMailMgr add-ons to qmail.  See the contrib directory for sample
scripts.""")

virtgroup.add_option("-v", "--vdomains-path",
                     default="/var/qmail/control/virtualdomains", 
                     metavar="/PATH/TO/FILE", dest="vdomainspath",
                     help= \
"""Full pathname to qmail's virtualdomains file.  The default is
/var/qmail/control/virtualdomains.  This is also tmda-ofmipd's
default, so you normally won't need to set this parameter.  If you
have installed qmail somewhere other than /var/qmail, you will need to
set this so tmda-ofmipd can find the virtualdomains file.  NOTE: This
is only used when you have a qmail installation with virtual domains
using the VMailMgr add-on.  It implies that you will also set the
'--vhome-script' option above.""")

for g in (gengroup, congroup, authgroup, virtgroup):
    parser.add_option_group(g)

(opts, args) = parser.parse_args()

if opts.full_version:
    print Version.ALL
    sys.exit()
if opts.vhomescript and opts.configdir:
    parser.error("options '--vhome-script' and '--configdir' are incompatible!")
if opts.debug or opts.log:
    DEBUGSTREAM = sys.stderr
else:
    DEBUGSTREAM = Devnull()

if opts.remoteauth:
    # arg is like: imap://host:port
    autharg = opts.remoteauth
    try:
        authproto, autharg = autharg.split('://', 1)
    except ValueError:
        authproto, autharg = autharg, None
    if authproto not in defaultauthports.keys():
        raise ValueError, 'Protocol not supported: ' + authproto + \
            '\nPlease pick one of ' + repr(defaultauthports.keys())
    remoteauth['proto'] = authproto
    remoteauth['port'] = defaultauthports[authproto]
    if autharg:
        try:
            autharg, dn = autharg.split('/', 1)
            remoteauth['dn'] = dn
        except ValueError:
            dn = ''
        try:
            authhost, authport = autharg.split(':', 1)
        except ValueError:
            authhost = autharg
            authport = defaultauthports[authproto]
        if authhost:
            remoteauth['host'] = authhost
        if authport:
            remoteauth['port'] = authport
    print >> DEBUGSTREAM, "auth method: %s://%s:%s/%s" % \
        (remoteauth['proto'], remoteauth['host'],
         remoteauth['port'], remoteauth['dn'])
    remoteauth['enable'] = 1

if running_as_root:
    if not opts.username:
        opts.username = 'tofmipd'
    if not opts.authfile:
        opts.authfile = '/usr/pkg/etc/tmda/tofmipd'
    ipauthmapfile = '/usr/pkg/etc/tmda/ipauthmap'
else:
    tmda_path = os.path.join(os.path.expanduser('~'), '.tmda')
    ipauthmapfile = os.path.join(tmda_path, 'ipauthmap')
    if not opts.authfile:
        opts.authfile = os.path.join(tmda_path, 'tofmipd')

# provide disclaimer if running as root
if running_as_root:
    msg = 'WARNING: The security implications and risks of running ' + \
          program + ' in "seteuid" mode have not been fully evaluated.  ' + \
          'If you are uncomfortable with this, quit now and instead run ' + \
          program + ' under your non-privileged TMDA user account.'
    warning(msg, exit=0)

if remoteauth['proto'] == 'ldap':
    try:
        import ldap
    except ImportError:
        raise ImportError, \
              'python-ldap (http://python-ldap.sf.net/) required.'
    if remoteauth['dn'] == '':
        print >> DEBUGSTREAM, "Error: Missing ldap dn\n"
        raise ValueError
    try:
        remoteauth['dn'].index('%s')
    except:
        print >> DEBUGSTREAM, "Error: Invalid ldap dn\n"
        raise ValueError

if opts.ssl or opts.tls:
    if opts.ssl and opts.tls:
        raise ValueError, 'Can\'t do SSL and TLS at the same time'

    try:
        from tlslite.api import *
        from tlslite.TLSConnection import TLSConnection
        from tlslite.integration.AsyncStateMachine import AsyncStateMachine
    except ImportError:
        raise ImportError, \
              'tlslite (http://trevp.net/tlslite/) required.'

    if (not opts.ssl_cert) or (not opts.ssl_key):
        raise ValueError, \
            '--ssl-cert and --ssl-key are required when using --ssl or --tls'

    fhc = file(os.path.expanduser(opts.ssl_cert), 'r')
    datac = fhc.read()
    fhc.close()
    x509 = X509()
    x509.parse(datac)
    opts.ssl_cert_value = X509CertChain([x509])

    fhk = file(os.path.expanduser(opts.ssl_key), 'r')
    datak = fhk.read()
    fhk.close()
    opts.ssl_key_value = parsePEMKey(datak, private=True)

    class TMDATLSAsyncDispatcherMixIn(AsyncStateMachine):
        """A custom version of tlslite's TLSAsyncDispatcherMixIn.
        This version:
        * Requires siblingClass to be specified explicitly, to ease use with
          inheritance.
        * Allows the mixin to remain dormant until activated, which allows
          implementation of deferred SSL startup, as required for the STARTTLS
          SMTP command.
        """

        def __init__(self, sock, siblingClass):
            AsyncStateMachine.__init__(self)

            self.siblingClass = siblingClass
            self._active = False

            self.tlsConnection = TLSConnection(sock)

        def tlsMixinSetActive(self):
            self._active = True

        def readable(self):
            if self._active:
                result = self.wantsReadEvent()
                if result != None:
                    return result
                return self.siblingClass.readable(self)
            else:
                return self.siblingClass.readable(self)

        def writable(self):
            if self._active:
                result = self.wantsWriteEvent()
                if result != None:
                    return result
                return self.siblingClass.readable(self)
            else:
                return self.siblingClass.writable(self)

        def handle_read(self):
            if self._active:
                self.inReadEvent()
            else:
                self.siblingClass.handle_read(self)

        def handle_write(self):
            if self._active:
                self.inWriteEvent()
            else:
                self.siblingClass.handle_write(self)

        def outConnectEvent(self):
            if not self._active:
                raise "Internal state confusion"
            self.handle_connect()

        def outCloseEvent(self):
            if not self._active:
                raise "Internal state confusion"
            asyncore.dispatcher.close(self)

        def outReadEvent(self, readBuffer):
            if not self._active:
                raise "Internal state confusion"
            if readBuffer == '':
                self.outCloseEvent()
            else:
                self.readBuffer = readBuffer
                self.siblingClass.handle_read(self)

        def outWriteEvent(self):
            if not self._active:
                raise "Internal state confusion"
            self.siblingClass.handle_write(self)

        def recv(self, bufferSize=16384):
            if self._active:
                if bufferSize < 16384 or self.readBuffer == None:
                    raise AssertionError()
                returnValue = self.readBuffer
                self.readBuffer = None
                return returnValue
            else:
                return self.siblingClass.recv(self, bufferSize)

        def send(self, writeBuffer):
            if self._active:
                self.setWriteOp(writeBuffer)
                return len(writeBuffer)
            else:
                return self.siblingClass.send(self, writeBuffer)

        def close(self):
            if self._active:
                if hasattr(self, "tlsConnection"):
                    self.setCloseOp()
                else:
                    asyncore.dispatcher.close(self)
            else:
                return self.siblingClass.close(self)


    SMTPSession.__bases__ = (TMDATLSAsyncDispatcherMixIn,) + SMTPSession.__bases__


def main():
    # check permissions of authfile if using only remote
    # authentication.
    if not (remoteauth['enable'] or opts.authprog) or opts.fallback:
        authfile_mode = Util.getfilemode(opts.authfile)
        if authfile_mode not in (400, 600):
            raise IOError, \
                opts.authfile + ' must be chmod 400 or 600!'

    if opts.vhomescript:
        process_msg_func = process_message_vdomain
    else:
        process_msg_func = process_message_sysuser

    if opts.one_session:
        create_smtp_session_from_stdin(process_msg_func)
    else:
        # try binding to the specified host:port
        host, port = opts.proxyport.split(':', 1)
        server = SMTPServer((host, int(port)), process_msg_func)

    # Switch user/group ID, etc.
    if running_as_root:
        pw_uid = Util.getuid(opts.username)
        # check ownership of authfile if using only remote
        # authentication.
        if not (remoteauth['enable'] or opts.authprog) or opts.fallback:
            if Util.getfileuid(opts.authfile) <> pw_uid:
                raise IOError, \
                    opts.authfile + ' must be owned by UID ' + str(pw_uid)
        # Set group ID
        os.setegid(Util.getgid(opts.username))
        # Set supplemental group ids
        os.setgroups(Util.getgrouplist(opts.username))
        # Set user ID
        os.seteuid(pw_uid)

    # Daemonize the process if required
    if not (opts.foreground or opts.one_session):
        if os.fork() <> 0:
            sys.exit()

        if True:
            os.setsid()

            if os.fork() <> 0:
                sys.exit()

            os.setpgrp()

            # Theoretically we should close all FDs in
            # range(os.getdtablesize()), but the API doesn't exist!
            os.close(0)
            os.close(1)
            os.close(2)
            os.open('/dev/null', os.O_RDWR | os.O_NOCTTY)
            os.dup(0)
            os.dup(0)
            sys.stdin = os.fdopen(0, 'r')
            sys.stdout = os.fdopen(1, 'w')
            sys.stderr = os.fdopen(2, 'w')

            signal.signal(signal.SIGTSTP, signal.SIG_IGN);
            signal.signal(signal.SIGTTOU, signal.SIG_IGN);
            signal.signal(signal.SIGTTIN, signal.SIG_IGN);

            signal.signal(signal.SIGHUP, sig_handler);
            signal.signal(signal.SIGTERM, sig_handler);

    # Start the event loop
    try:
        asyncore.loop()
    except KeyboardInterrupt:
        pass


# This is the end my friend.
if __name__ == '__main__':
    main()
