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