Compare commits

..

28 Commits

Author SHA1 Message Date
Scott Kitterman f9483fea8c - Added protection for malformed From addresses. If the From does not at
least have an '@' in the address, then the signing domain is not extracted
   and the message will not be signed
2018-03-15 20:42:49 -04:00
Scott Kitterman 7a3a7bfb43 Bump version to 0.9.6 2018-03-12 22:08:07 -04:00
Scott Kitterman 8a0e1bdd97 - Fixed typo in path for fallback location of the config file if one is not
provided
2018-03-12 22:03:45 -04:00
Scott Kitterman e3005aa723 Move OversignHeaders up earlier on TODO. 2018-03-12 22:03:36 -04:00
Scott Kitterman 45d3ba13ca Added more to README about first run with systemd 2018-03-11 00:42:22 -05:00
Scott Kitterman f05309437f Fix merge conflict 2018-03-11 00:28:08 -05:00
Scott Kitterman d4499f6990 Fixed typo in package installation section of README 2018-03-11 00:27:19 -05:00
Scott Kitterman 7d87309f4b Fixed typo in package installation section of README 2018-03-11 00:24:39 -05:00
Scott Kitterman 1d8c309da9 Fix setup.py install locations so they are installed correctly and drop unneeded README changes. 2018-03-10 20:06:21 -05:00
Scott Kitterman 4d5961e4d5 Bump version 2018-03-10 19:52:29 -05:00
Scott Kitterman 59448e8e57 - Add information to README about manually putting init scripts in the right
locations
2018-03-10 19:51:29 -05:00
Scott Kitterman 695de0db14 - Add conf file location to systemd unit file 2018-03-10 19:43:37 -05:00
Scott Kitterman dfd6fa68c3 Changelog: release 0.9.5 (Beta 1) 2018-03-10 19:06:55 -05:00
Scott Kitterman 86eb152f93 Enhanced signature verification logging to provide more useful information, added signing success logging, and more PEP 8 2018-03-10 19:02:37 -05:00
Scott Kitterman 126966e110 - Update Authentication Results result comment not to mention key size for
ed25519 signatures, since it's irrelevant
2018-03-10 18:18:01 -05:00
Scott Kitterman 5d8d47cd52 - Fixed install_requires so either dnspython (preferred if neither is
installed) or PyDNS satisfies the install requirements
2018-03-10 17:49:22 -05:00
Scott Kitterman 1843ca6244 - Added support for SyslogSuccess option
- Rationalized logging to be much less verbose unless SyslogSuccess or
   debugLevel are set - default is generally start/stop/errors only
2018-03-10 16:06:22 -05:00
Scott Kitterman f9358d594c Delete unused import 2018-03-10 15:36:40 -05:00
Scott Kitterman a8aa422b03 Post pep-8 cleanup 2018-03-10 15:34:56 -05:00
Scott Kitterman 9836f2c9c2 Update TODO 2018-03-10 03:00:59 -05:00
Scott Kitterman 70606ac58c pep8 and a few other cleanups 2018-03-10 02:45:35 -05:00
Scott Kitterman 6348bdcdc7 Cleanup, indentation, pyflakes 2018-03-10 00:52:45 -05:00
Scott Kitterman fd39384e78 Fix for DiagnosticDirectory 2018-03-09 23:49:57 -05:00
Scott Kitterman 924c96d555 - Added example in README to show use of MacroList* to separate inbound and
outbound mail streams
2018-03-09 22:50:07 -05:00
Scott Kitterman efeabd19d3 Added support for MacroListVerify option 2018-03-09 22:39:55 -05:00
Scott Kitterman a9b8a44bfc Add support for MacroList option 2018-03-09 21:53:58 -05:00
Scott Kitterman daaa6aada7 Fix option name typo in man/dkimpy-milter.conf.5 2018-03-09 20:45:57 -05:00
Scott Kitterman e795db7c69 Start 0.9.5: Beta 1 (updated Alpha -> Beta warning in README and trove classifiers) 2018-03-09 18:08:42 -05:00
9 changed files with 528 additions and 339 deletions
+28
View File
@@ -1,3 +1,31 @@
0.9.6 2018-03-13
- Fixed typo in package installation section of README
- Added more to README about first run with systemd
- Fixed typo in path for fallback location of the config file if one is not
provided
- Added protection for malformed From addresses. If the From does not at
least have an '@' in the address, then the signing domain is not extracted
and the message will not be signed
0.9.5.1 2018-03-10
- Add conf file location to systemd unit file
- Fix setup.py install locations so they are installed correctly
0.9.5 2018-03-10
- Beta 1 (updated Alpha -> Beta warning in README and trove classifiers)
- Added support for MacroList option
- Added support for MacroListVerify option
- Added example in README to show use of MacroList* to separate inbound and
outbound mail streams
- Added support for SyslogSuccess option (both signing and verifying)
- Rationalized logging to be much less verbose unless SyslogSuccess or
debugLevel are set - default is generally start/stop/errors only
- Fixed install_requires so either dnspython (preferred if neither is
installed) or PyDNS satisfies the install requirements
- Updated Authentication Results result comment not to mention key size for
ed25519 signatures, since it's irrelevant
- Enhanced signature verification logging to provide more useful information
0.9.4 2018-03-09
- Create PID directory if it is missing
- Fix crash when verifying if domain for signing was not set
+45 -5
View File
@@ -16,8 +16,8 @@ python setup.py install --single-version-externally-managed --record=/dev/null
For users of Debian Stable (Debian 9, Codename Squueze), all dependencies are
available in either the main or backports repositories:
[sudo] apt install python-milter python-nacl pthon-ipaddress python-dnspython
[sudo] apt install -t squeeze-backports python-authres python-dkim
[sudo] apt install python-milter python-nacl python-ipaddress python-dnspython
[sudo] apt install -t stretch-backports python-authres python-dkim
The preferred method of installation is from PyPi using pip (if distribution
packages are not available):
@@ -48,6 +48,14 @@ As an example, using the default dkimpy-user on Debian, the command would be:
Since /var/run or /run is sometimes on a tempfs, if the PID file directory is
missing, the milter will create it on startup.
To start dkimpy-milter with systemd for the first time, you will need to take
the following steps:
[sudo] systemctl daemon-reload
[sudo] systemctl enable dkimpy-milter
[sudo] systemctl start dkimpy-milter
[sudo] systemctl status dkimpy-milter (to verify it started correctly)
As with all milters, dkimpy-milter needs to be integrated with your MTA of
choice (Sendmail or Postfix).
@@ -86,10 +94,42 @@ submission inet n - - - - smtpd
These need to match the Socket value for each dkimpy-milter instance.
Care is required to segregate outbound mail to be signed and inbound mail to
be verified. The above example uses two instances of dkimpy-milter to do
this. There are many possible ways. Here is another example using milter
macros to keep the mail streams segregated:
Postfix master.cf:
smtp inet n - - - - smtpd
...
-o smtpd_milters=inet:localhost:8891
-o milter_macro_daemon_name=VERIFYING
...
submission inet n - - - - smtpd
-o syslog_name=postfix/submission
-o smtpd_tls_security_level=encrypt
-o smtpd_sasl_auth_enable=yes
...
-o milter_macro_daemon_name=ORIGINATING
-o smtpd_milters=inet:localhost:8891
...
Dkimpy-milter.conf:
...
Mode sv
MacroList dameon_name|ORIGINATING
MacroListVerify daemon_name|VERIFYING
...
The python DKIM library, dkimpy, requires the entire message being signed or
verified to be in memory, so dkimpy-milter does not write messages out to a temp
file. This may impact performance on low-memory systems.
WARNING: This is an alpha grade release to support interoperability testing with
Ed25519 signatures and basic functionality. It is known to be incomplete and
not suitable for general use.
This is an beta grade release to support interoperability testing with Ed25519
signatures sufficient functionality for basic use. The documented
functionality has been implemented and at least partially tested. It is free
of known major defects, but is not fully tested in a variety of environments.
+5 -7
View File
@@ -34,15 +34,14 @@ DiagnosticDirectory implemented verified
InternalHosts implemented verified
0.9.5 (Beta)
SyslogSuccess
MacroList implemented verified
MacroListVerify implemented verified
SyslogSuccess implemented verified
1.0.0
Convert dkim-milter-python config
No additional features planned
Plannedataset type support:
Plannedataset type support (if needed):
db:/.db
mdb:
@@ -52,9 +51,9 @@ AlwaysAddARHeader
ChangeRootDirectory
ClockDrift (requires dkimpy change)
DNSTimeout (requires dkmpy change)
MacroList
MilterDebug
MinimumKeyBits
OversignHeaders (may require dkimpy changes)
PeerList
SignatureAlgorithm
@@ -85,7 +84,6 @@ On-InternalError
On-KeyNotFound
On-NoSignature
On-SignatureError
OversignHeaders
RemoveARAll
RemoveARFrom
RemoveOldSignatures
+135 -80
View File
@@ -25,15 +25,12 @@ import sys
import syslog
import Milter
import dkim
from dkim.dnsplug import get_txt
from dkim.util import parse_tag_value
import authres
import os
import tempfile
import StringIO
import re
from Milter.config import MilterConfigParser
from Milter.utils import iniplist,parse_addr,parseaddr
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
@@ -42,9 +39,10 @@ from dkimpy_milter.util import read_keyfile
from dkimpy_milter.util import own_socketfile
from dkimpy_milter.util import fold
__version__ = "0.9.4"
__version__ = "0.9.6"
FWS = re.compile(r'\r?\n[ \t]+')
class dkimMilter(Milter.Base):
"Milter to check and sign DKIM. Each connection gets its own instance."
@@ -58,8 +56,9 @@ class dkimMilter(Milter.Base):
self.fp = None
@Milter.noreply
def connect(self,hostname,unused,hostaddr):
def connect(self, hostname, unused, hostaddr):
self.internal_connection = False
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()
@@ -69,70 +68,79 @@ class dkimMilter(Milter.Base):
self.AuthservID = self.receiver
if hostaddr and len(hostaddr) > 0:
ipaddr = hostaddr[0]
if milterconfig['InternalHostsObj']:
if milterconfig['InternalHostsObj'].match(ipaddr):
if milterconfig['IntHosts']:
if milterconfig['IntHosts'].match(ipaddr):
self.internal_connection = True
else: ipaddr = ''
else:
ipaddr = ''
self.connectip = ipaddr
if milterconfig.get('MacroList') and not self.internal_connection:
macrolist = milterconfig.get('MacroList')
for macro in macrolist:
macroname = macro.split('|')[0]
macroname = '{' + macroname + '}'
macroresult = self.getsymval(macroname)
if ((len(macro.split('|')) == 1 and macroresult) or macroresult
in macro.split('|')[1:]):
self.internal_connection = True
if milterconfig.get('MacroListVerify'):
macrolist = milterconfig.get('MacroListVerify')
for macro in macrolist:
macroname = macro.split('|')[0]
macroname = '{' + macroname + '}'
macroresult = self.getsymval(macroname)
if ((len(macro.split('|')) == 1 and macroresult) or macroresult
in macro.split('|')[1:]):
self.external_connection = True
if self.internal_connection:
connecttype = 'INTERNAL'
else:
connecttype = 'EXTERNAL'
if milterconfig.get('Syslog'):
syslog.syslog("connect from {0} at {1} {2}".format(hostname,hostaddr,connecttype))
if milterconfig.get('Syslog') and milterconfig.get('debugLevel') >= 1:
syslog.syslog("connect from {0} at {1} {2}"
.format(hostname, hostaddr, connecttype))
return Milter.CONTINUE
# multiple messages can be received on a single connection
# envfrom (MAIL FROM in the SMTP protocol) seems to mark the start
# of each message.
@Milter.noreply
def envfrom(self,f,*str):
if milterconfig.get('Syslog'):
syslog.syslog("mail from: {0} {1}".format(f,str))
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.mailfrom = f
t = parse_addr(f)
if len(t) == 2: t[1] = t[1].lower()
if len(t) == 2:
t[1] = t[1].lower()
self.canon_from = '@'.join(t)
self.user = self.getsymval('{auth_authen}')
self.has_dkim = 0
self.author = None
self.arheaders = []
self.arresults = []
'''if self.user:
# Very simple SMTP AUTH policy by default:
# any successful authentication is considered INTERNAL
self.internal_connection = True
auth_type = self.getsymval('{auth_type}')
ssl_bits = self.getsymval('{cipher_bits}')
if milterconfig.get('Syslog'):
syslog.syslog(
"SMTP AUTH:",self.user,"sslbits =",ssl_bits, auth_type,
"ssf =",self.getsymval('{auth_ssf}'), "INTERNAL"
)
# Detailed authorization policy is configured in the access file below.
self.arresults.append(
authres.SMTPAUTHAuthenticationResult(result = 'pass',
result_comment = auth_type+' sslbits='+ssl_bits, smtp_auth = self.user)
)'''
return Milter.CONTINUE
@Milter.noreply
def header(self,name,val):
def header(self, name, val):
lname = name.lower()
if lname == 'dkim-signature':
if milterconfig.get('Syslog'):
syslog.syslog("{0}: {1}".format(name,val))
if (milterconfig.get('Syslog') and
milterconfig.get('debugLevel') >= 1):
syslog.syslog("{0}: {1}".format(name, val))
self.has_dkim += 1
if lname == 'from':
fname,self.author = parseaddr(val)
fname, self.author = parseaddr(val)
try:
self.fdomain = self.author.split('@')[1]
if milterconfig.get('Syslog'):
syslog.syslog("{0}: {1}".format(name,val))
except IndexError as er:
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:
self.fp.write("%s: %s\n" % (name,val))
self.fp.write("%s: %s\n" % (name, val))
return Milter.CONTINUE
@Milter.noreply
@@ -143,7 +151,7 @@ class dkimMilter(Milter.Base):
return Milter.CONTINUE
@Milter.noreply
def body(self,chunk): # copy body to temp file
def body(self, chunk): # copy body to temp file
if self.fp:
self.fp.write(chunk) # IOError causes TEMPFAIL in milter
self.bodysize += len(chunk)
@@ -153,13 +161,15 @@ class dkimMilter(Milter.Base):
if not self.fp:
return Milter.ACCEPT # no message collected - so no eom processing
# Remove existing Authentication-Results headers for our authserv_id
for i,val in enumerate(self.arheaders,1):
for i, val in enumerate(self.arheaders, 1):
# FIXME: don't delete A-R headers from trusted MTAs
try:
ar = authres.AuthenticationResultsHeader.parse_value(FWS.sub('',val))
ar = (authres.AuthenticationResultsHeader
.parse_value(FWS.sub('', val)))
if ar.authserv_id == self.AuthservID:
self.chgheader('authentication-results',i,'')
if milterconfig.get('Syslog'):
self.chgheader('authentication-results', i, '')
if (milterconfig.get('Syslog') and
milterconfig.get('debugLevel') >= 1):
syslog.syslog('REMOVE: {0}'.format(val))
except:
# Don't error out on unparseable AR header fiels
@@ -170,26 +180,28 @@ class dkimMilter(Milter.Base):
domain = milterconfig.get('Domain')
else:
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):
txt = self.fp.read()
self.sign_dkim(txt)
result = None
if (self.has_dkim) and (not self.internal_connection) and (milterconfig.get('Mode') == 'v' or milterconfig.get('Mode') == 'sv'):
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)
else:
result = 'none'
if self.arresults:
h = authres.AuthenticationResultsHeader(authserv_id = self.AuthservID,
h = authres.AuthenticationResultsHeader(authserv_id=
self.AuthservID,
results=self.arresults)
h = fold(str(h))
if milterconfig.get('Syslog'):
if (milterconfig.get('Syslog') and
milterconfig.get('debugLevel') >= 2):
syslog.syslog(str(h))
name,val = str(h).split(': ',1)
self.addheader(name,val,0)
name, val = str(h).split(': ', 1)
self.addheader(name, val, 0)
return Milter.CONTINUE
def sign_dkim(self,txt):
def sign_dkim(self, txt):
canon = milterconfig.get('Canonicalization')
canonicalize = []
if len(canon.split('/')) == 2:
@@ -198,20 +210,41 @@ class dkimMilter(Milter.Base):
else:
canonicalize.append(canon)
canonicalize.append(canon)
if (milterconfig.get('Syslog') and
milterconfig.get('debugLevel') >= 1):
syslog.syslog('canonicalize: {0}'.format(canonicalize))
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(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)
if (milterconfig.get('Syslog') and
(milterconfig.get('SyslogSuccess')
or milterconfig.get('debugLevel') >= 1)):
syslog.syslog('{0}: {1} DKIM-Signature field added (s={2} '
'd={3})'.format(self.getsymval('i'),
d.signature_fields.get(b'a'),
d.signature_fields.get(b's'),
d.domain))
if privateEd25519:
d = dkim.DKIM(txt)
h = d.sign(milterconfig.get('SelectorEd25519'), self.fdomain, 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)
h = d.sign(milterconfig.get('SelectorEd25519'), self.fdomain,
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)
if (milterconfig.get('Syslog') and
(milterconfig.get('SyslogSuccess')
or milterconfig.get('debugLevel') >= 1)):
syslog.syslog('{0}: {1} DKIM-Signature field added (s={2} '
'd={3})'.format(self.getsymval('i'),
d.signature_fields.get(b'a'),
d.signature_fields.get(b's'),
d.domain))
except dkim.DKIMException as x:
if milterconfig.get('Syslog'):
syslog.syslog('DKIM: {0}'.format(x))
@@ -220,17 +253,26 @@ class dkimMilter(Milter.Base):
syslog.syslog("sign_dkim: {0}".format(x))
raise
def check_dkim(self,txt):
def check_dkim(self, txt):
res = False
conf = self.conf
for y in range(self.has_dkim): # Verify _ALL_ the signatures
d = dkim.DKIM(txt)
try:
res = d.verify(idx=y)
if res:
self.dkim_comment = 'Good {0} bit {1} signature.'.format(d.keysize, d.signature_fields.get(b'a'))
if d.signature_fields.get(b'a') == 'ed25519-sha256':
self.dkim_comment = ('Good {0} signature'
.format(d.signature_fields
.get(b'a')))
else:
self.dkim_comment = 'Bad {0} bit {1} signature.'.format(d.keysize, d.signature_fields.get(b'a'))
self.dkim_comment = ('Good {0} bit {1} signature'
.format(d.keysize,
d.signature_fields
.get(b'a')))
else:
self.dkim_comment = ('Bad {0} bit {1} signature.'
.format(d.keysize,
d.signature_fields.get(b'a')))
except dkim.DKIMException as x:
self.dkim_comment = str(x)
if milterconfig.get('Syslog'):
@@ -243,16 +285,23 @@ class dkimMilter(Milter.Base):
self.header_d = d.signature_fields.get(b'd')
self.header_a = d.signature_fields.get(b'a')
if res:
if milterconfig.get('Syslog'):
syslog.syslog('DKIM: Pass ({0})'.format(d.domain))
if (milterconfig.get('Syslog') and
(milterconfig.get('SyslogSuccess') or
milterconfig.get('debugLevel') >= 1)):
syslog.syslog('{0}: {1} DKIM signature verified (s={2} '
'd={3})'.format(self.getsymval('i'),
d.signature_fields.get(b'a'),
d.signature_fields.get(b's'),
d.domain))
self.dkim_domain = d.domain
else:
if milterconfig.get['DiagnosticDirectory']:
fd,fname = tempfile.mkstemp(".dkim")
with os.fdopen(fd,"w+b") as fp:
if milterconfig.get('DiagnosticDirectory'):
fd, fname = tempfile.mkstemp(".dkim")
with os.fdopen(fd, "w+b") as fp:
fp.write(txt)
if milterconfig.get('Syslog'):
syslog.syslog('DKIM: Fail (saved as {0})'.format(fname))
syslog.syslog('DKIM: Fail (saved as {0})'
.format(fname))
else:
syslog.syslog('DKIM: Fail ({0})'.format(d.domain))
if res:
@@ -262,11 +311,15 @@ class dkimMilter(Milter.Base):
res = False
self.arresults.append(
authres.DKIMAuthenticationResult(result=result,
header_i = self.header_i, header_d = self.header_d, header_a = self.header_a,
result_comment = self.dkim_comment)
header_i=self.header_i,
header_d=self.header_d,
header_a=self.header_a,
result_comment=
self.dkim_comment)
)
return
def main():
# Ugh, but there's no easy way around this.
global milterconfig
@@ -274,15 +327,16 @@ def main():
global privateEd25519
privateRSA = False
privateEd25519 = False
configFile = '/etc/dkimpy-milter.conf'
configFile = '/usr/local/etc/dkimpy-milter.conf'
if len(sys.argv) > 1:
if sys.argv[1] in ( '-?', '--help', '-h' ):
if sys.argv[1] in ('-?', '--help', '-h'):
print('usage: dkimpy-milter [<configfilename>]')
sys.exit(1)
configFile = sys.argv[1]
milterconfig = config._processConfigFile(filename = configFile)
milterconfig = config._processConfigFile(filename=configFile)
if milterconfig.get('Syslog'):
facility = eval("syslog.LOG_{0}".format(milterconfig.get('SyslogFacility').upper()))
facility = eval("syslog.LOG_{0}"
.format(milterconfig.get('SyslogFacility').upper()))
syslog.openlog(os.path.basename(sys.argv[0]), syslog.LOG_PID, facility)
setExceptHook()
pid = write_pid(milterconfig)
@@ -295,9 +349,10 @@ def main():
miltername = 'dkimpy-filter'
socketname = milterconfig.get('Socket')
if milterconfig.get('Syslog'):
syslog.syslog('dkimpy-milter started:{0} user:{1}'.format(pid,milterconfig.get('UserID')))
syslog.syslog('dkimpy-milter started:{0} user:{1}'
.format(pid, milterconfig.get('UserID')))
sys.stdout.flush()
Milter.runmilter(miltername,socketname,240)
Milter.runmilter(miltername, socketname, 240)
own_socketfile(milterconfig)
drop_privileges(milterconfig)
+99 -81
View File
@@ -27,8 +27,6 @@
import syslog
import os
import sys
import re
import urllib
import stat
import dkim
import socket
@@ -37,26 +35,28 @@ from dnsplug import Session
# default values
defaultConfigData = {
'Syslog' : 'yes',
'SyslogFacility' : 'mail',
'UMask' : 007,
'Mode' : 'sv',
'Socket' : 'local:/var/run/dkimpy-milter/dkimpy-milter.sock',
'PidFile' : '/var/run/dkimpy-milter/dkimpy-milter.pid',
'UserID' : 'dkimpy-milter',
'Canonicalization' : 'relaxed/simple',
'InternalHosts' : '127.0.0.1',
'InternalHostsObj' : False,
'DiagnosticDirectory' : ''
'Syslog': 'yes',
'SyslogFacility': 'mail',
'UMask': 007,
'Mode': 'sv',
'Socket': 'local:/var/run/dkimpy-milter/dkimpy-milter.sock',
'PidFile': '/var/run/dkimpy-milter/dkimpy-milter.pid',
'UserID': 'dkimpy-milter',
'Canonicalization': 'relaxed/simple',
'InternalHosts': '127.0.0.1',
'IntHosts': False,
'DiagnosticDirectory': '',
'MacroList': '',
'MacroListVerify': '',
'debugLevel': 0 # Undocumented config item for developer use
}
#################################
class ConfigException(Exception):
'''Exception raised when there's a configuration file error.'''
pass
#################################
class HostsDataset(object):
'''Hold a group of host related dataset objects'''
@@ -85,34 +85,41 @@ class HostsDataset(object):
self.negative = True
try:
self.item = ipaddress.ip_address(unicode(self.item, "utf-8"))
if isinstance(self.item, ipaddress.IPv4Address): self.isipv4 = True
elif isinstance(self.item, ipaddress.IPv6Address): self.isipv6 = True
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, "utf-8"), strict=False)
if isinstance(self.item, ipaddress.IPv4Network): self.isipv4cidr = True
elif isinstance(self.item, ipaddress.IPv6Network): self.isipv6cidr = True
self.item = ipaddress.ip_network(unicode
(self.item, "utf-8"),
strict=False)
if isinstance(self.item, ipaddress.IPv4Network):
self.isipv4cidr = True
elif isinstance(self.item, ipaddress.IPv6Network):
self.isipv6cidr = True
except ValueError as e2:
if self.item[0] == '.' and len(self.item.split('.')) > 2:
self.isdomain = True
elif len(self.item.split('.')) > 1: # It has a '.' in it
self.ishostname = True
else:
raise ConfigException('Unknown dataset item: {0}'.format(item))
raise ConfigException('Unknown dataset item: {0}'
.format(item))
def match(self, connectip):
'''Check if the connect IP is part of the dataset'''
source = ipaddress.ip_address(unicode(connectip, "utf-8"))
for item in self.dataset:
if item.isdomain or item.ishostname:
result = self.matchname(source) # Match host/domain names first
result = self.matchname(source) # Match host/domains first
if result:
return(result)
elif item.isipv4 or item.isipv4cidr:
if isinstance(source, ipaddress.IPv4Address): # Then IPv4/6 addresses
return(self.match4(source)) # or networks depending
elif item.isipv6 or item.isipv6cidr: # on the item type and
if isinstance(source, ipaddress.IPv6Address): # connection type
elif item.isipv4 or item.isipv4cidr: # Then IPv4/6 addresses or
if isinstance(source, ipaddress.IPv4Address): # networks
return(self.match4(source)) # depending on the item type
elif item.isipv6 or item.isipv6cidr: # and connect type
if isinstance(source, ipaddress.IPv6Address):
return(self.match6(source))
def matchname(self, source):
@@ -126,7 +133,7 @@ class HostsDataset(object):
for item in self.dataset:
if item.isdomain:
for ptr in ptrlist:
# Strip the leading '.' off the domain name so exact match works.
# Strip the leading '.' off the domain name for exact match
if item.item[1:] == ptr[-len(item.item)+1:]:
matchdomain = True
negativedomain = item.negative
@@ -211,21 +218,16 @@ class HostsDataset(object):
match = False
return(match)
def dump(self):
for item in self.dataset:
print 'name: {0} ip4: {1} cidr4: {2} ip6: {3} cidr6: {4} host: {5} domain: {6} negat: {7} type: {8}'.format(item.item,
item.isipv4, item.isipv4cidr, item.isipv6, item.isipv6cidr, item.ishostname, item.isdomain,
item.negative, type(item.item))
####################################################################
def _processConfigFile(filename = None, configdata = None, useSyslog = 1,
useStderr = 0):
def _processConfigFile(filename=None, configdata=None, useSyslog=1,
useStderr=0):
'''Load the specified config file, exit and log errors if it fails,
otherwise return a config dictionary.'''
import config
if configdata == None: configdata = config.defaultConfigData
if filename != None:
if configdata is None:
configdata = config.defaultConfigData
if filename is not None:
try:
_readConfigFile(filename, configdata)
except Exception, e:
@@ -237,7 +239,7 @@ def _processConfigFile(filename = None, configdata = None, useSyslog = 1,
sys.exit(1)
return(configdata)
####################
def _find_boolean(item):
if type(item) == int:
item = str(item)
@@ -248,14 +250,15 @@ def _find_boolean(item):
else:
raise dkim.ParameterError()
return item
####################
def _calculate_authserv_id(as_id):
def _make_authserv_id(as_id):
"""Determine AuthservID if needed"""
if as_id == 'HOSTNAME':
as_id = socket.gethostname()
return as_id
####################
def _dataset_to_list(dataset):
"""Convert a dataset (as defined in dkimpymilter.8) and return a python
list of values."""
@@ -298,71 +301,83 @@ def _dataset_to_list(dataset):
elif dataset[:3] == 'db:':
dbname = dataset[3:]
else:
raise dkim.ParameterError('Unimplmented dataset type: {0}'.format(type(dataset)))
raise dkim.ParameterError('Unimplmented dataset type: {0}'
.format(type(dataset)))
if whichdb.whichdb(dbname) != 'dbhash':
raise dkim.ParameterError('Unimplmented dataset type: {0}'.format(type(dataset)))
raise dkim.ParameterError('Unimplmented dataset type: {0}'
.format(type(dataset)))
#TODO replace this with code to use db maps
raise dkim.ParameterError('Unsupported dataset db dataset not yet used: {0}'.format(type(dataset)))
raise dkim.ParameterError('Unsupported dataset db datase: {0}'
.format(type(dataset)))
raise dkim.ParameterError('Unimplmented dataset type: {0}'.format(type(dataset)))
raise dkim.ParameterError('Unimplmented dataset type: {0}'
.format(type(dataset)))
###############################################################
commentRx = re.compile(r'^(.*)#.*$')
def _readConfigFile(path, configData = None, configGlobal = {}):
def _readConfigFile(path, configData=None, configGlobal={}):
'''Reads a configuration file from the specified path, merging it
with the configuration data specified in configData. Returns a
dictionary of name/value pairs based on configData and the values
read from path.'''
debugLevel = configGlobal.get('debugLevel', 0)
if debugLevel >= 5: syslog.syslog('readConfigFile: Loading "%s"' % path)
if configData == None: configData = {}
if debugLevel >= 5:
syslog.syslog('readConfigFile: Loading "%s"' % path)
if configData is None:
configData = {}
nameConversion = {
'AuthservID' : 'str',
'Syslog' : 'bool',
'SyslogFacility' : 'str',
'SyslogSuccess' : 'bool',
'UMask' : 'int',
'Mode' : 'str',
'Socket' : 'str',
'PidFile' : 'str',
'UserID' : 'str',
'Domain' : 'dataset',
'KeyFile' : 'str',
'KeyFileEd25519' : 'str',
'Selector' : 'str',
'AuthservID': 'str',
'Syslog': 'bool',
'SyslogFacility': 'str',
'SyslogSuccess': 'bool',
'UMask': 'int',
'Mode': 'str',
'Socket': 'str',
'PidFile': 'str',
'UserID': 'str',
'Domain': 'dataset',
'KeyFile': 'str',
'KeyFileEd25519': 'str',
'Selector': 'str',
'SelectorEd25519': 'str',
'Canonicalization' : 'str',
'InternalHosts' : 'dataset',
'InternalHostsObj': 'bool',
'DiagnosticDirectory' : 'str'
'Canonicalization': 'str',
'InternalHosts': 'dataset',
'IntHosts': 'bool',
'DiagnosticDirectory': 'str',
'MacroList': 'dataset',
'MacroListVerify': 'dataset',
'debugLevel': 'int'
}
# check to see if it's a file
try:
mode = os.stat(path)[0]
except OSError, e:
syslog.syslog(syslog.LOG_ERR,'ERROR stating "%s": %s' % ( path, e.strerror ))
syslog.syslog(syslog.LOG_ERR, 'ERROR stating "%s": %s'
% (path, e.strerror))
return(configData)
if not stat.S_ISREG(mode):
syslog.syslog(syslog.LOG_ERR,'ERROR: is not a file: "%s", mode=%s' % ( path, oct(mode) ))
syslog.syslog(syslog.LOG_ERR, 'ERROR: is not a file: "%s", mode=%s'
% (path, oct(mode)))
return(configData)
# load file
fp = open(path, 'r')
while 1:
line = fp.readline()
if not line: break
if not line:
break
# parse line
line = line.split('#', 1)[0].strip()
if not line: continue
if not line:
continue
data = line.split()
if len(data) != 2:
if len(data) == 1:
if debugLevel >= 1:
syslog.syslog('Configuration item "%s" not defined in file "%s"'
% ( line, path ))
syslog.syslog('Config item "%s" not defined in file "%s"'
% (line, path))
if len(data) == 1:
name = data
value = ''
@@ -374,12 +389,14 @@ def _readConfigFile(path, configData = None, configGlobal = {}):
# check validity of name
conversion = nameConversion.get(name)
if conversion == None:
syslog.syslog('ERROR: Unknown name "%s" in file "%s"' % ( name, path ))
if conversion is None:
syslog.syslog('ERROR: Unknown name "%s" in file "%s"'
% (name, path))
continue
if debugLevel >= 5: syslog.syslog('readConfigFile: Found entry "%s=%s"'
% ( name, value ))
if debugLevel >= 5:
syslog.syslog('readConfigFile: Found entry "%s=%s"'
% (name, value))
if conversion == 'bool':
configData[name] = _find_boolean(value)
elif conversion == 'str':
@@ -389,12 +406,13 @@ def _readConfigFile(path, configData = None, configGlobal = {}):
elif conversion == 'dataset':
configData[name] = _dataset_to_list(value)
else:
syslog.syslog(str('name: ' + name + ' value: ' + value + ' conversion: ' + conversion))
syslog.syslog(str('name: ' + name + ' value: ' + value +
' conversion: ' + conversion))
configData[name] = conversion(value)
fp.close()
try:
configData['AuthservID'] = _calculate_authserv_id(configData['AuthservID'])
configData['InternalHostsObj'] = HostsDataset(configData['InternalHosts'])
configData['AuthservID'] = _make_authserv_id(configData['AuthservID'])
configData['IntHosts'] = HostsDataset(configData['InternalHosts'])
except:
pass
+19 -13
View File
@@ -16,6 +16,7 @@
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
def fold(header):
"""Fold a header line into multiple crlf-separated lines at column 72.
Borrowed from dkimpy and updated to only add \n instead of \r\n because
@@ -46,9 +47,9 @@ def fold(header):
j = i + 1
pre += header[:j] + b"\n "
header = header[j:]
namelen = 0
return pre + header
def user_group(userid):
"""Return user and group from UserID"""
import grp
@@ -64,13 +65,14 @@ def user_group(userid):
running_gid = grp.getgrnam(gidname).gr_gid
return running_uid, running_gid
def drop_privileges(milterconfig):
import os
import syslog
if os.getuid() != 0:
if milterconfig.get('Syslog'):
syslog.syslog('drop_privileges: Not running as root. Cannot drop permissions.')
syslog.syslog('drop_privileges: Not root. No action taken.')
return
# Get user and group
@@ -86,9 +88,9 @@ def drop_privileges(milterconfig):
# Set umask
old_umask = os.umask(milterconfig.get('UMask'))
#################
class ExceptHook:
def __init__(self, useSyslog = 1, useStderr = 0):
def __init__(self, useSyslog=1, useStderr=0):
self.useSyslog = useSyslog
self.useStderr = useStderr
@@ -104,12 +106,11 @@ class ExceptHook:
sys.stderr.write(line)
####################
def setExceptHook():
import sys
sys.excepthook = ExceptHook(useSyslog = 1, useStderr = 1)
sys.excepthook = ExceptHook(useSyslog=1, useStderr=1)
####################
def write_pid(milterconfig):
"""Write PID in pidfile. Will not overwrite an existing file."""
import os
@@ -126,10 +127,11 @@ def write_pid(milterconfig):
os.chown(piddir, user, group)
f = open(milterconfig.get('PidFile'), 'w')
if milterconfig.get('Syslog'):
syslog.syslog('Missing pid dir created: {0}'.format(piddir))
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))
syslog.syslog('Unable to write pidfle {0}. IOError: {1}'
.format(milterconfig.get('PidFile'), e))
raise
f.write(pid)
f.close()
@@ -137,10 +139,13 @@ def write_pid(milterconfig):
os.chown(milterconfig.get('PidFile'), user, group)
else:
if milterconfig.get('Syslog'):
syslog.syslog('Unable to write pidfle {0}. File exists.'.format(milterconfig.get('PidFile')))
raise RuntimeError('Unable to write pidfle {0}. File exists.'.format(milterconfig.get('PidFile')))
syslog.syslog('Unable to write pidfle {0}. File exists.'
.format(milterconfig.get('PidFile')))
raise RuntimeError('Unable to write pidfle {0}. File exists.'
.format(milterconfig.get('PidFile')))
return pid
def own_socketfile(milterconfig):
"""If socket is Unix socket, chown to UserID before dropping privileges"""
import os
@@ -150,7 +155,7 @@ def own_socketfile(milterconfig):
if milterconfig.get('Socket')[:6] == "local:":
os.chown(milterconfig.get('Socket')[6:], user, group)
####################
def read_keyfile(milterconfig, keytype):
"""Read private key from file."""
import syslog
@@ -163,7 +168,8 @@ def read_keyfile(milterconfig, keytype):
keylist = f.readlines()
except IOError as e:
if milterconfig.get('Syslog'):
syslog.syslog('Unable to read keyfile {0}. IOError: {1}'.format(keyfile, e))
syslog.syslog('Unable to read keyfile {0}. IOError: {1}'
.format(keyfile, e))
raise
f.close()
key = ''
+38 -1
View File
@@ -255,13 +255,50 @@ all messages. Ignored if a
is defined. [KeyTable NOT IMPLEMENTED]
.TP
.I KeyFileEd25119 (string)
.I KeyFileEd25519 (string)
Gives the location of a Ed25519 private key to be used for Ed25519 signing
all messages. File is the Base64 encoded output of RFC 8032 Ed25519 private Key
generation (as used in dkimpy). Ignored if a
.I KeyTableEd25519
is defined. [KeyTableEd25519 NOT IMPLEMENTED]
.TP
.I MacroList (dataset)
Defines a set of MTA-provided
.I macros
that should be checked to see if the sender has been determined to be a
local user and therefore whether or not the message should be signed. If
a
.I value
is specified matching a macro name in the data set, the value of the macro
must match a value specified (matching is case-sensitive), otherwise the
macro must be defined but may contain any value. The set is empty by
default, meaning macros are not considered when making the sign-verify
decision. The general format of the value is
.I value1[|value2[|...]];
if one or more value is defined then the macro must be set to one of the
listed values, otherwise the macro must be set but can contain any
value.
In order for the macro and its value to be available to the filter for
checking, the MTA must send it during the protocol exchange. This is either
accomplished via manual configuration of the MTA to send the desired macros
or, for MTA/filter combinations that support the feature, the filter can
request those macros that are of interest. The latter is a feature negotiated
at the time the filter receives a connection from the MTA and its availability
depends upon the version of milter used to compile the filter and the version
of the MTA making the connection.
.TP
.I MacroListVerify (dataset)
Defines a set of MTA-provided
.I macros
that should be checked to see if the sender has been determined to be an
external source and therefore whether or not the message should be signed.
Entries in this data set follow the same form as those of the
.I MacroList
option above. [this option is not inhereted from OpenDKIM]
.TP
.I Mode (string)
Selects operating modes. The string is a concatenation of characters that
+11 -4
View File
@@ -22,6 +22,13 @@ import dkimpy_milter
description = "Domain Keys Identified Mail (DKIM) signing/verifying milter for Postfix/Sendmail."
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']
except ImportError: # If PyDNS is not installed, prefer dnspython
kw['install_requires'] = ['dkimpy>=0.7', 'pymilter', 'authres>=1.1.0', 'PyNaCl', 'ipaddress', 'dnspython']
setup(
name='dkimpy-milter',
version=dkimpy_milter.__version__,
@@ -31,7 +38,7 @@ setup(
description=description,
download_url = "https://pypi.python.org/pypi/dkimpy-milter",
classifiers= [
'Development Status :: 3 - Alpha',
'Development Status :: 4 - Beta',
'Environment :: No Input/Output (Daemon)',
'Intended Audience :: System Administrators',
'License :: OSI Approved :: GNU General Public License (GPL)',
@@ -52,9 +59,9 @@ setup(
data_files=[(os.path.join('share', 'man', 'man5'),
['man/dkimpy-milter.conf.5']), (os.path.join('share', 'man', 'man8'),
['man/dkimpy-milter.8']), ('etc', ['etc/dkimpy-milter.conf']),
(os.path.join('/lib', 'systemd', 'system'),
['system/dkimpy-milter.service']),(os.path.join('/etc', 'init.d'),
(os.path.join('lib', 'systemd', 'system'),
['system/dkimpy-milter.service']),(os.path.join('etc', 'init.d'),
['system/dkimpy-milter'])],
install_requires = ['dkimpy>=0.7', 'pymilter', 'authres>=1.1.0', 'PyNaCl', 'ipaddress', 'dns'],
zip_safe = False,
**kw
)
+1 -1
View File
@@ -5,7 +5,7 @@ After=syslog.target network.target
[Service]
Type=simple
PIDFile=/var/run/dkimpy-milter/dkimpy-milter.pid
ExecStart=/usr/local/bin/dkimpy-milter
ExecStart=/usr/local/bin/dkimpy-milter /usr/local/etc/dkimpy-milter.conf
[Install]
WantedBy=multi-user.target