Compare commits

..

17 Commits

Author SHA1 Message Date
Daniel Kahn Gillmor ea09bab1a8 Convert __init__.py to python3
The main work here is about bytes vs. strings.  This work was
confusing for several reasons:

 * pymilter thinks that headers are all strings, but body is bytes

 * dkimpy wants to deal with bytes objects generally (though it
   accepts a string object as an ed25519 secret key for some reason,
   despite requiring bytes as an RSA secret key)

 * authres.AuthenticationResultsHeader object converts easily to a
   string, but has no direct bytes conversion.  meanwhile, it wants
   its arguments as strings, but will accept them if they are bytes
   and convert them with something like str(), which leaves weird
   cruft like "header.a=b'ed25519-sha256'"

 * dkimpy_milter/utils.py contains fold() which expects bytes

 * self.fp needs to accumulate the on-the-wire version of the message
   as a whole (so it needs to be bytes).  That means converting the
   headers.  Header names and values are US-ASCII, per §2.2 of RFC
   5322, so they should be convertible cleanly, but we still have to
   convert them explicitly so that python knows the right thing to do.

At any rate, tests/runtests all passes with these changes, and the
output for both Authentication-Results: and DKIM-Signature headers
looks the same.
2019-02-21 19:22:11 -05:00
Daniel Kahn Gillmor 391b5352f3 Convert mostly to python3 (still need strings/bytes conversions)
This covers conversion of the whole project to python3, *except* for
the strings/bytes distinction in __init__.py, which i'm leaving for a
second commit.

The changes in this commit are intended to be relatively
uncontroversial, so that the following commit contains the tricky
bits.
2019-02-21 19:22:09 -05:00
Daniel Kahn Gillmor ad8f396db0 Expand test suite to cover RSA as well as ed25519 2019-02-21 19:21:38 -05:00
Daniel Kahn Gillmor 479820a07d tests: test DKIM signing and verification
This test makes use of DNSOverride and the new verifying milter to
ensure that signatures can be verified properly.

It doesn't test the actual interaction with the public DNS, but
getting that kind of test to work on arbitrary platforms might be more
trouble than it's worth.

I note that the DNSOverride only works as long as testkey.dns is a
single line, which is fine for ed25519, but maybe not for RSA.
2019-02-21 19:21:38 -05:00
Daniel Kahn Gillmor 7bfb87fab7 Set up __main__.py, use it in tests
This allows us to invoke dkimpy-milter as "python -m dkimpy_milter
dkimpy-milter.conf", which makes running the test suite easier.
2019-02-21 19:21:38 -05:00
Daniel Kahn Gillmor 5c1d5d6e52 tests: Run a verifying milter as well as a signing milter
Having a verifying milter will come in handy when we want to test both
sides of the DKIM process.
2019-02-21 19:21:38 -05:00
Daniel Kahn Gillmor ae31730593 check for actions claimed by the filter 2019-02-21 19:21:38 -05:00
Daniel Kahn Gillmor 72ed000ccf simple testing framework 2019-02-21 19:21:38 -05:00
Daniel Kahn Gillmor b3db013754 config: Reassemble strings sensibly
If a string-based configuation entry had whitespace in it, it would be
reassembled via a round-trip through the python interpreter, resulting
in a line like this:

    PidFile /home/dkimpy-milter/pid file

produces a string like "['/home/dkimpy-milter/pid', 'file']", which is
clearly wrong.

I don't want to encourage people to use paths or other strings with
whitespace in them, but if we're going to fail on them we should be
failing explicitly, not doing a weird transformation that will just
break.

This is concretely useful for the DNSOverride mechanism, which is
where i ran into the problem when trying to set up testing that could
work without setting up an emulated DNS system.
2019-02-21 19:21:38 -05:00
Daniel Kahn Gillmor bd1d25d83e Set up correct AuthservID defaults
Without this fix, a verifying dkimpy-milter that has no explicit
AuthservID produces the following crashing behavior as it tries to
create the authres header:

Traceback (most recent call last):
  File "/usr/lib/python2.7/dist-packages/Milter/__init__.py", line 772, in <lambda>
    milter.set_eom_callback(lambda ctx: ctx.getpriv().eom())
  File "…/dkimpy_milter/__init__.py", line 199, in eom
    h = fold(str(h))
  File "/usr/lib/python2.7/dist-packages/authres/core.py", line 476, in __str__
    return ''.join((self.HEADER_FIELD_NAME, ': ', self.header_value()))
  File "/usr/lib/python2.7/dist-packages/authres/core.py", line 496, in header_value
    return ''.join(strs)
2019-02-21 19:21:38 -05:00
Daniel Kahn Gillmor 1c6030024d add DNSOverride configuration for testing 2019-02-21 19:21:38 -05:00
Daniel Kahn Gillmor 71c0c3f20a Avoid failing to chown non-existent Unix-domain sockets
Changing ownership of sockets that doesn't exist isn't a great
practice.

A better approach would be to apply os.chown() to the file descriptor
of the open socket, but at the very least dkimpy-milter shouldn't
crash the way it currently does if the socket isn't already present.
2019-02-21 19:21:38 -05:00
Daniel Kahn Gillmor a9a6893c89 Handle unix: socket prefix the same as local:
sendmail's milter.c treats these two declarations the same way, so
what we do for one should also be done for the other.
2019-02-21 19:21:38 -05:00
Daniel Kahn Gillmor bb44f36519 When Socket is absolute path, do not strip leading /
This appears to just be an untested codepath.
2019-02-21 19:21:38 -05:00
Daniel Kahn Gillmor 9e11b75ec3 Avoid AttributeError on simple connection and disconnection
Without this patch, this simple script for miltertest:

----
conn = mt.connect("unix:milter.sock")
if conn == nil then
  error "mt.connect() failed"
end
if mt.conninfo(conn, nil, "unspec") ~= nil then
  error "mt.conninfo() failed"
end
if mt.getreply(conn) ~= SMFIR_CONTINUE then
     error "mt.conninfo() unexpected reply"
end
mt.disconnect(conn)
----

Produces the following error:

Traceback (most recent call last):
  File "/usr/lib/python2.7/dist-packages/Milter/__init__.py", line 702, in connect_callback
    return m.connect(hostname,family,hostaddr)
  File "/usr/lib/python2.7/dist-packages/Milter/__init__.py", line 173, in wrapper
    rc = func(self,*args)
  File "/home/dkg/src/dkimpy-milter/dkimpy-milter/dkimpy_milter/__init__.py", line 64, in connect
    self.receiver = self.getsymval('j').strip()
AttributeError: 'NoneType' object has no attribute 'strip'
2019-02-21 19:21:38 -05:00
Daniel Kahn Gillmor f60ea12e86 Prefer dnspython over PyDNS in setup.py
README and dkimpy_milter/dnsplug.py both prefer dnspython if
available, over PyDNS.  setup.py should order the preferences in the
same way.
2019-02-21 19:21:36 -05:00
Daniel Kahn Gillmor e872bd44b0 ignore emacs turds 2019-02-19 18:03:54 -05:00
16 changed files with 365 additions and 123 deletions
+1
View File
@@ -1,3 +1,4 @@
dist
dkimpy_milter.egg-info
*.pyc
*~
-17
View File
@@ -1,20 +1,3 @@
1.0.3 2019-11-22
- Make error logging more explicit to aid debugging
- Delete own_socketfile to resolve race condition where the permissions
change fails on a Unix socket because it hasn't been created yet (libmilter
will do this correctly on its own based on umask, the milter doesn't need
to do it) (LP: #1849712)
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
* Reorder milter start and dropping privileges so permissions on Unix socket
are correct (LP: 1797720)
+4 -3
View File
@@ -37,7 +37,7 @@ an C compiler. Alternately, install these dependencies from dsitribution/OS
packages and then pip install dkimpy_milter.
The milter will work with either pydns (DNS) or dnspython (dns), preferring
dnspython if both are available. The dkimpy DKIM module also works with
dnspython is both are available. The dkimpy DKIM module also works with
either.
@@ -84,8 +84,9 @@ MTA INTEGRATION
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
used, they will need to be updated. The sysv init file uses start-stop-deamon
from Debian. It is not portable to systems without that available.
used, they will need to be updated. The sysv init file is Debian specific and
untested, since the developers are not using sysv init. Feedback/patches
welcome.
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.
+49 -64
View File
@@ -1,4 +1,4 @@
#! /usr/bin/python2
#! /usr/bin/python3
# Original dkim-milter.py code:
# Author: Stuart D. Gathman <stuart@bmsi.com>
# Copyright 2007 Business Management Systems, Inc.
@@ -28,14 +28,16 @@ import dkim
import authres
import os
import tempfile
import StringIO
import io
import re
import codecs
from Milter.utils import parse_addr, parseaddr
import dkimpy_milter.config as config
from dkimpy_milter.util import drop_privileges
from dkimpy_milter.util import setExceptHook
from dkimpy_milter.util import write_pid
from dkimpy_milter.util import read_keyfile
from dkimpy_milter.util import own_socketfile
from dkimpy_milter.util import fold
__version__ = "1.0.1"
@@ -53,7 +55,6 @@ class dkimMilter(Milter.Base):
self.privatersa = privateRSA
self.privateed25519 = privateEd25519
self.fp = None
self.fdomain = ''
@Milter.noreply
def connect(self, hostname, unused, hostaddr):
@@ -61,7 +62,9 @@ class dkimMilter(Milter.Base):
self.external_connection = False
self.hello_name = None
# sometimes people put extra space in sendmail config, so we strip
self.receiver = self.getsymval('j').strip()
self.receiver = self.getsymval('j')
if self.receiver is not None:
self.receiver = self.receiver.strip()
try:
self.AuthservID = milterconfig['AuthservID']
except:
@@ -108,7 +111,7 @@ class dkimMilter(Milter.Base):
def envfrom(self, f, *str):
if milterconfig.get('Syslog') and milterconfig.get('debugLevel') >= 2:
syslog.syslog("mail from: {0} {1}".format(f, str))
self.fp = StringIO.StringIO()
self.fp = io.BytesIO()
self.mailfrom = f
t = parse_addr(f)
if len(t) == 2:
@@ -133,24 +136,20 @@ class dkimMilter(Milter.Base):
try:
self.fdomain = self.author.split('@')[1].lower()
except IndexError as er:
pass # self.author was not a proper email address
self.fdomain = '' # self.author was not a proper email address
if (milterconfig.get('Syslog') and
milterconfig.get('debugLevel') >= 1):
syslog.syslog("{0}: {1}".format(name, val))
elif lname == 'authentication-results':
self.arheaders.append(val)
if self.fp:
try:
self.fp.write("%s: %s\n" % (name, val))
except:
# Don't choke on header fields with garbage in them.
pass
self.fp.write(b"%s: %s\n" % (codecs.encode(name, 'ascii'), codecs.encode(val, 'ascii')))
return Milter.CONTINUE
@Milter.noreply
def eoh(self):
if self.fp:
self.fp.write("\n") # terminate headers
self.fp.write(b"\n") # terminate headers
self.bodysize = 0
return Milter.CONTINUE
@@ -178,38 +177,39 @@ class dkimMilter(Milter.Base):
except:
# Don't error out on unparseable AR header fiels
pass
# Check and/or sign DKIM
# Check or sign DKIM
self.fp.seek(0)
txt = self.fp.read()
if milterconfig.get('Domain'):
domain = milterconfig.get('Domain')
else:
domain = ''
if ((self.fdomain in domain) and not milterconfig.get('Mode') == 'v'
and not self.external_connection):
txt = self.fp.read()
self.sign_dkim(txt)
if ((self.has_dkim) and (not self.internal_connection) and
(milterconfig.get('Mode') == 'v' or
milterconfig.get('Mode') == 'sv')):
txt = self.fp.read()
self.check_dkim(txt)
if self.arresults:
h = authres.AuthenticationResultsHeader(authserv_id=
self.AuthservID,
results=self.arresults)
h = fold(str(h))
h = fold(codecs.encode(str(h), 'ascii'))
if (milterconfig.get('Syslog') and
milterconfig.get('debugLevel') >= 2):
syslog.syslog(str(h))
name, val = str(h).split(': ', 1)
syslog.syslog(codecs.decode(h, 'ascii'))
name, val = codecs.decode(h, 'ascii').split(': ', 1)
self.addheader(name, val, 0)
return Milter.CONTINUE
def sign_dkim(self, txt):
canon = milterconfig.get('Canonicalization')
canon = codecs.encode(milterconfig.get('Canonicalization'), 'ascii')
canonicalize = []
if len(canon.split('/')) == 2:
canonicalize.append(canon.split('/')[0])
canonicalize.append(canon.split('/')[1])
if len(canon.split(b'/')) == 2:
canonicalize.append(canon.split(b'/')[0])
canonicalize.append(canon.split(b'/')[1])
else:
canonicalize.append(canon)
canonicalize.append(canon)
@@ -219,11 +219,12 @@ class dkimMilter(Milter.Base):
try:
if privateRSA:
d = dkim.DKIM(txt)
h = d.sign(milterconfig.get('Selector'), self.fdomain,
privateRSA, canonicalize=(canonicalize[0],
h = d.sign(codecs.encode(milterconfig.get('Selector'), 'ascii'), codecs.encode(self.fdomain, 'ascii'),
codecs.encode(privateRSA, 'ascii'),
canonicalize=(canonicalize[0],
canonicalize[1]))
name, val = h.split(': ', 1)
self.addheader(name, val.strip().replace('\r\n', '\n'), 0)
name, val = h.split(b': ', 1)
self.addheader(codecs.decode(name, 'ascii'), codecs.decode(val, 'ascii').strip().replace('\r\n', '\n'), 0)
if (milterconfig.get('Syslog') and
(milterconfig.get('SyslogSuccess')
or milterconfig.get('debugLevel') >= 1)):
@@ -234,12 +235,12 @@ class dkimMilter(Milter.Base):
d.domain.lower()))
if privateEd25519:
d = dkim.DKIM(txt)
h = d.sign(milterconfig.get('SelectorEd25519'), self.fdomain,
h = d.sign(codecs.encode(milterconfig.get('SelectorEd25519'), 'ascii'), codecs.encode(self.fdomain, 'ascii'),
privateEd25519, canonicalize=(canonicalize[0],
canonicalize[1]),
signature_algorithm='ed25519-sha256')
name, val = h.split(': ', 1)
self.addheader(name, val.strip().replace('\r\n', '\n'), 0)
signature_algorithm=b'ed25519-sha256')
name, val = h.split(b': ', 1)
self.addheader(codecs.decode(name, 'ascii'), codecs.decode(val, 'ascii').strip().replace('\r\n', '\n'), 0)
if (milterconfig.get('Syslog') and
(milterconfig.get('SyslogSuccess')
or milterconfig.get('debugLevel') >= 1)):
@@ -258,25 +259,26 @@ class dkimMilter(Milter.Base):
def check_dkim(self, txt):
res = False
self.header_a = None
for y in range(self.has_dkim): # Verify _ALL_ the signatures
d = dkim.DKIM(txt)
try:
dnsoverride = milterconfig.get('DNSOverride')
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 d.signature_fields.get(b'a') == 'ed25519-sha256':
if algo == 'ed25519-sha256':
self.dkim_comment = ('Good {0} signature'
.format(d.signature_fields
.get(b'a')))
.format(algo))
else:
self.dkim_comment = ('Good {0} bit {1} signature'
.format(d.keysize,
d.signature_fields
.get(b'a')))
.format(d.keysize, algo))
else:
self.dkim_comment = ('Bad {0} bit {1} signature.'
.format(d.keysize,
d.signature_fields.get(b'a')))
.format(d.keysize, algo))
except dkim.DKIMException as x:
self.dkim_comment = str(x)
if milterconfig.get('Syslog'):
@@ -284,21 +286,10 @@ class dkimMilter(Milter.Base):
except Exception as x:
self.dkim_comment = str(x)
if milterconfig.get('Syslog'):
syslog.syslog("check_dkim: Internal program fault while verifying: {0}".format(x))
try:
self.header_i = d.signature_fields.get(b'i')
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: Internal proram fault extracting header a or d: {0}".format(x))
self.header_d = None
if not self.header_a:
self.header_a = 'rsa-sha256'
syslog.syslog("check_dkim: {0}".format(x))
self.header_i = codecs.decode(d.signature_fields.get(b'i'), 'ascii')
self.header_d = codecs.decode(d.signature_fields.get(b'd'), 'ascii')
self.header_a = codecs.decode(d.signature_fields.get(b'a'), 'ascii')
if res:
if (milterconfig.get('Syslog') and
(milterconfig.get('SyslogSuccess') or
@@ -318,18 +309,12 @@ class dkimMilter(Milter.Base):
syslog.syslog('DKIM: Fail (saved as {0})'
.format(fname))
else:
if milterconfig.get('Syslog'):
if d.domain:
syslog.syslog('DKIM: Fail ({0})'
.format(d.domain.lower()))
else:
syslog.syslog('DKIM: Fail, unextractable domain')
syslog.syslog('DKIM: Fail ({0})'.format(d.domain.lower()))
if res:
result = 'pass'
else:
result = 'fail'
res = False
if self.header_d:
self.arresults.append(
authres.DKIMAuthenticationResult(result=result,
header_i=self.header_i,
@@ -338,7 +323,6 @@ class dkimMilter(Milter.Base):
result_comment=
self.dkim_comment)
)
self.header_a = None
return
@@ -370,12 +354,13 @@ def main():
Milter.set_flags(Milter.CHGHDRS + Milter.ADDHDRS)
miltername = 'dkimpy-filter'
socketname = milterconfig.get('Socket')
own_socketfile(milterconfig)
drop_privileges(milterconfig)
sys.stdout.flush()
if milterconfig.get('Syslog'):
syslog.syslog('dkimpy-milter starting:{0} user:{1}'
.format(pid, milterconfig.get('UserID')))
Milter.runmilter(miltername, socketname, 240)
if milterconfig.get('Syslog'):
syslog.syslog('dkimpy-milter started:{0} user:{1}'
.format(pid, milterconfig.get('UserID')))
if __name__ == "__main__":
main()
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/python3
from dkimpy_milter import main
if __name__ == "__main__":
main()
+16 -11
View File
@@ -31,13 +31,13 @@ import stat
import dkim
import socket
import ipaddress
from dnsplug import Session
from .dnsplug import Session
# default values
defaultConfigData = {
'Syslog': 'yes',
'SyslogFacility': 'mail',
'UMask': 007,
'UMask': 0o07,
'Mode': 'sv',
'Socket': 'local:/var/run/dkimpy-milter/dkimpy-milter.sock',
'PidFile': '/var/run/dkimpy-milter/dkimpy-milter.pid',
@@ -48,6 +48,7 @@ defaultConfigData = {
'DiagnosticDirectory': '',
'MacroList': '',
'MacroListVerify': '',
'DNSOverride': None,
'debugLevel': 0 # Undocumented config item for developer use
}
@@ -84,14 +85,14 @@ class HostsDataset(object):
self.item = item[1:]
self.negative = True
try:
self.item = ipaddress.ip_address(unicode(self.item, "utf-8"))
self.item = ipaddress.ip_address(str(self.item, "utf-8"))
if isinstance(self.item, ipaddress.IPv4Address):
self.isipv4 = True
elif isinstance(self.item, ipaddress.IPv6Address):
self.isipv6 = True
except ValueError as e:
try:
self.item = ipaddress.ip_network(unicode
self.item = ipaddress.ip_network(str
(self.item, "utf-8"),
strict=False)
if isinstance(self.item, ipaddress.IPv4Network):
@@ -109,7 +110,7 @@ class HostsDataset(object):
def match(self, connectip):
'''Check if the connect IP is part of the dataset'''
source = ipaddress.ip_address(unicode(connectip, "utf-8"))
source = ipaddress.ip_address(str(connectip, "utf-8"))
for item in self.dataset:
if item.isdomain or item.ishostname:
result = self.matchname(source) # Match host/domains first
@@ -159,13 +160,13 @@ class HostsDataset(object):
if isinstance(source, ipaddress.IPv4Address):
ips = s.dns(name, 'A')
for ip in ips:
ip = ipaddress.IPv4Address(unicode(ip, 'UTF-8'))
ip = ipaddress.IPv4Address(str(ip, 'UTF-8'))
if ip == source:
results.append(name)
if isinstance(source, ipaddress.IPv6Address):
ips = s.dns(name, 'AAAA')
for ip in ips:
ip = ipaddress.IPv6Address(unicode(ip, 'UTF-8'))
ip = ipaddress.IPv6Address(str(ip, 'UTF-8'))
if ip == source:
results.append(name)
return results
@@ -224,13 +225,13 @@ def _processConfigFile(filename=None, configdata=None, useSyslog=1,
'''Load the specified config file, exit and log errors if it fails,
otherwise return a config dictionary.'''
import config
from . import config
if configdata is None:
configdata = config.defaultConfigData
if filename is not None:
try:
_readConfigFile(filename, configdata)
except Exception, e:
except Exception as e:
raise
if useSyslog:
syslog.syslog(e.args[0])
@@ -334,13 +335,14 @@ def _readConfigFile(path, configData=None, configGlobal={}):
'DiagnosticDirectory': 'str',
'MacroList': 'dataset',
'MacroListVerify': 'dataset',
'DNSOverride': 'str',
'debugLevel': 'int'
}
# check to see if it's a file
try:
mode = os.stat(path)[0]
except OSError, e:
except OSError as e:
syslog.syslog(syslog.LOG_ERR, 'ERROR stating "%s": %s'
% (path, e.strerror))
return(configData)
@@ -388,6 +390,9 @@ def _readConfigFile(path, configData=None, configGlobal={}):
if conversion == 'bool':
configData[name] = _find_boolean(value)
elif conversion == 'str':
if isinstance(value, list):
configData[name] = line.split(None, 1)[1]
else:
configData[name] = str(value)
elif conversion == 'int':
configData[name] = int(value)
@@ -399,7 +404,7 @@ def _readConfigFile(path, configData=None, configGlobal={}):
configData[name] = conversion(value)
fp.close()
try:
configData['AuthservID'] = _make_authserv_id(configData['AuthservID'])
configData['AuthservID'] = _make_authserv_id(configData.get('AuthservID', 'HOSTNAME'))
configData['IntHosts'] = HostsDataset(configData['InternalHosts'])
except:
pass
+8 -8
View File
@@ -84,7 +84,7 @@ class Session(object):
raise DNSError('Length of CNAME chain exceeds %d' % MAX_CNAME)
cnames[name] = cname
if cname in cnames:
raise DNSError, 'CNAME loop'
raise DNSError('CNAME loop')
result = self.dns(cname, qtype, cnames=cnames)
return result
@@ -103,16 +103,16 @@ def DNSLookup_pydns(name, qtype, tcpfallback=True, timeout=30):
#
if resp.header['tc'] == True:
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:
req = DNS.DnsRequest(name, qtype=qtype, protocol='tcp',
timeout=timeout)
resp = req.req()
except DNS.DNSError, x:
raise DNS.DNSError, 'TCP Fallback error: ' + str(x)
except DNS.DNSError as x:
raise DNS.DNSError('TCP Fallback error: ' + str(x))
return [((a['name'], a['typename']), a['data']) for a in resp.answers]
except IOError, x:
raise DNS.DNSError, 'DNS: ' + str(x)
except IOError as x:
raise DNS.DNSError('DNS: ' + str(x))
def DNSLookup_dnspython(name,qtype,tcpfallback=True,timeout=30):
retVal = []
@@ -164,5 +164,5 @@ if __name__ == '__main__':
import sys
s = Session()
for n,t in zip(*[iter(sys.argv[1:])]*2):
print n,t
print s.dns(n,t)
print(n,t)
print(s.dns(n,t))
+18
View File
@@ -146,6 +146,24 @@ def write_pid(milterconfig):
return pid
def own_socketfile(milterconfig):
"""If socket is Unix socket, chown to UserID before dropping privileges"""
import os
user, group = user_group(milterconfig.get('UserID'))
offset = None
sockname = milterconfig.get('Socket')
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):
"""Read private key from file."""
import syslog
+7
View File
@@ -311,6 +311,13 @@ be set:
(b) KeyTable, SigningTable, no Domain, no KeyFile, no Selector;
[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
.I PeerList (dataset)
Identifies a set of "peers" that identifies clients whose connections
+6 -6
View File
@@ -1,4 +1,4 @@
#! /usr/bin/python
#! /usr/bin/python3
# dkimpy-milter: A DKIM signing/verification Milter application
# Author: Scott Kitterman <scott@kitterman.com>
# 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.
try:
import DNS
kw['install_requires'] = ['dkimpy>=0.7', 'pymilter', 'authres>=1.1.0', 'PyNaCl', 'ipaddress', 'PyDNS']
import dns
kw['install_requires'] = ['dkimpy>=0.7', 'pymilter', 'authres>=1.1.0', 'PyNaCl', 'dnspython']
except ImportError: # If PyDNS is not installed, prefer dnspython
kw['install_requires'] = ['dkimpy>=0.7', 'pymilter', 'authres>=1.1.0', 'PyNaCl', 'ipaddress', 'dnspython']
kw['install_requires'] = ['dkimpy>=0.7', 'pymilter', 'authres>=1.1.0', 'PyNaCl', 'PyDNS']
setup(
name='dkimpy-milter',
version='1.0.3',
version='1.0.1',
author='Scott Kitterman',
author_email='scott@kitterman.com',
url='https://launchpad.net/dkimpy-milter',
@@ -43,7 +43,7 @@ setup(
'License :: OSI Approved :: GNU General Public License (GPL)',
'Natural Language :: English',
'Operating System :: POSIX',
'Programming Language :: Python :: 2 :: Only',
'Programming Language :: Python :: 3 :: Only',
'Topic :: Communications :: Email :: Mail Transport Agents',
'Topic :: Communications :: Email :: Filters',
'Topic :: Security',
+7 -9
View File
@@ -20,9 +20,9 @@
### END INIT INFO
prefix="/usr/local"
exec_prefix=${prefix}
sysconfdir="/usr/local/etc"
sysconfdir="/etc/dkimpy-milter"
bindir="${exec_prefix}/bin/"
RUNDIR="/run/dkimpy-milter"
RUNDIR="/var/run/dkimpy-milter"
DAEMON=${bindir}/dkimpy-milter
PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/bin:
NAME=dkimpy-milter
@@ -67,14 +67,14 @@ case "$1" in
fi
fi
fi
start-stop-daemon --start --background --quiet --pidfile \
$RUNDIR/$NAME.pid --exec $DAEMON $sysconfdir/$NAME.conf
start-stop-daemon --start --quiet --pidfile $RUNDIR/$NAME.pid --startas \
$DAEMON $sysconfdir/$NAME.conf --name $NAME --test > /dev/null \
echo "$NAME."
;;
stop)
echo -n "Stopping $DESC: "
if [ -f $RUNDIR/$NAME.pid ]; then
chown root:root $RUNDIR/$NAME.pid
start-stop-daemon --stop --pidfile $RUNDIR/$NAME.pid
rm $RUNDIR/$NAME.pid
#echo $SOCKET
@@ -87,7 +87,6 @@ case "$1" in
force-reload)
echo -n "Force reloading $DESC: "
if [ -f $RUNDIR/$NAME.pid ]; then
chown root:root $RUNDIR/$NAME.pid
start-stop-daemon --stop --pidfile $RUNDIR/$NAME.pid
rm $RUNDIR/$NAME.pid
#echo $SOCKET
@@ -96,7 +95,7 @@ case "$1" in
fi
fi
sleep 1
start-stop-daemon --start --background --quiet --pidfile \
start-stop-daemon --start --chuid $USER --background --quiet --pidfile \
$RUNDIR/$NAME.pid --exec $DAEMON $sysconfdir/$NAME.conf
echo "$NAME."
;;
@@ -104,7 +103,6 @@ case "$1" in
echo "Restarting $DESC: "
echo -n "Stopping $DESC: "
if [ -f $RUNDIR/$NAME.pid ]; then
chown root:root $RUNDIR/$NAME.pid
start-stop-daemon --stop --pidfile $RUNDIR/$NAME.pid
rm $RUNDIR/$NAME.pid
#echo $SOCKET
@@ -115,7 +113,7 @@ case "$1" in
echo "$NAME."
sleep 1
echo -n "Starting $DESC: "
start-stop-daemon --start --background --quiet --pidfile \
start-stop-daemon --start --chuid $USER --background --quiet --pidfile \
$RUNDIR/$NAME.pid --exec $DAEMON $sysconfdir/$NAME.conf
echo "$NAME."
;;
+12
View File
@@ -0,0 +1,12 @@
-- -*- 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
@@ -0,0 +1,40 @@
-- -*- 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
@@ -0,0 +1,100 @@
-- -*- 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
@@ -0,0 +1,2 @@
#!/bin/sh
python3 -m dkimpy_milter "$@"
Executable
+84
View File
@@ -0,0 +1,84 @@
#!/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