Compare commits

..

20 Commits

Author SHA1 Message Date
Daniel Kahn Gillmor 7092874729 Enable sd_listen_fds(3)-style socket-activation support
I've added straightforward systemd unit files in
system/socket-activation/ that make use of this approach, and a
README.md in the same location that describes the tradeoffs.
2019-02-21 19:22:11 -05:00
Daniel Kahn Gillmor 25fdd3b81c Do not create PidFile by default
By default, avoid creating a PIDFile.

PIDFiles are racy and potentially dangerous.  Modern system
supervision systems don't need them, because they manage the process
groups directly.

If the configuration file doesn't specify a PidFile, dkimpy-milter
shouldn't try to create one.
2019-02-21 19:22:11 -05:00
Daniel Kahn Gillmor 9d5316ca0e Handle defaults for Socket differently
We want to be able to select the default for Socket differently in the
future.

This change augments the API for dkimpy_milter.util.own_socketfile()
by adding an optional sockname argument.  This is a
backward-compatible change.  If we aren't committed to API stability
for this function, we could make a more invasive change that would
probably be a more reasonable API going forward, but this is probably
good enough.
2019-02-21 19:22:11 -05:00
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 419 additions and 76 deletions
+1
View File
@@ -1,3 +1,4 @@
dist
dkimpy_milter.egg-info
*.pyc
*~
+52 -35
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,8 +28,9 @@ 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
@@ -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:
@@ -140,13 +143,13 @@ class dkimMilter(Milter.Base):
elif lname == 'authentication-results':
self.arheaders.append(val)
if self.fp:
self.fp.write("%s: %s\n" % (name, val))
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
@@ -193,20 +196,20 @@ class dkimMilter(Milter.Base):
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)
@@ -216,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],
canonicalize[1]))
name, val = h.split(': ', 1)
self.addheader(name, val.strip().replace('\r\n', '\n'), 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(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)):
@@ -231,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,21 +262,23 @@ class dkimMilter(Milter.Base):
for y in range(self.has_dkim): # Verify _ALL_ the signatures
d = dkim.DKIM(txt)
try:
res = d.verify(idx=y)
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'):
@@ -281,9 +287,9 @@ class dkimMilter(Milter.Base):
self.dkim_comment = str(x)
if milterconfig.get('Syslog'):
syslog.syslog("check_dkim: {0}".format(x))
self.header_i = d.signature_fields.get(b'i')
self.header_d = d.signature_fields.get(b'd')
self.header_a = d.signature_fields.get(b'a')
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
@@ -348,7 +354,18 @@ def main():
Milter.set_flags(Milter.CHGHDRS + Milter.ADDHDRS)
miltername = 'dkimpy-filter'
socketname = milterconfig.get('Socket')
own_socketfile(milterconfig)
if socketname is None:
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)
sys.stdout.flush()
Milter.runmilter(miltername, socketname, 240)
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/python3
from dkimpy_milter import main
if __name__ == "__main__":
main()
+19 -14
View File
@@ -31,16 +31,16 @@ 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',
'Socket': None,
'PidFile': None,
'UserID': 'dkimpy-milter',
'Canonicalization': 'relaxed/simple',
'InternalHosts': '127.0.0.1',
@@ -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,7 +390,10 @@ def _readConfigFile(path, configData=None, configGlobal={}):
if conversion == 'bool':
configData[name] = _find_boolean(value)
elif conversion == 'str':
configData[name] = str(value)
if isinstance(value, list):
configData[name] = line.split(None, 1)[1]
else:
configData[name] = str(value)
elif conversion == 'int':
configData[name] = int(value)
elif conversion == 'dataset':
@@ -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))
+27 -13
View File
@@ -115,45 +115,59 @@ def write_pid(milterconfig):
"""Write PID in pidfile. Will not overwrite an existing file."""
import os
import syslog
if not os.path.isfile(milterconfig.get('PidFile')):
pidfile = milterconfig.get('PidFile')
if pidfile is None:
return
if not os.path.isfile(pidfile):
pid = str(os.getpid())
try:
f = open(milterconfig.get('PidFile'), 'w')
f = open(pidfile, 'w')
except IOError as e:
if str(e)[:35] == '[Errno 2] No such file or directory':
piddir = milterconfig.get('PidFile').rsplit('/', 1)[0]
piddir = pidfile.rsplit('/', 1)[0]
os.mkdir(piddir)
user, group = user_group(milterconfig.get('UserID'))
os.chown(piddir, user, group)
f = open(milterconfig.get('PidFile'), 'w')
f = open(pidfile, 'w')
if milterconfig.get('Syslog'):
syslog.syslog('PID dir created: {0}'.format(piddir))
else:
if milterconfig.get('Syslog'):
syslog.syslog('Unable to write pidfle {0}. IOError: {1}'
.format(milterconfig.get('PidFile'), e))
.format(pidfile, e))
raise
f.write(pid)
f.close()
user, group = user_group(milterconfig.get('UserID'))
os.chown(milterconfig.get('PidFile'), user, group)
os.chown(pidfile, user, group)
else:
if milterconfig.get('Syslog'):
syslog.syslog('Unable to write pidfle {0}. File exists.'
.format(milterconfig.get('PidFile')))
.format(pidfile))
raise RuntimeError('Unable to write pidfle {0}. File exists.'
.format(milterconfig.get('PidFile')))
.format(pidfile))
return pid
def own_socketfile(milterconfig):
def own_socketfile(milterconfig, sockname=None):
"""If socket is Unix socket, chown to UserID before dropping privileges"""
import os
user, group = user_group(milterconfig.get('UserID'))
if milterconfig.get('Socket')[:1] == '/':
os.chown(milterconfig.get('Socket')[1:], user, group)
if milterconfig.get('Socket')[:6] == "local:":
os.chown(milterconfig.get('Socket')[6:], user, group)
offset = None
if sockname is None:
sockname = milterconfig.get('Socket')
if sockname is None:
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):
+8 -1
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
@@ -331,7 +338,7 @@ will be checked. [PeerList NOT IMPLEMENTED - included for reference only]
.TP
.I PidFile (string)
Specifies the path to a file that should be created at process start
containing the process ID.
containing the process ID. If not specified, no such file will be created.
.TP
.I Selector (string)
+5 -5
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,10 +23,10 @@ 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',
@@ -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',
+32
View File
@@ -0,0 +1,32 @@
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/
@@ -0,0 +1,11 @@
[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
@@ -0,0 +1,12 @@
[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
@@ -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