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