Files
nixos-config/servers/hetzner/modules/services/tlsa-updater.nix
T
jory cba6b18914 Harden server and add Nix-native CI + self-hosted Gitea Actions
- caddy: security headers (X-Content-Type-Options/X-XSS-Protection/
  X-Frame-Options) on all vhosts + baseline CSP; strip SnappyMail
  upstream copies via header_down on mail.severijnse.eu
- tlsa-updater: compute TLSA 3 1 1 from cert SPKI (SHA-256), sync
  _25/_465/_993, fail-safe placeholders; coredns zone updated
- pre-commit: wire cachix/git-hooks.nix (alejandra, statix, actionlint,
  ...); CI pre-commit job over x86_64 + aarch64 matrix
- gitea: enable Gitea Actions + self-hosted runner (native:host,
  aarch64 via binfmt); add .gitea/workflows/ci.yml and local hook
- fix statix warnings (merge repeated systemd/database/configFile keys,
  inherit, bool-compare guards); add missing trailing newlines
2026-07-12 01:48:46 +02:00

97 lines
4.0 KiB
Nix

{pkgs, ...}: let
# Caddy's canonical certificate storage (XDG data dir). Renewals land here,
# owned caddy:caddy 0600 — the mail server's non-root Postfix/Dovecot cannot
# read it directly, so we copy it into a world-readable distribution dir.
caddyCertDir = "/var/lib/caddy/.local/share/caddy/certificates/acme-v02.api.letsencrypt.org-directory/mail.severijnse.eu";
# World-readable distribution dir mounted (RO) into the mail server container.
distCertDir = "/var/lib/caddy/certificates/acme-v02.api.letsencrypt.org-directory/mail.severijnse.eu";
zoneFile = "/var/lib/coredns/zones/severijnse.eu.db";
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 the
# mail server mounts. Caddy stores certs 0600 caddy:caddy. The cert is
# world-readable (Postfix/Dovecot read it as root before dropping
# privileges); the private key is restricted to root (0640) 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"
# 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
podman exec mailserver postfix reload || true
podman exec mailserver dovecot reload || true
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 - -"
];
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 podman 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;
};
};
};
}