diff --git a/.github/workflows/test-and-deploy-ipv4only.yaml b/.github/workflows/test-and-deploy-ipv4only.yaml
index f6266dfc..b9e7407b 100644
--- a/.github/workflows/test-and-deploy-ipv4only.yaml
+++ b/.github/workflows/test-and-deploy-ipv4only.yaml
@@ -80,7 +80,7 @@ jobs:
- name: set DNS entries
run: |
- ssh -o StrictHostKeyChecking=accept-new -v root@staging-ipv4.testrun.org chown opendkim:opendkim -R /etc/dkimkeys
+ ssh -o StrictHostKeyChecking=accept-new -v root@staging-ipv4.testrun.org chown dkim-milter:dkim-milter -R /etc/dkimkeys
cmdeploy dns --zonefile staging-generated.zone
cat staging-generated.zone >> .github/workflows/staging-ipv4.testrun.org-default.zone
cat .github/workflows/staging-ipv4.testrun.org-default.zone
diff --git a/.github/workflows/test-and-deploy.yaml b/.github/workflows/test-and-deploy.yaml
index d38a8869..dd326cb2 100644
--- a/.github/workflows/test-and-deploy.yaml
+++ b/.github/workflows/test-and-deploy.yaml
@@ -82,7 +82,7 @@ jobs:
- name: set DNS entries
run: |
- ssh -o StrictHostKeyChecking=accept-new root@staging2.testrun.org chown opendkim:opendkim -R /etc/dkimkeys
+ ssh -o StrictHostKeyChecking=accept-new root@staging2.testrun.org chown dkim-milter:dkim-milter -R /etc/dkimkeys
cmdeploy dns --zonefile staging-generated.zone --verbose
cat staging-generated.zone >> .github/workflows/staging.testrun.org-default.zone
cat .github/workflows/staging.testrun.org-default.zone
diff --git a/cmdeploy/src/cmdeploy/deployers.py b/cmdeploy/src/cmdeploy/deployers.py
index 0314528e..8be4c001 100644
--- a/cmdeploy/src/cmdeploy/deployers.py
+++ b/cmdeploy/src/cmdeploy/deployers.py
@@ -25,11 +25,11 @@ from .basedeploy import (
configure_remote_units,
get_resource,
)
+from .dkim_milter.deployer import DkimMilterDeployer
from .dovecot.deployer import DovecotDeployer
from .filtermail.deployer import FiltermailDeployer
from .mtail.deployer import MtailDeployer
from .nginx.deployer import NginxDeployer
-from .opendkim.deployer import OpendkimDeployer
from .postfix.deployer import PostfixDeployer
from .www import build_webpages, find_merge_conflict, get_paths
@@ -572,7 +572,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
WebsiteDeployer(config),
ChatmailVenvDeployer(config),
MtastsDeployer(),
- OpendkimDeployer(mail_domain),
+ DkimMilterDeployer(mail_domain),
# Dovecot should be started before Postfix
# because it creates authentication socket
# required by Postfix.
diff --git a/cmdeploy/src/cmdeploy/dkim_milter/deployer.py b/cmdeploy/src/cmdeploy/dkim_milter/deployer.py
new file mode 100644
index 00000000..17c5ec5a
--- /dev/null
+++ b/cmdeploy/src/cmdeploy/dkim_milter/deployer.py
@@ -0,0 +1,169 @@
+"""
+Installs DKIM Milter.
+"""
+
+from pyinfra import facts, host
+from pyinfra.facts.files import File, Sha256File
+from pyinfra.operations import apt, files, server, systemd
+
+from cmdeploy.basedeploy import Deployer, get_resource
+
+
+class DkimMilterDeployer(Deployer):
+ required_users = [("dkim-milter", None, ["dkim-milter"])]
+
+ def __init__(self, mail_domain):
+ self.mail_domain = mail_domain
+ self.need_restart = False
+
+ def install(self):
+ """Builds and installs dkim-milter"""
+
+ # openssl is required to generate the signing key
+ apt.packages(
+ name="Install openssl required by DKIM Milter",
+ packages=["openssl"],
+ )
+
+ (url, sha256sum) = {
+ "x86_64": (
+ "https://github.com/chatmail/dkim-milter/releases/download/0.1.0/dkim-milter-x86_64",
+ "e676837b362ebef461881079e3e1151ed2db2d942d98b7103974921ac69ce5de",
+ ),
+ "aarch64": (
+ "https://github.com/chatmail/dkim-milter/releases/download/0.1.0/dkim-milter-aarch64",
+ "b853ab85a535b7e7e548ae0e4d85a61d4c0fd44f2912c3439662c56ca8a369e6",
+ ),
+ }[host.get_fact(facts.server.Arch)]
+
+ existing_sha256sum = host.get_fact(Sha256File, "/usr/local/sbin/dkim-milter")
+ if existing_sha256sum != sha256sum:
+ server.shell(
+ name="Download DKIM Milter",
+ commands=[
+ f"(curl -L {url} >/usr/local/sbin/dkim-milter.new && (echo '{sha256sum} /usr/local/sbin/dkim-milter.new' | sha256sum -c) && mv /usr/local/sbin/dkim-milter.new /usr/local/sbin/dkim-milter)",
+ "chmod 755 /usr/local/sbin/dkim-milter",
+ ],
+ )
+ self.need_restart = True
+
+ def configure(self):
+ """Configures dkim-milter"""
+
+ domain = self.mail_domain
+ # note - we are using "opendkim" for backward compatibility
+ # for relays that were set up before we migrated from OpenDKIM
+ # to DKIM Milter.
+ selector = "opendkim"
+ signing_key_name = selector
+ # for backward compatibility with opendkim-genkey
+ signing_key_filename = f"{signing_key_name}.private"
+ config_common = {
+ "domain": domain,
+ "selector": selector,
+ "signing_key_name": signing_key_name,
+ "signing_key_filename": signing_key_filename,
+ }
+ config_verify = {
+ **config_common,
+ "mode": "verify",
+ "config_file": "/etc/dkim-milter/dkim-milter-verify.conf",
+ "socket_name": "dkim-milter-verify.sock",
+ }
+ config_sign = {
+ **config_common,
+ "mode": "sign",
+ "config_file": "/etc/dkim-milter/dkim-milter-sign.conf",
+ "socket_name": "dkim-milter-sign.sock",
+ }
+
+ self.need_restart |= files.directory(
+ name="Create a directory for DKIM Milter configs",
+ path="/etc/dkim-milter",
+ user="dkim-milter",
+ group="dkim-milter",
+ mode="750",
+ present=True,
+ ).changed
+
+ for config in [config_verify, config_sign]:
+ self.need_restart |= files.template(
+ src=get_resource("dkim_milter/dkim-milter.conf.j2"),
+ dest=config["config_file"],
+ user="dkim-milter",
+ group="dkim-milter",
+ mode="644",
+ config=config,
+ ).changed
+
+ self.need_restart |= files.directory(
+ name="Create dkimkeys directory",
+ path="/etc/dkimkeys",
+ user="dkim-milter",
+ group="dkim-milter",
+ mode="750",
+ present=True,
+ ).changed
+
+ self.need_restart |= files.template(
+ src=get_resource("dkim_milter/signing-keys"),
+ dest="/etc/dkim-milter/signing-keys",
+ user="dkim-milter",
+ group="dkim-milter",
+ mode="644",
+ config=config_common,
+ ).changed
+
+ self.need_restart |= files.template(
+ src=get_resource("dkim_milter/signing-senders"),
+ dest="/etc/dkim-milter/signing-senders",
+ user="dkim-milter",
+ group="dkim-milter",
+ mode="644",
+ config=config_common,
+ ).changed
+
+ self.need_restart |= files.directory(
+ name="Create DKIM Milter unix sockets directory",
+ path="/var/spool/postfix/dkim-milter",
+ user="dkim-milter",
+ group="dkim-milter",
+ mode="770",
+ ).changed
+
+ if not host.get_fact(File, f"/etc/dkimkeys/{signing_key_filename}"):
+ server.shell(
+ name=f"Generate DKIM Milter signing key '{signing_key_name}'",
+ commands=[
+ f"openssl genpkey -algorithm RSA -out /etc/dkimkeys/{signing_key_filename}"
+ ],
+ )
+ self.need_restart = True
+
+ # enforce restrictive permissions for the signing key
+ self.need_restart |= files.file(
+ path=f"/etc/dkimkeys/{signing_key_filename}",
+ present=True,
+ user="dkim-milter",
+ group="dkim-milter",
+ mode="0400",
+ ).changed
+
+ self.need_restart |= files.put(
+ name="Create dkim-milter service",
+ src=get_resource("dkim_milter/dkim-milter@.service"),
+ dest=f"/etc/systemd/system/dkim-milter@.service",
+ ).changed
+
+ def activate(self):
+ """Start and enable DKIM Milter"""
+ for mode in ["sign", "verify"]:
+ systemd.service(
+ name=f"Start and enable DKIM Milter in {mode} mode",
+ service=f"dkim-milter@{mode}",
+ running=True,
+ enabled=True,
+ daemon_reload=self.need_restart,
+ restarted=self.need_restart,
+ )
+ self.need_restart = False
diff --git a/cmdeploy/src/cmdeploy/dkim_milter/dkim-milter.conf.j2 b/cmdeploy/src/cmdeploy/dkim_milter/dkim-milter.conf.j2
new file mode 100644
index 00000000..44d65802
--- /dev/null
+++ b/cmdeploy/src/cmdeploy/dkim_milter/dkim-milter.conf.j2
@@ -0,0 +1,30 @@
+mode = {{ config.mode }}
+
+{% if config.mode == "verify" %}
+# DKIM milter will skip verification for trusted sources,
+# which in our case is everything, since we run DKIM milter on a reinjection port,
+# and all connections are local.
+# We force verification for local connections by not trusting anyone.
+trusted_networks =
+{% endif %}
+
+log_destination = syslog
+log_level = info
+
+canonicalization = relaxed/simple
+
+lookup_timeout = 60s
+
+signing_keys = /etc/dkim-milter/signing-keys
+signing_senders = /etc/dkim-milter/signing-senders
+
+# Signing
+sign_headers = default; autocrypt:content-type
+oversign_headers = signed-extended
+
+# Verification
+required_signed_headers = From*
+forbid_unsigned_content = yes
+reject_failures = missing, no-pass, author-mismatch
+
+socket = unix:/var/spool/postfix/dkim-milter/{{ config.socket_name }}
diff --git a/cmdeploy/src/cmdeploy/dkim_milter/dkim-milter@.service b/cmdeploy/src/cmdeploy/dkim_milter/dkim-milter@.service
new file mode 100644
index 00000000..67b55289
--- /dev/null
+++ b/cmdeploy/src/cmdeploy/dkim_milter/dkim-milter@.service
@@ -0,0 +1,15 @@
+[Unit]
+Description=DKIM Milter %i
+Documentation=man:dkim-milter(8) man:dkim-milter.conf(5)
+After=network-online.target nss-lookup.target
+Wants=network-online.target
+
+[Service]
+User=dkim-milter
+UMask=007
+ExecStart=/usr/local/sbin/dkim-milter -c /etc/dkim-milter/dkim-milter-%i.conf
+ExecReload=/bin/kill -HUP $MAINPID
+Restart=on-failure
+
+[Install]
+WantedBy=multi-user.target
diff --git a/cmdeploy/src/cmdeploy/dkim_milter/signing-keys b/cmdeploy/src/cmdeploy/dkim_milter/signing-keys
new file mode 100644
index 00000000..7095b6de
--- /dev/null
+++ b/cmdeploy/src/cmdeploy/dkim_milter/signing-keys
@@ -0,0 +1,2 @@
+# Key name Signing key
+{{ config.signing_key_name }}
-SignHeaders *,+autocrypt,+content-type
-
-# Prevent addition of second Content-Type header
-# and other important headers that should not be added
-# after signing the message.
-# See
-#
-# and RFC 6376 (page 41) for reference.
-#
-# We don't use "l=" body length so the problem described in RFC 6376
-# is not applicable, but adding e.g. a second "From" header
-# or second "Autocrypt" header is better prevented in any case.
-#
-# Default is empty.
-OversignHeaders from,reply-to,subject,date,to,cc,resent-date,resent-from,resent-sender,resent-to,resent-cc,in-reply-to,references,list-id,list-help,list-unsubscribe,list-subscribe,list-post,list-owner,list-archive,autocrypt
-
-# Script to ignore signatures that do not correspond to the From: domain.
-ScreenPolicyScript /etc/opendkim/screen.lua
-
-# Script to reject mails without a valid DKIM signature.
-FinalPolicyScript /etc/opendkim/final.lua
-
-# In Debian, opendkim runs as user "opendkim". A umask of 007 is required when
-# using a local socket with MTAs that access the socket as a non-privileged
-# user (for example, Postfix). You may need to add user "postfix" to group
-# "opendkim" in that case.
-UserID opendkim
-UMask 007
-
-Socket local:/var/spool/postfix/opendkim/opendkim.sock
-
-PidFile /run/opendkim/opendkim.pid
-
-# The trust anchor enables DNSSEC. In Debian, the trust anchor file is provided
-# by the package dns-root-data.
-TrustAnchorFile /usr/share/dns/root.key
-
-# Sign messages when `-o milter_macro_daemon_name=ORIGINATING` is set.
-MTA ORIGINATING
-
-# No hosts are treated as internal, ORIGINATING daemon name should be set explicitly.
-InternalHosts -
diff --git a/cmdeploy/src/cmdeploy/opendkim/screen.lua b/cmdeploy/src/cmdeploy/opendkim/screen.lua
deleted file mode 100644
index 0f083e2d..00000000
--- a/cmdeploy/src/cmdeploy/opendkim/screen.lua
+++ /dev/null
@@ -1,21 +0,0 @@
--- Ignore signatures that do not correspond to the From: domain.
-
-from_domain = odkim.get_fromdomain(ctx)
-if from_domain == nil then
- return nil
-end
-
-n = odkim.get_sigcount(ctx)
-if n == nil then
- return nil
-end
-
-for i = 1, n do
- sig = odkim.get_sighandle(ctx, i - 1)
- sig_domain = odkim.sig_getdomain(sig)
- if from_domain ~= sig_domain then
- odkim.sig_ignore(sig)
- end
-end
-
-return nil
diff --git a/cmdeploy/src/cmdeploy/opendkim/systemd.conf b/cmdeploy/src/cmdeploy/opendkim/systemd.conf
deleted file mode 100644
index 0e150af7..00000000
--- a/cmdeploy/src/cmdeploy/opendkim/systemd.conf
+++ /dev/null
@@ -1,3 +0,0 @@
-[Service]
-Restart=always
-RuntimeMaxSec=1d
diff --git a/cmdeploy/src/cmdeploy/postfix/deployer.py b/cmdeploy/src/cmdeploy/postfix/deployer.py
index 34cbffa4..980a32af 100644
--- a/cmdeploy/src/cmdeploy/postfix/deployer.py
+++ b/cmdeploy/src/cmdeploy/postfix/deployer.py
@@ -4,7 +4,7 @@ from cmdeploy.basedeploy import Deployer, get_resource
class PostfixDeployer(Deployer):
- required_users = [("postfix", None, ["opendkim"])]
+ required_users = [("postfix", None, ["dkim-milter"])]
daemon_reload = False
def __init__(self, config, disable_mail):
diff --git a/cmdeploy/src/cmdeploy/postfix/master.cf.j2 b/cmdeploy/src/cmdeploy/postfix/master.cf.j2
index 3f3a3a07..7a8613df 100644
--- a/cmdeploy/src/cmdeploy/postfix/master.cf.j2
+++ b/cmdeploy/src/cmdeploy/postfix/master.cf.j2
@@ -80,13 +80,13 @@ filter unix - n n - - lmtp
127.0.0.1:{{ config.postfix_reinject_port }} inet n - n - 100 smtpd
-o syslog_name=postfix/reinject
-o milter_macro_daemon_name=ORIGINATING
- -o smtpd_milters=unix:opendkim/opendkim.sock
+ -o smtpd_milters=unix:dkim-milter/dkim-milter-sign.sock
-o cleanup_service_name=authclean
# Local SMTP server for reinjecting incoming filtered mail
127.0.0.1:{{ config.postfix_reinject_port_incoming }} inet n - n - 100 smtpd
-o syslog_name=postfix/reinject_incoming
- -o smtpd_milters=unix:opendkim/opendkim.sock
+ -o smtpd_milters=unix:dkim-milter/dkim-milter-verify.sock
# Cleanup `Received` headers for authenticated mail
# to avoid leaking client IP.
diff --git a/cmdeploy/src/cmdeploy/tests/online/test_1_basic.py b/cmdeploy/src/cmdeploy/tests/online/test_1_basic.py
index c8525125..bbc45520 100644
--- a/cmdeploy/src/cmdeploy/tests/online/test_1_basic.py
+++ b/cmdeploy/src/cmdeploy/tests/online/test_1_basic.py
@@ -1,4 +1,3 @@
-import datetime
import smtplib
import socket
import subprocess
@@ -58,15 +57,6 @@ class TestSSHExecutor:
else:
pytest.fail("didn't raise exception")
- def test_opendkim_restarted(self, sshexec):
- """check that opendkim is not running for longer than a day."""
- cmd = "systemctl show opendkim --timestamp=utc --property=ActiveEnterTimestamp"
- out = sshexec(call=remote.rshell.shell, kwargs=dict(command=cmd))
- datestring = out.split("=")[1]
- since_date = datetime.datetime.strptime(datestring, "%a %Y-%m-%d %H:%M:%S %Z")
- now = datetime.datetime.now(since_date.tzinfo)
- assert (now - since_date).total_seconds() < 60 * 60 * 51
-
def test_timezone_env(remote):
for line in remote.iter_output("env"):
@@ -146,7 +136,7 @@ def test_reject_missing_dkim(cmsetup, maildata, from_addr):
conn.starttls()
with conn as s:
- with pytest.raises(smtplib.SMTPDataError, match="No valid DKIM signature"):
+ with pytest.raises(smtplib.SMTPDataError, match="No DKIM signature found"):
s.sendmail(from_addr=from_addr, to_addrs=recipient.addr, msg=msg)
diff --git a/cmdeploy/src/cmdeploy/tests/online/test_3_status.py b/cmdeploy/src/cmdeploy/tests/online/test_3_status.py
index 1783c32e..8bfb08ed 100644
--- a/cmdeploy/src/cmdeploy/tests/online/test_3_status.py
+++ b/cmdeploy/src/cmdeploy/tests/online/test_3_status.py
@@ -24,7 +24,7 @@ def test_status_cmd(chatmail_config, capsys, request):
"filtermail",
"lastlogin",
"nginx",
- "opendkim",
+ "dkim-milter",
"postfix@-",
"systemd-journald",
"turnserver",
diff --git a/doc/source/migrate.rst b/doc/source/migrate.rst
index 6f52da10..75d82af0 100644
--- a/doc/source/migrate.rst
+++ b/doc/source/migrate.rst
@@ -72,7 +72,7 @@ in this case, just run ``ssh-keygen -R "mail.example.org"`` as recommended.
ssh root@$NEW_IP4
chown root: -R /var/lib/acme
- chown opendkim: -R /etc/dkimkeys
+ chown dkim-milter: -R /etc/dkimkeys
chown vmail: -R /home/vmail/mail
diff --git a/doc/source/overview.rst b/doc/source/overview.rst
index 107f2e2d..264703fd 100644
--- a/doc/source/overview.rst
+++ b/doc/source/overview.rst
@@ -52,7 +52,7 @@ The deployed system components of a chatmail relay are:
- `acmetool `_ manages TLS
certificates for Dovecot, Postfix, and Nginx
-- `OpenDKIM `_ for signing messages with
+- `DKIM Milter `_ for signing messages with
DKIM and rejecting inbound messages without DKIM
- `mtail `_ for collecting anonymized