contrib/ksmtpproxy: SMTP proxy to ask account password in between sending mail
[navymail.git] / contrib / ksmtpproxy
blob5bf4379250214d030654d68cfd000fd9f2b34f5d
1 #!/usr/bin/python
2 """
3 SMTP proxy to ask account password in between sending mail.
4 Useful if one do not want to store passwords in /etc/exim/passwd.client .
5 Also it always uses SSL on outbound connections.
7 Copyright (C) 2012 Kirill Smelkov <kirr@navytux.spb.ru>
9 This program is free software: you can Use, Study, Modify and Redistribute
10 it under the terms of the GNU General Public License version 2, or any
11 later version. This program is distributed WITHOUT ANY WARRANTY. See
12 COPYING file for full License terms.
15 ~~~~~~~~
16 For this proxy to work
18 1) turn Exim's send-to-self on (see Exim FAQ Q0206 about why this is
19 necessary):
21 --- a/exim4/conf.d/router/200_exim4-config_primary
22 +++ b/exim4/conf.d/router/200_exim4-config_primary
23 @@ -73,9 +73,10 @@ nonlocal:
24 # domains, you'll need to copy the dnslookup_relay_to_domains router
25 # here so that mail to relay_domains is handled separately.
27 smarthost:
28 debug_print = "R: smarthost for $local_part@$domain"
29 driver = manualroute
30 + self = send
31 domains = ! +local_domains
32 transport = remote_smtp_smarthost
33 route_list = * DCsmarthost byname
37 2) point Exim smarthost to this program's incoming port, e.g.:
39 smarthost -> localhost::2525
42 3) and run the proxy, e.g.
44 ksmtpproxy :2525 smtp.yandex.ru
47 then Exim will send all outgoing mail through this proxy.
49 ~~~~~~~~
50 Here is how it works:
52 ssh-askpass
54 - - - - - - - - - - - | - - - -
55 | V |SSL
56 Exim -----> SMTPServer ---> sendmail() -----> smarthost
57 | |
58 | |
59 - - - - - - - - - - - - - - - -
60 """
62 import smtpd
63 import smtplib
64 import asyncore
65 import socket
66 import ssl
67 import pprint
68 from sys import stderr
69 from subprocess import Popen, PIPE
70 import prctl # for prctl(PR_SET_DUMPABLE, 0)
71 import traceback # to show details, when something goes wrong
73 localhost= 'localhost'
74 localport= None
75 smtphost = None
76 smtpport = 465 # default to smtps
77 ca_certs = "/etc/ssl/certs/ca-certificates.crt" # default to system-wide ca-certs
78 debug = 0
81 # account -> password
82 # NOTE since we are protecting ourself from poking from gdb, it is
83 # (relatively) ok to store passwords in memory. An if it is not - we have
84 # bigger problems, because ssh-agent uses the same protection.
86 passwd_invalid = False # special object marking previous password was invalid
87 passwd = {}
89 # password entry was cancelled
90 class PasswdCancel(Exception):
91 pass
93 # what's the password for an account?
94 def password4(account):
95 password = passwd.get(account)
96 if password is not None and password is not passwd_invalid:
97 return passwd[account]
99 text = 'Password for %s' % account
100 if password is passwd_invalid:
101 text += ' (prev auth attempt failed)'
103 p = Popen(['/usr/bin/ssh-askpass', text], stdout=PIPE)
104 password, _ = p.communicate()
105 # x11-ssh-askpass prints password with trailing \n
106 password = password.rstrip('\n')
108 if p.returncode:
109 # password cancelled
110 raise PasswdCancel()
112 passwd[account] = password
113 return password
116 # like smtplib.SMTP_SSL but ALWAYS checks peer (ssl.CERT_REQUIRED)
117 class SMTP_SSL_STRICT(smtplib.SMTP_SSL):
119 def _get_socket(self, host, port, timeout):
120 if self.debuglevel > 0:
121 print >>stderr, 'connect: %s via ssl' % ((host, port),)
122 sk = socket.create_connection((host, port), timeout)
123 sk_ssl = ssl.wrap_socket(sk,
124 ca_certs=ca_certs,
125 cert_reqs=ssl.CERT_REQUIRED # ! *always* check peer
126 # NOTE we don't use self.keyfile
127 # NOTE we don't use self.certfile
129 self.file = smtplib.SSLFakeFile(sk_ssl)
130 return sk_ssl
133 def sendmail(fromaddr, toaddrs, msg):
134 srv = SMTP_SSL_STRICT()
135 srv.set_debuglevel(debug)
137 srv.connect(smtphost, smtpport)
139 sk_ssl = srv.sock
141 # print brief summary of peer cert
142 peer, port = sk_ssl.getpeername()
143 cipher, sslver, bits = sk_ssl.cipher()
144 peercert = sk_ssl.getpeercert()
146 subj = {}
147 for rdn in peercert['subject']:
148 # each RDN is a sequence of name/value pairs
149 for n,v in rdn:
150 # XXX what to do for duplicate names?
151 subj[n] = v
153 print '\t via %s:%s %s,%s,%i' % (peer,port, cipher,sslver,bits)
154 print '\t %s/%s/%s/%s/%s (%s)' % (
155 subj.get('countryName', '?'),
156 subj.get('stateOrProvinceName', '?'),
157 subj.get('localityName', '?'),
158 subj.get('organizationName', '?'),
159 subj.get('organizationalUnitName', '?'),
160 subj.get('commonName', '?'))
162 if debug:
163 # print whole peer cert
164 print >>stderr, 'Full certificate info:'
165 print >>stderr, pprint.pformat(peercert)
167 # usual smtp repertoire
168 srv.login(fromaddr, password4(fromaddr))
169 srv.sendmail(fromaddr, toaddrs, msg)
170 srv.quit()
175 # SMTPServer accepts incoming mail and handles it over to our sendmail()
176 class SMTPServer(smtpd.SMTPServer):
178 # we've got incoming message - relay it to sendmail()
179 def process_message(self, peer, mailfrom, rcpttos, data):
180 print '\t* %s\t-> %s ...' % (mailfrom, ', '.join(rcpttos))
181 if debug:
182 print >>stderr, '---------- MESSAGE BEGIN ----------'
183 print >>stderr, 'peer:\t', peer
184 print >>stderr, 'from:\t', mailfrom
185 print >>stderr, 'tos:\t', rcpttos
186 print >>stderr, 'data:\n'
187 print >>stderr, data
188 print >>stderr, '----------- MESSAGE END -----------'
190 while 1:
191 # retry:
192 try:
193 sendmail(mailfrom, rcpttos, data)
195 # password entry was cancelled
196 except PasswdCancel, e:
197 print '\t password cancel'
198 # 450 = "Requested mail action not taken:
199 # mailbox unavailable [E.g., mailbox busy]"
200 return '450 User cancelled password entry'
202 # SSL error - something wrong is going on!
203 except ssl.SSLError, e:
204 print '\t !SSL %r' % e
205 return '450 SSL connection to smarthost not ok: %r' % e
207 # on auth error, retry asking for password
208 except smtplib.SMTPAuthenticationError, e:
209 print '\t %s %s' % (e.smtp_code, e.smtp_error)
210 passwd[mailfrom] = passwd_invalid
211 continue # goto retry;
213 # proxy back any other SMTP errors
214 except smtplib.SMTPResponseException, e:
215 emsg = '%s %s' % (e.smtp_code, e.smtp_error)
216 print emsg
217 return '\t %s' % emsg
219 # all other problems
220 except Exception, e:
221 traceback.print_exc()
223 # 451 = "Requested action aborted: error in processing"
224 return '451 %r' % e
227 # sent ok
228 print '\t ok'
229 return '250 Ok, thanks for using ksmtpproxy'
232 def main2():
233 # don't let gdb attach and read our memory, disable /proc/me/mem
234 # from being readable, /proc/me/fd/* from being peekfd'able, etc...
235 # (we store passwords in memory!)
236 prctl.set_dumpable(0)
238 # XXX likewise maybe don't allow our memory to be swapped?
239 # but locking MCL_CURRENT works only for root (on 3.3-rc1)
240 #mlockall(MCL_CURRENT | MCL_FUTURE)
242 proxy = SMTPServer((localhost, localport), (None,None))
243 if debug:
244 smtpd.DEBUGSTREAM = stderr # NOTE it's per module, not per object
246 try:
247 asyncore.loop()
248 except KeyboardInterrupt:
249 pass
252 ########################################
254 import os
255 import sys
256 from getopt import getopt, GetoptError
258 progname= os.path.basename(sys.argv[0])
260 def exithelp(s):
261 print s
262 print "Try `%s --help' for more information." % progname
263 sys.exit(2)
265 def usage_short():
266 print 'Usage: %s [OPTIONS] localhost smarthost' % progname
268 def usage():
269 usage_short()
270 print \
271 """Proxy SMTP from localhost to smarthost, asking passwords in between
273 localhost: e.g. localhost:2525 or :2525
274 smarthost: e.g. smtp.yandex.ru or smtp.yandex.ru:465
276 NOTE: smarthost is always connected via SSL
278 --ca-certs=FILE file with CA certificates to use
279 (default %(ca_certs)s)
280 -d, --debug show debug output on stderr
281 -h, --help display this help and exit
282 """ % globals()
285 # localhost:2525 -> localhost, 2525
286 def parse_addrport(addrspec):
287 addr = None
288 port = None
290 if not ':' in addrspec:
291 addr = addrspec
292 else:
293 addr, port = addrspec.split(':', 1)
294 port = int(port)
296 return addr, port
299 def main():
300 global localhost,localport, smtphost,smtpport, debug, ca_certs
302 # parse options
303 try:
304 opts, args = getopt(sys.argv[1:], 'dh', ['ca-certs=', 'debug', 'help'])
305 except GetoptError, e:
306 exithelp('%s: %s' % (progname, e.msg))
308 for o, a in opts:
309 if o in [ '--ca-certs']:
310 ca_certs = a
312 if o in ['-d', '--debug']:
313 debug = 1
316 if o in ['-h', '--help']:
317 usage()
318 sys.exit(0)
320 if len(args) != 2:
321 usage_short()
322 sys.exit(1)
324 # parse localhost
325 try:
326 host, port = parse_addrport(args[0])
327 except ValueError:
328 exithelp('E: can\'t parse localhost')
330 localhost = host or localhost
331 localport = port or localport
332 if not (localhost and localport):
333 exithelp('E: localhost not fully specified')
335 # parse smarthost
336 try:
337 host, port = parse_addrport(args[1])
338 except ValueError:
339 exithelp('E: can\'t parse smarthost')
341 smtphost = host or smtphost
342 smtpport = port or smtpport
343 if not (smtphost and smtpport):
344 exithelp('E: smarthost not fully specified')
347 # tail to main loop
348 main2()
351 if __name__ == '__main__':
352 main()