Compare commits
94 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7eed8995a2 | |||
| a16d887ac6 | |||
| 23d91b2b50 | |||
| 481fbdae29 | |||
| 4b0c39b0c7 | |||
| 7092874729 | |||
| ea09bab1a8 | |||
| 25fdd3b81c | |||
| 9d5316ca0e | |||
| 391b5352f3 | |||
| ad8f396db0 | |||
| 479820a07d | |||
| 7bfb87fab7 | |||
| 5c1d5d6e52 | |||
| ae31730593 | |||
| 72ed000ccf | |||
| b3db013754 | |||
| bd1d25d83e | |||
| 1c6030024d | |||
| 71c0c3f20a | |||
| a9a6893c89 | |||
| bb44f36519 | |||
| 9e11b75ec3 | |||
| f60ea12e86 | |||
| e872bd44b0 | |||
| 51c8fdcb6c | |||
| aa4dadc22f | |||
| b1abbf9d61 | |||
| ea2b612e8d | |||
| 5945e818ca | |||
| f38fed3bee | |||
| 06948b3dbc | |||
| e951ab6c5e | |||
| 03c86a2b08 | |||
| 2cda1758c1 | |||
| a188bd3960 | |||
| c91a12f0d1 | |||
| 286ffbb6c9 | |||
| ec3252c367 | |||
| a2ff03727d | |||
| af4b05e242 | |||
| 6509eaad35 | |||
| f9483fea8c | |||
| 7a3a7bfb43 | |||
| 8a0e1bdd97 | |||
| e3005aa723 | |||
| 45d3ba13ca | |||
| f05309437f | |||
| d4499f6990 | |||
| 7d87309f4b | |||
| 1d8c309da9 | |||
| 4d5961e4d5 | |||
| 59448e8e57 | |||
| 695de0db14 | |||
| dfd6fa68c3 | |||
| 86eb152f93 | |||
| 126966e110 | |||
| 5d8d47cd52 | |||
| 1843ca6244 | |||
| f9358d594c | |||
| a8aa422b03 | |||
| 9836f2c9c2 | |||
| 70606ac58c | |||
| 6348bdcdc7 | |||
| fd39384e78 | |||
| 924c96d555 | |||
| efeabd19d3 | |||
| a9b8a44bfc | |||
| daaa6aada7 | |||
| e795db7c69 | |||
| 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,3 +1,4 @@
|
||||
dist
|
||||
dkimpy_milter.egg-info
|
||||
*.pyc
|
||||
*~
|
||||
|
||||
@@ -1,11 +1,85 @@
|
||||
1.1.0 UNRELEASED
|
||||
- Port to python3
|
||||
- Add test suite using opendkim miltertest
|
||||
- When Socket is absolute path, do not strip leading /
|
||||
- Handle unix: socket prefix the same as local:
|
||||
- Set up correct AuthservID defaults
|
||||
- config: Reassemble strings sensibly
|
||||
|
||||
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
|
||||
1.0.1 2019-02-11
|
||||
- Reorder milter start and dropping privileges so permissions on Unix socket
|
||||
are correct (LP: 1797720)
|
||||
- Make domain checks case insensitive for determining if signing should be
|
||||
done (LP: #1815311)
|
||||
- Add additional Sendmail configuration information to README from OpenDKIM
|
||||
update based on input from Дилян Палаузов (LP: #1801619)
|
||||
- Add information on Ed25519 key creation to README (LP: #1815313)
|
||||
|
||||
1.0.0 2018-05-11
|
||||
- Minor documentation updates
|
||||
- Deleted reference to obsolete syslog target in unit file
|
||||
|
||||
0.9.7 2018-03-19
|
||||
- Made sysv init executable
|
||||
- Add missing documentation key to system/dkimpy-milter.service
|
||||
- Put version directly in setup.py and do not import dkimpy_milter to ease
|
||||
install via pip
|
||||
- Minor sysv init improvments
|
||||
|
||||
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
|
||||
- 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
|
||||
- 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 +90,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
|
||||
|
||||
|
||||
@@ -1,17 +1,86 @@
|
||||
This is a DKIM signing and verification milter. In theory it works with both
|
||||
Postfix and Sendmail, but the author has zero experience with Sendmail, so
|
||||
reports of success/failure with Sendmail and patches are welcom.
|
||||
OVERVIEW
|
||||
========
|
||||
|
||||
This is a DKIM signing and verification milter. It has been tested with both
|
||||
Postfix and Sendmail.
|
||||
|
||||
The configuration file is designed to be compatible with OpenDKIM, but only
|
||||
a subset of OpenDKIM options are supported. If an unsupported option is
|
||||
specified, an error will be raised.
|
||||
|
||||
|
||||
INSTALLATION
|
||||
===========
|
||||
|
||||
This package includes a default configuration file and man pages. For those
|
||||
to be installed when installing using setup.py, the following incantation is
|
||||
required because setuptools developers decided not being able to do this by
|
||||
default is a feature:
|
||||
|
||||
python setup.py install --single-version-externally-managed --record=/dev/null
|
||||
python3 setup.py install --single-version-externally-managed --record=/dev/null
|
||||
|
||||
For users of Debian Stable (Debian 9, Codename Squeeze), all dependencies are
|
||||
available in either the main or backports repositories:
|
||||
|
||||
[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):
|
||||
|
||||
[sudo] pip install dkimpy_milter
|
||||
|
||||
Using pip will cause required packages to be installed via easy_install if they
|
||||
have not been previously installed. Because pymilter and PyNaCl are compiled
|
||||
Python extensions, the system will need appropriate development packages and
|
||||
an C compiler. Alternately, install these dependencies from dsitribution/OS
|
||||
packages and then pip install dkimpy_milter.
|
||||
|
||||
The milter will work with either pydns (DNS) or dnspython (dns), preferring
|
||||
dnspython is both are available. The dkimpy DKIM module also works with
|
||||
either.
|
||||
|
||||
|
||||
SETUP
|
||||
====
|
||||
|
||||
SIGNING KEYS
|
||||
============
|
||||
|
||||
In order to create DKIM signatures, a private key must be available. Signing
|
||||
keys should be protected (owned by root:root with permissions 600 in a
|
||||
directory that is not world readable). Different keys are required for RSA
|
||||
and (if used) Ed25519.
|
||||
|
||||
RSA
|
||||
===
|
||||
|
||||
Both public and private keys for RSA have standard formats and there are many
|
||||
tools available to create them. Keys must (RFC 8302) have a minimum size of
|
||||
1024 bits and should have a size of at least 2048 bits. The dknewkey script
|
||||
that is provided with dkimpy is one such tool:
|
||||
|
||||
dknewkey exampleprivkey
|
||||
|
||||
will produce both the private key file (.key suffix) and a file with the DKIM
|
||||
public key record to be published DNS (.dns suffix). RSA is the default key
|
||||
type. 2048 bits is the default key size.
|
||||
|
||||
ED25519
|
||||
=======
|
||||
|
||||
There is no standardized non-binary representation for Ed25519 private keys,
|
||||
so in order to generate Ed25519 keys for dkimpy-milter, dkimpy specific tools
|
||||
must be used to be compatible. The same dknewkey script support Ed25519:
|
||||
|
||||
dknewkey --ktype ed25519 anothernewkey
|
||||
|
||||
will provide both the private key file (.key suffix) and a file with the DKIM
|
||||
public key record to be published DNS (.dns suffix). Ed25519 keys do not have
|
||||
variable bit lengths.
|
||||
|
||||
MTA INTEGRATION
|
||||
==============
|
||||
|
||||
Both a systemd unit file and a sysv init file are provided. Both make
|
||||
assumptions about defaults being used, e.g. if a non-standard pidfile name is
|
||||
@@ -19,10 +88,156 @@ 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 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.
|
||||
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:
|
||||
|
||||
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.
|
||||
[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.
|
||||
|
||||
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).
|
||||
|
||||
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
|
||||
|
||||
ISSUES USING SENDMAIL TO SIGN AND VERIFY
|
||||
========================================
|
||||
|
||||
When using the sendmail MTA in both signing and verifying mode, there are
|
||||
a few issues of which to be aware that might cause operational problems
|
||||
and deserve consideration.
|
||||
|
||||
(a) When the MTA will be used for relaying emails, e.g. delivering to other
|
||||
hosts using the aliases mechanism, it is important not to break
|
||||
signatures inserted by the original sender. This is particularly sensitive
|
||||
particular when the sending domain has published a "reject" DMARC policy.
|
||||
|
||||
By default, sendmail quotes to address header fields when there are no
|
||||
quotes and the display part of the address contains a period or an
|
||||
apostrophe. However, opendkim only sees the raw, unmodified form of
|
||||
the header field, and so the content that gets verified and what gets
|
||||
signed will not be the same, guaranteeing the attached signature is not
|
||||
valid.
|
||||
|
||||
To direct sendmail not to modify the headers, add this to your sendmail.mc:
|
||||
|
||||
conf(`confMUST_QUOTE_CHARS', `')
|
||||
|
||||
(b) As stated in sendmail's KNOWNBUGS file, sendmail truncates header field
|
||||
values longer than 256 characters, which could mean truncating the domain
|
||||
of a long From: header field value and invalidating the signature.
|
||||
You may wish to consider increasing MAXNAME in sendmail/conf.h to mitigate
|
||||
changing the messages and invalidating their signatures. This change
|
||||
requires recompiling sendmail.
|
||||
|
||||
(c) Similar to (a) above, sendmail may wrap very long single-line recipient
|
||||
fields for presentation purposes; for example:
|
||||
|
||||
To: very long name <a@example.org>,anotherloo...ong name b <b@example.org>
|
||||
|
||||
...might be rewritten as:
|
||||
|
||||
To: very long name <a@example.org>,
|
||||
anotherloo...ong name b <b@example.org>
|
||||
|
||||
This rewrite is also done after opendkim has seen the message, meaning
|
||||
the signature opendkim attaches to the message does not match the
|
||||
content it signed. There is not a known configuration change to
|
||||
mitigate this mutation.
|
||||
|
||||
The only known mechanism for dealing with this is to have distinct
|
||||
instances of opendkim do the verifying (inbound) and signing (outbound)
|
||||
so that the version that arrives at the signing instance is already
|
||||
in the rewritten form, guaranteeing the input and output are the same
|
||||
and thus the signature matches the payload.
|
||||
|
||||
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 that 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.
|
||||
|
||||
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
|
||||
...
|
||||
|
||||
|
||||
NOTES
|
||||
=====
|
||||
|
||||
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.
|
||||
|
||||
DKIM with Ed25519 signatures are described in RFC 8463. Version 1.0.0 and
|
||||
later support Ed25519 signing and verification. RFC 8301 removed rsa-sha1
|
||||
from DKIM. dkimpy-milter does not sign with rsa-sha1, but still considers
|
||||
rsa-sha1 signatures as valid for verification because they are still in
|
||||
common use and are not known to be cryptographically broken.
|
||||
|
||||
@@ -25,23 +25,30 @@ Sign based on Domain implemented verified
|
||||
Canonicalization implemented verified
|
||||
SyslogFacility implemented verified
|
||||
|
||||
0.9.5 (Beta)
|
||||
AuthservID
|
||||
Diagnostics
|
||||
DiagnosticDirectory
|
||||
InternalHosts
|
||||
0.9.3 (Alpha)
|
||||
File dataset implemented verified
|
||||
|
||||
SyslogSuccess
|
||||
0.9.4 (Alpha)
|
||||
AuthservID implemented verified
|
||||
DiagnosticDirectory implemented verified
|
||||
InternalHosts implemented verified
|
||||
|
||||
0.9.5 (Beta)
|
||||
MacroList implemented verified
|
||||
MacroListVerify implemented verified
|
||||
SyslogSuccess implemented verified
|
||||
|
||||
1.0.0
|
||||
Convert dkim-milter-python config
|
||||
No additional features planned
|
||||
No additional features
|
||||
|
||||
Plannedataset type support:
|
||||
file://
|
||||
refile:
|
||||
db:/.db
|
||||
csl:
|
||||
1.0.1
|
||||
Bug fix only, improved documentation
|
||||
|
||||
1.1.0 (planned)
|
||||
Port to Python 3
|
||||
Subdomain support
|
||||
|
||||
Planned dataset type support (if needed):
|
||||
mdb:
|
||||
|
||||
Considered for near-term feature release
|
||||
@@ -49,16 +56,17 @@ Considered for near-term feature release
|
||||
AlwaysAddARHeader
|
||||
ChangeRootDirectory
|
||||
ClockDrift (requires dkimpy change)
|
||||
DNSTimeout (requires dkmpy change)
|
||||
MacroList
|
||||
DNSTimeout (requires dkimpy change)
|
||||
MilterDebug
|
||||
MinimumKeyBits
|
||||
OversignHeaders (may require dkimpy changes)
|
||||
PeerList
|
||||
SignatureAlgorithm
|
||||
|
||||
Later
|
||||
|
||||
BaseDirectory
|
||||
Diagnostics (requires dkimpy changes)
|
||||
DontSignMailTo
|
||||
ExemptDomains
|
||||
ExternalIgnoreList
|
||||
@@ -82,7 +90,6 @@ On-InternalError
|
||||
On-KeyNotFound
|
||||
On-NoSignature
|
||||
On-SignatureError
|
||||
OversignHeaders
|
||||
RemoveARAll
|
||||
RemoveARFrom
|
||||
RemoveOldSignatures
|
||||
|
||||
+299
-205
@@ -1,4 +1,4 @@
|
||||
#! /usr/bin/python2
|
||||
#! /usr/bin/python3
|
||||
# Original dkim-milter.py code:
|
||||
# Author: Stuart D. Gathman <stuart@bmsi.com>
|
||||
# Copyright 2007 Business Management Systems, Inc.
|
||||
@@ -25,226 +25,306 @@ 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 io
|
||||
import re
|
||||
from Milter.config import MilterConfigParser
|
||||
from Milter.utils import iniplist,parse_addr,parseaddr
|
||||
import codecs
|
||||
from Milter.utils import parse_addr, parseaddr
|
||||
import dkimpy_milter.config as config
|
||||
from dkimpy_milter.util import drop_privileges
|
||||
from dkimpy_milter.util import setExceptHook
|
||||
from dkimpy_milter.util import write_pid
|
||||
from dkimpy_milter.util import read_keyfile
|
||||
from dkimpy_milter.util import own_socketfile
|
||||
from dkimpy_milter.util import fold
|
||||
|
||||
__version__ = "0.9.2"
|
||||
__version__ = "1.0.1"
|
||||
FWS = re.compile(r'\r?\n[ \t]+')
|
||||
|
||||
|
||||
class dkimMilter(Milter.Base):
|
||||
"Milter to check and sign DKIM. Each connection gets its own instance."
|
||||
"Milter to check and sign DKIM. Each connection gets its own instance."
|
||||
|
||||
def __init__(self):
|
||||
self.mailfrom = None
|
||||
self.id = Milter.uniqueID()
|
||||
# we don't want config used to change during a connection
|
||||
self.conf = milterconfig
|
||||
self.privatersa = privateRSA
|
||||
self.privateed25519 = privateEd25519
|
||||
self.fp = None
|
||||
def __init__(self):
|
||||
self.mailfrom = None
|
||||
self.id = Milter.uniqueID()
|
||||
# we don't want config used to change during a connection
|
||||
self.conf = milterconfig
|
||||
self.privatersa = privateRSA
|
||||
self.privateed25519 = privateEd25519
|
||||
self.fp = None
|
||||
|
||||
@Milter.noreply
|
||||
def connect(self,hostname,unused,hostaddr):
|
||||
self.internal_connection = False
|
||||
self.hello_name = None
|
||||
# sometimes people put extra space in sendmail config, so we strip
|
||||
self.receiver = self.getsymval('j').strip()
|
||||
if hostaddr and len(hostaddr) > 0:
|
||||
ipaddr = hostaddr[0]
|
||||
"""if iniplist(ipaddr,self.conf.internal_connect): FIXME
|
||||
self.internal_connection = True"""
|
||||
else: ipaddr = ''
|
||||
self.connectip = ipaddr
|
||||
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))
|
||||
return Milter.CONTINUE
|
||||
@Milter.noreply
|
||||
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')
|
||||
if self.receiver is not None:
|
||||
self.receiver = self.receiver.strip()
|
||||
try:
|
||||
self.AuthservID = milterconfig['AuthservID']
|
||||
except:
|
||||
self.AuthservID = self.receiver
|
||||
if hostaddr and len(hostaddr) > 0:
|
||||
ipaddr = hostaddr[0]
|
||||
if milterconfig['IntHosts']:
|
||||
if milterconfig['IntHosts'].match(ipaddr):
|
||||
self.internal_connection = True
|
||||
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') 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))
|
||||
self.fp = StringIO.StringIO()
|
||||
self.mailfrom = f
|
||||
t = parse_addr(f)
|
||||
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
|
||||
# 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') and milterconfig.get('debugLevel') >= 2:
|
||||
syslog.syslog("mail from: {0} {1}".format(f, str))
|
||||
self.fp = io.BytesIO()
|
||||
self.mailfrom = f
|
||||
t = parse_addr(f)
|
||||
if len(t) == 2:
|
||||
t[1] = t[1].lower()
|
||||
self.canon_from = '@'.join(t)
|
||||
self.has_dkim = 0
|
||||
self.author = None
|
||||
self.arheaders = []
|
||||
self.arresults = []
|
||||
return Milter.CONTINUE
|
||||
|
||||
@Milter.noreply
|
||||
def header(self,name,val):
|
||||
lname = name.lower()
|
||||
if lname == 'dkim-signature':
|
||||
if milterconfig.get('Syslog'):
|
||||
syslog.syslog("{0}: {1}".format(name,val))
|
||||
self.has_dkim += 1
|
||||
if lname == 'from':
|
||||
fname,self.author = parseaddr(val)
|
||||
self.fdomain = self.author.split('@')[1]
|
||||
if milterconfig.get('Syslog'):
|
||||
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))
|
||||
return Milter.CONTINUE
|
||||
@Milter.noreply
|
||||
def header(self, name, val):
|
||||
lname = name.lower()
|
||||
if lname == 'dkim-signature':
|
||||
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)
|
||||
try:
|
||||
self.fdomain = self.author.split('@')[1].lower()
|
||||
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(b"%s: %s\n" % (codecs.encode(name, 'ascii'), codecs.encode(val, 'ascii')))
|
||||
return Milter.CONTINUE
|
||||
|
||||
@Milter.noreply
|
||||
def eoh(self):
|
||||
if self.fp:
|
||||
self.fp.write("\n") # terminate headers
|
||||
self.bodysize = 0
|
||||
return Milter.CONTINUE
|
||||
@Milter.noreply
|
||||
def eoh(self):
|
||||
if self.fp:
|
||||
self.fp.write(b"\n") # terminate headers
|
||||
self.bodysize = 0
|
||||
return Milter.CONTINUE
|
||||
|
||||
@Milter.noreply
|
||||
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)
|
||||
return Milter.CONTINUE
|
||||
@Milter.noreply
|
||||
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)
|
||||
return Milter.CONTINUE
|
||||
|
||||
def eom(self):
|
||||
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):
|
||||
# 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))
|
||||
# Check or sign DKIM
|
||||
self.fp.seek(0)
|
||||
if (self.fdomain in milterconfig.get('Domain')) and (not milterconfig.get('Mode') == 'v'):
|
||||
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'):
|
||||
txt = self.fp.read()
|
||||
self.check_dkim(txt)
|
||||
else:
|
||||
result = 'none'
|
||||
if self.arresults:
|
||||
h = authres.AuthenticationResultsHeader(authserv_id = self.receiver,
|
||||
results=self.arresults)
|
||||
if milterconfig.get('Syslog'):
|
||||
syslog.syslog(str(h))
|
||||
name,val = str(h).split(': ',1)
|
||||
self.addheader(name,val,0)
|
||||
return Milter.CONTINUE
|
||||
def eom(self):
|
||||
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):
|
||||
# FIXME: don't delete A-R headers from trusted MTAs
|
||||
try:
|
||||
ar = (authres.AuthenticationResultsHeader
|
||||
.parse_value(FWS.sub('', val)))
|
||||
if ar.authserv_id == self.AuthservID:
|
||||
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
|
||||
pass
|
||||
# Check or sign DKIM
|
||||
self.fp.seek(0)
|
||||
if milterconfig.get('Domain'):
|
||||
domain = milterconfig.get('Domain')
|
||||
else:
|
||||
domain = ''
|
||||
if ((self.fdomain in domain) and not milterconfig.get('Mode') == 'v'
|
||||
and not self.external_connection):
|
||||
txt = self.fp.read()
|
||||
self.sign_dkim(txt)
|
||||
if ((self.has_dkim) and (not self.internal_connection) and
|
||||
(milterconfig.get('Mode') == 'v' or
|
||||
milterconfig.get('Mode') == 'sv')):
|
||||
txt = self.fp.read()
|
||||
self.check_dkim(txt)
|
||||
if self.arresults:
|
||||
h = authres.AuthenticationResultsHeader(authserv_id=
|
||||
self.AuthservID,
|
||||
results=self.arresults)
|
||||
h = fold(codecs.encode(str(h), 'ascii'))
|
||||
if (milterconfig.get('Syslog') and
|
||||
milterconfig.get('debugLevel') >= 2):
|
||||
syslog.syslog(codecs.decode(h, 'ascii'))
|
||||
name, val = codecs.decode(h, 'ascii').split(': ', 1)
|
||||
self.addheader(name, val, 0)
|
||||
return Milter.CONTINUE
|
||||
|
||||
def sign_dkim(self,txt):
|
||||
canon = milterconfig.get('Canonicalization')
|
||||
canonicalize = []
|
||||
if len(canon.split('/')) == 2:
|
||||
canonicalize.append(canon.split('/')[0])
|
||||
canonicalize.append(canon.split('/')[1])
|
||||
else:
|
||||
canonicalize.append(canon)
|
||||
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 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)
|
||||
except dkim.DKIMException as x:
|
||||
if milterconfig.get('Syslog'):
|
||||
syslog.syslog('DKIM: {0}'.format(x))
|
||||
except Exception as x:
|
||||
if milterconfig.get('Syslog'):
|
||||
syslog.syslog("sign_dkim: {0}".format(x))
|
||||
raise
|
||||
|
||||
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'))
|
||||
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)
|
||||
def sign_dkim(self, txt):
|
||||
canon = codecs.encode(milterconfig.get('Canonicalization'), 'ascii')
|
||||
canonicalize = []
|
||||
if len(canon.split(b'/')) == 2:
|
||||
canonicalize.append(canon.split(b'/')[0])
|
||||
canonicalize.append(canon.split(b'/')[1])
|
||||
else:
|
||||
canonicalize.append(canon)
|
||||
canonicalize.append(canon)
|
||||
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(codecs.encode(milterconfig.get('Selector'), 'ascii'), codecs.encode(self.fdomain, 'ascii'),
|
||||
codecs.encode(privateRSA, 'ascii'),
|
||||
canonicalize=(canonicalize[0],
|
||||
canonicalize[1]))
|
||||
name, val = h.split(b': ', 1)
|
||||
self.addheader(codecs.decode(name, 'ascii'), codecs.decode(val, 'ascii').strip().replace('\r\n', '\n'), 0)
|
||||
if (milterconfig.get('Syslog') and
|
||||
(milterconfig.get('SyslogSuccess')
|
||||
or milterconfig.get('debugLevel') >= 1)):
|
||||
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.lower()))
|
||||
if privateEd25519:
|
||||
d = dkim.DKIM(txt)
|
||||
h = d.sign(codecs.encode(milterconfig.get('SelectorEd25519'), 'ascii'), codecs.encode(self.fdomain, 'ascii'),
|
||||
privateEd25519, canonicalize=(canonicalize[0],
|
||||
canonicalize[1]),
|
||||
signature_algorithm=b'ed25519-sha256')
|
||||
name, val = h.split(b': ', 1)
|
||||
self.addheader(codecs.decode(name, 'ascii'), codecs.decode(val, 'ascii').strip().replace('\r\n', '\n'), 0)
|
||||
if (milterconfig.get('Syslog') and
|
||||
(milterconfig.get('SyslogSuccess')
|
||||
or milterconfig.get('debugLevel') >= 1)):
|
||||
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.lower()))
|
||||
except dkim.DKIMException as x:
|
||||
if milterconfig.get('Syslog'):
|
||||
syslog.syslog('DKIM: {0}'.format(x))
|
||||
except Exception as x:
|
||||
self.dkim_comment = str(x)
|
||||
except Exception as x:
|
||||
if milterconfig.get('Syslog'):
|
||||
syslog.syslog("check_dkim: {0}".format(x))
|
||||
self.header_i = d.signature_fields.get(b'i')
|
||||
self.header_d = d.signature_fields.get(b'd')
|
||||
self.header_a = d.signature_fields.get(b'a')
|
||||
if res:
|
||||
if milterconfig.get('Syslog'):
|
||||
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 res:
|
||||
result = 'pass'
|
||||
else:
|
||||
result = 'fail'
|
||||
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)
|
||||
)
|
||||
return
|
||||
syslog.syslog("sign_dkim: {0}".format(x))
|
||||
raise
|
||||
|
||||
def check_dkim(self, txt):
|
||||
res = False
|
||||
for y in range(self.has_dkim): # Verify _ALL_ the signatures
|
||||
d = dkim.DKIM(txt)
|
||||
try:
|
||||
dnsoverride = milterconfig.get('DNSOverride')
|
||||
if isinstance(dnsoverride, str):
|
||||
syslog.syslog("DNSOverride: {0}".format(dnsoverride))
|
||||
res = d.verify(idx=y, dnsfunc=lambda _x: dnsoverride)
|
||||
else:
|
||||
res = d.verify(idx=y)
|
||||
algo = codecs.decode(d.signature_fields.get(b'a'), 'ascii')
|
||||
if res:
|
||||
if algo == 'ed25519-sha256':
|
||||
self.dkim_comment = ('Good {0} signature'
|
||||
.format(algo))
|
||||
else:
|
||||
self.dkim_comment = ('Good {0} bit {1} signature'
|
||||
.format(d.keysize, algo))
|
||||
else:
|
||||
self.dkim_comment = ('Bad {0} bit {1} signature.'
|
||||
.format(d.keysize, algo))
|
||||
except dkim.DKIMException as x:
|
||||
self.dkim_comment = str(x)
|
||||
if milterconfig.get('Syslog'):
|
||||
syslog.syslog('DKIM: {0}'.format(x))
|
||||
except Exception as x:
|
||||
self.dkim_comment = str(x)
|
||||
if milterconfig.get('Syslog'):
|
||||
syslog.syslog("check_dkim: {0}".format(x))
|
||||
self.header_i = codecs.decode(d.signature_fields.get(b'i'), 'ascii')
|
||||
self.header_d = codecs.decode(d.signature_fields.get(b'd'), 'ascii')
|
||||
self.header_a = codecs.decode(d.signature_fields.get(b'a'), 'ascii')
|
||||
if res:
|
||||
if (milterconfig.get('Syslog') and
|
||||
(milterconfig.get('SyslogSuccess') or
|
||||
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.lower()))
|
||||
self.dkim_domain = d.domain.lower()
|
||||
else:
|
||||
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.lower()))
|
||||
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,
|
||||
result_comment=
|
||||
self.dkim_comment)
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
def main():
|
||||
# Ugh, but there's no easy way around this.
|
||||
@@ -253,15 +333,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)
|
||||
@@ -269,15 +350,28 @@ 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 socketname is None:
|
||||
if int(os.environ.get('LISTEN_PID', '0')) == os.getpid():
|
||||
lfds = os.environ.get('LISTEN_FDS')
|
||||
if lfds is not None:
|
||||
if lfds != '1':
|
||||
syslog.syslog('LISTEN_FDS is set to "{0}", but we only know how to deal with "1", ignoring it'.
|
||||
format(lfds))
|
||||
else:
|
||||
socketname = 'fd:3'
|
||||
if socketname is None:
|
||||
socketname = 'local:/var/run/dkimpy-milter/dkimpy-milter.sock'
|
||||
own_socketfile(milterconfig, socketname)
|
||||
drop_privileges(milterconfig)
|
||||
sys.stdout.flush()
|
||||
Milter.runmilter(miltername,socketname,240)
|
||||
Milter.runmilter(miltername, socketname, 240)
|
||||
if milterconfig.get('Syslog'):
|
||||
syslog.syslog('dkimpy-milter started:{0} user:{1}'
|
||||
.format(pid, milterconfig.get('UserID')))
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/python3
|
||||
|
||||
from dkimpy_milter import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+286
-59
@@ -27,42 +27,211 @@
|
||||
import syslog
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import urllib
|
||||
import stat
|
||||
import dkim
|
||||
|
||||
import socket
|
||||
import ipaddress
|
||||
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'
|
||||
}
|
||||
'Syslog': 'yes',
|
||||
'SyslogFacility': 'mail',
|
||||
'UMask': 0o07,
|
||||
'Mode': 'sv',
|
||||
'Socket': None,
|
||||
'PidFile': None,
|
||||
'UserID': 'dkimpy-milter',
|
||||
'Canonicalization': 'relaxed/simple',
|
||||
'InternalHosts': '127.0.0.1',
|
||||
'IntHosts': False,
|
||||
'DiagnosticDirectory': '',
|
||||
'MacroList': '',
|
||||
'MacroListVerify': '',
|
||||
'DNSOverride': None,
|
||||
'debugLevel': 0 # Undocumented config item for developer use
|
||||
}
|
||||
|
||||
|
||||
#################################
|
||||
class ConfigException(Exception):
|
||||
'''Exception raised when there's a configuration file error.'''
|
||||
pass
|
||||
|
||||
####################################################################
|
||||
def _processConfigFile(filename = None, configdata = None, useSyslog = 1,
|
||||
useStderr = 0):
|
||||
|
||||
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(str(self.item, "utf-8"))
|
||||
if isinstance(self.item, ipaddress.IPv4Address):
|
||||
self.isipv4 = True
|
||||
elif isinstance(self.item, ipaddress.IPv6Address):
|
||||
self.isipv6 = True
|
||||
except ValueError as e:
|
||||
try:
|
||||
self.item = ipaddress.ip_network(str
|
||||
(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(str(connectip, "utf-8"))
|
||||
for item in self.dataset:
|
||||
if item.isdomain or item.ishostname:
|
||||
result = self.matchname(source) # Match host/domains first
|
||||
if result:
|
||||
return(result)
|
||||
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):
|
||||
'''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 for exact match
|
||||
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(str(ip, 'UTF-8'))
|
||||
if ip == source:
|
||||
results.append(name)
|
||||
if isinstance(source, ipaddress.IPv6Address):
|
||||
ips = s.dns(name, 'AAAA')
|
||||
for ip in ips:
|
||||
ip = ipaddress.IPv6Address(str(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 _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:
|
||||
from . import config
|
||||
if configdata is None:
|
||||
configdata = config.defaultConfigData
|
||||
if filename is not None:
|
||||
try:
|
||||
_readConfigFile(filename, configdata)
|
||||
except Exception, e:
|
||||
except Exception as e:
|
||||
raise
|
||||
if useSyslog:
|
||||
syslog.syslog(e.args[0])
|
||||
@@ -71,7 +240,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)
|
||||
@@ -84,74 +253,121 @@ def _find_boolean(item):
|
||||
return item
|
||||
|
||||
|
||||
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."""
|
||||
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, which we dont support
|
||||
raise dkim.ParameterError('Unsupported dataset db datase: {0}'
|
||||
.format(type(dataset)))
|
||||
|
||||
###############################################################
|
||||
commentRx = re.compile(r'^(.*)#.*$')
|
||||
def _readConfigFile(path, configData = None, configGlobal = {}):
|
||||
raise dkim.ParameterError('Unimplmented dataset type: {0}'
|
||||
.format(type(dataset)))
|
||||
|
||||
|
||||
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',
|
||||
}
|
||||
'Canonicalization': 'str',
|
||||
'InternalHosts': 'dataset',
|
||||
'IntHosts': 'bool',
|
||||
'DiagnosticDirectory': 'str',
|
||||
'MacroList': 'dataset',
|
||||
'MacroListVerify': 'dataset',
|
||||
'DNSOverride': 'str',
|
||||
'debugLevel': 'int'
|
||||
}
|
||||
|
||||
# check to see if it's a file
|
||||
try:
|
||||
mode = os.stat(path)[0]
|
||||
except OSError, e:
|
||||
syslog.syslog(syslog.LOG_ERR,'ERROR stating "%s": %s' % ( path, e.strerror ))
|
||||
except OSError as e:
|
||||
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 = ''
|
||||
@@ -163,23 +379,34 @@ 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':
|
||||
configData[name] = str(value)
|
||||
if isinstance(value, list):
|
||||
configData[name] = line.split(None, 1)[1]
|
||||
else:
|
||||
configData[name] = str(value)
|
||||
elif conversion == 'int':
|
||||
configData[name] = int(value)
|
||||
elif conversion == 'dataset':
|
||||
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'] = _make_authserv_id(configData.get('AuthservID', 'HOSTNAME'))
|
||||
configData['IntHosts'] = 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 as x:
|
||||
raise DNS.DNSError('TCP Fallback error: ' + str(x))
|
||||
return [((a['name'], a['typename']), a['data']) for a in resp.answers]
|
||||
except IOError as 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))
|
||||
+106
-30
@@ -16,43 +16,81 @@
|
||||
# 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:]
|
||||
return pre + header
|
||||
|
||||
|
||||
def user_group(userid):
|
||||
"""Return user and group from UserID"""
|
||||
import grp
|
||||
import pwd
|
||||
import syslog
|
||||
|
||||
if os.getuid() != 0:
|
||||
if milterconfig.get('Syslog'):
|
||||
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(':')
|
||||
userlist = userid.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_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:
|
||||
if milterconfig.get('Syslog'):
|
||||
syslog.syslog('drop_privileges: Not root. No action taken.')
|
||||
return
|
||||
|
||||
# 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'))
|
||||
|
||||
#################
|
||||
|
||||
class ExceptHook:
|
||||
def __init__(self, useSyslog = 1, useStderr = 0):
|
||||
def __init__(self, useSyslog=1, useStderr=0):
|
||||
self.useSyslog = useSyslog
|
||||
self.useStderr = useStderr
|
||||
|
||||
@@ -68,33 +106,70 @@ 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
|
||||
import syslog
|
||||
if not os.path.isfile(milterconfig.get('PidFile')):
|
||||
pidfile = milterconfig.get('PidFile')
|
||||
if pidfile is None:
|
||||
return
|
||||
if not os.path.isfile(pidfile):
|
||||
pid = str(os.getpid())
|
||||
try:
|
||||
f = open(milterconfig.get('PidFile'), 'w')
|
||||
f = open(pidfile, 'w')
|
||||
except IOError as e:
|
||||
if 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 = pidfile.rsplit('/', 1)[0]
|
||||
os.mkdir(piddir)
|
||||
user, group = user_group(milterconfig.get('UserID'))
|
||||
os.chown(piddir, user, group)
|
||||
f = open(pidfile, 'w')
|
||||
if milterconfig.get('Syslog'):
|
||||
syslog.syslog('PID dir created: {0}'.format(piddir))
|
||||
else:
|
||||
if milterconfig.get('Syslog'):
|
||||
syslog.syslog('Unable to write pidfle {0}. IOError: {1}'
|
||||
.format(pidfile, e))
|
||||
raise
|
||||
f.write(pid)
|
||||
f.close()
|
||||
user, group = user_group(milterconfig.get('UserID'))
|
||||
os.chown(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(pidfile))
|
||||
raise RuntimeError('Unable to write pidfle {0}. File exists.'
|
||||
.format(pidfile))
|
||||
return pid
|
||||
|
||||
####################
|
||||
|
||||
def own_socketfile(milterconfig, sockname=None):
|
||||
"""If socket is Unix socket, chown to UserID before dropping privileges"""
|
||||
import os
|
||||
user, group = user_group(milterconfig.get('UserID'))
|
||||
offset = None
|
||||
if sockname is None:
|
||||
sockname = milterconfig.get('Socket')
|
||||
if sockname is None:
|
||||
return
|
||||
if sockname[:1] == '/':
|
||||
offset = 0
|
||||
elif sockname[:6] == "local:":
|
||||
offset = 6
|
||||
elif sockname[:5] == "unix:":
|
||||
offset = 5
|
||||
|
||||
if offset is not None:
|
||||
if os.path.exists(sockname[offset:]):
|
||||
os.chown(sockname[offset:], user, group)
|
||||
|
||||
|
||||
def read_keyfile(milterconfig, keytype):
|
||||
"""Read private key from file."""
|
||||
import syslog
|
||||
@@ -107,7 +182,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 = ''
|
||||
|
||||
@@ -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
|
||||
|
||||
+9
-18
@@ -127,7 +127,6 @@
|
||||
.rm #[ #] #H #V #F C
|
||||
.\" ========================================================================
|
||||
.\"
|
||||
.IX Title "dkimpy-milter 8"
|
||||
.TH dkimpyy-milter 8
|
||||
.SH NAME
|
||||
.B dkimpy
|
||||
@@ -156,7 +155,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 +168,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 +185,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 +268,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 1.1.0 of
|
||||
.I dkimpy-milter.
|
||||
.SH COPYRIGHT
|
||||
Copyright (c) 2005-2008, Sendmail, Inc. and its suppliers. All rights
|
||||
@@ -288,7 +277,7 @@ reserved.
|
||||
Copyright (c) 2009-2013, 2015, The Trusted Domain Project.
|
||||
All rights reserved.
|
||||
|
||||
Copyright (c) 2018 Scott Kitterman <scott@kitterman.com>
|
||||
Copyright (c) 2018, 2019 Scott Kitterman <scott@kitterman.com>
|
||||
.SH SEE ALSO
|
||||
.I dkimpy-milter.conf(5), sendmail(8)
|
||||
.P
|
||||
@@ -302,4 +291,6 @@ RFC6376 - DomainKeys Identified Mail
|
||||
.P
|
||||
RFC7601 - Message Header Field for Indicating Message Authentication Status
|
||||
.P
|
||||
draft-ietf-dcrup-dkim-crypto - A new cryptographic signature method for DKIM
|
||||
RFC8301 - Cryptographic Algorithm and Key Usage Update to DomainKeys Identified Mail (DKIM)
|
||||
.P
|
||||
RFC8463 - A New Cryptographic Signature Method for DomainKeys Identified Mail (DKIM)
|
||||
|
||||
+50
-21
@@ -127,16 +127,13 @@
|
||||
.rm #[ #] #H #V #F C
|
||||
.\" ========================================================================
|
||||
.\"
|
||||
.IX Title "dkimpy-milter.conf 5"
|
||||
.TH dkimpy-milter.conf 5 "2018-02-12"
|
||||
.SH "NAME"
|
||||
dkimpy-milter \- Python milter for DKIM signing and validation
|
||||
.SH "VERSION"
|
||||
.IX Header "VERSION"
|
||||
0\.9\.2
|
||||
|
||||
.SH "DESCRIPTION"
|
||||
.IX Header "DESCRIPTION"
|
||||
.I dkimpy-milter(8)
|
||||
implements the
|
||||
.B DKIM
|
||||
@@ -160,18 +157,15 @@ The provided setup.py installs this configuration file in /etc or
|
||||
Command line invocation of parameters as is done by OpenDKIM is not supported.
|
||||
|
||||
.SH "USAGE"
|
||||
.IX Header "USAGE"
|
||||
Usage:
|
||||
dkimpy-milter [/etc/dkimpy-milter.conf]
|
||||
|
||||
.SH "OTHER DOCUMENTATION"
|
||||
.IX Header "OTHER DOCUMENTATION"
|
||||
This documentation assumes you have read Postfix's README_FILES/MILTER_README
|
||||
(or Sendmail equivalent) and are generally familiar with Domain Keys Identified
|
||||
Mail (DKIM). See RFC 6376 for details.
|
||||
|
||||
.SH "SYNOPSIS"
|
||||
.IX Header "SYNOPSIS"
|
||||
|
||||
dkimpy-milter operates with a default installed configuration file and
|
||||
set of default configuration options that are used if the configuration file
|
||||
@@ -181,14 +175,12 @@ files can be used directly. Not all OpenDKIM options are supported. If an
|
||||
unsupported option from OpenDKIM is specified, an error will be raised.
|
||||
|
||||
.SH "DESCRIPTION"
|
||||
.IX Header "DESCRIPTION"
|
||||
|
||||
Configuration options are described here and in the configuration file
|
||||
provided with the package. The provided setup.py installs this configuration
|
||||
file in /etc or /usr/local/etc.
|
||||
|
||||
.SH "OPTIONS"
|
||||
.IX Header "OPTIONS"
|
||||
|
||||
.TP
|
||||
.I AuthservID (string)
|
||||
@@ -215,18 +207,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)
|
||||
@@ -261,13 +247,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
|
||||
@@ -288,6 +311,13 @@ be set:
|
||||
(b) KeyTable, SigningTable, no Domain, no KeyFile, no Selector;
|
||||
[fooTable options NOT IMPLEMENTED]
|
||||
|
||||
.TP
|
||||
.I DNSOverride (string)
|
||||
Provide a text string that a verifying milter should use instead of
|
||||
consulting the DNS on each message. This is useful primarily for
|
||||
testing purposes in environments where it is awkward to modify the
|
||||
system DNS resolution. It should not be used in production.
|
||||
|
||||
.TP
|
||||
.I PeerList (dataset)
|
||||
Identifies a set of "peers" that identifies clients whose connections
|
||||
@@ -308,7 +338,7 @@ will be checked. [PeerList NOT IMPLEMENTED - included for reference only]
|
||||
.TP
|
||||
.I PidFile (string)
|
||||
Specifies the path to a file that should be created at process start
|
||||
containing the process ID.
|
||||
containing the process ID. If not specified, no such file will be created.
|
||||
|
||||
.TP
|
||||
.I Selector (string)
|
||||
@@ -374,7 +404,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)
|
||||
@@ -410,7 +440,6 @@ unless an alternate
|
||||
is specified.
|
||||
|
||||
.SH "AUTHORS"
|
||||
.IX Header "AUTHORS"
|
||||
\ddkimpy-milter\fR was written by Scott Kitterman <scott@kitterman.com>.
|
||||
It is based on dkimpy-milter.py Copyright (c) 2001-2013 Business Management Systems, Inc.
|
||||
Copyright (c) 2013-2015 Stuart D. Gathman
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#! /usr/bin/python
|
||||
#! /usr/bin/python3
|
||||
# dkimpy-milter: A DKIM signing/verification Milter application
|
||||
# Author: Scott Kitterman <scott@kitterman.com>
|
||||
# Copyright 2018 Scott Kitterman
|
||||
# Copyright 2018,2019 Scott Kitterman
|
||||
""" This program is free software; you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 2 of the License, or
|
||||
@@ -18,26 +18,32 @@
|
||||
|
||||
from setuptools import setup
|
||||
import os
|
||||
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', 'dnspython']
|
||||
except ImportError: # If PyDNS is not installed, prefer dnspython
|
||||
kw['install_requires'] = ['dkimpy>=0.7', 'pymilter', 'authres>=1.1.0', 'PyNaCl', 'PyDNS']
|
||||
|
||||
setup(
|
||||
name='dkimpy-milter',
|
||||
version=dkimpy_milter.__version__,
|
||||
version='1.1.0',
|
||||
author='Scott Kitterman',
|
||||
author_email='scott@kitterman.com',
|
||||
url='https://launchpad.net/dkimpy-milter',
|
||||
description=description,
|
||||
download_url = "https://pypi.python.org/pypi/dkimpy-milter",
|
||||
classifiers= [
|
||||
'Development Status :: 3 - Alpha',
|
||||
'Development Status :: 5 - Production/Stable',
|
||||
'Environment :: No Input/Output (Daemon)',
|
||||
'Intended Audience :: System Administrators',
|
||||
'License :: OSI Approved :: GNU General Public License (GPL)',
|
||||
'Natural Language :: English',
|
||||
'Operating System :: POSIX',
|
||||
'Programming Language :: Python :: 2 :: Only',
|
||||
'Programming Language :: Python :: 3 :: Only',
|
||||
'Topic :: Communications :: Email :: Mail Transport Agents',
|
||||
'Topic :: Communications :: Email :: Filters',
|
||||
'Topic :: Security',
|
||||
@@ -52,9 +58,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.0.2', 'PyNaCl'],
|
||||
zip_safe = False,
|
||||
**kw
|
||||
)
|
||||
|
||||
Regular → Executable
+3
-5
@@ -13,8 +13,6 @@
|
||||
# Provides: dkim-milter dkim-milter-python dkimpy-milter
|
||||
# Required-Start: $remote_fs $syslog $network $time
|
||||
# Required-Stop: $remote_fs $syslog $network
|
||||
# Should-Start:
|
||||
# Should-Stop:
|
||||
# Default-Start: 2 3 4 5
|
||||
# Default-Stop: 0 1 6
|
||||
# Short-Description: dkimpy-milter
|
||||
@@ -31,7 +29,7 @@ NAME=dkimpy-milter
|
||||
DESC="Python DKIM Milter"
|
||||
USER=dkimpy-milter
|
||||
GROUP=dkimpy-milter
|
||||
SOCKET=$RUNDIR/dkimpy-milter.pid
|
||||
SOCKET=$RUNDIR/dkimpy-milter.sock
|
||||
|
||||
test -x $DAEMON || exit 0
|
||||
|
||||
@@ -70,8 +68,8 @@ case "$1" in
|
||||
fi
|
||||
fi
|
||||
|
||||
start-stop-daemon --start --chuid $USER --background --quiet --pidfile \
|
||||
$RUNDIR/$NAME.pid --exec $DAEMON $sysconfdir/$NAME.conf
|
||||
start-stop-daemon --start --quiet --pidfile $RUNDIR/$NAME.pid --startas \
|
||||
$DAEMON $sysconfdir/$NAME.conf --name $NAME --test > /dev/null \
|
||||
echo "$NAME."
|
||||
;;
|
||||
stop)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
[Unit]
|
||||
Description=DKIMpy Milter
|
||||
After=syslog.target network.target
|
||||
Documentation=man:dkimpy-milter(8) man:dkimpy-milter.conf(5)
|
||||
After=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
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
This directory contains example systemd unit files for running a
|
||||
supervised, socket-activated instance of dkimpy-milter.
|
||||
|
||||
There are several advantages of using socket activation:
|
||||
|
||||
- dkimpy-milter never runs with elevated privileges, they are dropped
|
||||
before any dkimpy-milter code is executed.
|
||||
|
||||
- The socket is opened before dkimpy-milter runs. This means that
|
||||
clients can connect() to the socket immediately. So even if there
|
||||
is a delay in dkimpy-milter startup, or in libmilter itself, the
|
||||
connection will not fail.
|
||||
|
||||
- You can set the privileges of a listening Unix-domain socket by an
|
||||
override of ListenGroup= in dkimpy-milter.socket (see
|
||||
systemd.unit(5) for how to override). This lets you control who has
|
||||
access to the daemon with finer granularity than is available with
|
||||
dkimpy-milter on its own.
|
||||
|
||||
- dkimpy-milter will not consume system resources if it is not used.
|
||||
|
||||
- A fully-supervised dkimpy-milter needs no PIDFile, UMask, UserID, or
|
||||
Socket configuation. This eliminates common race conditions and
|
||||
startup failures, and simplifies the resulting configuration file.
|
||||
|
||||
There is one downside to using socket activation:
|
||||
|
||||
- it will only work on systems where libmilter can support connection
|
||||
strings like "fd:3". This has been supported on Debian and derived
|
||||
systems since sendmail 8.14.4-6 (before Debian Jessie, in early
|
||||
2014), see for example:
|
||||
https://sources.debian.org/src/sendmail/8.15.2-8/debian/patches/socket_activation.patch/
|
||||
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=DKIMpy Milter
|
||||
Documentation=man:dkimpy-milter(8) man:dkimpy-milter.conf(5)
|
||||
Requires=dkimpy-milter.socket
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/bin/dkimpy-milter /etc/dkimpy-milter.conf
|
||||
User=dkimpy-milter
|
||||
|
||||
[Install]
|
||||
Also=dkimpy-milter.socket
|
||||
@@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=DKIMpy Milter socket
|
||||
Documentation=man:dkimpy-milter(8) man:dkimpy-milter.conf(5)
|
||||
|
||||
[Socket]
|
||||
ListenStream=/run/dkimpy-milter/dkimpy-milter.sock
|
||||
SocketMode=0660
|
||||
# override SocketGroup to grant access to members of another system group:
|
||||
SocketGroup=dkimpy-milter
|
||||
|
||||
[Install]
|
||||
WantedBy=sockets.target
|
||||
@@ -0,0 +1,12 @@
|
||||
-- -*- lua -*-
|
||||
for _, keytype in ipairs({"ed25519", "rsa"}) do
|
||||
for _, func in ipairs({"signing", "verify"}) do
|
||||
mt.echo("testing "..keytype.." "..func)
|
||||
conn = mt.connect("unix:"..keytype.."."..func..".sock")
|
||||
if conn == nil then
|
||||
error("mt.connect() failed "..keytype.." "..func)
|
||||
end
|
||||
mt.disconnect(conn)
|
||||
mt.echo(keytype.." "..func.." complete")
|
||||
end
|
||||
end
|
||||
Executable
+40
@@ -0,0 +1,40 @@
|
||||
-- -*- lua -*-
|
||||
for _, keytype in ipairs({"ed25519", "rsa"}) do
|
||||
for _, func in ipairs({"signing", "verify"}) do
|
||||
mt.echo("testing "..keytype.." "..func)
|
||||
conn = mt.connect("unix:"..keytype.."."..func..".sock")
|
||||
if conn == nil then
|
||||
error("mt.connect() failed "..keytype.." "..func)
|
||||
end
|
||||
if mt.conninfo(conn, "localhost", "127.0.0.1") ~= nil then
|
||||
error("mt.conninfo() failed "..keytype.." "..func)
|
||||
end
|
||||
if mt.getreply(conn) ~= SMFIR_CONTINUE then
|
||||
error("mt.conninfo() unexpected reply "..keytype.." "..func)
|
||||
end
|
||||
|
||||
if mt.test_action(conn, SMFIF_ADDHDRS) then
|
||||
print("could add headers "..keytype.." "..func)
|
||||
else
|
||||
error("mt.test_action() says could not add headers "..keytype.." "..func)
|
||||
end
|
||||
|
||||
if mt.test_action(conn, SMFIF_CHGHDRS) then
|
||||
print("could change headers "..keytype.." "..func)
|
||||
else
|
||||
error("mt.test_action() says could not change headers "..keytype.." "..func)
|
||||
end
|
||||
|
||||
-- -- FIXME: this part of the test fails, as apparently the
|
||||
-- -- dkimpy-milter claims the right to change the body of a message,
|
||||
-- -- even though it shouldn't. How can we fix the negotiation?
|
||||
-- if mt.test_action(conn, SMFIF_CHGBODY) then
|
||||
-- error("mt.test_action() says could change body "..keytype.." "..func)
|
||||
-- else
|
||||
-- print("could not change body "..keytype.." "..func)
|
||||
-- end
|
||||
|
||||
mt.disconnect(conn)
|
||||
mt.echo(keytype.." "..func.." test complete")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,100 @@
|
||||
-- -*- lua -*-
|
||||
|
||||
msg = {
|
||||
['headers'] = {
|
||||
['From'] = 'Alice <alice@example.net>',
|
||||
['Message-Id'] = '<dkimpy-milter-test-02@example.net>',
|
||||
['To'] = 'Bob <bob@example.biz>',
|
||||
['Date'] = 'Mon, 18 Feb 2019 08:32:50 -0500',
|
||||
['Subject'] = 'Signing test',
|
||||
['Content-Type'] = 'text/plain',
|
||||
},
|
||||
['body'] = "This is a test!\r\n",
|
||||
}
|
||||
|
||||
-- returns miltertest connection object
|
||||
function connect_and_send (sockname, headers, body)
|
||||
conn = mt.connect(sockname)
|
||||
if conn == nil then
|
||||
error "mt.connect() failed"
|
||||
end
|
||||
if mt.conninfo(conn, "localhost", "127.0.0.1") ~= nil then
|
||||
error "mt.conninfo() failed"
|
||||
end
|
||||
if mt.getreply(conn) ~= SMFIR_CONTINUE then
|
||||
error "mt.conninfo() unexpected reply"
|
||||
end
|
||||
|
||||
-- mt.macro(conn, SMFIC_MAIL, "i", "simple-message")
|
||||
if mt.mailfrom(conn, "<alice@example.net>") ~= nil then
|
||||
error "mt.mailfrom() failed"
|
||||
end
|
||||
if mt.getreply(conn) ~= SMFIR_CONTINUE then
|
||||
error "mt.mailfrom() unexpected reply"
|
||||
end
|
||||
-- mt.rcptto() is called implicitly
|
||||
|
||||
-- send headers
|
||||
for key,value in pairs(headers) do
|
||||
if mt.header(conn, key, value) ~= nil then
|
||||
error("mt.header(" .. key .. ") failed")
|
||||
end
|
||||
if mt.getreply(conn) ~= SMFIR_CONTINUE then
|
||||
error("mt.header(" .. key .. ") unexpected reply")
|
||||
end
|
||||
end
|
||||
-- send EOH
|
||||
if mt.eoh(conn) ~= nil then
|
||||
error "mt.eoh() failed"
|
||||
end
|
||||
if mt.getreply(conn) ~= SMFIR_CONTINUE then
|
||||
error "mt.eoh() unexpected reply"
|
||||
end
|
||||
|
||||
-- send body
|
||||
if mt.bodystring(conn, body) ~= nil then
|
||||
error "mt.bodystring() failed"
|
||||
end
|
||||
if mt.getreply(conn) ~= SMFIR_CONTINUE then
|
||||
error "mt.bodystring() unexpected reply"
|
||||
end
|
||||
-- end of message; let the filter react
|
||||
if mt.eom(conn) ~= nil then
|
||||
error "mt.eom() failed"
|
||||
end
|
||||
reply = mt.getreply(conn)
|
||||
if reply ~= SMFIR_CONTINUE then
|
||||
error ("mt.eom() unexpected reply: " .. reply)
|
||||
end
|
||||
return conn
|
||||
end
|
||||
|
||||
for _, keytype in ipairs({"ed25519", "rsa"}) do
|
||||
mt.echo("testing "..keytype)
|
||||
signing = connect_and_send("unix:"..keytype..".signing.sock", msg.headers, msg.body)
|
||||
-- verify that a test header field got added
|
||||
if not mt.eom_check(signing, MT_HDRINSERT) then
|
||||
error "no header added by signer"
|
||||
end
|
||||
|
||||
signature = mt.getheader(signing, "DKIM-Signature", 0)
|
||||
|
||||
mt.disconnect(signing)
|
||||
|
||||
mt.echo("DKIM-Signature: " .. signature)
|
||||
|
||||
msg.headers['DKIM-Signature'] = signature
|
||||
|
||||
verify = connect_and_send("unix:"..keytype..".verify.sock", msg.headers, msg.body)
|
||||
|
||||
if not mt.eom_check(verify, MT_HDRINSERT) then
|
||||
error "no header added in verify"
|
||||
end
|
||||
|
||||
authres = mt.getheader(verify, "Authentication-Results", 0)
|
||||
mt.echo("Authentication-Results: "..authres)
|
||||
|
||||
mt.disconnect(verify)
|
||||
|
||||
mt.echo(keytype.." complete")
|
||||
end
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
python3 -m dkimpy_milter "$@"
|
||||
Executable
+84
@@ -0,0 +1,84 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
WORKDIR=$(mktemp -d)
|
||||
TESTDIR=$(realpath "$(dirname "$0")")
|
||||
DKIMPY_MILTER=${DKIMPY_MILTER:-"$TESTDIR/dkimpy-milter"}
|
||||
KEY_TYPES=(ed25519 rsa)
|
||||
|
||||
cd "$WORKDIR"
|
||||
|
||||
printf "Testing %s from directory %s\n" "$DKIMPY_MILTER" "$WORKDIR"
|
||||
|
||||
for keytype in "${KEY_TYPES[@]}"; do
|
||||
dknewkey --ktype "$keytype" "testkey.$keytype"
|
||||
if [ "$keytype" = ed25519 ]; then
|
||||
keyfile=KeyFileEd25519
|
||||
selector=SelectorEd25519
|
||||
else
|
||||
keyfile=KeyFile
|
||||
selector=Selector
|
||||
fi
|
||||
cat > "$keytype.signing.conf" <<EOF
|
||||
Domain example.net
|
||||
$keyfile testkey.$keytype.key
|
||||
$selector testkey
|
||||
Socket unix:$keytype.signing.sock
|
||||
PidFile $keytype.signing.pid
|
||||
Mode s
|
||||
UserID $(id --name --user):$(id --name --group)
|
||||
EOF
|
||||
|
||||
cat > "$keytype.verify.conf" <<EOF
|
||||
Socket unix:$keytype.verify.sock
|
||||
PidFile $keytype.verify.pid
|
||||
Mode v
|
||||
DNSOverride $(cat testkey.$keytype.dns)
|
||||
UserID $(id --name --user):$(id --name --group)
|
||||
EOF
|
||||
done
|
||||
|
||||
cleanup() {
|
||||
echo cleaning up jobs:
|
||||
jobs
|
||||
for keytype in "${KEY_TYPES[@]}"; do
|
||||
for func in signing verify; do
|
||||
if [ -s "$keytype.$func.pid" ] && kill -0 "$(cat "$keytype.$func.pid")"; then
|
||||
kill "$(cat $keytype.$func.pid)"
|
||||
fi
|
||||
done
|
||||
done
|
||||
wait
|
||||
for keytype in "${KEY_TYPES[@]}"; do
|
||||
for func in signing verify; do
|
||||
errdata="$keytype.$func.stderr"
|
||||
if [ -s "$errdata" ]; then
|
||||
printf -- "-> %s:\n" "$errdata"
|
||||
cat "$errdata"
|
||||
printf -- "-> end %s\n" "$errdata"
|
||||
fi
|
||||
done
|
||||
done
|
||||
rm -rf "$WORKDIR"
|
||||
}
|
||||
|
||||
for keytype in "${KEY_TYPES[@]}"; do
|
||||
for func in signing verify; do
|
||||
PYTHONPATH="$(dirname "$TESTDIR")" "$DKIMPY_MILTER" "$keytype.$func.conf" 2>"$keytype.$func.stderr" &
|
||||
done
|
||||
done
|
||||
trap cleanup EXIT
|
||||
|
||||
# ugly ugly (how are we supposed to know that the milters are all ready?):
|
||||
sleep 2
|
||||
|
||||
# uses miltertest from opendkim:
|
||||
for x in ${TESTS:-"$TESTDIR"/*.miltertest}; do
|
||||
if ! [ -e "$x" ]; then
|
||||
if [ -e "$TESTDIR/$x" ]; then
|
||||
x="$TESTDIR/$x"
|
||||
fi
|
||||
fi
|
||||
printf -- "-> running %s...\n" "$x"
|
||||
miltertest -s "$x"
|
||||
done
|
||||
Reference in New Issue
Block a user