Compare commits

..

9 Commits

Author SHA1 Message Date
Scott Kitterman 2528632ba6 Release 1.0.2 2019-10-07 00:14:20 -04:00
Scott Kitterman 3ea22f1529 Spelling fix 2019-10-07 00:14:03 -04:00
Scott Kitterman 097e053309 Update README now that sysv init is tested 2019-10-06 00:15:30 -04:00
Scott Kitterman 419d2b54ea Set version in setup.py to 1.0.2~rc1 2019-10-05 21:51:43 -04:00
Scott Kitterman 3ff685205c Fix sysv init so it works (LP: #1839487) 2019-10-05 21:50:47 -04:00
Scott Kitterman 7986de6629 - Catch more ascii encoding errors to improve resilience against bad data
(LP: #1844189)
2019-09-23 11:52:17 -04:00
Scott Kitterman 5322c81027 0Fix variable initialization so mailformed mails missing body From do not
cause a traceback (LP: #1844161)
2019-09-16 20:09:55 -04:00
Scott Kitterman 94538ffa6b Fix startup logging so it provides information at a useful time 2019-09-06 00:44:04 -04:00
Scott Kitterman 721da801fe - Fix message extraction so that signing in the same pass through the milter
as verifying works correctly
2019-09-05 23:57:36 -04:00
19 changed files with 131 additions and 440 deletions
-1
View File
@@ -1,4 +1,3 @@
dist dist
dkimpy_milter.egg-info dkimpy_milter.egg-info
*.pyc *.pyc
*~
+10
View File
@@ -1,3 +1,13 @@
1.0.2 2019-10-07
- Fix startup logging so it provides information at a useful time
- Fix message extraction so that signing in the same pass through the milter
as verifying works correctly
- Fix variable initialization so mailformed mails missing body From do not
cause a traceback (LP: #1844161)
- Catch more ascii encoding errors to improve resilience against bad data
(LP: #1844189)
- Fix sysv init so it works (LP: #1839487)
1.0.1 2019-02-11 1.0.1 2019-02-11
* Reorder milter start and dropping privileges so permissions on Unix socket * Reorder milter start and dropping privileges so permissions on Unix socket
are correct (LP: 1797720) are correct (LP: 1797720)
+3 -4
View File
@@ -37,7 +37,7 @@ an C compiler. Alternately, install these dependencies from dsitribution/OS
packages and then pip install dkimpy_milter. packages and then pip install dkimpy_milter.
The milter will work with either pydns (DNS) or dnspython (dns), preferring The milter will work with either pydns (DNS) or dnspython (dns), preferring
dnspython is both are available. The dkimpy DKIM module also works with dnspython if both are available. The dkimpy DKIM module also works with
either. either.
@@ -84,9 +84,8 @@ MTA INTEGRATION
Both a systemd unit file and a sysv init file are provided. Both make Both a systemd unit file and a sysv init file are provided. Both make
assumptions about defaults being used, e.g. if a non-standard pidfile name is assumptions about defaults being used, e.g. if a non-standard pidfile name is
used, they will need to be updated. The sysv init file is Debian specific and used, they will need to be updated. The sysv init file uses start-stop-deamon
untested, since the developers are not using sysv init. Feedback/patches from Debian. It is not portable to systems without that available.
welcome.
The dkimpy-milter drops priviledges after setup to the user/group specified in The dkimpy-milter drops priviledges after setup to the user/group specified in
UserID. During initial setup, this system user needs to be manually created. UserID. During initial setup, this system user needs to be manually created.
+67 -61
View File
@@ -1,4 +1,4 @@
#! /usr/bin/python3 #! /usr/bin/python2
# Original dkim-milter.py code: # Original dkim-milter.py code:
# Author: Stuart D. Gathman <stuart@bmsi.com> # Author: Stuart D. Gathman <stuart@bmsi.com>
# Copyright 2007 Business Management Systems, Inc. # Copyright 2007 Business Management Systems, Inc.
@@ -28,9 +28,8 @@ import dkim
import authres import authres
import os import os
import tempfile import tempfile
import io import StringIO
import re import re
import codecs
from Milter.utils import parse_addr, parseaddr from Milter.utils import parse_addr, parseaddr
import dkimpy_milter.config as config import dkimpy_milter.config as config
from dkimpy_milter.util import drop_privileges from dkimpy_milter.util import drop_privileges
@@ -55,6 +54,7 @@ class dkimMilter(Milter.Base):
self.privatersa = privateRSA self.privatersa = privateRSA
self.privateed25519 = privateEd25519 self.privateed25519 = privateEd25519
self.fp = None self.fp = None
self.fdomain = ''
@Milter.noreply @Milter.noreply
def connect(self, hostname, unused, hostaddr): def connect(self, hostname, unused, hostaddr):
@@ -62,9 +62,7 @@ class dkimMilter(Milter.Base):
self.external_connection = False self.external_connection = False
self.hello_name = None self.hello_name = None
# sometimes people put extra space in sendmail config, so we strip # sometimes people put extra space in sendmail config, so we strip
self.receiver = self.getsymval('j') self.receiver = self.getsymval('j').strip()
if self.receiver is not None:
self.receiver = self.receiver.strip()
try: try:
self.AuthservID = milterconfig['AuthservID'] self.AuthservID = milterconfig['AuthservID']
except: except:
@@ -111,7 +109,7 @@ class dkimMilter(Milter.Base):
def envfrom(self, f, *str): def envfrom(self, f, *str):
if milterconfig.get('Syslog') and milterconfig.get('debugLevel') >= 2: if milterconfig.get('Syslog') and milterconfig.get('debugLevel') >= 2:
syslog.syslog("mail from: {0} {1}".format(f, str)) syslog.syslog("mail from: {0} {1}".format(f, str))
self.fp = io.BytesIO() self.fp = StringIO.StringIO()
self.mailfrom = f self.mailfrom = f
t = parse_addr(f) t = parse_addr(f)
if len(t) == 2: if len(t) == 2:
@@ -136,20 +134,24 @@ class dkimMilter(Milter.Base):
try: try:
self.fdomain = self.author.split('@')[1].lower() self.fdomain = self.author.split('@')[1].lower()
except IndexError as er: except IndexError as er:
self.fdomain = '' # self.author was not a proper email address pass # self.author was not a proper email address
if (milterconfig.get('Syslog') and if (milterconfig.get('Syslog') and
milterconfig.get('debugLevel') >= 1): milterconfig.get('debugLevel') >= 1):
syslog.syslog("{0}: {1}".format(name, val)) syslog.syslog("{0}: {1}".format(name, val))
elif lname == 'authentication-results': elif lname == 'authentication-results':
self.arheaders.append(val) self.arheaders.append(val)
if self.fp: if self.fp:
self.fp.write(b"%s: %s\n" % (codecs.encode(name, 'ascii'), codecs.encode(val, 'ascii'))) try:
self.fp.write("%s: %s\n" % (name, val))
except:
# Don't choke on header fields with garbage in them.
pass
return Milter.CONTINUE return Milter.CONTINUE
@Milter.noreply @Milter.noreply
def eoh(self): def eoh(self):
if self.fp: if self.fp:
self.fp.write(b"\n") # terminate headers self.fp.write("\n") # terminate headers
self.bodysize = 0 self.bodysize = 0
return Milter.CONTINUE return Milter.CONTINUE
@@ -177,39 +179,38 @@ class dkimMilter(Milter.Base):
except: except:
# Don't error out on unparseable AR header fiels # Don't error out on unparseable AR header fiels
pass pass
# Check or sign DKIM # Check and/or sign DKIM
self.fp.seek(0) self.fp.seek(0)
txt = self.fp.read()
if milterconfig.get('Domain'): if milterconfig.get('Domain'):
domain = milterconfig.get('Domain') domain = milterconfig.get('Domain')
else: else:
domain = '' domain = ''
if ((self.fdomain in domain) and not milterconfig.get('Mode') == 'v' if ((self.fdomain in domain) and not milterconfig.get('Mode') == 'v'
and not self.external_connection): and not self.external_connection):
txt = self.fp.read()
self.sign_dkim(txt) self.sign_dkim(txt)
if ((self.has_dkim) and (not self.internal_connection) and if ((self.has_dkim) and (not self.internal_connection) and
(milterconfig.get('Mode') == 'v' or (milterconfig.get('Mode') == 'v' or
milterconfig.get('Mode') == 'sv')): milterconfig.get('Mode') == 'sv')):
txt = self.fp.read()
self.check_dkim(txt) self.check_dkim(txt)
if self.arresults: if self.arresults:
h = authres.AuthenticationResultsHeader(authserv_id= h = authres.AuthenticationResultsHeader(authserv_id=
self.AuthservID, self.AuthservID,
results=self.arresults) results=self.arresults)
h = fold(codecs.encode(str(h), 'ascii')) h = fold(str(h))
if (milterconfig.get('Syslog') and if (milterconfig.get('Syslog') and
milterconfig.get('debugLevel') >= 2): milterconfig.get('debugLevel') >= 2):
syslog.syslog(codecs.decode(h, 'ascii')) syslog.syslog(str(h))
name, val = codecs.decode(h, 'ascii').split(': ', 1) name, val = str(h).split(': ', 1)
self.addheader(name, val, 0) self.addheader(name, val, 0)
return Milter.CONTINUE return Milter.CONTINUE
def sign_dkim(self, txt): def sign_dkim(self, txt):
canon = codecs.encode(milterconfig.get('Canonicalization'), 'ascii') canon = milterconfig.get('Canonicalization')
canonicalize = [] canonicalize = []
if len(canon.split(b'/')) == 2: if len(canon.split('/')) == 2:
canonicalize.append(canon.split(b'/')[0]) canonicalize.append(canon.split('/')[0])
canonicalize.append(canon.split(b'/')[1]) canonicalize.append(canon.split('/')[1])
else: else:
canonicalize.append(canon) canonicalize.append(canon)
canonicalize.append(canon) canonicalize.append(canon)
@@ -219,12 +220,11 @@ class dkimMilter(Milter.Base):
try: try:
if privateRSA: if privateRSA:
d = dkim.DKIM(txt) d = dkim.DKIM(txt)
h = d.sign(codecs.encode(milterconfig.get('Selector'), 'ascii'), codecs.encode(self.fdomain, 'ascii'), h = d.sign(milterconfig.get('Selector'), self.fdomain,
codecs.encode(privateRSA, 'ascii'), privateRSA, canonicalize=(canonicalize[0],
canonicalize=(canonicalize[0], canonicalize[1]))
canonicalize[1])) name, val = h.split(': ', 1)
name, val = h.split(b': ', 1) self.addheader(name, val.strip().replace('\r\n', '\n'), 0)
self.addheader(codecs.decode(name, 'ascii'), codecs.decode(val, 'ascii').strip().replace('\r\n', '\n'), 0)
if (milterconfig.get('Syslog') and if (milterconfig.get('Syslog') and
(milterconfig.get('SyslogSuccess') (milterconfig.get('SyslogSuccess')
or milterconfig.get('debugLevel') >= 1)): or milterconfig.get('debugLevel') >= 1)):
@@ -235,12 +235,12 @@ class dkimMilter(Milter.Base):
d.domain.lower())) d.domain.lower()))
if privateEd25519: if privateEd25519:
d = dkim.DKIM(txt) d = dkim.DKIM(txt)
h = d.sign(codecs.encode(milterconfig.get('SelectorEd25519'), 'ascii'), codecs.encode(self.fdomain, 'ascii'), h = d.sign(milterconfig.get('SelectorEd25519'), self.fdomain,
privateEd25519, canonicalize=(canonicalize[0], privateEd25519, canonicalize=(canonicalize[0],
canonicalize[1]), canonicalize[1]),
signature_algorithm=b'ed25519-sha256') signature_algorithm='ed25519-sha256')
name, val = h.split(b': ', 1) name, val = h.split(': ', 1)
self.addheader(codecs.decode(name, 'ascii'), codecs.decode(val, 'ascii').strip().replace('\r\n', '\n'), 0) self.addheader(name, val.strip().replace('\r\n', '\n'), 0)
if (milterconfig.get('Syslog') and if (milterconfig.get('Syslog') and
(milterconfig.get('SyslogSuccess') (milterconfig.get('SyslogSuccess')
or milterconfig.get('debugLevel') >= 1)): or milterconfig.get('debugLevel') >= 1)):
@@ -259,26 +259,25 @@ class dkimMilter(Milter.Base):
def check_dkim(self, txt): def check_dkim(self, txt):
res = False res = False
self.header_a = None
for y in range(self.has_dkim): # Verify _ALL_ the signatures for y in range(self.has_dkim): # Verify _ALL_ the signatures
d = dkim.DKIM(txt) d = dkim.DKIM(txt)
try: try:
dnsoverride = milterconfig.get('DNSOverride') res = d.verify(idx=y)
if isinstance(dnsoverride, str):
syslog.syslog("DNSOverride: {0}".format(dnsoverride))
res = d.verify(idx=y, dnsfunc=lambda _x: dnsoverride)
else:
res = d.verify(idx=y)
algo = codecs.decode(d.signature_fields.get(b'a'), 'ascii')
if res: if res:
if algo == 'ed25519-sha256': if d.signature_fields.get(b'a') == 'ed25519-sha256':
self.dkim_comment = ('Good {0} signature' self.dkim_comment = ('Good {0} signature'
.format(algo)) .format(d.signature_fields
.get(b'a')))
else: else:
self.dkim_comment = ('Good {0} bit {1} signature' self.dkim_comment = ('Good {0} bit {1} signature'
.format(d.keysize, algo)) .format(d.keysize,
d.signature_fields
.get(b'a')))
else: else:
self.dkim_comment = ('Bad {0} bit {1} signature.' self.dkim_comment = ('Bad {0} bit {1} signature.'
.format(d.keysize, algo)) .format(d.keysize,
d.signature_fields.get(b'a')))
except dkim.DKIMException as x: except dkim.DKIMException as x:
self.dkim_comment = str(x) self.dkim_comment = str(x)
if milterconfig.get('Syslog'): if milterconfig.get('Syslog'):
@@ -287,9 +286,20 @@ class dkimMilter(Milter.Base):
self.dkim_comment = str(x) self.dkim_comment = str(x)
if milterconfig.get('Syslog'): if milterconfig.get('Syslog'):
syslog.syslog("check_dkim: {0}".format(x)) syslog.syslog("check_dkim: {0}".format(x))
self.header_i = codecs.decode(d.signature_fields.get(b'i'), 'ascii') try:
self.header_d = codecs.decode(d.signature_fields.get(b'd'), 'ascii') self.header_i = d.signature_fields.get(b'i')
self.header_a = codecs.decode(d.signature_fields.get(b'a'), 'ascii') except TypeError as x:
self.header_i = None
try:
self.header_d = d.signature_fields.get(b'd')
self.header_a = d.signature_fields.get(b'a')
except Exception as x:
self.dkim_comment = str(x)
if milterconfig.get('Syslog'):
syslog.syslog("check_dkim: {0}".format(x))
self.header_d = None
if not self.header_a:
self.header_a = 'rsa-sha256'
if res: if res:
if (milterconfig.get('Syslog') and if (milterconfig.get('Syslog') and
(milterconfig.get('SyslogSuccess') or (milterconfig.get('SyslogSuccess') or
@@ -309,20 +319,27 @@ class dkimMilter(Milter.Base):
syslog.syslog('DKIM: Fail (saved as {0})' syslog.syslog('DKIM: Fail (saved as {0})'
.format(fname)) .format(fname))
else: else:
syslog.syslog('DKIM: Fail ({0})'.format(d.domain.lower())) if milterconfig.get('Syslog'):
if d.domain:
syslog.syslog('DKIM: Fail ({0})'
.format(d.domain.lower()))
else:
syslog.syslog('DKIM: Fail, unextractable domain')
if res: if res:
result = 'pass' result = 'pass'
else: else:
result = 'fail' result = 'fail'
res = False res = False
self.arresults.append( if self.header_d:
authres.DKIMAuthenticationResult(result=result, self.arresults.append(
authres.DKIMAuthenticationResult(result=result,
header_i=self.header_i, header_i=self.header_i,
header_d=self.header_d, header_d=self.header_d,
header_a=self.header_a, header_a=self.header_a,
result_comment= result_comment=
self.dkim_comment) self.dkim_comment)
) )
self.header_a = None
return return
@@ -354,24 +371,13 @@ def main():
Milter.set_flags(Milter.CHGHDRS + Milter.ADDHDRS) Milter.set_flags(Milter.CHGHDRS + Milter.ADDHDRS)
miltername = 'dkimpy-filter' miltername = 'dkimpy-filter'
socketname = milterconfig.get('Socket') socketname = milterconfig.get('Socket')
if socketname is None: own_socketfile(milterconfig)
if int(os.environ.get('LISTEN_PID', '0')) == os.getpid():
lfds = os.environ.get('LISTEN_FDS')
if lfds is not None:
if lfds != '1':
syslog.syslog('LISTEN_FDS is set to "{0}", but we only know how to deal with "1", ignoring it'.
format(lfds))
else:
socketname = 'fd:3'
if socketname is None:
socketname = 'local:/var/run/dkimpy-milter/dkimpy-milter.sock'
own_socketfile(milterconfig, socketname)
drop_privileges(milterconfig) drop_privileges(milterconfig)
sys.stdout.flush() sys.stdout.flush()
Milter.runmilter(miltername, socketname, 240)
if milterconfig.get('Syslog'): if milterconfig.get('Syslog'):
syslog.syslog('dkimpy-milter started:{0} user:{1}' syslog.syslog('dkimpy-milter starting:{0} user:{1}'
.format(pid, milterconfig.get('UserID'))) .format(pid, milterconfig.get('UserID')))
Milter.runmilter(miltername, socketname, 240)
if __name__ == "__main__": if __name__ == "__main__":
main() main()
-6
View File
@@ -1,6 +0,0 @@
#!/usr/bin/python3
from dkimpy_milter import main
if __name__ == "__main__":
main()
+14 -19
View File
@@ -31,16 +31,16 @@ import stat
import dkim import dkim
import socket import socket
import ipaddress import ipaddress
from .dnsplug import Session from dnsplug import Session
# default values # default values
defaultConfigData = { defaultConfigData = {
'Syslog': 'yes', 'Syslog': 'yes',
'SyslogFacility': 'mail', 'SyslogFacility': 'mail',
'UMask': 0o07, 'UMask': 007,
'Mode': 'sv', 'Mode': 'sv',
'Socket': None, 'Socket': 'local:/var/run/dkimpy-milter/dkimpy-milter.sock',
'PidFile': None, 'PidFile': '/var/run/dkimpy-milter/dkimpy-milter.pid',
'UserID': 'dkimpy-milter', 'UserID': 'dkimpy-milter',
'Canonicalization': 'relaxed/simple', 'Canonicalization': 'relaxed/simple',
'InternalHosts': '127.0.0.1', 'InternalHosts': '127.0.0.1',
@@ -48,7 +48,6 @@ defaultConfigData = {
'DiagnosticDirectory': '', 'DiagnosticDirectory': '',
'MacroList': '', 'MacroList': '',
'MacroListVerify': '', 'MacroListVerify': '',
'DNSOverride': None,
'debugLevel': 0 # Undocumented config item for developer use 'debugLevel': 0 # Undocumented config item for developer use
} }
@@ -85,14 +84,14 @@ class HostsDataset(object):
self.item = item[1:] self.item = item[1:]
self.negative = True self.negative = True
try: try:
self.item = ipaddress.ip_address(str(self.item, "utf-8")) self.item = ipaddress.ip_address(unicode(self.item, "utf-8"))
if isinstance(self.item, ipaddress.IPv4Address): if isinstance(self.item, ipaddress.IPv4Address):
self.isipv4 = True self.isipv4 = True
elif isinstance(self.item, ipaddress.IPv6Address): elif isinstance(self.item, ipaddress.IPv6Address):
self.isipv6 = True self.isipv6 = True
except ValueError as e: except ValueError as e:
try: try:
self.item = ipaddress.ip_network(str self.item = ipaddress.ip_network(unicode
(self.item, "utf-8"), (self.item, "utf-8"),
strict=False) strict=False)
if isinstance(self.item, ipaddress.IPv4Network): if isinstance(self.item, ipaddress.IPv4Network):
@@ -110,7 +109,7 @@ class HostsDataset(object):
def match(self, connectip): def match(self, connectip):
'''Check if the connect IP is part of the dataset''' '''Check if the connect IP is part of the dataset'''
source = ipaddress.ip_address(str(connectip, "utf-8")) source = ipaddress.ip_address(unicode(connectip, "utf-8"))
for item in self.dataset: for item in self.dataset:
if item.isdomain or item.ishostname: if item.isdomain or item.ishostname:
result = self.matchname(source) # Match host/domains first result = self.matchname(source) # Match host/domains first
@@ -160,13 +159,13 @@ class HostsDataset(object):
if isinstance(source, ipaddress.IPv4Address): if isinstance(source, ipaddress.IPv4Address):
ips = s.dns(name, 'A') ips = s.dns(name, 'A')
for ip in ips: for ip in ips:
ip = ipaddress.IPv4Address(str(ip, 'UTF-8')) ip = ipaddress.IPv4Address(unicode(ip, 'UTF-8'))
if ip == source: if ip == source:
results.append(name) results.append(name)
if isinstance(source, ipaddress.IPv6Address): if isinstance(source, ipaddress.IPv6Address):
ips = s.dns(name, 'AAAA') ips = s.dns(name, 'AAAA')
for ip in ips: for ip in ips:
ip = ipaddress.IPv6Address(str(ip, 'UTF-8')) ip = ipaddress.IPv6Address(unicode(ip, 'UTF-8'))
if ip == source: if ip == source:
results.append(name) results.append(name)
return results return results
@@ -225,13 +224,13 @@ def _processConfigFile(filename=None, configdata=None, useSyslog=1,
'''Load the specified config file, exit and log errors if it fails, '''Load the specified config file, exit and log errors if it fails,
otherwise return a config dictionary.''' otherwise return a config dictionary.'''
from . import config import config
if configdata is None: if configdata is None:
configdata = config.defaultConfigData configdata = config.defaultConfigData
if filename is not None: if filename is not None:
try: try:
_readConfigFile(filename, configdata) _readConfigFile(filename, configdata)
except Exception as e: except Exception, e:
raise raise
if useSyslog: if useSyslog:
syslog.syslog(e.args[0]) syslog.syslog(e.args[0])
@@ -335,14 +334,13 @@ def _readConfigFile(path, configData=None, configGlobal={}):
'DiagnosticDirectory': 'str', 'DiagnosticDirectory': 'str',
'MacroList': 'dataset', 'MacroList': 'dataset',
'MacroListVerify': 'dataset', 'MacroListVerify': 'dataset',
'DNSOverride': 'str',
'debugLevel': 'int' 'debugLevel': 'int'
} }
# check to see if it's a file # check to see if it's a file
try: try:
mode = os.stat(path)[0] mode = os.stat(path)[0]
except OSError as e: except OSError, e:
syslog.syslog(syslog.LOG_ERR, 'ERROR stating "%s": %s' syslog.syslog(syslog.LOG_ERR, 'ERROR stating "%s": %s'
% (path, e.strerror)) % (path, e.strerror))
return(configData) return(configData)
@@ -390,10 +388,7 @@ def _readConfigFile(path, configData=None, configGlobal={}):
if conversion == 'bool': if conversion == 'bool':
configData[name] = _find_boolean(value) configData[name] = _find_boolean(value)
elif conversion == 'str': elif conversion == 'str':
if isinstance(value, list): configData[name] = str(value)
configData[name] = line.split(None, 1)[1]
else:
configData[name] = str(value)
elif conversion == 'int': elif conversion == 'int':
configData[name] = int(value) configData[name] = int(value)
elif conversion == 'dataset': elif conversion == 'dataset':
@@ -404,7 +399,7 @@ def _readConfigFile(path, configData=None, configGlobal={}):
configData[name] = conversion(value) configData[name] = conversion(value)
fp.close() fp.close()
try: try:
configData['AuthservID'] = _make_authserv_id(configData.get('AuthservID', 'HOSTNAME')) configData['AuthservID'] = _make_authserv_id(configData['AuthservID'])
configData['IntHosts'] = HostsDataset(configData['InternalHosts']) configData['IntHosts'] = HostsDataset(configData['InternalHosts'])
except: except:
pass pass
+8 -8
View File
@@ -84,7 +84,7 @@ class Session(object):
raise DNSError('Length of CNAME chain exceeds %d' % MAX_CNAME) raise DNSError('Length of CNAME chain exceeds %d' % MAX_CNAME)
cnames[name] = cname cnames[name] = cname
if cname in cnames: if cname in cnames:
raise DNSError('CNAME loop') raise DNSError, 'CNAME loop'
result = self.dns(cname, qtype, cnames=cnames) result = self.dns(cname, qtype, cnames=cnames)
return result return result
@@ -103,16 +103,16 @@ def DNSLookup_pydns(name, qtype, tcpfallback=True, timeout=30):
# #
if resp.header['tc'] == True: if resp.header['tc'] == True:
if not tcpfallback: if not tcpfallback:
raise DNS.DNSError('DNS: Truncated UDP Reply, SPF records should fit in a UDP packet') raise DNS.DNSError, 'DNS: Truncated UDP Reply, SPF records should fit in a UDP packet'
try: try:
req = DNS.DnsRequest(name, qtype=qtype, protocol='tcp', req = DNS.DnsRequest(name, qtype=qtype, protocol='tcp',
timeout=timeout) timeout=timeout)
resp = req.req() resp = req.req()
except DNS.DNSError as x: except DNS.DNSError, x:
raise DNS.DNSError('TCP Fallback error: ' + str(x)) raise DNS.DNSError, 'TCP Fallback error: ' + str(x)
return [((a['name'], a['typename']), a['data']) for a in resp.answers] return [((a['name'], a['typename']), a['data']) for a in resp.answers]
except IOError as x: except IOError, x:
raise DNS.DNSError('DNS: ' + str(x)) raise DNS.DNSError, 'DNS: ' + str(x)
def DNSLookup_dnspython(name,qtype,tcpfallback=True,timeout=30): def DNSLookup_dnspython(name,qtype,tcpfallback=True,timeout=30):
retVal = [] retVal = []
@@ -164,5 +164,5 @@ if __name__ == '__main__':
import sys import sys
s = Session() s = Session()
for n,t in zip(*[iter(sys.argv[1:])]*2): for n,t in zip(*[iter(sys.argv[1:])]*2):
print(n,t) print n,t
print(s.dns(n,t)) print s.dns(n,t)
+13 -27
View File
@@ -115,59 +115,45 @@ def write_pid(milterconfig):
"""Write PID in pidfile. Will not overwrite an existing file.""" """Write PID in pidfile. Will not overwrite an existing file."""
import os import os
import syslog import syslog
pidfile = milterconfig.get('PidFile') if not os.path.isfile(milterconfig.get('PidFile')):
if pidfile is None:
return
if not os.path.isfile(pidfile):
pid = str(os.getpid()) pid = str(os.getpid())
try: try:
f = open(pidfile, 'w') f = open(milterconfig.get('PidFile'), 'w')
except IOError as e: except IOError as e:
if str(e)[:35] == '[Errno 2] No such file or directory': if str(e)[:35] == '[Errno 2] No such file or directory':
piddir = pidfile.rsplit('/', 1)[0] piddir = milterconfig.get('PidFile').rsplit('/', 1)[0]
os.mkdir(piddir) os.mkdir(piddir)
user, group = user_group(milterconfig.get('UserID')) user, group = user_group(milterconfig.get('UserID'))
os.chown(piddir, user, group) os.chown(piddir, user, group)
f = open(pidfile, 'w') f = open(milterconfig.get('PidFile'), 'w')
if milterconfig.get('Syslog'): if milterconfig.get('Syslog'):
syslog.syslog('PID dir created: {0}'.format(piddir)) syslog.syslog('PID dir created: {0}'.format(piddir))
else: else:
if milterconfig.get('Syslog'): if milterconfig.get('Syslog'):
syslog.syslog('Unable to write pidfle {0}. IOError: {1}' syslog.syslog('Unable to write pidfle {0}. IOError: {1}'
.format(pidfile, e)) .format(milterconfig.get('PidFile'), e))
raise raise
f.write(pid) f.write(pid)
f.close() f.close()
user, group = user_group(milterconfig.get('UserID')) user, group = user_group(milterconfig.get('UserID'))
os.chown(pidfile, user, group) os.chown(milterconfig.get('PidFile'), user, group)
else: else:
if milterconfig.get('Syslog'): if milterconfig.get('Syslog'):
syslog.syslog('Unable to write pidfle {0}. File exists.' syslog.syslog('Unable to write pidfle {0}. File exists.'
.format(pidfile)) .format(milterconfig.get('PidFile')))
raise RuntimeError('Unable to write pidfle {0}. File exists.' raise RuntimeError('Unable to write pidfle {0}. File exists.'
.format(pidfile)) .format(milterconfig.get('PidFile')))
return pid return pid
def own_socketfile(milterconfig, sockname=None): def own_socketfile(milterconfig):
"""If socket is Unix socket, chown to UserID before dropping privileges""" """If socket is Unix socket, chown to UserID before dropping privileges"""
import os import os
user, group = user_group(milterconfig.get('UserID')) user, group = user_group(milterconfig.get('UserID'))
offset = None if milterconfig.get('Socket')[:1] == '/':
if sockname is None: os.chown(milterconfig.get('Socket')[1:], user, group)
sockname = milterconfig.get('Socket') if milterconfig.get('Socket')[:6] == "local:":
if sockname is None: os.chown(milterconfig.get('Socket')[6:], user, group)
return
if sockname[:1] == '/':
offset = 0
elif sockname[:6] == "local:":
offset = 6
elif sockname[:5] == "unix:":
offset = 5
if offset is not None:
if os.path.exists(sockname[offset:]):
os.chown(sockname[offset:], user, group)
def read_keyfile(milterconfig, keytype): def read_keyfile(milterconfig, keytype):
+1 -8
View File
@@ -311,13 +311,6 @@ be set:
(b) KeyTable, SigningTable, no Domain, no KeyFile, no Selector; (b) KeyTable, SigningTable, no Domain, no KeyFile, no Selector;
[fooTable options NOT IMPLEMENTED] [fooTable options NOT IMPLEMENTED]
.TP
.I DNSOverride (string)
Provide a text string that a verifying milter should use instead of
consulting the DNS on each message. This is useful primarily for
testing purposes in environments where it is awkward to modify the
system DNS resolution. It should not be used in production.
.TP .TP
.I PeerList (dataset) .I PeerList (dataset)
Identifies a set of "peers" that identifies clients whose connections Identifies a set of "peers" that identifies clients whose connections
@@ -338,7 +331,7 @@ will be checked. [PeerList NOT IMPLEMENTED - included for reference only]
.TP .TP
.I PidFile (string) .I PidFile (string)
Specifies the path to a file that should be created at process start Specifies the path to a file that should be created at process start
containing the process ID. If not specified, no such file will be created. containing the process ID.
.TP .TP
.I Selector (string) .I Selector (string)
+6 -6
View File
@@ -1,4 +1,4 @@
#! /usr/bin/python3 #! /usr/bin/python
# dkimpy-milter: A DKIM signing/verification Milter application # dkimpy-milter: A DKIM signing/verification Milter application
# Author: Scott Kitterman <scott@kitterman.com> # Author: Scott Kitterman <scott@kitterman.com>
# Copyright 2018 Scott Kitterman # Copyright 2018 Scott Kitterman
@@ -23,14 +23,14 @@ description = "Domain Keys Identified Mail (DKIM) signing/verifying milter for P
kw = {} # Work-around for lack of 'or' requires in setuptools. kw = {} # Work-around for lack of 'or' requires in setuptools.
try: try:
import dns import DNS
kw['install_requires'] = ['dkimpy>=0.7', 'pymilter', 'authres>=1.1.0', 'PyNaCl', 'dnspython'] kw['install_requires'] = ['dkimpy>=0.7', 'pymilter', 'authres>=1.1.0', 'PyNaCl', 'ipaddress', 'PyDNS']
except ImportError: # If PyDNS is not installed, prefer dnspython except ImportError: # If PyDNS is not installed, prefer dnspython
kw['install_requires'] = ['dkimpy>=0.7', 'pymilter', 'authres>=1.1.0', 'PyNaCl', 'PyDNS'] kw['install_requires'] = ['dkimpy>=0.7', 'pymilter', 'authres>=1.1.0', 'PyNaCl', 'ipaddress', 'dnspython']
setup( setup(
name='dkimpy-milter', name='dkimpy-milter',
version='1.0.1', version='1.0.2',
author='Scott Kitterman', author='Scott Kitterman',
author_email='scott@kitterman.com', author_email='scott@kitterman.com',
url='https://launchpad.net/dkimpy-milter', url='https://launchpad.net/dkimpy-milter',
@@ -43,7 +43,7 @@ setup(
'License :: OSI Approved :: GNU General Public License (GPL)', 'License :: OSI Approved :: GNU General Public License (GPL)',
'Natural Language :: English', 'Natural Language :: English',
'Operating System :: POSIX', 'Operating System :: POSIX',
'Programming Language :: Python :: 3 :: Only', 'Programming Language :: Python :: 2 :: Only',
'Topic :: Communications :: Email :: Mail Transport Agents', 'Topic :: Communications :: Email :: Mail Transport Agents',
'Topic :: Communications :: Email :: Filters', 'Topic :: Communications :: Email :: Filters',
'Topic :: Security', 'Topic :: Security',
+9 -7
View File
@@ -20,9 +20,9 @@
### END INIT INFO ### END INIT INFO
prefix="/usr/local" prefix="/usr/local"
exec_prefix=${prefix} exec_prefix=${prefix}
sysconfdir="/etc/dkimpy-milter" sysconfdir="/usr/local/etc"
bindir="${exec_prefix}/bin/" bindir="${exec_prefix}/bin/"
RUNDIR="/var/run/dkimpy-milter" RUNDIR="/run/dkimpy-milter"
DAEMON=${bindir}/dkimpy-milter DAEMON=${bindir}/dkimpy-milter
PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/bin: PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/bin:
NAME=dkimpy-milter NAME=dkimpy-milter
@@ -67,14 +67,14 @@ case "$1" in
fi fi
fi fi
fi fi
start-stop-daemon --start --background --quiet --pidfile \
start-stop-daemon --start --quiet --pidfile $RUNDIR/$NAME.pid --startas \ $RUNDIR/$NAME.pid --exec $DAEMON $sysconfdir/$NAME.conf
$DAEMON $sysconfdir/$NAME.conf --name $NAME --test > /dev/null \
echo "$NAME." echo "$NAME."
;; ;;
stop) stop)
echo -n "Stopping $DESC: " echo -n "Stopping $DESC: "
if [ -f $RUNDIR/$NAME.pid ]; then if [ -f $RUNDIR/$NAME.pid ]; then
chown root:root $RUNDIR/$NAME.pid
start-stop-daemon --stop --pidfile $RUNDIR/$NAME.pid start-stop-daemon --stop --pidfile $RUNDIR/$NAME.pid
rm $RUNDIR/$NAME.pid rm $RUNDIR/$NAME.pid
#echo $SOCKET #echo $SOCKET
@@ -87,6 +87,7 @@ case "$1" in
force-reload) force-reload)
echo -n "Force reloading $DESC: " echo -n "Force reloading $DESC: "
if [ -f $RUNDIR/$NAME.pid ]; then if [ -f $RUNDIR/$NAME.pid ]; then
chown root:root $RUNDIR/$NAME.pid
start-stop-daemon --stop --pidfile $RUNDIR/$NAME.pid start-stop-daemon --stop --pidfile $RUNDIR/$NAME.pid
rm $RUNDIR/$NAME.pid rm $RUNDIR/$NAME.pid
#echo $SOCKET #echo $SOCKET
@@ -95,7 +96,7 @@ case "$1" in
fi fi
fi fi
sleep 1 sleep 1
start-stop-daemon --start --chuid $USER --background --quiet --pidfile \ start-stop-daemon --start --background --quiet --pidfile \
$RUNDIR/$NAME.pid --exec $DAEMON $sysconfdir/$NAME.conf $RUNDIR/$NAME.pid --exec $DAEMON $sysconfdir/$NAME.conf
echo "$NAME." echo "$NAME."
;; ;;
@@ -103,6 +104,7 @@ case "$1" in
echo "Restarting $DESC: " echo "Restarting $DESC: "
echo -n "Stopping $DESC: " echo -n "Stopping $DESC: "
if [ -f $RUNDIR/$NAME.pid ]; then if [ -f $RUNDIR/$NAME.pid ]; then
chown root:root $RUNDIR/$NAME.pid
start-stop-daemon --stop --pidfile $RUNDIR/$NAME.pid start-stop-daemon --stop --pidfile $RUNDIR/$NAME.pid
rm $RUNDIR/$NAME.pid rm $RUNDIR/$NAME.pid
#echo $SOCKET #echo $SOCKET
@@ -113,7 +115,7 @@ case "$1" in
echo "$NAME." echo "$NAME."
sleep 1 sleep 1
echo -n "Starting $DESC: " echo -n "Starting $DESC: "
start-stop-daemon --start --chuid $USER --background --quiet --pidfile \ start-stop-daemon --start --background --quiet --pidfile \
$RUNDIR/$NAME.pid --exec $DAEMON $sysconfdir/$NAME.conf $RUNDIR/$NAME.pid --exec $DAEMON $sysconfdir/$NAME.conf
echo "$NAME." echo "$NAME."
;; ;;
-32
View File
@@ -1,32 +0,0 @@
This directory contains example systemd unit files for running a
supervised, socket-activated instance of dkimpy-milter.
There are several advantages of using socket activation:
- dkimpy-milter never runs with elevated privileges, they are dropped
before any dkimpy-milter code is executed.
- The socket is opened before dkimpy-milter runs. This means that
clients can connect() to the socket immediately. So even if there
is a delay in dkimpy-milter startup, or in libmilter itself, the
connection will not fail.
- You can set the privileges of a listening Unix-domain socket by an
override of ListenGroup= in dkimpy-milter.socket (see
systemd.unit(5) for how to override). This lets you control who has
access to the daemon with finer granularity than is available with
dkimpy-milter on its own.
- dkimpy-milter will not consume system resources if it is not used.
- A fully-supervised dkimpy-milter needs no PIDFile, UMask, UserID, or
Socket configuation. This eliminates common race conditions and
startup failures, and simplifies the resulting configuration file.
There is one downside to using socket activation:
- it will only work on systems where libmilter can support connection
strings like "fd:3". This has been supported on Debian and derived
systems since sendmail 8.14.4-6 (before Debian Jessie, in early
2014), see for example:
https://sources.debian.org/src/sendmail/8.15.2-8/debian/patches/socket_activation.patch/
@@ -1,11 +0,0 @@
[Unit]
Description=DKIMpy Milter
Documentation=man:dkimpy-milter(8) man:dkimpy-milter.conf(5)
Requires=dkimpy-milter.socket
[Service]
ExecStart=/usr/bin/dkimpy-milter /etc/dkimpy-milter.conf
User=dkimpy-milter
[Install]
Also=dkimpy-milter.socket
@@ -1,12 +0,0 @@
[Unit]
Description=DKIMpy Milter socket
Documentation=man:dkimpy-milter(8) man:dkimpy-milter.conf(5)
[Socket]
ListenStream=/run/dkimpy-milter/dkimpy-milter.sock
SocketMode=0660
# override SocketGroup to grant access to members of another system group:
SocketGroup=dkimpy-milter
[Install]
WantedBy=sockets.target
-12
View File
@@ -1,12 +0,0 @@
-- -*- lua -*-
for _, keytype in ipairs({"ed25519", "rsa"}) do
for _, func in ipairs({"signing", "verify"}) do
mt.echo("testing "..keytype.." "..func)
conn = mt.connect("unix:"..keytype.."."..func..".sock")
if conn == nil then
error("mt.connect() failed "..keytype.." "..func)
end
mt.disconnect(conn)
mt.echo(keytype.." "..func.." complete")
end
end
-40
View File
@@ -1,40 +0,0 @@
-- -*- lua -*-
for _, keytype in ipairs({"ed25519", "rsa"}) do
for _, func in ipairs({"signing", "verify"}) do
mt.echo("testing "..keytype.." "..func)
conn = mt.connect("unix:"..keytype.."."..func..".sock")
if conn == nil then
error("mt.connect() failed "..keytype.." "..func)
end
if mt.conninfo(conn, "localhost", "127.0.0.1") ~= nil then
error("mt.conninfo() failed "..keytype.." "..func)
end
if mt.getreply(conn) ~= SMFIR_CONTINUE then
error("mt.conninfo() unexpected reply "..keytype.." "..func)
end
if mt.test_action(conn, SMFIF_ADDHDRS) then
print("could add headers "..keytype.." "..func)
else
error("mt.test_action() says could not add headers "..keytype.." "..func)
end
if mt.test_action(conn, SMFIF_CHGHDRS) then
print("could change headers "..keytype.." "..func)
else
error("mt.test_action() says could not change headers "..keytype.." "..func)
end
-- -- FIXME: this part of the test fails, as apparently the
-- -- dkimpy-milter claims the right to change the body of a message,
-- -- even though it shouldn't. How can we fix the negotiation?
-- if mt.test_action(conn, SMFIF_CHGBODY) then
-- error("mt.test_action() says could change body "..keytype.." "..func)
-- else
-- print("could not change body "..keytype.." "..func)
-- end
mt.disconnect(conn)
mt.echo(keytype.." "..func.." test complete")
end
end
-100
View File
@@ -1,100 +0,0 @@
-- -*- lua -*-
msg = {
['headers'] = {
['From'] = 'Alice <alice@example.net>',
['Message-Id'] = '<dkimpy-milter-test-02@example.net>',
['To'] = 'Bob <bob@example.biz>',
['Date'] = 'Mon, 18 Feb 2019 08:32:50 -0500',
['Subject'] = 'Signing test',
['Content-Type'] = 'text/plain',
},
['body'] = "This is a test!\r\n",
}
-- returns miltertest connection object
function connect_and_send (sockname, headers, body)
conn = mt.connect(sockname)
if conn == nil then
error "mt.connect() failed"
end
if mt.conninfo(conn, "localhost", "127.0.0.1") ~= nil then
error "mt.conninfo() failed"
end
if mt.getreply(conn) ~= SMFIR_CONTINUE then
error "mt.conninfo() unexpected reply"
end
-- mt.macro(conn, SMFIC_MAIL, "i", "simple-message")
if mt.mailfrom(conn, "<alice@example.net>") ~= nil then
error "mt.mailfrom() failed"
end
if mt.getreply(conn) ~= SMFIR_CONTINUE then
error "mt.mailfrom() unexpected reply"
end
-- mt.rcptto() is called implicitly
-- send headers
for key,value in pairs(headers) do
if mt.header(conn, key, value) ~= nil then
error("mt.header(" .. key .. ") failed")
end
if mt.getreply(conn) ~= SMFIR_CONTINUE then
error("mt.header(" .. key .. ") unexpected reply")
end
end
-- send EOH
if mt.eoh(conn) ~= nil then
error "mt.eoh() failed"
end
if mt.getreply(conn) ~= SMFIR_CONTINUE then
error "mt.eoh() unexpected reply"
end
-- send body
if mt.bodystring(conn, body) ~= nil then
error "mt.bodystring() failed"
end
if mt.getreply(conn) ~= SMFIR_CONTINUE then
error "mt.bodystring() unexpected reply"
end
-- end of message; let the filter react
if mt.eom(conn) ~= nil then
error "mt.eom() failed"
end
reply = mt.getreply(conn)
if reply ~= SMFIR_CONTINUE then
error ("mt.eom() unexpected reply: " .. reply)
end
return conn
end
for _, keytype in ipairs({"ed25519", "rsa"}) do
mt.echo("testing "..keytype)
signing = connect_and_send("unix:"..keytype..".signing.sock", msg.headers, msg.body)
-- verify that a test header field got added
if not mt.eom_check(signing, MT_HDRINSERT) then
error "no header added by signer"
end
signature = mt.getheader(signing, "DKIM-Signature", 0)
mt.disconnect(signing)
mt.echo("DKIM-Signature: " .. signature)
msg.headers['DKIM-Signature'] = signature
verify = connect_and_send("unix:"..keytype..".verify.sock", msg.headers, msg.body)
if not mt.eom_check(verify, MT_HDRINSERT) then
error "no header added in verify"
end
authres = mt.getheader(verify, "Authentication-Results", 0)
mt.echo("Authentication-Results: "..authres)
mt.disconnect(verify)
mt.echo(keytype.." complete")
end
-2
View File
@@ -1,2 +0,0 @@
#!/bin/sh
python3 -m dkimpy_milter "$@"
-84
View File
@@ -1,84 +0,0 @@
#!/bin/bash
set -e
WORKDIR=$(mktemp -d)
TESTDIR=$(realpath "$(dirname "$0")")
DKIMPY_MILTER=${DKIMPY_MILTER:-"$TESTDIR/dkimpy-milter"}
KEY_TYPES=(ed25519 rsa)
cd "$WORKDIR"
printf "Testing %s from directory %s\n" "$DKIMPY_MILTER" "$WORKDIR"
for keytype in "${KEY_TYPES[@]}"; do
dknewkey --ktype "$keytype" "testkey.$keytype"
if [ "$keytype" = ed25519 ]; then
keyfile=KeyFileEd25519
selector=SelectorEd25519
else
keyfile=KeyFile
selector=Selector
fi
cat > "$keytype.signing.conf" <<EOF
Domain example.net
$keyfile testkey.$keytype.key
$selector testkey
Socket unix:$keytype.signing.sock
PidFile $keytype.signing.pid
Mode s
UserID $(id --name --user):$(id --name --group)
EOF
cat > "$keytype.verify.conf" <<EOF
Socket unix:$keytype.verify.sock
PidFile $keytype.verify.pid
Mode v
DNSOverride $(cat testkey.$keytype.dns)
UserID $(id --name --user):$(id --name --group)
EOF
done
cleanup() {
echo cleaning up jobs:
jobs
for keytype in "${KEY_TYPES[@]}"; do
for func in signing verify; do
if [ -s "$keytype.$func.pid" ] && kill -0 "$(cat "$keytype.$func.pid")"; then
kill "$(cat $keytype.$func.pid)"
fi
done
done
wait
for keytype in "${KEY_TYPES[@]}"; do
for func in signing verify; do
errdata="$keytype.$func.stderr"
if [ -s "$errdata" ]; then
printf -- "-> %s:\n" "$errdata"
cat "$errdata"
printf -- "-> end %s\n" "$errdata"
fi
done
done
rm -rf "$WORKDIR"
}
for keytype in "${KEY_TYPES[@]}"; do
for func in signing verify; do
PYTHONPATH="$(dirname "$TESTDIR")" "$DKIMPY_MILTER" "$keytype.$func.conf" 2>"$keytype.$func.stderr" &
done
done
trap cleanup EXIT
# ugly ugly (how are we supposed to know that the milters are all ready?):
sleep 2
# uses miltertest from opendkim:
for x in ${TESTS:-"$TESTDIR"/*.miltertest}; do
if ! [ -e "$x" ]; then
if [ -e "$TESTDIR/$x" ]; then
x="$TESTDIR/$x"
fi
fi
printf -- "-> running %s...\n" "$x"
miltertest -s "$x"
done