From 79ecd5eeacc70c13fbbfbfa75f1f0da790e2967a Mon Sep 17 00:00:00 2001 From: Kirill Smelkov Date: Sun, 29 Jan 2012 21:56:01 +0400 Subject: [PATCH] contrib/ksmtpproxy: SMTP proxy to ask account password in between sending mail This is the program I use to send mail. Useful if one do not want to store passwords in /etc/exim/passwd.client . Also it always uses SSL on outbound connections. Details inside. Signed-off-by: Kirill Smelkov --- contrib/ksmtpproxy | 352 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 352 insertions(+) create mode 100755 contrib/ksmtpproxy diff --git a/contrib/ksmtpproxy b/contrib/ksmtpproxy new file mode 100755 index 0000000..5bf4379 --- /dev/null +++ b/contrib/ksmtpproxy @@ -0,0 +1,352 @@ +#!/usr/bin/python +""" +SMTP proxy to ask account password in between sending mail. +Useful if one do not want to store passwords in /etc/exim/passwd.client . +Also it always uses SSL on outbound connections. + +Copyright (C) 2012 Kirill Smelkov + +This program is free software: you can Use, Study, Modify and Redistribute +it under the terms of the GNU General Public License version 2, or any +later version. This program is distributed WITHOUT ANY WARRANTY. See +COPYING file for full License terms. + + +~~~~~~~~ +For this proxy to work + + 1) turn Exim's send-to-self on (see Exim FAQ Q0206 about why this is + necessary): + + --- a/exim4/conf.d/router/200_exim4-config_primary + +++ b/exim4/conf.d/router/200_exim4-config_primary + @@ -73,9 +73,10 @@ nonlocal: + # domains, you'll need to copy the dnslookup_relay_to_domains router + # here so that mail to relay_domains is handled separately. + + smarthost: + debug_print = "R: smarthost for $local_part@$domain" + driver = manualroute + + self = send + domains = ! +local_domains + transport = remote_smtp_smarthost + route_list = * DCsmarthost byname + + + + 2) point Exim smarthost to this program's incoming port, e.g.: + + smarthost -> localhost::2525 + + + 3) and run the proxy, e.g. + + ksmtpproxy :2525 smtp.yandex.ru + + +then Exim will send all outgoing mail through this proxy. + +~~~~~~~~ +Here is how it works: + + ssh-askpass + | + - - - - - - - - - - - | - - - - + | V |SSL + Exim -----> SMTPServer ---> sendmail() -----> smarthost + | | + | | + - - - - - - - - - - - - - - - - +""" + +import smtpd +import smtplib +import asyncore +import socket +import ssl +import pprint +from sys import stderr +from subprocess import Popen, PIPE +import prctl # for prctl(PR_SET_DUMPABLE, 0) +import traceback # to show details, when something goes wrong + +localhost= 'localhost' +localport= None +smtphost = None +smtpport = 465 # default to smtps +ca_certs = "/etc/ssl/certs/ca-certificates.crt" # default to system-wide ca-certs +debug = 0 + + +# account -> password +# NOTE since we are protecting ourself from poking from gdb, it is +# (relatively) ok to store passwords in memory. An if it is not - we have +# bigger problems, because ssh-agent uses the same protection. + +passwd_invalid = False # special object marking previous password was invalid +passwd = {} + +# password entry was cancelled +class PasswdCancel(Exception): + pass + +# what's the password for an account? +def password4(account): + password = passwd.get(account) + if password is not None and password is not passwd_invalid: + return passwd[account] + + text = 'Password for %s' % account + if password is passwd_invalid: + text += ' (prev auth attempt failed)' + + p = Popen(['/usr/bin/ssh-askpass', text], stdout=PIPE) + password, _ = p.communicate() + # x11-ssh-askpass prints password with trailing \n + password = password.rstrip('\n') + + if p.returncode: + # password cancelled + raise PasswdCancel() + + passwd[account] = password + return password + + +# like smtplib.SMTP_SSL but ALWAYS checks peer (ssl.CERT_REQUIRED) +class SMTP_SSL_STRICT(smtplib.SMTP_SSL): + + def _get_socket(self, host, port, timeout): + if self.debuglevel > 0: + print >>stderr, 'connect: %s via ssl' % ((host, port),) + sk = socket.create_connection((host, port), timeout) + sk_ssl = ssl.wrap_socket(sk, + ca_certs=ca_certs, + cert_reqs=ssl.CERT_REQUIRED # ! *always* check peer + # NOTE we don't use self.keyfile + # NOTE we don't use self.certfile + ) + self.file = smtplib.SSLFakeFile(sk_ssl) + return sk_ssl + + +def sendmail(fromaddr, toaddrs, msg): + srv = SMTP_SSL_STRICT() + srv.set_debuglevel(debug) + + srv.connect(smtphost, smtpport) + + sk_ssl = srv.sock + + # print brief summary of peer cert + peer, port = sk_ssl.getpeername() + cipher, sslver, bits = sk_ssl.cipher() + peercert = sk_ssl.getpeercert() + + subj = {} + for rdn in peercert['subject']: + # each RDN is a sequence of name/value pairs + for n,v in rdn: + # XXX what to do for duplicate names? + subj[n] = v + + print '\t via %s:%s %s,%s,%i' % (peer,port, cipher,sslver,bits) + print '\t %s/%s/%s/%s/%s (%s)' % ( + subj.get('countryName', '?'), + subj.get('stateOrProvinceName', '?'), + subj.get('localityName', '?'), + subj.get('organizationName', '?'), + subj.get('organizationalUnitName', '?'), + subj.get('commonName', '?')) + + if debug: + # print whole peer cert + print >>stderr, 'Full certificate info:' + print >>stderr, pprint.pformat(peercert) + + # usual smtp repertoire + srv.login(fromaddr, password4(fromaddr)) + srv.sendmail(fromaddr, toaddrs, msg) + srv.quit() + + + + +# SMTPServer accepts incoming mail and handles it over to our sendmail() +class SMTPServer(smtpd.SMTPServer): + + # we've got incoming message - relay it to sendmail() + def process_message(self, peer, mailfrom, rcpttos, data): + print '\t* %s\t-> %s ...' % (mailfrom, ', '.join(rcpttos)) + if debug: + print >>stderr, '---------- MESSAGE BEGIN ----------' + print >>stderr, 'peer:\t', peer + print >>stderr, 'from:\t', mailfrom + print >>stderr, 'tos:\t', rcpttos + print >>stderr, 'data:\n' + print >>stderr, data + print >>stderr, '----------- MESSAGE END -----------' + + while 1: + # retry: + try: + sendmail(mailfrom, rcpttos, data) + + # password entry was cancelled + except PasswdCancel, e: + print '\t password cancel' + # 450 = "Requested mail action not taken: + # mailbox unavailable [E.g., mailbox busy]" + return '450 User cancelled password entry' + + # SSL error - something wrong is going on! + except ssl.SSLError, e: + print '\t !SSL %r' % e + return '450 SSL connection to smarthost not ok: %r' % e + + # on auth error, retry asking for password + except smtplib.SMTPAuthenticationError, e: + print '\t %s %s' % (e.smtp_code, e.smtp_error) + passwd[mailfrom] = passwd_invalid + continue # goto retry; + + # proxy back any other SMTP errors + except smtplib.SMTPResponseException, e: + emsg = '%s %s' % (e.smtp_code, e.smtp_error) + print emsg + return '\t %s' % emsg + + # all other problems + except Exception, e: + traceback.print_exc() + + # 451 = "Requested action aborted: error in processing" + return '451 %r' % e + + + # sent ok + print '\t ok' + return '250 Ok, thanks for using ksmtpproxy' + + +def main2(): + # don't let gdb attach and read our memory, disable /proc/me/mem + # from being readable, /proc/me/fd/* from being peekfd'able, etc... + # (we store passwords in memory!) + prctl.set_dumpable(0) + + # XXX likewise maybe don't allow our memory to be swapped? + # but locking MCL_CURRENT works only for root (on 3.3-rc1) + #mlockall(MCL_CURRENT | MCL_FUTURE) + + proxy = SMTPServer((localhost, localport), (None,None)) + if debug: + smtpd.DEBUGSTREAM = stderr # NOTE it's per module, not per object + + try: + asyncore.loop() + except KeyboardInterrupt: + pass + + +######################################## + +import os +import sys +from getopt import getopt, GetoptError + +progname= os.path.basename(sys.argv[0]) + +def exithelp(s): + print s + print "Try `%s --help' for more information." % progname + sys.exit(2) + +def usage_short(): + print 'Usage: %s [OPTIONS] localhost smarthost' % progname + +def usage(): + usage_short() + print \ +"""Proxy SMTP from localhost to smarthost, asking passwords in between + + localhost: e.g. localhost:2525 or :2525 + smarthost: e.g. smtp.yandex.ru or smtp.yandex.ru:465 + + NOTE: smarthost is always connected via SSL + + --ca-certs=FILE file with CA certificates to use + (default %(ca_certs)s) + -d, --debug show debug output on stderr + -h, --help display this help and exit +""" % globals() + + +# localhost:2525 -> localhost, 2525 +def parse_addrport(addrspec): + addr = None + port = None + + if not ':' in addrspec: + addr = addrspec + else: + addr, port = addrspec.split(':', 1) + port = int(port) + + return addr, port + + +def main(): + global localhost,localport, smtphost,smtpport, debug, ca_certs + + # parse options + try: + opts, args = getopt(sys.argv[1:], 'dh', ['ca-certs=', 'debug', 'help']) + except GetoptError, e: + exithelp('%s: %s' % (progname, e.msg)) + + for o, a in opts: + if o in [ '--ca-certs']: + ca_certs = a + + if o in ['-d', '--debug']: + debug = 1 + + + if o in ['-h', '--help']: + usage() + sys.exit(0) + + if len(args) != 2: + usage_short() + sys.exit(1) + + # parse localhost + try: + host, port = parse_addrport(args[0]) + except ValueError: + exithelp('E: can\'t parse localhost') + + localhost = host or localhost + localport = port or localport + if not (localhost and localport): + exithelp('E: localhost not fully specified') + + # parse smarthost + try: + host, port = parse_addrport(args[1]) + except ValueError: + exithelp('E: can\'t parse smarthost') + + smtphost = host or smtphost + smtpport = port or smtpport + if not (smtphost and smtpport): + exithelp('E: smarthost not fully specified') + + + # tail to main loop + main2() + + +if __name__ == '__main__': + main() -- 2.11.4.GIT