This worked fine, but with one problem - the Received lines always had the localhost IP/port combo, due to the port forwarding performed by stunnel.
So, I set about hacking tmda-ofmipd to run under tcpserver directly, and hence it can also (and does) run under the combindation of tcpserver and stunnel.
I've included the full tmda-ofmipd-hacked file that I run, plus a patch relative to the distributed tmda-0.82.
I haven't tested this in the non-tcpserver mode, but I'm thinking it works. Here's hoping this is something people will find useful.
I'm also trying out TMDA commands in the subject line for the first time - here's hoping that you guys get a dated address!
--
Stephen Warren, Software Engineer, Parama Networks, San Jose, CA
http://www.wwwdotorg.org/work_contact/
#!/usr/bin/env python # # Copyright (C) 2001,2002,2003 Jason R. Mastaler <[EMAIL PROTECTED]> # # 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. """An authenticated ofmip proxy for TMDA. Tag your outgoing mail through SMTP. See <URL:http://tmda.net/tmda-ofmipd.html> for complete setup and usage information. Usage: %(program)s [OPTIONS] OPTIONS: -h --help Print this message and exit. -V --version Print TMDA version information and exit. -d --debug Turn on debugging prints. -f --foreground Run in foreground. -b --background Run in background (default). -u <username> --username <username> 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. -p <host:port> --proxyport <host:port> 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). -R proto[://host[:port]] --remoteauth proto[://host[:port]][/dn] Host to connect to 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) - host defaults to localhost - port defaults to 143 (imap), 993 (imaps), 110 (pop3/apop), 389 (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://host.com/cn=%%s,dc=host,dc=com -A <program> --authprog <program> 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 exactly. http://cr.yp.to/checkpwd/interface.html Also, checkpassword-type programs expect to find the name of another program to run on their command line. For tmda-ofmipd's purpose, (/usr)/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. -a <file> --authfile <file> Path to the file holding authentication information for this proxy. Default location is /etc/tofmipd if running as root/tofmipd, otherwise ~user/.tmda/tofmipd. Use this option to override these defaults. -F --fallback 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. -C <n> --connections <n> Do not handle more than n simultaneous connections. If there are n active connections, defer acceptance of a new connection until one finishes. n must be a positive integer. Default: 20 -c <directory> --configdir <directory> 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. -S <script> --vhome-script <script> Full pathname of script that prints a virtual email user's home directory on standard output. tmda-ofmipd will read that path 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 tmda0.XX/contrib directory for sample scripts. -v <path_to_qmails_virtualdomains_file> --vdomains-path <path_to_qmails_virtualdomains_file> Full pathname to qmail's virtualdomains file. The default for most installations 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 parameter above. -t <script> --throttle-script <script> 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. -1 --one-session Don't bind to a port and accept new connections - Process a single SMTP session (e.g. when started from tcpserver).""" import getopt import os import signal import socket import sys 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 class Devnull: def write(self, msg): pass def flush(self): pass # Some defaults FQDN = socket.getfqdn() DEBUGSTREAM = Devnull() proxyport = '%s:%s' % (FQDN, 8025) program = sys.argv[0] configdir = None authprog = None fallback = 0 foreground = None one_session = 0 remoteauth = { 'proto': None, 'host': 'localhost', 'port': None, 'dn': '', 'enable': 0, } defaultauthports = { 'imap': 143, 'imaps': 993, 'apop': 110, 'pop3': 110, 'ldap': 389, # 'pop3s': 995, } connections = 20 vhomescript = None vdomainspath = '/var/qmail/control/virtualdomains' throttlescript = None if os.getuid() == 0: running_as_root = 1 else: running_as_root = 0 if running_as_root: username = 'tofmipd' authfile = '/etc/tofmipd' ipauthmapfile = '/etc/ipauthmap' else: username = None tmda_path = os.path.join(os.path.expanduser('~'), '.tmda') authfile = os.path.join(tmda_path, 'tofmipd') ipauthmapfile = os.path.join(tmda_path, 'ipauthmap') 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() # check whether we are running a recent enough Python if not Version.PYTHON >= '2.2': msg = 'Python 2.2 or greater is required to run ' + program + \ ' -- Visit http://python.org/download/ to upgrade.' warning(msg) # 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) def usage(code, msg=''): print __doc__ % globals() if msg: print msg sys.exit(code) try: opts, args = getopt.getopt(sys.argv[1:], 'p:u:a:R:A:Fc:C:dVhfbS:v:t:1', ['proxyport=', 'username=', 'authfile=', 'remoteauth=', 'authprog=', 'fallback', 'configdir=', 'connections=', 'debug', 'version', 'help', 'foreground', 'background', 'vhome-script=', 'vdomains-path=', 'throttle-script=', 'one-session']) except getopt.error, msg: usage(1, msg) for opt, arg in opts: if opt in ('-h', '--help'): usage(0) if opt == '-V': print Version.ALL sys.exit() if opt == '--version': print Version.TMDA sys.exit() elif opt in ('-d', '--debug'): DEBUGSTREAM = sys.stderr elif opt in ('-F', '--fallback'): fallback = 1 elif opt in ('-f', '--foreground'): foreground = 1 elif opt in ('-b', '--background'): foreground = 0 elif opt in ('-p', '--proxyport'): proxyport = arg elif opt in ('-u', '--username'): username = arg elif opt in ('-R', '--remoteauth'): # arg is like: imap://host:port try: authproto, arg = arg.split('://', 1) except ValueError: authproto, arg = arg, 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 arg: try: arg, dn = arg.split('/', 1) remoteauth['dn'] = dn except ValueError: dn = '' try: authhost, authport = arg.split(':', 1) except ValueError: authhost = arg 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 elif opt in ('-A', '--authprog'): authprog = arg elif opt in ('-a', '--authfile'): authfile = arg elif opt in ('-c', '--configdir'): configdir = arg elif opt in ('-C', '--connections'): connections = arg elif opt in ('-S', '--vhome-script'): vhomescript = arg elif opt in ('-v', '--vdomains-path'): vdomainspath = arg elif opt in ('-t', '--throttle-script'): throttlescript = arg elif opt in ('-1', '--one-session'): one_session = 1 if vhomescript and configdir: msg = "WARNING: --vhome-script and --config-dir are incompatible." + \ " Ignoring --config-dir." configdir = None warning(msg, exit=0) import asynchat import asyncore import base64 import email.Utils import hmac import md5 import popen2 import random import time __version__ = Version.TMDA NEWLINE = '\n' EMPTYSTRING = '' COMMASPACE = ', ' if remoteauth['proto'] == 'imaps': vmaj, vmin = sys.version_info[:2] # Python version 2.2 and before don't have IMAP4_SSL import imaplib if vmaj <= 2 or (vmaj == 2 and vmin <= 2): class IMAP4_SSL(imaplib.IMAP4): # extends IMAP4 class to talk SSL cause it's not yet # implemented in python 2.2 def open(self, host, port): """Setup connection to remote server on "host:port". This connection will be used by the routines: read, readline, send, shutdown. """ self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.sock.connect((self.host, self.port)) self.sslsock = socket.ssl(self.sock) self.file = self.sock.makefile('rb') def read(self, size): """Read 'size' bytes from remote.""" buf = self.sslsock.read(size) return buf def readline(self): """Read line from remote.""" line = [ ] c = self.sslsock.read(1) while c: line.append(c) if c == '\n': break c = self.sslsock.read(1) buf = ''.join(line) return buf def send(self, data): """Send data to remote.""" bytes = len(data) while bytes > 0: sent = self.sslsock.write(data) if sent == bytes: break # avoid copy data = data[sent:] bytes = bytes - sent else: IMAP4_SSL = imaplib.IMAP4_SSL 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 # Utility functions 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'" % (authprog,) return pipecmd(cmd, '%s\0%s\0' % (username, password)) def quote_rcpts(rcpttos): """Each address should be properly quoted to prevent malicious users from executing code by passing args to tmda-inject.""" rcpttos_quoted = [] for rcptto in rcpttos: rcpttos_quoted.append("'%s'" % rcptto.replace ("\\", "\\\\").replace("'", "'\\\\\\''")) return rcpttos_quoted 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 [EMAIL PROTECTED]:%s" % \ (remoteauth['proto'], username, authhost, authport) if remoteauth['proto'] == 'imap': import imaplib M = imaplib.IMAP4(authhost, int(authport)) try: M.login(username, password) M.logout() return 1 except: print >> DEBUGSTREAM, "imap authentication for [EMAIL PROTECTED] failed" % \ (username, authhost) return 0 elif remoteauth['proto'] == 'imaps': import imaplib M = IMAP4_SSL(authhost, int(authport)) try: M.login(username, password) M.logout() return 1 except: print >> DEBUGSTREAM, "imaps authentication for [EMAIL PROTECTED] failed" % \ (username, authhost) return 0 elif remoteauth['proto'] in ('pop3', 'apop'): import poplib 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 [EMAIL PROTECTED] 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 [EMAIL PROTECTED] 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) # Classes class SMTPChannel(asynchat.async_chat): COMMAND = 0 DATA = 1 AUTH = 2 def __init__(self, server, conn): asynchat.async_chat.__init__(self, conn) # 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 authprog: self.__sasl_types.remove('cram-md5') self.__auth_cram_md5_ticket = '<[EMAIL PROTECTED]>' % (random.randrange(10000), int(time.time()), FQDN) self.__server = server self.__conn = conn self.__line = [] self.__state = self.COMMAND #self.__greeting = 0 self.__mailfrom = None self.__rcpttos = [] self.__data = '' self.__fqdn = FQDN # 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 regular fd... if os.environ.has_key('TCPLOCALIP'): self.__peerip = os.environ['TCPREMOTEIP'] self.__peername = socket.getfqdn(self.__peerip) #os.environ['TCPREMOTEHOST'] self.__peerport = os.environ['TCPREMOTEPORT'] self.__peer = (self.__peerip, self.__peerport) self._localip = os.environ['TCPLOCALIP'] self._localname = socket.getfqdn(self._localip) #os.environ['TCPLOCALHOST'] self._localport = os.environ['TCPLOCALPORT'] self._local = (self._localip, self._localport) else: self.__peer = conn.getpeername() self.__peerip = self.__peer[0] self.__peername = socket.getfqdn(self.__peerip) self.__peerport = self.__peer[1] self._local= conn.getsockname() self._localip = self._local[0] self._localname = socket.getfqdn(self._localip) self._localport = self._local[1] print >> DEBUGSTREAM, 'Incoming connection from %s' % repr(self.__peer) print >> DEBUGSTREAM, 'Incoming connection to %s' % repr(self._local) self.push('220 %s ESMTP tmda-ofmipd' % (self.__fqdn)) 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) 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 os.environ['TCPLOCALIP'] = self._localip if remoteauth['enable']: # Try first with the remote auth if run_remoteauth(username, password, self._localip): return 1 if authprog: # Then with the authprog if run_authprog(username, password) == 0: return 1 # Now can we fall back on the authfile if (not fallback) and (remoteauth['enable'] or authprog): return 0 authdict = authfile2dict(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 os.environ['TCPLOCALIP'] = self._localip if remoteauth['enable']: # Try first with the remote auth if run_remoteauth(username, password, localip): return 1 if authprog: # Then with the authprog if run_authprog(username, password) == 0: return 1 # Now can we fall back on the authfile if (not fallback) and (remoteauth['enable'] or authprog): return 0 authdict = authfile2dict(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(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 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 # Implementation of base class abstract method def found_terminator(self): line = EMPTYSTRING.join(self.__line) 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() 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 throttlescript or not os.system("%s %s" % (throttlescript, self.__auth_username)): status = self.__server.process_message(self.__peer, self.__mailfrom, self.__rcpttos, self.__data, self.__auth_username) 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 # ESMTP/SMTP commands def smtp_EHLO(self, arg): if not arg: self.push('501 Syntax: EHLO hostname') return #self.__greeting = arg self.push('250-%s' % self.__fqdn) self.push('250 AUTH %s' % (' '.join(map(lambda s: s.upper(), self.__sasl_types)))) # 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)) rh.append('by %s (tmda-ofmipd) with ESMTP;' % (self.__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() # 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 <[EMAIL PROTECTED]> but watch out # for null address, e.g. <> address = address[1:-1] return address 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() class MessageProcessor: """Base 'pure' SMTP message processing class. Raises NotImplementedError if you try to use it.""" # 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 NotImplementedError class DebuggingMessageProcessor(MessageProcessor): """Simply prints each message it receives on stdout.""" # 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 SMTPServer(asyncore.dispatcher, MessageProcessor): """Run an SMTP server daemon - accept new socket connections and process SMTP sessions on each conneciton.""" 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.set_reuse_addr() self.bind(localaddr) self.listen(5) print >> DEBUGSTREAM, \ 'tmda-ofmipd started at %s\n\tListening on %s' % \ (Util.unixdate(), proxyport) def readable(self): if len(asyncore.socket_map) > int(connections): # too many simultaneous connections return 0 else: return 1 def handle_accept(self): conn = self.accept()[0] self._channel = SMTPChannel(self, conn) class SMTPProcessor(asyncore.dispatcher, MessageProcessor): """Run a single SMTP session.""" def __init__(self): asyncore.dispatcher.__init__(self) conn = socket.fromfd(0, socket.AF_INET, socket.SOCK_STREAM) self._channel = SMTPChannel(self, conn) class PureProxy: """Proxies all messages to a real smtpd which does final delivery.""" 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:', refused 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 VDomainProxy(PureProxy): """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 (/etc/passwd) users.""" def process_message(self, peer, mailfrom, rcpttos, data, auth_username): # Set the TCPLOCALIP environment variable to support VPopMail's reverse # IP domain mapping. os.environ['TCPLOCALIP'] = self._channel._localip # Set up partial tmda-inject command line. execdir = os.path.dirname(os.path.abspath(program)) inject_cmd = os.path.join(execdir, 'tmda-inject') 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 (/etc/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, 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, vhomescript) print >> DEBUGSTREAM, 'vuser homedir: "%s"' % (vhomedir,) # This is so "~" will work in the .tmda/* files. os.environ['HOME'] = vhomedir try: Util.pipecmd('%s %s' % (inject_cmd, ' '.join (quote_rcpts(rcpttos))), 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) class VDomainProxyServer(VDomainProxy, SMTPServer): pass class VDomainProxyProcessor(VDomainProxy, SMTPProcessor): pass class TMDAProxy(PureProxy): """Using this server for outgoing smtpd, the authenticated user will have his mail tagged using his TMDA config file.""" def process_message(self, peer, mailfrom, rcpttos, data, auth_username): if 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 (configdir), auth_username) tmda_configfile = os.path.join(tmda_configdir, 'config') execdir = os.path.dirname(os.path.abspath(program)) inject_path = os.path.join(execdir, 'tmda-inject') inject_cmd = '%s --config-file %s' % (inject_path, tmda_configfile) # 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('%s %s' % (inject_cmd, ' '.join (quote_rcpts(rcpttos))), 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('%s %s' % (inject_cmd, ' '.join (quote_rcpts(rcpttos))), data) class TMDAProxyServer(TMDAProxy, SMTPServer): pass class TMDAProxyProcessor(TMDAProxy, SMTPProcessor): pass def main(): # check permissions of authfile if using only remote # authentication. if not (remoteauth['enable'] or authprog) or fallback: authfile_mode = Util.getfilemode(authfile) if authfile_mode not in (400, 600): raise IOError, \ authfile + ' must be chmod 400 or 600!' # try binding to the specified host:port host, port = proxyport.split(':', 1) if vhomescript: if one_session: proxy = VDomainProxyProcessor() else: proxy = VDomainProxyServer((host, int(port)), ('localhost', 25)) else: if one_session: proxy = TMDAProxyProcessor() else: proxy = TMDAProxyServer((host, int(port)), ('localhost', 25)) if running_as_root: pw_uid = Util.getuid(username) # check ownership of authfile if using only remote # authentication. if not (remoteauth['enable'] or authprog) or fallback: if Util.getfileuid(authfile) != pw_uid: raise IOError, \ authfile + ' must be owned by UID ' + str(pw_uid) # try setegid() os.setegid(Util.getgid(username)) # try setting the supplemental group ids os.setgroups(Util.getgrouplist(username)) # try seteuid() os.seteuid(pw_uid) # Issue a warning if neither -f nor -b options specified #if foreground is None: # print "WARNING: you should specify -b", # print "(background) or -f (foreground) option." # print "The default (background) behavior", # print "could be changed in a future version." # Try to fork to go to daemon unless foreground mode if not (foreground or one_session): signal.signal(signal.SIGHUP, signal.SIG_IGN) # ignore SIGHUP if os.fork() != 0: sys.exit() # Start the event loop try: asyncore.loop() except KeyboardInterrupt: pass # This is the end my friend. if __name__ == '__main__': main()
--- tmda-ofmipd 2003-06-15 10:27:24.000000000 -0700
+++ tmda-ofmipd-hacked 2003-08-18 15:09:33.000000000 -0700
@@ -164,7 +164,12 @@
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."""
+ value, the message is rejected.
+
+ -1
+ --one-session
+ Don't bind to a port and accept new connections -
+ Process a single SMTP session (e.g. when started from tcpserver)."""
import getopt
import os
@@ -196,6 +201,7 @@
authprog = None
fallback = 0
foreground = None
+one_session = 0
remoteauth = { 'proto': None,
'host': 'localhost',
'port': None,
@@ -261,22 +267,23 @@
try:
opts, args = getopt.getopt(sys.argv[1:],
- 'p:u:a:R:A:Fc:C:dVhfbS:v:t:', ['proxyport=',
- 'username=',
- 'authfile=',
- 'remoteauth=',
- 'authprog=',
- 'fallback',
- 'configdir=',
- 'connections=',
- 'debug',
- 'version',
- 'help',
- 'foreground',
- 'background',
- 'vhome-script=',
- 'vdomains-path=',
- 'throttle-script='])
+ 'p:u:a:R:A:Fc:C:dVhfbS:v:t:1', ['proxyport=',
+ 'username=',
+ 'authfile=',
+ 'remoteauth=',
+ 'authprog=',
+ 'fallback',
+ 'configdir=',
+ 'connections=',
+ 'debug',
+ 'version',
+ 'help',
+ 'foreground',
+ 'background',
+ 'vhome-script=',
+ 'vdomains-path=',
+ 'throttle-script=',
+ 'one-session'])
except getopt.error, msg:
usage(1, msg)
@@ -345,6 +352,8 @@
vdomainspath = arg
elif opt in ('-t', '--throttle-script'):
throttlescript = arg
+ elif opt in ('-1', '--one-session'):
+ one_session = 1
if vhomescript and configdir:
msg = "WARNING: --vhome-script and --config-dir are incompatible." + \
@@ -601,8 +610,9 @@
DATA = 1
AUTH = 2
- def __init__(self, server, conn, addr):
+ def __init__(self, server, conn):
asynchat.async_chat.__init__(self, conn)
+
# SMTP AUTH
self.__smtpauth = 0
self.__auth_resp1 = None
@@ -619,7 +629,6 @@
int(time.time()), FQDN)
self.__server = server
self.__conn = conn
- self.__addr = addr
self.__line = []
self.__state = self.COMMAND
#self.__greeting = 0
@@ -627,11 +636,35 @@
self.__rcpttos = []
self.__data = ''
self.__fqdn = FQDN
- self.__peer = conn.getpeername()
- self.__peerip = self.__peer[0]
- self.__peername = socket.getfqdn(self.__peerip)
- self.__sockip = conn.getsockname()[0]
- print >> DEBUGSTREAM, 'Peer:', repr(self.__peer)
+
+ # 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 regular fd...
+ if os.environ.has_key('TCPLOCALIP'):
+ self.__peerip = os.environ['TCPREMOTEIP']
+ self.__peername = socket.getfqdn(self.__peerip)
#os.environ['TCPREMOTEHOST']
+ self.__peerport = os.environ['TCPREMOTEPORT']
+ self.__peer = (self.__peerip, self.__peerport)
+ self._localip = os.environ['TCPLOCALIP']
+ self._localname = socket.getfqdn(self._localip)
#os.environ['TCPLOCALHOST']
+ self._localport = os.environ['TCPLOCALPORT']
+ self._local = (self._localip, self._localport)
+ else:
+ self.__peer = conn.getpeername()
+ self.__peerip = self.__peer[0]
+ self.__peername = socket.getfqdn(self.__peerip)
+ self.__peerport = self.__peer[1]
+ self._local= conn.getsockname()
+ self._localip = self._local[0]
+ self._localname = socket.getfqdn(self._localip)
+ self._localport = self._local[1]
+
+ print >> DEBUGSTREAM, 'Incoming connection from %s' % repr(self.__peer)
+ print >> DEBUGSTREAM, 'Incoming connection to %s' % repr(self._local)
+
self.push('220 %s ESMTP tmda-ofmipd' % (self.__fqdn))
self.set_terminator('\r\n')
@@ -654,11 +687,10 @@
return 501
self.__auth_username = username.lower()
self.__auth_password = password
- localip = self.__conn.getsockname()[0]
- os.environ['TCPLOCALIP'] = localip
+ os.environ['TCPLOCALIP'] = self._localip
if remoteauth['enable']:
# Try first with the remote auth
- if run_remoteauth(username, password, localip):
+ if run_remoteauth(username, password, self._localip):
return 1
if authprog:
# Then with the authprog
@@ -685,8 +717,7 @@
return 0
self.__auth_username = username.lower()
self.__auth_password = password
- localip = self.__conn.getsockname()[0]
- os.environ['TCPLOCALIP'] = localip
+ os.environ['TCPLOCALIP'] = self._localip
if remoteauth['enable']:
# Try first with the remote auth
if run_remoteauth(username, password, localip):
@@ -994,36 +1025,9 @@
self.auth_challenge()
-class SMTPServer(asyncore.dispatcher):
- """The base class for the backend. Raises NotImplementedError if
- you try to use it."""
- 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.set_reuse_addr()
- self.bind(localaddr)
- self.listen(5)
- print >> DEBUGSTREAM, \
- 'tmda-ofmipd started at %s\n\tListening on %s' % \
- (Util.unixdate(), proxyport)
-
- def readable(self):
- if len(asyncore.socket_map) > int(connections):
- # too many simultaneous connections
- return 0
- else:
- return 1
-
- def handle_accept(self):
- conn, addr = self.accept()
- print >> DEBUGSTREAM, 'Incoming connection from %s' % repr(addr)
- locaddr = conn.getsockname()
- self._localip = locaddr[0]
- print >> DEBUGSTREAM, 'Incoming connection to %s' % repr(locaddr)
- channel = SMTPChannel(self, conn, addr)
+class MessageProcessor:
+ """Base 'pure' SMTP message processing class.
+ Raises NotImplementedError if you try to use it."""
# API for "doing something useful with the message"
def process_message(self, peer, mailfrom, rcpttos, data):
@@ -1051,8 +1055,9 @@
raise NotImplementedError
-class DebuggingServer(SMTPServer):
+class DebuggingMessageProcessor(MessageProcessor):
"""Simply prints each message it receives on stdout."""
+
# Do something with the gathered message
def process_message(self, peer, mailfrom, rcpttos, data):
inheaders = 1
@@ -1067,9 +1072,47 @@
print '------------ END MESSAGE ------------'
-class PureProxy(SMTPServer):
- """Proxies all messages to a real smtpd which does final
- delivery."""
+class SMTPServer(asyncore.dispatcher, MessageProcessor):
+ """Run an SMTP server daemon - accept new socket connections and
+ process SMTP sessions on each conneciton."""
+
+ 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.set_reuse_addr()
+ self.bind(localaddr)
+ self.listen(5)
+ print >> DEBUGSTREAM, \
+ 'tmda-ofmipd started at %s\n\tListening on %s' % \
+ (Util.unixdate(), proxyport)
+
+ def readable(self):
+ if len(asyncore.socket_map) > int(connections):
+ # too many simultaneous connections
+ return 0
+ else:
+ return 1
+
+ def handle_accept(self):
+ conn = self.accept()[0]
+ self._channel = SMTPChannel(self, conn)
+
+
+class SMTPProcessor(asyncore.dispatcher, MessageProcessor):
+ """Run a single SMTP session."""
+
+ def __init__(self):
+ asyncore.dispatcher.__init__(self)
+ conn = socket.fromfd(0, socket.AF_INET, socket.SOCK_STREAM)
+ self._channel = SMTPChannel(self, conn)
+
+
+class PureProxy:
+ """Proxies all messages to a real smtpd which does final delivery."""
+
def process_message(self, peer, mailfrom, rcpttos, data):
lines = data.split('\n')
# Look for the last header
@@ -1114,10 +1157,11 @@
(VPopMail or VMailMgr) environment. It needs to behave differently from
the standard TMDA proxy in that authenticated users are not system
(/etc/passwd) users."""
+
def process_message(self, peer, mailfrom, rcpttos, data, auth_username):
# Set the TCPLOCALIP environment variable to support VPopMail's reverse
# IP domain mapping.
- os.environ['TCPLOCALIP'] = self._localip
+ os.environ['TCPLOCALIP'] = self._channel._localip
# Set up partial tmda-inject command line.
execdir = os.path.dirname(os.path.abspath(program))
inject_cmd = os.path.join(execdir, 'tmda-inject')
@@ -1172,9 +1216,18 @@
os._exit(0)
+class VDomainProxyServer(VDomainProxy, SMTPServer):
+ pass
+
+
+class VDomainProxyProcessor(VDomainProxy, SMTPProcessor):
+ pass
+
+
class TMDAProxy(PureProxy):
"""Using this server for outgoing smtpd, the authenticated user
will have his mail tagged using his TMDA config file."""
+
def process_message(self, peer, mailfrom, rcpttos, data, auth_username):
if configdir is None:
# ~user/.tmda/
@@ -1217,6 +1270,14 @@
(quote_rcpts(rcpttos))), data)
+class TMDAProxyServer(TMDAProxy, SMTPServer):
+ pass
+
+
+class TMDAProxyProcessor(TMDAProxy, SMTPProcessor):
+ pass
+
+
def main():
# check permissions of authfile if using only remote
# authentication.
@@ -1228,11 +1289,17 @@
# try binding to the specified host:port
host, port = proxyport.split(':', 1)
if vhomescript:
- proxy = VDomainProxy((host, int(port)),
- ('localhost', 25))
+ if one_session:
+ proxy = VDomainProxyProcessor()
+ else:
+ proxy = VDomainProxyServer((host, int(port)),
+ ('localhost', 25))
else:
- proxy = TMDAProxy((host, int(port)),
- ('localhost', 25))
+ if one_session:
+ proxy = TMDAProxyProcessor()
+ else:
+ proxy = TMDAProxyServer((host, int(port)),
+ ('localhost', 25))
if running_as_root:
pw_uid = Util.getuid(username)
# check ownership of authfile if using only remote
@@ -1255,7 +1322,7 @@
# print "The default (background) behavior",
# print "could be changed in a future version."
# Try to fork to go to daemon unless foreground mode
- if not foreground:
+ if not (foreground or one_session):
signal.signal(signal.SIGHUP, signal.SIG_IGN) # ignore SIGHUP
if os.fork() != 0:
sys.exit()
