Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 977fac5fae | |||
| 36ff60d8d3 | |||
| 4769bde19c | |||
| e6021dd960 | |||
| 9d28ab3567 | |||
| df19aa081e | |||
| 2e9d0f607f | |||
| fb32a8fe0b | |||
| 3e57876361 | |||
| 7683fa7187 | |||
| fc893a62c3 | |||
| c01c04b83f | |||
| fc583a6e3c | |||
| ebfb0b5fc3 | |||
| 48a44916e7 | |||
| 5a81886a5e | |||
| 5e2cff5e5d | |||
| 5886edda42 | |||
| e4a17d7be6 | |||
| 96978c2747 | |||
| 77722a0ffd | |||
| ced16fda72 | |||
| f381986f7a | |||
| 02ad614657 |
@@ -1,11 +1,29 @@
|
||||
0.9.4 2018-03-09
|
||||
- Create PID directory if it is missing
|
||||
- Fix crash when verifying if domain for signing was not set
|
||||
- Fix header folding to use \n only to align with milter protocol
|
||||
requirements
|
||||
- Added information about creating a dedicated user and PID file directory
|
||||
creation to README
|
||||
- Fixed a bug where dkim fail might be reported as pass when verifying
|
||||
multiple signatures and a previous signature had passed
|
||||
- Make RSA signatures in dkimpy-milter optional, so dkimpy-milter can be
|
||||
added after an existing DKIM signing application to add an Ed25519
|
||||
signature (Thanks to A. Schulze for the patch)
|
||||
- Added support for AuthservID option
|
||||
- Added support for InternalHosts option (ipaddress and either dns (dnspython)
|
||||
or pydns (DNS) modules are now required)
|
||||
- Added support for DiagnosticDirectory and updated dkimpy-milter specifics in
|
||||
dkimpy-milter.conf.5
|
||||
|
||||
0.9.1 2018-02-17
|
||||
- DKIM signing and verification using both RSA and Ed25519
|
||||
- The following configuration options are supported (same definition as
|
||||
OpenDKIM): Domain, KeyFile, KeyFileEd25519, Mode, PidFile, Selector,
|
||||
Socket, Syslog, UMask, and UserID (see dkimpy-milter.conf.5)
|
||||
- This is an Alpha grade release and while the implemented features work, it
|
||||
is nowhere near being a complete package
|
||||
0.9.3 2018-03-02
|
||||
- Fixup csl dataset processing for single item lists
|
||||
- file: dataset support
|
||||
- Bump minimum authres version to 1.1.0 due to known issues with 1.0.2
|
||||
- Ignore errors parsing broken authres header fields
|
||||
- Fold added authres header fields
|
||||
- Fix pidfile permissions
|
||||
- Fix socket setup sequence so Unix sockets work
|
||||
|
||||
0.9.2 2018-02-19
|
||||
- Improved package requirements definition
|
||||
@@ -16,3 +34,12 @@
|
||||
- Initial dataset support: csl
|
||||
- Only sign if mail from from a domain in Domain and only if Mode is not
|
||||
verfication only
|
||||
|
||||
0.9.1 2018-02-17
|
||||
- DKIM signing and verification using both RSA and Ed25519
|
||||
- The following configuration options are supported (same definition as
|
||||
OpenDKIM): Domain, KeyFile, KeyFileEd25519, Mode, PidFile, Selector,
|
||||
Socket, Syslog, UMask, and UserID (see dkimpy-milter.conf.5)
|
||||
- This is an Alpha grade release and while the implemented features work, it
|
||||
is nowhere near being a complete package
|
||||
|
||||
|
||||
@@ -13,12 +13,79 @@ default is a feature:
|
||||
|
||||
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
|
||||
|
||||
The preferred method of installation is from PyPi using pip (if distribution
|
||||
packages are not available):
|
||||
|
||||
[sudo] pip install dkimpy_milter
|
||||
|
||||
Using pip will cause required packages to be installed via easy_install if they
|
||||
have not been previously installed.
|
||||
|
||||
The milter will work with either pydns (DNS) or dnspython (dns), preferring
|
||||
dnspython is both are available. The dkimpy DKIM module also works with
|
||||
either.
|
||||
|
||||
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 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.
|
||||
As an example, using the default dkimpy-user on Debian, the command would be:
|
||||
|
||||
[sudo] adduser --system --no-create-home --quiet --disabled-password \
|
||||
--disabled-login --shell /bin/false --group \
|
||||
--home /var/run/dkimpy-milter dkimpy-milter
|
||||
|
||||
Since /var/run or /run is sometimes on a tempfs, if the PID file directory is
|
||||
missing, the milter will create it on startup.
|
||||
|
||||
As with all milters, dkimpy-milter needs to be integrated with your MTA of
|
||||
choice (Sendmail or Postfix).
|
||||
|
||||
For Sendmail:
|
||||
|
||||
Configuration is very similar to opendkim, but needs some adjustment for
|
||||
dkimpy-milter. Here's an example configuration line to include in your
|
||||
sendmail.mc:
|
||||
|
||||
INPUT_MAIL_FILTER(`dkimpy-milter', `S=local:/var/run/dkimpy-milter/dkimpy-milter.sock')dnl
|
||||
|
||||
Changing the sendmail.mc file requires a Make (to compile it into sendmail.cf)
|
||||
and a restart of sendmail. Note that S= needs to match the value of Socket in
|
||||
the dkimpy-milter configuration file.
|
||||
|
||||
Milter support should be present by default in most versions of sendmail
|
||||
these days, but if not included in your Sendmail build, see:
|
||||
http://www.elandsys.com/resources/sendmail/milter.html
|
||||
|
||||
For Postfix:
|
||||
|
||||
Integration of dkimpy-milter into Postfix is like any milter (See Postfix's
|
||||
README_FILES/MILTER_README). Here's an example master.cf excerpt the talks to
|
||||
two dkimpy-milter instances, one configured for signing and one configured for
|
||||
verification:
|
||||
|
||||
smtp inet n - - - - smtpd
|
||||
...
|
||||
-o smtpd_milters=inet:localhost:8892
|
||||
...
|
||||
|
||||
submission inet n - - - - smtpd
|
||||
...
|
||||
-o smtpd_milters=inet:localhost:8891
|
||||
...
|
||||
|
||||
These need to match the Socket value for each dkimpy-milter instance.
|
||||
|
||||
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.
|
||||
|
||||
@@ -25,11 +25,16 @@ Sign based on Domain implemented verified
|
||||
Canonicalization implemented verified
|
||||
SyslogFacility implemented verified
|
||||
|
||||
0.9.3 (Alpha)
|
||||
File dataset implemented verified
|
||||
|
||||
0.9.4 (Alpha)
|
||||
AuthservID implemented verified
|
||||
DiagnosticDirectory implemented verified
|
||||
InternalHosts implemented verified
|
||||
|
||||
0.9.5 (Beta)
|
||||
AuthservID
|
||||
Diagnostics
|
||||
DiagnosticDirectory
|
||||
InternalHosts
|
||||
|
||||
|
||||
SyslogSuccess
|
||||
|
||||
@@ -38,10 +43,7 @@ Convert dkim-milter-python config
|
||||
No additional features planned
|
||||
|
||||
Plannedataset type support:
|
||||
file://
|
||||
refile:
|
||||
db:/.db
|
||||
csl:
|
||||
mdb:
|
||||
|
||||
Considered for near-term feature release
|
||||
@@ -59,6 +61,7 @@ SignatureAlgorithm
|
||||
Later
|
||||
|
||||
BaseDirectory
|
||||
Diagnostics (requires dkimpy changes)
|
||||
DontSignMailTo
|
||||
ExemptDomains
|
||||
ExternalIgnoreList
|
||||
|
||||
+45
-23
@@ -39,8 +39,10 @@ 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__ = "0.9.2"
|
||||
__version__ = "0.9.4"
|
||||
FWS = re.compile(r'\r?\n[ \t]+')
|
||||
|
||||
class dkimMilter(Milter.Base):
|
||||
@@ -61,10 +63,15 @@ class dkimMilter(Milter.Base):
|
||||
self.hello_name = None
|
||||
# sometimes people put extra space in sendmail config, so we strip
|
||||
self.receiver = self.getsymval('j').strip()
|
||||
try:
|
||||
self.AuthservID = milterconfig['AuthservID']
|
||||
except:
|
||||
self.AuthservID = self.receiver
|
||||
if hostaddr and len(hostaddr) > 0:
|
||||
ipaddr = hostaddr[0]
|
||||
"""if iniplist(ipaddr,self.conf.internal_connect): FIXME
|
||||
self.internal_connection = True"""
|
||||
if milterconfig['InternalHostsObj']:
|
||||
if milterconfig['InternalHostsObj'].match(ipaddr):
|
||||
self.internal_connection = True
|
||||
else: ipaddr = ''
|
||||
self.connectip = ipaddr
|
||||
if self.internal_connection:
|
||||
@@ -148,14 +155,22 @@ class dkimMilter(Milter.Base):
|
||||
# Remove existing Authentication-Results headers for our authserv_id
|
||||
for i,val in enumerate(self.arheaders,1):
|
||||
# FIXME: don't delete A-R headers from trusted MTAs
|
||||
ar = authres.AuthenticationResultsHeader.parse_value(FWS.sub('',val))
|
||||
if ar.authserv_id == self.receiver:
|
||||
self.chgheader('authentication-results',i,'')
|
||||
if milterconfig.get('Syslog'):
|
||||
syslog.syslog('REMOVE: {0}'.format(val))
|
||||
try:
|
||||
ar = authres.AuthenticationResultsHeader.parse_value(FWS.sub('',val))
|
||||
if ar.authserv_id == self.AuthservID:
|
||||
self.chgheader('authentication-results',i,'')
|
||||
if milterconfig.get('Syslog'):
|
||||
syslog.syslog('REMOVE: {0}'.format(val))
|
||||
except:
|
||||
# Don't error out on unparseable AR header fiels
|
||||
pass
|
||||
# Check or sign DKIM
|
||||
self.fp.seek(0)
|
||||
if (self.fdomain in milterconfig.get('Domain')) and (not milterconfig.get('Mode') == 'v'):
|
||||
if milterconfig.get('Domain'):
|
||||
domain = milterconfig.get('Domain')
|
||||
else:
|
||||
domain = ''
|
||||
if (self.fdomain in domain) and (not milterconfig.get('Mode') == 'v'):
|
||||
txt = self.fp.read()
|
||||
self.sign_dkim(txt)
|
||||
result = None
|
||||
@@ -165,8 +180,9 @@ class dkimMilter(Milter.Base):
|
||||
else:
|
||||
result = 'none'
|
||||
if self.arresults:
|
||||
h = authres.AuthenticationResultsHeader(authserv_id = self.receiver,
|
||||
h = authres.AuthenticationResultsHeader(authserv_id = self.AuthservID,
|
||||
results=self.arresults)
|
||||
h = fold(str(h))
|
||||
if milterconfig.get('Syslog'):
|
||||
syslog.syslog(str(h))
|
||||
name,val = str(h).split(': ',1)
|
||||
@@ -184,11 +200,12 @@ class dkimMilter(Milter.Base):
|
||||
canonicalize.append(canon)
|
||||
syslog.syslog('canonicalize: {0}'.format(canonicalize))
|
||||
try:
|
||||
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)
|
||||
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)
|
||||
if privateEd25519:
|
||||
d = dkim.DKIM(txt)
|
||||
h = d.sign(milterconfig.get('SelectorEd25519'), self.fdomain, privateEd25519,
|
||||
@@ -230,15 +247,19 @@ class dkimMilter(Milter.Base):
|
||||
syslog.syslog('DKIM: Pass ({0})'.format(d.domain))
|
||||
self.dkim_domain = d.domain
|
||||
else:
|
||||
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))
|
||||
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))
|
||||
else:
|
||||
syslog.syslog('DKIM: Fail ({0})'.format(d.domain))
|
||||
if res:
|
||||
result = 'pass'
|
||||
else:
|
||||
result = 'fail'
|
||||
res = False
|
||||
self.arresults.append(
|
||||
authres.DKIMAuthenticationResult(result=result,
|
||||
header_i = self.header_i, header_d = self.header_d, header_a = self.header_a,
|
||||
@@ -269,15 +290,16 @@ def main():
|
||||
privateRSA = read_keyfile(milterconfig, 'RSA')
|
||||
if milterconfig.get('KeyFileEd25519'):
|
||||
privateEd25519 = read_keyfile(milterconfig, 'Ed25519')
|
||||
drop_privileges(milterconfig)
|
||||
if milterconfig.get('Syslog'):
|
||||
syslog.syslog('dkimpy-milter started:{0} user:{1}'.format(pid,milterconfig.get('UserID')))
|
||||
Milter.factory = dkimMilter
|
||||
Milter.set_flags(Milter.CHGHDRS + Milter.ADDHDRS)
|
||||
miltername = 'dkimpy-filter'
|
||||
socketname = milterconfig.get('Socket')
|
||||
if milterconfig.get('Syslog'):
|
||||
syslog.syslog('dkimpy-milter started:{0} user:{1}'.format(pid,milterconfig.get('UserID')))
|
||||
sys.stdout.flush()
|
||||
Milter.runmilter(miltername,socketname,240)
|
||||
own_socketfile(milterconfig)
|
||||
drop_privileges(milterconfig)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
+225
-9
@@ -31,7 +31,9 @@ import re
|
||||
import urllib
|
||||
import stat
|
||||
import dkim
|
||||
|
||||
import socket
|
||||
import ipaddress
|
||||
from dnsplug import Session
|
||||
|
||||
# default values
|
||||
defaultConfigData = {
|
||||
@@ -42,7 +44,10 @@ defaultConfigData = {
|
||||
'Socket' : 'local:/var/run/dkimpy-milter/dkimpy-milter.sock',
|
||||
'PidFile' : '/var/run/dkimpy-milter/dkimpy-milter.pid',
|
||||
'UserID' : 'dkimpy-milter',
|
||||
'Canonicalization' : 'relaxed/simple'
|
||||
'Canonicalization' : 'relaxed/simple',
|
||||
'InternalHosts' : '127.0.0.1',
|
||||
'InternalHostsObj' : False,
|
||||
'DiagnosticDirectory' : ''
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +56,167 @@ class ConfigException(Exception):
|
||||
'''Exception raised when there's a configuration file error.'''
|
||||
pass
|
||||
|
||||
#################################
|
||||
class HostsDataset(object):
|
||||
'''Hold a group of host related dataset objects'''
|
||||
|
||||
def __init__(self, dataset):
|
||||
self.dataset = []
|
||||
# Self.dataset will end up being a list of DataSetItem(s).
|
||||
for item in dataset:
|
||||
item = item.rstrip(']')
|
||||
item = item.lstrip('[')
|
||||
self.dataset.append(self.DatasetItem(item))
|
||||
|
||||
class DatasetItem(object):
|
||||
'''Individual dataset item'''
|
||||
|
||||
def __init__(self, item):
|
||||
self.item = item
|
||||
self.isipv4 = False
|
||||
self.isipv4cidr = False
|
||||
self.isipv6 = False
|
||||
self.isipv6cidr = False
|
||||
self.ishostname = False
|
||||
self.isdomain = False
|
||||
self.negative = False
|
||||
if self.item[0] == '!':
|
||||
self.item = item[1:]
|
||||
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
|
||||
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
|
||||
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))
|
||||
|
||||
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
|
||||
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
|
||||
return(self.match6(source))
|
||||
|
||||
def matchname(self, source):
|
||||
'''Does source IP address relate to a domain/hostname in the dataset'''
|
||||
match = False
|
||||
matchone = False
|
||||
negativeone = False
|
||||
matchdomain = False
|
||||
negativedomain = False
|
||||
ptrlist = self.getptr(source)
|
||||
for item in self.dataset:
|
||||
if item.isdomain:
|
||||
for ptr in ptrlist:
|
||||
# Strip the leading '.' off the domain name so exact match works.
|
||||
if item.item[1:] == ptr[-len(item.item)+1:]:
|
||||
matchdomain = True
|
||||
negativedomain = item.negative
|
||||
elif item.ishostname:
|
||||
for ptr in ptrlist:
|
||||
if item.item == ptr:
|
||||
matchone = True
|
||||
negativeone = item.negative
|
||||
if matchdomain and not negativedomain:
|
||||
match = True
|
||||
if matchone and not negativeone:
|
||||
return True
|
||||
if matchone and negativeone:
|
||||
match = False
|
||||
return(match)
|
||||
|
||||
def getptr(self, source):
|
||||
'''Get validated PTR name of IP address'''
|
||||
results = []
|
||||
s = Session()
|
||||
ptrnames = s.dns(source.reverse_pointer, 'PTR')
|
||||
for name in ptrnames:
|
||||
if isinstance(source, ipaddress.IPv4Address):
|
||||
ips = s.dns(name, 'A')
|
||||
for ip in ips:
|
||||
ip = ipaddress.IPv4Address(unicode(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'))
|
||||
if ip == source:
|
||||
results.append(name)
|
||||
return results
|
||||
|
||||
def match4(self, source):
|
||||
'''Is the source IP related to a IPv4 address/network in the dataset'''
|
||||
match = False
|
||||
matchone = False
|
||||
negativeone = False
|
||||
matchcidr = False
|
||||
negativecidr = False
|
||||
for item in self.dataset:
|
||||
if item.isipv4:
|
||||
if source == item.item:
|
||||
matchone = True
|
||||
negativeone = item.negative
|
||||
elif item.isipv4cidr:
|
||||
if source in item.item:
|
||||
matchcidr = True
|
||||
negativecidr = item.negative
|
||||
if matchcidr and not negativecidr:
|
||||
match = True
|
||||
if matchone and not negativeone:
|
||||
return True
|
||||
if matchone and negativeone:
|
||||
match = False
|
||||
return(match)
|
||||
|
||||
def match6(self, source):
|
||||
'''Is the source IP realted to a IPv6 address/network in the dataset'''
|
||||
match = False
|
||||
matchone = False
|
||||
negativeone = False
|
||||
matchcidr = False
|
||||
negativecidr = False
|
||||
for item in self.dataset:
|
||||
if item.isipv6:
|
||||
if source == item.item:
|
||||
matchone = True
|
||||
negativeone = item.negative
|
||||
elif item.isipv6cidr:
|
||||
if source in item.item:
|
||||
matchcidr = True
|
||||
negativecidr = item.negative
|
||||
if matchcidr and not negativecidr:
|
||||
match = True
|
||||
if matchone and not negativeone:
|
||||
return True
|
||||
if matchone and negativeone:
|
||||
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):
|
||||
@@ -82,21 +248,63 @@ def _find_boolean(item):
|
||||
else:
|
||||
raise dkim.ParameterError()
|
||||
return item
|
||||
####################
|
||||
def _calculate_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."""
|
||||
if not isinstance(dataset, basestring):
|
||||
# If it was a csl, it's already a list, we only need to remove the name
|
||||
# from the first value
|
||||
if not isinstance(dataset, str):
|
||||
# If it was a csl with more than one value, it's already a list, we
|
||||
# only need to remove the name from the first value.
|
||||
if dataset[0][:4] == 'csl:':
|
||||
dataset[0] = dataset[0][4:]
|
||||
for item in dataset:
|
||||
dataset[dataset.index(item)] = item.strip().strip(',')
|
||||
return dataset
|
||||
else:
|
||||
raise dkim.ParameterError('Unimplmented dataset type')
|
||||
elif isinstance(dataset, str):
|
||||
if dataset[0] == '/' or dataset[:5] == 'file:':
|
||||
# This is a flat file dataset
|
||||
ds = []
|
||||
if dataset[0] == '/':
|
||||
dsname = dataset
|
||||
if dataset[:5] == 'file:':
|
||||
dsname = dataset[5:]
|
||||
dsf = open(dsname, 'r')
|
||||
for line in dsf.readlines():
|
||||
if line[0] != '#':
|
||||
if len(line.split(':')) == 1:
|
||||
ds.append(line.strip())
|
||||
else:
|
||||
for element in line.split(':'):
|
||||
ds.append(element.strip().strip(':'))
|
||||
dsf.close()
|
||||
return ds
|
||||
# If it's a str and csl, it has one value and we return a list
|
||||
if dataset[:4] == 'csl:':
|
||||
return [dataset[4:].strip().strip(',')]
|
||||
else:
|
||||
return [dataset.strip().strip(',')]
|
||||
if dataset[-3:] == '.db' or dataset[:3] == 'db:':
|
||||
# This is a Sleepycat (Oracle) DB dataset
|
||||
import whichdb # Will need rewriting someday for python3
|
||||
if dataset[-3:] == '.db':
|
||||
dbname = dataset
|
||||
elif dataset[:3] == 'db:':
|
||||
dbname = dataset[3:]
|
||||
else:
|
||||
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)))
|
||||
#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('Unimplmented dataset type: {0}'.format(type(dataset)))
|
||||
|
||||
###############################################################
|
||||
commentRx = re.compile(r'^(.*)#.*$')
|
||||
@@ -125,6 +333,9 @@ def _readConfigFile(path, configData = None, configGlobal = {}):
|
||||
'Selector' : 'str',
|
||||
'SelectorEd25519': 'str',
|
||||
'Canonicalization' : 'str',
|
||||
'InternalHosts' : 'dataset',
|
||||
'InternalHostsObj': 'bool',
|
||||
'DiagnosticDirectory' : 'str'
|
||||
}
|
||||
|
||||
# check to see if it's a file
|
||||
@@ -181,5 +392,10 @@ def _readConfigFile(path, configData = None, configGlobal = {}):
|
||||
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'])
|
||||
except:
|
||||
pass
|
||||
|
||||
return(configData)
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
## @package dnsplug
|
||||
# Provide a higher level interface to pydns or dnspython (or other provider).
|
||||
# NOT RELEASED: this is a proposed API and implementation.
|
||||
# Goals - work with both pydns and dnspython (and possibly other libraries)
|
||||
# at a simplied level.
|
||||
# TODO:
|
||||
# 1. map exceptions to common dnsplug.DNSError exception (with
|
||||
# original exception saved as a member).
|
||||
# 2. include dict based implementation (handy for test suites)
|
||||
# 3. move implementations to subpackages to enable autoselect on first call.
|
||||
|
||||
## Maximum number of CNAME records to follow
|
||||
MAX_CNAME = 10
|
||||
|
||||
## Lookup DNS records by label and RR type.
|
||||
# The response can include records of other types that the DNS
|
||||
# server thinks we might need. FIXME: empty result
|
||||
# could mean NXDOMAIN or NOANSWER.
|
||||
# @param name the DNS label to lookup
|
||||
# @param qtype the name of the DNS RR type to lookup
|
||||
# @param tcpfallback if False, raise exception instead of TCP fallback
|
||||
# @return a list of ((name,type),data) tuples
|
||||
def DNSLookup(name, qtype, tcpfallback=True, timeout=30):
|
||||
raise NotImplementedError('No supported dns library found')
|
||||
|
||||
class Session(object):
|
||||
"""A Session object has a simple cache with no TTL that is valid
|
||||
for a single "session", for example an SMTP conversation."""
|
||||
def __init__(self):
|
||||
self.cache = {}
|
||||
|
||||
## Additional DNS RRs we can safely cache.
|
||||
# We have to be careful which additional DNS RRs we cache. For
|
||||
# instance, PTR records are controlled by the connecting IP, and they
|
||||
# could poison our local cache with bogus A and MX records.
|
||||
# Each entry is a tuple of (query_type,rr_type). So for instance,
|
||||
# the entry ('MX','A') says it is safe (for milter purposes) to cache
|
||||
# any 'A' RRs found in an 'MX' query.
|
||||
SAFE2CACHE = frozenset((
|
||||
('MX','MX'), ('MX','A'),
|
||||
('CNAME','CNAME'), ('CNAME','A'),
|
||||
('A','A'),
|
||||
('AAAA','AAAA'),
|
||||
('PTR','PTR'),
|
||||
('NS','NS'), ('NS','A'),
|
||||
('TXT','TXT'),
|
||||
('SPF','SPF')
|
||||
))
|
||||
|
||||
## Cached DNS lookup.
|
||||
# @param name the DNS label to query
|
||||
# @param qtype the query type, e.g. 'A'
|
||||
# @param cnames tracks CNAMES already followed in recursive calls
|
||||
def dns(self, name, qtype, cnames=None):
|
||||
"""DNS query.
|
||||
|
||||
If the result is in cache, return that. Otherwise pull the
|
||||
result from DNS, and cache ALL answers, so additional info
|
||||
is available for further queries later.
|
||||
|
||||
CNAMEs are followed.
|
||||
|
||||
If there is no data, [] is returned.
|
||||
|
||||
pre: qtype in ['A', 'AAAA', 'MX', 'PTR', 'TXT', 'SPF']
|
||||
post: isinstance(__return__, types.ListType)
|
||||
"""
|
||||
result = self.cache.get( (name, qtype) )
|
||||
cname = None
|
||||
|
||||
if not result:
|
||||
safe2cache = Session.SAFE2CACHE
|
||||
for k, v in DNSLookup(name, qtype):
|
||||
if k == (name, 'CNAME'):
|
||||
cname = v
|
||||
if (qtype,k[1]) in safe2cache:
|
||||
self.cache.setdefault(k, []).append(v)
|
||||
result = self.cache.get( (name, qtype), [])
|
||||
if not result and cname:
|
||||
if not cnames:
|
||||
cnames = {}
|
||||
elif len(cnames) >= MAX_CNAME:
|
||||
#return result # if too many == NX_DOMAIN
|
||||
raise DNSError('Length of CNAME chain exceeds %d' % MAX_CNAME)
|
||||
cnames[name] = cname
|
||||
if cname in cnames:
|
||||
raise DNSError, 'CNAME loop'
|
||||
result = self.dns(cname, qtype, cnames=cnames)
|
||||
return result
|
||||
|
||||
def DNSLookup_pydns(name, qtype, tcpfallback=True, timeout=30):
|
||||
try:
|
||||
# FIXME: To be thread safe, we create a fresh DnsRequest with
|
||||
# each call. It would be more efficient to reuse
|
||||
# a req object stored in a Session.
|
||||
req = DNS.DnsRequest(name, qtype=qtype, timeout=timeout)
|
||||
resp = req.req()
|
||||
#resp.show()
|
||||
# key k: ('wayforward.net', 'A'), value v
|
||||
# FIXME: pydns returns AAAA RR as 16 byte binary string, but
|
||||
# A RR as dotted quad. For consistency, this driver should
|
||||
# return both as binary string.
|
||||
#
|
||||
if resp.header['tc'] == True:
|
||||
if not tcpfallback:
|
||||
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)
|
||||
return [((a['name'], a['typename']), a['data']) for a in resp.answers]
|
||||
except IOError, x:
|
||||
raise DNS.DNSError, 'DNS: ' + str(x)
|
||||
|
||||
def DNSLookup_dnspython(name,qtype,tcpfallback=True,timeout=30):
|
||||
retVal = []
|
||||
try:
|
||||
# FIXME: how to disable TCP fallback in dnspython if not tcpfallback?
|
||||
answers = dns.resolver.query(name, qtype)
|
||||
for rdata in answers:
|
||||
if qtype == 'A' or qtype == 'AAAA':
|
||||
retVal.append(((name, qtype), rdata.address))
|
||||
elif qtype == 'MX':
|
||||
retVal.append(((name, qtype), (rdata.preference, rdata.exchange)))
|
||||
elif qtype == 'PTR':
|
||||
retVal.append(((name, qtype), rdata.target.to_text(True)))
|
||||
elif qtype == 'TXT' or qtype == 'SPF':
|
||||
retVal.append(((name, qtype), rdata.strings))
|
||||
except dns.resolver.NoAnswer:
|
||||
pass
|
||||
except dns.resolver.NXDOMAIN:
|
||||
pass
|
||||
return retVal
|
||||
|
||||
try:
|
||||
# prefer dnspython (the more complete library)
|
||||
import dns
|
||||
import dns.resolver # http://www.dnspython.org
|
||||
import dns.exception
|
||||
|
||||
if not hasattr(dns.rdatatype,'SPF'):
|
||||
# patch in type99 support
|
||||
dns.rdatatype.SPF = 99
|
||||
dns.rdatatype._by_text['SPF'] = dns.rdatatype.SPF
|
||||
|
||||
DNSLookup = DNSLookup_dnspython
|
||||
except:
|
||||
import DNS # http://pydns.sourceforge.net
|
||||
|
||||
if not hasattr(DNS.Type, 'SPF'):
|
||||
# patch in type99 support
|
||||
DNS.Type.SPF = 99
|
||||
DNS.Type.typemap[99] = 'SPF'
|
||||
DNS.Lib.RRunpacker.getSPFdata = DNS.Lib.RRunpacker.getTXTdata
|
||||
|
||||
# Fails on Mac OS X? Add domain to /etc/resolv.conf
|
||||
DNS.DiscoverNameServers()
|
||||
|
||||
DNSLookup = DNSLookup_pydns
|
||||
|
||||
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)
|
||||
+75
-19
@@ -16,10 +16,56 @@
|
||||
# with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
def drop_privileges(milterconfig):
|
||||
import os
|
||||
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
|
||||
that's what the milter protocol wants.
|
||||
|
||||
>>> text(fold(b'foo'))
|
||||
'foo'
|
||||
>>> text(fold(b'foo '+b'foo'*24).splitlines()[0])
|
||||
'foo '
|
||||
>>> text(fold(b'foo'*25).splitlines()[-1])
|
||||
' foo'
|
||||
>>> len(fold(b'foo'*25).splitlines()[0])
|
||||
72
|
||||
"""
|
||||
i = header.rfind(b"\r\n ")
|
||||
if i == -1:
|
||||
pre = b""
|
||||
else:
|
||||
i += 3
|
||||
pre = header[:i]
|
||||
header = header[i:]
|
||||
maxleng = 72
|
||||
while len(header) > maxleng:
|
||||
i = header[:maxleng].rfind(b" ")
|
||||
if i == -1:
|
||||
j = maxleng
|
||||
else:
|
||||
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
|
||||
import pwd
|
||||
|
||||
userlist = userid.split(':')
|
||||
if len(userlist) == 1:
|
||||
gidname = userlist[0]
|
||||
else:
|
||||
gidname = userlist[1]
|
||||
# Get the uid/gid from the name
|
||||
running_uid = pwd.getpwnam(userlist[0]).pw_uid
|
||||
running_gid = grp.getgrnam(gidname).gr_gid
|
||||
return running_uid, running_gid
|
||||
|
||||
def drop_privileges(milterconfig):
|
||||
import os
|
||||
import syslog
|
||||
|
||||
if os.getuid() != 0:
|
||||
@@ -27,25 +73,15 @@ def drop_privileges(milterconfig):
|
||||
syslog.syslog('drop_privileges: Not running as root. Cannot drop permissions.')
|
||||
return
|
||||
|
||||
# Figure out if user and group are specified
|
||||
userstr = milterconfig.get('UserID')
|
||||
userlist = userstr.split(':')
|
||||
if len(userlist) == 1:
|
||||
gidname = userlist[0]
|
||||
else:
|
||||
gidname = userlist[1]
|
||||
uidname = userlist[0]
|
||||
|
||||
# Get the uid/gid from the name
|
||||
running_uid = pwd.getpwnam(uidname).pw_uid
|
||||
running_gid = grp.getgrnam(gidname).gr_gid
|
||||
# Get user and group
|
||||
uid, gid = user_group(milterconfig.get('UserID'))
|
||||
|
||||
# Remove group privileges
|
||||
os.setgroups([])
|
||||
|
||||
# Try setting the new uid/gid
|
||||
os.setgid(running_gid)
|
||||
os.setuid(running_uid)
|
||||
os.setgid(gid)
|
||||
os.setuid(uid)
|
||||
|
||||
# Set umask
|
||||
old_umask = os.umask(milterconfig.get('UMask'))
|
||||
@@ -83,17 +119,37 @@ def write_pid(milterconfig):
|
||||
try:
|
||||
f = open(milterconfig.get('PidFile'), 'w')
|
||||
except IOError as e:
|
||||
if milterconfig.get('Syslog'):
|
||||
syslog.syslog('Unable to write pidfle {0}. IOError: {1}'.format(milterconfig.get('PidFile'), e))
|
||||
raise
|
||||
if str(e)[:35] == '[Errno 2] No such file or directory':
|
||||
piddir = milterconfig.get('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')
|
||||
if milterconfig.get('Syslog'):
|
||||
syslog.syslog('Missing 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))
|
||||
raise
|
||||
f.write(pid)
|
||||
f.close()
|
||||
user, group = user_group(milterconfig.get('UserID'))
|
||||
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')))
|
||||
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'))
|
||||
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)
|
||||
|
||||
####################
|
||||
def read_keyfile(milterconfig, keytype):
|
||||
"""Read private key from file."""
|
||||
|
||||
@@ -11,9 +11,9 @@ UMask 007
|
||||
|
||||
# Sign for example.com with key in /etc/dkimkeys/dkim.key using
|
||||
# selector '2007' (e.g. 2007._domainkey.example.com)
|
||||
Domain example.com
|
||||
KeyFile /etc/mail/dkim.key
|
||||
Selector default
|
||||
#Domain example.com
|
||||
#KeyFile /etc/mail/dkim.key
|
||||
#Selector default
|
||||
|
||||
# Commonly-used options; the commented-out versions show the defaults.
|
||||
#Canonicalization relaxed/simple
|
||||
|
||||
+5
-15
@@ -156,7 +156,7 @@ values, or to a file that contains them, or a database containing the data.
|
||||
|
||||
Some data sets require that the value contain more than one entry. How this
|
||||
is done depends on which data set type is used. Not all these datasets are
|
||||
currently used by dkimp-milter. See
|
||||
currently used by dkimpy-milter. See
|
||||
.B dkimpy-milter.conf(5)
|
||||
for details about specific options and which dataset types they use.
|
||||
|
||||
@@ -169,17 +169,7 @@ one per line. If a line contains whitespace-separated values, then the
|
||||
line is presumed to define a key and its corresponding value. Blank lines
|
||||
are ignored, and the hash ("#") character denotes the start of a comment.
|
||||
If a value contains multiple entries, the entries should be separated by
|
||||
colons. [Not implemented yet]
|
||||
.TP
|
||||
.I b)
|
||||
If the string begins with "refile:", then the remainder of the string is
|
||||
presumed to specify a file that contains a set of patterns, one per line,
|
||||
and their associated values. The pattern is taken as the start of the line
|
||||
to the first whitespace, and the portion after that whitespace is taken as
|
||||
the value to be used when that pattern is matched. Patterns are simple
|
||||
wildcard patterns, matching all text except that the asterisk ("*") character
|
||||
is considered a wildcard. If a value contains multiple entries, the entries
|
||||
should be separated by colons. [Not implemented yet]
|
||||
colons.
|
||||
.TP
|
||||
.I c)
|
||||
If the string begins with "db:" and the program was compiled with
|
||||
@@ -196,11 +186,11 @@ is compiled in). [Not implemented yet]
|
||||
.TP
|
||||
.I i)
|
||||
If the string contains none of these prefixes but starts with a slash ("/")
|
||||
character, it is presumed to be a flat file as described above. [Not implemented yet]
|
||||
character, it is presumed to be a flat file as described above.
|
||||
.TP
|
||||
.I j)
|
||||
If the string begins with "csl:", the string is treated as a comma-separated
|
||||
list as described in m) below. [Not implemented yet]
|
||||
list as described in m) below.
|
||||
.TP
|
||||
.I l)
|
||||
If the string begins with "mdb:", it refers to a directory that contains
|
||||
@@ -279,7 +269,7 @@ proposal, and Cisco's
|
||||
.B Internet Identified Mail
|
||||
(IIM) proposal.
|
||||
.SH VERSION
|
||||
This man page covers version 0.9.2 of
|
||||
This man page covers version 0.9.4 of
|
||||
.I dkimpy-milter.
|
||||
.SH COPYRIGHT
|
||||
Copyright (c) 2005-2008, Sendmail, Inc. and its suppliers. All rights
|
||||
|
||||
@@ -215,18 +215,12 @@ The value may include two different canonicalizations separated by a
|
||||
slash ("/") character, in which case the first will be applied to the
|
||||
header and the second to the body.
|
||||
|
||||
.TP
|
||||
.I Diagnostics (Boolean)
|
||||
Requests the inclusion of "z=" tags in signatures, which encode the
|
||||
original header field set for use by verifiers when diagnosing verification
|
||||
failures. Not recommended for normal operation. [dkimpy-milter specific: also
|
||||
increases the verbosity of Syslog logging if enabled.]
|
||||
|
||||
.TP
|
||||
.I DiagnosticDirectory (string)
|
||||
Directory into which to write diagnostic reports when message verification
|
||||
fails on a message bearing a "z=" tag. If not set (the default), these files
|
||||
are not generated.
|
||||
fails. If not set (the default), these files are not generated. [Unlike
|
||||
OpenDKIM, this applies to all messages, not just on messages bearing a "z=" tag
|
||||
because dkimpy does not yet support "z=".]
|
||||
|
||||
.TP
|
||||
.I Domain (dataset)
|
||||
@@ -374,7 +368,7 @@ Log via calls to
|
||||
using the named facility. The facility names are the same as the ones
|
||||
allowed in
|
||||
.I syslog.conf(5).
|
||||
The default is "mail". [Hardcoded to default for now]
|
||||
The default is "mail".
|
||||
|
||||
.TP
|
||||
.I SyslogSuccess (Boolean)
|
||||
|
||||
@@ -55,6 +55,6 @@ setup(
|
||||
(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.0.2', 'PyNaCl'],
|
||||
install_requires = ['dkimpy>=0.7', 'pymilter', 'authres>=1.1.0', 'PyNaCl', 'ipaddress', 'dns'],
|
||||
zip_safe = False,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user