Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 977fac5fae | |||
| 36ff60d8d3 | |||
| 4769bde19c | |||
| e6021dd960 | |||
| 9d28ab3567 | |||
| df19aa081e | |||
| 2e9d0f607f | |||
| fb32a8fe0b | |||
| 3e57876361 | |||
| 7683fa7187 | |||
| fc893a62c3 | |||
| c01c04b83f | |||
| fc583a6e3c | |||
| ebfb0b5fc3 | |||
| 48a44916e7 | |||
| 5a81886a5e |
@@ -1,3 +1,21 @@
|
|||||||
|
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.3 2018-03-02
|
0.9.3 2018-03-02
|
||||||
- Fixup csl dataset processing for single item lists
|
- Fixup csl dataset processing for single item lists
|
||||||
- file: dataset support
|
- file: dataset support
|
||||||
|
|||||||
@@ -16,22 +16,76 @@ python setup.py install --single-version-externally-managed --record=/dev/null
|
|||||||
For users of Debian Stable (Debian 9, Codename Squueze), all dependencies are
|
For users of Debian Stable (Debian 9, Codename Squueze), all dependencies are
|
||||||
available in either the main or backports repositories:
|
available in either the main or backports repositories:
|
||||||
|
|
||||||
[sudo] apt install python-milter python-nacl
|
[sudo] apt install python-milter python-nacl pthon-ipaddress python-dnspython
|
||||||
[sudo] apt install -t squeeze-backports python-authres python-dkim
|
[sudo] apt install -t squeeze-backports python-authres python-dkim
|
||||||
|
|
||||||
The preferred method of installation is from PyPi using pip:
|
The preferred method of installation is from PyPi using pip (if distribution
|
||||||
|
packages are not available):
|
||||||
|
|
||||||
[sudo] pip install dkimpy_milter
|
[sudo] pip install dkimpy_milter
|
||||||
|
|
||||||
Using pip will cause required packages to be installed via easy_install if they
|
Using pip will cause required packages to be installed via easy_install if they
|
||||||
have not been previously installed.
|
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
|
Both a systemd unit file and a sysv init file are provided. Both make
|
||||||
assumptions about defaults being used, e.g. if a non-standard pidfile name is
|
assumptions about defaults being used, e.g. if a non-standard pidfile name is
|
||||||
used, they will need to be updated. The sysv init file is Debian specific and
|
used, they will need to be updated. The sysv init file is Debian specific and
|
||||||
untested, since the developers are not using sysv init. Feedback/patches
|
untested, since the developers are not using sysv init. Feedback/patches
|
||||||
welcome.
|
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
|
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
|
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.
|
file. This may impact performance on low-memory systems.
|
||||||
|
|||||||
@@ -25,11 +25,16 @@ Sign based on Domain implemented verified
|
|||||||
Canonicalization implemented verified
|
Canonicalization implemented verified
|
||||||
SyslogFacility 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)
|
0.9.5 (Beta)
|
||||||
AuthservID
|
|
||||||
Diagnostics
|
|
||||||
DiagnosticDirectory
|
|
||||||
InternalHosts
|
|
||||||
|
|
||||||
SyslogSuccess
|
SyslogSuccess
|
||||||
|
|
||||||
@@ -38,10 +43,7 @@ Convert dkim-milter-python config
|
|||||||
No additional features planned
|
No additional features planned
|
||||||
|
|
||||||
Plannedataset type support:
|
Plannedataset type support:
|
||||||
file://
|
|
||||||
refile:
|
|
||||||
db:/.db
|
db:/.db
|
||||||
csl:
|
|
||||||
mdb:
|
mdb:
|
||||||
|
|
||||||
Considered for near-term feature release
|
Considered for near-term feature release
|
||||||
@@ -59,6 +61,7 @@ SignatureAlgorithm
|
|||||||
Later
|
Later
|
||||||
|
|
||||||
BaseDirectory
|
BaseDirectory
|
||||||
|
Diagnostics (requires dkimpy changes)
|
||||||
DontSignMailTo
|
DontSignMailTo
|
||||||
ExemptDomains
|
ExemptDomains
|
||||||
ExternalIgnoreList
|
ExternalIgnoreList
|
||||||
|
|||||||
+32
-17
@@ -40,8 +40,9 @@ from dkimpy_milter.util import setExceptHook
|
|||||||
from dkimpy_milter.util import write_pid
|
from dkimpy_milter.util import write_pid
|
||||||
from dkimpy_milter.util import read_keyfile
|
from dkimpy_milter.util import read_keyfile
|
||||||
from dkimpy_milter.util import own_socketfile
|
from dkimpy_milter.util import own_socketfile
|
||||||
|
from dkimpy_milter.util import fold
|
||||||
|
|
||||||
__version__ = "0.9.3"
|
__version__ = "0.9.4"
|
||||||
FWS = re.compile(r'\r?\n[ \t]+')
|
FWS = re.compile(r'\r?\n[ \t]+')
|
||||||
|
|
||||||
class dkimMilter(Milter.Base):
|
class dkimMilter(Milter.Base):
|
||||||
@@ -62,10 +63,15 @@ class dkimMilter(Milter.Base):
|
|||||||
self.hello_name = None
|
self.hello_name = None
|
||||||
# sometimes people put extra space in sendmail config, so we strip
|
# sometimes people put extra space in sendmail config, so we strip
|
||||||
self.receiver = self.getsymval('j').strip()
|
self.receiver = self.getsymval('j').strip()
|
||||||
|
try:
|
||||||
|
self.AuthservID = milterconfig['AuthservID']
|
||||||
|
except:
|
||||||
|
self.AuthservID = self.receiver
|
||||||
if hostaddr and len(hostaddr) > 0:
|
if hostaddr and len(hostaddr) > 0:
|
||||||
ipaddr = hostaddr[0]
|
ipaddr = hostaddr[0]
|
||||||
"""if iniplist(ipaddr,self.conf.internal_connect): FIXME
|
if milterconfig['InternalHostsObj']:
|
||||||
self.internal_connection = True"""
|
if milterconfig['InternalHostsObj'].match(ipaddr):
|
||||||
|
self.internal_connection = True
|
||||||
else: ipaddr = ''
|
else: ipaddr = ''
|
||||||
self.connectip = ipaddr
|
self.connectip = ipaddr
|
||||||
if self.internal_connection:
|
if self.internal_connection:
|
||||||
@@ -151,7 +157,7 @@ class dkimMilter(Milter.Base):
|
|||||||
# FIXME: don't delete A-R headers from trusted MTAs
|
# FIXME: don't delete A-R headers from trusted MTAs
|
||||||
try:
|
try:
|
||||||
ar = authres.AuthenticationResultsHeader.parse_value(FWS.sub('',val))
|
ar = authres.AuthenticationResultsHeader.parse_value(FWS.sub('',val))
|
||||||
if ar.authserv_id == self.receiver:
|
if ar.authserv_id == self.AuthservID:
|
||||||
self.chgheader('authentication-results',i,'')
|
self.chgheader('authentication-results',i,'')
|
||||||
if milterconfig.get('Syslog'):
|
if milterconfig.get('Syslog'):
|
||||||
syslog.syslog('REMOVE: {0}'.format(val))
|
syslog.syslog('REMOVE: {0}'.format(val))
|
||||||
@@ -160,7 +166,11 @@ class dkimMilter(Milter.Base):
|
|||||||
pass
|
pass
|
||||||
# Check or sign DKIM
|
# Check or sign DKIM
|
||||||
self.fp.seek(0)
|
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()
|
txt = self.fp.read()
|
||||||
self.sign_dkim(txt)
|
self.sign_dkim(txt)
|
||||||
result = None
|
result = None
|
||||||
@@ -170,9 +180,9 @@ class dkimMilter(Milter.Base):
|
|||||||
else:
|
else:
|
||||||
result = 'none'
|
result = 'none'
|
||||||
if self.arresults:
|
if self.arresults:
|
||||||
h = authres.AuthenticationResultsHeader(authserv_id = self.receiver,
|
h = authres.AuthenticationResultsHeader(authserv_id = self.AuthservID,
|
||||||
results=self.arresults)
|
results=self.arresults)
|
||||||
h = dkim.fold(str(h))
|
h = fold(str(h))
|
||||||
if milterconfig.get('Syslog'):
|
if milterconfig.get('Syslog'):
|
||||||
syslog.syslog(str(h))
|
syslog.syslog(str(h))
|
||||||
name,val = str(h).split(': ',1)
|
name,val = str(h).split(': ',1)
|
||||||
@@ -190,11 +200,12 @@ class dkimMilter(Milter.Base):
|
|||||||
canonicalize.append(canon)
|
canonicalize.append(canon)
|
||||||
syslog.syslog('canonicalize: {0}'.format(canonicalize))
|
syslog.syslog('canonicalize: {0}'.format(canonicalize))
|
||||||
try:
|
try:
|
||||||
d = dkim.DKIM(txt)
|
if privateRSA:
|
||||||
h = d.sign(milterconfig.get('Selector'), self.fdomain, privateRSA,
|
d = dkim.DKIM(txt)
|
||||||
canonicalize=(canonicalize[0], canonicalize[1]))
|
h = d.sign(milterconfig.get('Selector'), self.fdomain, privateRSA,
|
||||||
name,val = h.split(': ',1)
|
canonicalize=(canonicalize[0], canonicalize[1]))
|
||||||
self.addheader(name,val.strip().replace('\r\n','\n'),0)
|
name,val = h.split(': ',1)
|
||||||
|
self.addheader(name,val.strip().replace('\r\n','\n'),0)
|
||||||
if privateEd25519:
|
if privateEd25519:
|
||||||
d = dkim.DKIM(txt)
|
d = dkim.DKIM(txt)
|
||||||
h = d.sign(milterconfig.get('SelectorEd25519'), self.fdomain, privateEd25519,
|
h = d.sign(milterconfig.get('SelectorEd25519'), self.fdomain, privateEd25519,
|
||||||
@@ -236,15 +247,19 @@ class dkimMilter(Milter.Base):
|
|||||||
syslog.syslog('DKIM: Pass ({0})'.format(d.domain))
|
syslog.syslog('DKIM: Pass ({0})'.format(d.domain))
|
||||||
self.dkim_domain = d.domain
|
self.dkim_domain = d.domain
|
||||||
else:
|
else:
|
||||||
fd,fname = tempfile.mkstemp(".dkim")
|
if milterconfig.get['DiagnosticDirectory']:
|
||||||
with os.fdopen(fd,"w+b") as fp:
|
fd,fname = tempfile.mkstemp(".dkim")
|
||||||
fp.write(txt)
|
with os.fdopen(fd,"w+b") as fp:
|
||||||
if milterconfig.get('Syslog'):
|
fp.write(txt)
|
||||||
syslog.syslog('DKIM: Fail (saved as {0})'.format(fname))
|
if milterconfig.get('Syslog'):
|
||||||
|
syslog.syslog('DKIM: Fail (saved as {0})'.format(fname))
|
||||||
|
else:
|
||||||
|
syslog.syslog('DKIM: Fail ({0})'.format(d.domain))
|
||||||
if res:
|
if res:
|
||||||
result = 'pass'
|
result = 'pass'
|
||||||
else:
|
else:
|
||||||
result = 'fail'
|
result = 'fail'
|
||||||
|
res = False
|
||||||
self.arresults.append(
|
self.arresults.append(
|
||||||
authres.DKIMAuthenticationResult(result=result,
|
authres.DKIMAuthenticationResult(result=result,
|
||||||
header_i = self.header_i, header_d = self.header_d, header_a = self.header_a,
|
header_i = self.header_i, header_d = self.header_d, header_a = self.header_a,
|
||||||
|
|||||||
+198
-5
@@ -31,7 +31,9 @@ import re
|
|||||||
import urllib
|
import urllib
|
||||||
import stat
|
import stat
|
||||||
import dkim
|
import dkim
|
||||||
|
import socket
|
||||||
|
import ipaddress
|
||||||
|
from dnsplug import Session
|
||||||
|
|
||||||
# default values
|
# default values
|
||||||
defaultConfigData = {
|
defaultConfigData = {
|
||||||
@@ -42,7 +44,10 @@ defaultConfigData = {
|
|||||||
'Socket' : 'local:/var/run/dkimpy-milter/dkimpy-milter.sock',
|
'Socket' : 'local:/var/run/dkimpy-milter/dkimpy-milter.sock',
|
||||||
'PidFile' : '/var/run/dkimpy-milter/dkimpy-milter.pid',
|
'PidFile' : '/var/run/dkimpy-milter/dkimpy-milter.pid',
|
||||||
'UserID' : 'dkimpy-milter',
|
'UserID' : 'dkimpy-milter',
|
||||||
'Canonicalization' : 'relaxed/simple'
|
'Canonicalization' : 'relaxed/simple',
|
||||||
|
'InternalHosts' : '127.0.0.1',
|
||||||
|
'InternalHostsObj' : False,
|
||||||
|
'DiagnosticDirectory' : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -51,6 +56,167 @@ class ConfigException(Exception):
|
|||||||
'''Exception raised when there's a configuration file error.'''
|
'''Exception raised when there's a configuration file error.'''
|
||||||
pass
|
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,
|
def _processConfigFile(filename = None, configdata = None, useSyslog = 1,
|
||||||
useStderr = 0):
|
useStderr = 0):
|
||||||
@@ -82,8 +248,14 @@ def _find_boolean(item):
|
|||||||
else:
|
else:
|
||||||
raise dkim.ParameterError()
|
raise dkim.ParameterError()
|
||||||
return item
|
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):
|
def _dataset_to_list(dataset):
|
||||||
"""Convert a dataset (as defined in dkimpymilter.8) and return a python
|
"""Convert a dataset (as defined in dkimpymilter.8) and return a python
|
||||||
list of values."""
|
list of values."""
|
||||||
@@ -118,7 +290,20 @@ def _dataset_to_list(dataset):
|
|||||||
return [dataset[4:].strip().strip(',')]
|
return [dataset[4:].strip().strip(',')]
|
||||||
else:
|
else:
|
||||||
return [dataset.strip().strip(',')]
|
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)))
|
raise dkim.ParameterError('Unimplmented dataset type: {0}'.format(type(dataset)))
|
||||||
|
|
||||||
###############################################################
|
###############################################################
|
||||||
@@ -148,6 +333,9 @@ def _readConfigFile(path, configData = None, configGlobal = {}):
|
|||||||
'Selector' : 'str',
|
'Selector' : 'str',
|
||||||
'SelectorEd25519': 'str',
|
'SelectorEd25519': 'str',
|
||||||
'Canonicalization' : 'str',
|
'Canonicalization' : 'str',
|
||||||
|
'InternalHosts' : 'dataset',
|
||||||
|
'InternalHostsObj': 'bool',
|
||||||
|
'DiagnosticDirectory' : 'str'
|
||||||
}
|
}
|
||||||
|
|
||||||
# check to see if it's a file
|
# check to see if it's a file
|
||||||
@@ -204,5 +392,10 @@ def _readConfigFile(path, configData = None, configGlobal = {}):
|
|||||||
syslog.syslog(str('name: ' + name + ' value: ' + value + ' conversion: ' + conversion))
|
syslog.syslog(str('name: ' + name + ' value: ' + value + ' conversion: ' + conversion))
|
||||||
configData[name] = conversion(value)
|
configData[name] = conversion(value)
|
||||||
fp.close()
|
fp.close()
|
||||||
|
try:
|
||||||
|
configData['AuthservID'] = _calculate_authserv_id(configData['AuthservID'])
|
||||||
|
configData['InternalHostsObj'] = HostsDataset(configData['InternalHosts'])
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
return(configData)
|
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)
|
||||||
+45
-3
@@ -16,6 +16,39 @@
|
|||||||
# with this program; if not, write to the Free Software Foundation, Inc.,
|
# with this program; if not, write to the Free Software Foundation, Inc.,
|
||||||
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
# 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
|
||||||
|
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):
|
def user_group(userid):
|
||||||
"""Return user and group from UserID"""
|
"""Return user and group from UserID"""
|
||||||
import grp
|
import grp
|
||||||
@@ -86,9 +119,18 @@ def write_pid(milterconfig):
|
|||||||
try:
|
try:
|
||||||
f = open(milterconfig.get('PidFile'), 'w')
|
f = open(milterconfig.get('PidFile'), 'w')
|
||||||
except IOError as e:
|
except IOError as e:
|
||||||
if milterconfig.get('Syslog'):
|
if str(e)[:35] == '[Errno 2] No such file or directory':
|
||||||
syslog.syslog('Unable to write pidfle {0}. IOError: {1}'.format(milterconfig.get('PidFile'), e))
|
piddir = milterconfig.get('PidFile').rsplit('/', 1)[0]
|
||||||
raise
|
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.write(pid)
|
||||||
f.close()
|
f.close()
|
||||||
user, group = user_group(milterconfig.get('UserID'))
|
user, group = user_group(milterconfig.get('UserID'))
|
||||||
|
|||||||
+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
|
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
|
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)
|
.B dkimpy-milter.conf(5)
|
||||||
for details about specific options and which dataset types they use.
|
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
|
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.
|
are ignored, and the hash ("#") character denotes the start of a comment.
|
||||||
If a value contains multiple entries, the entries should be separated by
|
If a value contains multiple entries, the entries should be separated by
|
||||||
colons. [Not implemented yet]
|
colons.
|
||||||
.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]
|
|
||||||
.TP
|
.TP
|
||||||
.I c)
|
.I c)
|
||||||
If the string begins with "db:" and the program was compiled with
|
If the string begins with "db:" and the program was compiled with
|
||||||
@@ -196,11 +186,11 @@ is compiled in). [Not implemented yet]
|
|||||||
.TP
|
.TP
|
||||||
.I i)
|
.I i)
|
||||||
If the string contains none of these prefixes but starts with a slash ("/")
|
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
|
.TP
|
||||||
.I j)
|
.I j)
|
||||||
If the string begins with "csl:", the string is treated as a comma-separated
|
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
|
.TP
|
||||||
.I l)
|
.I l)
|
||||||
If the string begins with "mdb:", it refers to a directory that contains
|
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
|
.B Internet Identified Mail
|
||||||
(IIM) proposal.
|
(IIM) proposal.
|
||||||
.SH VERSION
|
.SH VERSION
|
||||||
This man page covers version 0.9.2 of
|
This man page covers version 0.9.4 of
|
||||||
.I dkimpy-milter.
|
.I dkimpy-milter.
|
||||||
.SH COPYRIGHT
|
.SH COPYRIGHT
|
||||||
Copyright (c) 2005-2008, Sendmail, Inc. and its suppliers. All rights
|
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
|
slash ("/") character, in which case the first will be applied to the
|
||||||
header and the second to the body.
|
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
|
.TP
|
||||||
.I DiagnosticDirectory (string)
|
.I DiagnosticDirectory (string)
|
||||||
Directory into which to write diagnostic reports when message verification
|
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
|
fails. If not set (the default), these files are not generated. [Unlike
|
||||||
are not generated.
|
OpenDKIM, this applies to all messages, not just on messages bearing a "z=" tag
|
||||||
|
because dkimpy does not yet support "z=".]
|
||||||
|
|
||||||
.TP
|
.TP
|
||||||
.I Domain (dataset)
|
.I Domain (dataset)
|
||||||
@@ -374,7 +368,7 @@ Log via calls to
|
|||||||
using the named facility. The facility names are the same as the ones
|
using the named facility. The facility names are the same as the ones
|
||||||
allowed in
|
allowed in
|
||||||
.I syslog.conf(5).
|
.I syslog.conf(5).
|
||||||
The default is "mail". [Hardcoded to default for now]
|
The default is "mail".
|
||||||
|
|
||||||
.TP
|
.TP
|
||||||
.I SyslogSuccess (Boolean)
|
.I SyslogSuccess (Boolean)
|
||||||
|
|||||||
@@ -55,6 +55,6 @@ setup(
|
|||||||
(os.path.join('/lib', 'systemd', 'system'),
|
(os.path.join('/lib', 'systemd', 'system'),
|
||||||
['system/dkimpy-milter.service']),(os.path.join('/etc', 'init.d'),
|
['system/dkimpy-milter.service']),(os.path.join('/etc', 'init.d'),
|
||||||
['system/dkimpy-milter'])],
|
['system/dkimpy-milter'])],
|
||||||
install_requires = ['dkimpy>=0.7', 'pymilter', 'authres>=1.1.0', 'PyNaCl'],
|
install_requires = ['dkimpy>=0.7', 'pymilter', 'authres>=1.1.0', 'PyNaCl', 'ipaddress', 'dns'],
|
||||||
zip_safe = False,
|
zip_safe = False,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user