# -*- Mode: Python; tab-width: 4 -*-

RCS_ID =  '$Id: spamwall.py,v 1.6 1997/12/04 04:52:40 rushing Exp $'

# ======================================================================
# Copyright 1997 by Sam Rushing
# 
#                         All Rights Reserved
# 
# Permission to use, copy, modify, and distribute this software and
# its documentation for any purpose and without fee is hereby
# granted, provided that the above copyright notice appear in all
# copies and that both that copyright notice and this permission
# notice appear in supporting documentation, and that the name of Sam
# Rushing not be used in advertising or publicity pertaining to
# distribution of the software without specific, written prior
# permission.
# 
# SAM RUSHING DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE,
# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN
# NO EVENT SHALL SAM RUSHING BE LIABLE FOR ANY SPECIAL, INDIRECT OR
# CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
# OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT,
# NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
# CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
# ======================================================================

# techniques:
#
# 1) Access sendmail/smail using popen.
#  This is certainly possible, but less portable
#  and reliable: I've had trouble in the past trying to
#  do async i/o through popen.
#
# 2) Access SMTP on a different port.
#  The best way to set this up is to bind the server
#  to the loopback address (127.0.0.1).  This way only
#  the local machine can connect to the true SMTP server.
#  I use port 8025.

import asynchat
import asyncore
import socket
import string
import sys
import time
import ident

VERSION_STRING = string.split(RCS_ID)[2]

class smtp_proxy (asynchat.async_chat):
	def __init__ (self, channel):
		asynchat.async_chat.__init__ (self)
		self.channel = channel
		self.set_terminator ('\r\n')
		self.create_socket (socket.AF_INET, socket.SOCK_STREAM)
		self.connect (channel.server.proxy_addr)
		self.data = ''
		self.expect_data_reply = 0

	def handle_connect (self):
		pass

	def collect_incoming_data (self, data):
		self.data = self.data + data

	def found_terminator (self):
		line = self.data
		print '==>',repr(line)
		self.data = ''
		if self.expect_data_reply:
			self.expect_data_reply = 0
			if line[:3] != '354':
				# something's gone wrong on the server side, abort
				# this transaction with an error...
				self.channel.abort ("unexpected response from server: <%s>" % repr(line))
			else:
				# the client was already sent this reply by spamwall.
				pass
		else:
			if self.channel:
				self.channel.push (line+'\r\n')
		
	def handle_close (self):
		self.close()
		self.channel.close()

# this is the server channel that redirects through the proxy

# The machine has 3 states:

COMMAND = 0
HEADER  = 1
BODY    = 2

# The connection is only 'hijacked' while in HEADER mode.
# everything else is passed through untouched.

class smtp_channel (asynchat.async_chat):
	
	ac_in_buffer_size = 8192

	def __init__ (self, server, conn, addr):
		asynchat.async_chat.__init__ (self, conn)
		self.server = server
		self.set_terminator ('\r\n')
		self.addr = addr
		self.proxy = smtp_proxy (self)
		self.line = ''
		self.headers = []
		self.state = COMMAND
		self.ident_username = None

	def collect_incoming_data (self, data):
		if self.state == BODY:
			# pass the body of the message directly
			self.proxy.push (data)
		else:
			self.line = self.line + data

	def found_terminator (self):
		line, self.line = self.line, ''

		print '<==', repr(line)

		if self.state == COMMAND:

			if line:
				command = string.lower (string.split(line)[0])
			else:
				command = ''

			if command == 'data':
				self.state = HEADER
				# don't send the DATA command to the real server yet, but go ahead
				# and send a response to the client.
				self.push ('354 Enter mail, end with "." on a line by itself\r\n')
			elif command == 'spam':
				# Intercept SPAM commands.
				sub_command = string.lower (string.split (line)[1])
				if sub_command == 'reload':
					import swconfig
					reload (swconfig)
					self.filter = swconfig.build_filter()
					self.push ('filters reloaded successfully\r\n')
				elif sub_command == 'count':
					self.push ('%d messages rejected\r\n' %  self.server.rejections)
				elif sub_command == 'help':
					self.push ('SPAM count|help|reload|report\r\n')
				elif sub_command == 'report':
					f = self.server.filter
					if hasattr (f, 'report'):
						f.report(self)
					else:
						self.push ('nothing to report')
				else:
					self.push ('500 Unrecognized SPAM command\r\n')
			else:
				self.proxy.push (line+'\r\n')
		elif self.state == HEADER:
			if line == '':
				if self.feed_headers ():
					return
				self.headers = []
				self.set_terminator ('\r\n.\r\n')
				self.state = BODY
			elif line == '.':
				if self.feed_headers():
					return
				self.headers = []
				self.proxy.push ('.\r\n')
				self.state = COMMAND
			else:
				self.headers.append (line)
		elif self.state == BODY:
			self.proxy.push ('\r\n.\r\n')
			self.state = COMMAND
			self.set_terminator ('\r\n')
		else:
			self.abort ("spamwall is confused")

	def feed_headers (self):
		# ok, we have the headers now.
		# check against filters and feed the data to the real server.

		if self.server.filter.scan (self.headers):

			# tell the server to forget it
			self.proxy.channel = None
			self.proxy.push ('QUIT\r\n')
			self.proxy.close_when_done()
			# say goodbye to the client
			self.push ('551 SPAM filter triggered.\r\n')
			self.close_when_done()

			if self.server.log_file:
				self.server.log_file.write (
					string.join (self.headers, '\n') + '\n\n'
					)
				self.server.log_file.flush()

			self.server.rejections = self.server.rejections + 1

			return 1

		# we survived the filter, now go on delivering the mail.

		if self.ident_username:
			user_ident = self.ident_username
		else:
			user_ident = 'no info'

		self.headers.insert (
			0,
			'X-SpamWall: Received via %s:%d (ident:%s)' % (
				self.addr[0],
				self.addr[1],
				user_ident
				)
			)

		self.proxy.push (
			'DATA\r\n' + string.join (self.headers, '\r\n') + '\r\n\r\n'
			)

		# tell the proxy class to ignore the real server's reply to DATA
		self.proxy.expect_data_reply = 1
		self.headers = []
		return 0

	def abort (self, message):
		self.proxy.push ('500 spamwall error (%s)\r\n' % message)
		self.proxy.close_when_done()
		self.close()

	def ident_callback (self, username):
		self.ident_username = username

	def handle_close (self):
		self.close()
		self.proxy.close()

class smtp_proxy_server (asyncore.dispatcher):

	channel_class = smtp_channel

	def __init__ (self, local_addr, proxy_addr, filter, do_ident=1, log_file=None):
		self.local_addr = local_addr
		self.proxy_addr = proxy_addr
		self.do_ident = do_ident
		self.log_file = log_file
		self.rejections = 0
		self.filter = filter
		asyncore.dispatcher.__init__ (self)
		self.create_socket (socket.AF_INET, socket.SOCK_STREAM)

		# try to re-use a server port if possible
		self.socket.setsockopt (
			socket.SOL_SOCKET, socket.SO_REUSEADDR,
			self.socket.getsockopt (socket.SOL_SOCKET, socket.SO_REUSEADDR) | 1
			)

		self.bind (local_addr)
		self.listen (5)
		print 'Proxy SMTP server started at %s\n\tLocal Address:%s\n\tRemote Address:%s\n' % (
			time.ctime(time.time()),
			local_addr,
			proxy_addr
			)
		self.proxy_addr = proxy_addr

	def handle_accept (self):
		conn, addr = self.accept()
		print 'incoming connection from %s' % repr(addr)
		sys.stdout.flush()
		c = self.channel_class (self, conn, addr)
		if self.do_ident:
			ident.ident_client (
				(addr[1], self.local_addr[1]), addr[0], c.ident_callback
				)

import os
if os.name == 'posix':
	def become_nobody ():
		import os
		import pwd
		nobody_uid = pwd.getpwnam ('nobody')[2]
		os.setuid (nobody_uid)
else:
	def become_nobody ():
		pass

def run (local_addr, server_addr):
	log = open ('spamlog.txt', 'a')
	import swconfig
	filter = swconfig.build_filter()
	ps = smtp_proxy_server (local_addr, server_addr, filter, log_file=log)

	# os-specific:
	become_nobody()

	asyncore.loop()

if __name__ == '__main__':
	run (('', 25), ('127.0.0.1', 8025))
