122 lines
5.6 KiB
Nix
122 lines
5.6 KiB
Nix
{pkgs, ...}: let
|
|
# Caddy's canonical certificate storage (XDG data dir). Renewals land here,
|
|
# owned caddy:caddy 0600 — stalwart cannot read it directly, so we copy it
|
|
# into a distribution dir stalwart's service (user "stalwart") can reach.
|
|
caddyCertDir = "/var/lib/caddy/.local/share/caddy/certificates/acme-v02.api.letsencrypt.org-directory/mail.severijnse.eu";
|
|
# Distribution dir read by stalwart (cert 0644, private key regranted to the
|
|
# "stalwart" group by systemd.services.stalwart-cert-perm).
|
|
distCertDir = "/var/lib/caddy/certificates/acme-v02.api.letsencrypt.org-directory/mail.severijnse.eu";
|
|
zoneFile = "/var/lib/coredns/zones/severijnse.eu.db";
|
|
# Records the SPKI hash applied at the last restart of stalwart, so cert
|
|
# renewals trigger exactly one restart and unchanged certs never do.
|
|
stateFile = "/var/lib/tlsa-update/.last-spki";
|
|
|
|
syncScript = pkgs.writeShellScript "tlsa-update" ''
|
|
set -euo pipefail
|
|
OPENSSL="${pkgs.openssl}/bin/openssl"
|
|
|
|
SRC_CERT="${caddyCertDir}/mail.severijnse.eu.crt"
|
|
SRC_KEY="${caddyCertDir}/mail.severijnse.eu.key"
|
|
DST_CERT="${distCertDir}/mail.severijnse.eu.crt"
|
|
DST_KEY="${distCertDir}/mail.severijnse.eu.key"
|
|
|
|
# Caddy has not obtained the certificate yet (e.g. first boot before HTTP-01).
|
|
if [ ! -f "$SRC_CERT" ] || [ ! -f "$SRC_KEY" ]; then
|
|
echo "tlsa-update: certificate not found at $caddyCertDir, skipping" >&2
|
|
exit 0
|
|
fi
|
|
|
|
# 1) Propagate Caddy's renewed certificate into the distribution dir stalwart
|
|
# reads. Caddy stores certs 0600 caddy:caddy. The cert is world-readable;
|
|
# the private key is restricted to root (0640) and stalwart-cert-perm
|
|
# regrants it to the "stalwart" group so it is not exposed to other local
|
|
# users.
|
|
install -D -m 0644 "$SRC_CERT" "$DST_CERT"
|
|
install -D -m 0640 "$SRC_KEY" "$DST_KEY"
|
|
# Stalwart reads the key as user "stalwart" via %{file:...}%; regrant the
|
|
# group immediately so every sync leaves it readable (0640 root:stalwart)
|
|
# and webadmin config reload never fails with EACCES.
|
|
chgrp stalwart "$DST_KEY"
|
|
|
|
# 2) TLSA 3 1 1 = SHA-256 of the certificate's SubjectPublicKeyInfo (SPKI),
|
|
# NOT the whole certificate. Matching type 1 = SHA-256 of the SPKI DER.
|
|
HEX=$("$OPENSSL" x509 -in "$DST_CERT" -noout -pubkey 2>/dev/null | "$OPENSSL" pkey -pubin -outform DER 2>/dev/null | "$OPENSSL" dgst -sha256 | cut -d' ' -f2)
|
|
|
|
# 3) Update the CoreDNS zone. coredns.service regenerates this file from the
|
|
# Nix store on every start, so this unit (which is partOf coredns and runs
|
|
# after it) re-applies the correct TLSA after each rebuild/restart.
|
|
if [ -f "${zoneFile}" ]; then
|
|
sed -i -E "s/^(_25\._tcp\.mail.*TLSA 3 1 1).*/\1 $HEX/" "${zoneFile}"
|
|
sed -i -E "s/^(_465\._tcp\.mail.*TLSA 3 1 1).*/\1 $HEX/" "${zoneFile}"
|
|
sed -i -E "s/^(_993\._tcp\.mail.*TLSA 3 1 1).*/\1 $HEX/" "${zoneFile}"
|
|
chown coredns:coredns "${zoneFile}"
|
|
chmod 0640 "${zoneFile}"
|
|
fi
|
|
|
|
# 4) Reload services so the changes take effect immediately.
|
|
systemctl reload coredns.service || true
|
|
# Stalwart reads its TLS certs ($certDir) via %{file:...}% placeholders at
|
|
# startup only; there is no signal-based reload (management API reload needs
|
|
# admin credentials we must not store). Restart it, but only when the cert
|
|
# actually changed (SPKI hash differs from the last applied run), so the
|
|
# daily timer and inotify events for unchanged certs do not drop connections.
|
|
# NOTE: stalwart.service `requires` this unit, so the restart must be issued
|
|
# with --no-block (async): a synchronous restart waits for stalwart to come
|
|
# back up, which in turn waits for this unit to finish — a deadlock. The state
|
|
# file is updated BEFORE the restart so the tlsa-update run that stalwart's
|
|
# required-activation re-triggers sees a matching hash and exits immediately.
|
|
if [ "$(cat "${stateFile}" 2>/dev/null || true)" != "$HEX" ]; then
|
|
printf '%s\n' "$HEX" > "${stateFile}"
|
|
# Restarting stalwart re-runs its Requires=tlsa-update dependency; guard
|
|
# with is-active so a boot-time run never races stalwart's initial start.
|
|
if systemctl is-active --quiet stalwart.service 2>/dev/null; then
|
|
systemctl --no-block restart stalwart.service || true
|
|
fi
|
|
fi
|
|
|
|
echo "tlsa-update: TLSA set to $HEX"
|
|
'';
|
|
in {
|
|
systemd = {
|
|
# Ensure the distribution dir exists (Caddy does not write here).
|
|
tmpfiles.rules = [
|
|
"d ${distCertDir} 0755 root root - -"
|
|
"d /var/lib/tlsa-update 0755 root root - -"
|
|
];
|
|
|
|
services.tlsa-update = {
|
|
description = "Sync Caddy TLS certificate to mail server and update DANE/TLSA records";
|
|
after = ["caddy.service" "coredns.service"];
|
|
partOf = ["coredns.service"];
|
|
wantedBy = ["multi-user.target"];
|
|
path = with pkgs; [openssl coreutils gnused systemd];
|
|
serviceConfig = {
|
|
Type = "oneshot";
|
|
ExecStart = "${syncScript}";
|
|
User = "root";
|
|
Group = "root";
|
|
};
|
|
};
|
|
|
|
# Fire as soon as Caddy rewrites the certificate on renewal (the atomic rewrite
|
|
# changes the directory mtime), eliminating the up-to-24h DANE drift window.
|
|
paths.tlsa-update = {
|
|
description = "Watch Caddy certificate directory for renewal";
|
|
wantedBy = ["paths.target"];
|
|
pathConfig = {
|
|
PathModified = [caddyCertDir];
|
|
Unit = "tlsa-update.service";
|
|
};
|
|
};
|
|
|
|
# Fallback in case a renewal event is missed (e.g. inotify overflow).
|
|
timers.tlsa-update = {
|
|
wantedBy = ["timers.target"];
|
|
timerConfig = {
|
|
OnCalendar = "daily";
|
|
Persistent = true;
|
|
};
|
|
};
|
|
};
|
|
}
|