From 1cb6f39ea2d811b2b6e52531ea6f5797dd85a16e Mon Sep 17 00:00:00 2001 From: Berwn Date: Thu, 18 Jun 2026 14:47:20 +0700 Subject: [PATCH] Add declarative SNM mail stack on mx1 with DNS-01, DANE, MTA-STS mx1 runs Simple NixOS Mailserver (Postfix/Dovecot/Rspamd/OpenDKIM) for cnx.email. The TLS cert is obtained via ACME DNS-01 using a dedicated, scoped TSIG key (acme_mx1) that ns1 authorizes for only _acme-challenge.mx1 and _acme-challenge.mta-sts on the cnx.email zone, so the credential can write nothing else. Mailbox passwords are auto-minted by a clan vars generator (four-word passphrase + number). DANE TLSA (3 1 1) is published for _25._tcp.mx1; --reuse-key keeps the key digest stable across renewals. MTA-STS is enforced via a Caddy vhost serving the policy on :443 from the same cert (mta-sts SAN). Firewall opens 25/587/465/143/993/443; 80 stays closed. --- flake.lock | 105 ++++++++++++++++++++++ flake.nix | 3 + fmt.nix | 1 + machines/mx1/configuration.nix | 7 +- machines/ns1/configuration.nix | 53 +++++++++-- modules/dns/acme-mx1-secret.nix | 19 ++++ modules/dns/zones/cnx.email.zone | 21 +++++ modules/hetzner-firewall-rules.nix | 24 +++-- modules/mail.nix | 139 +++++++++++++++++++++++++++++ 9 files changed, 353 insertions(+), 19 deletions(-) create mode 100644 modules/dns/acme-mx1-secret.nix create mode 100644 modules/mail.nix diff --git a/flake.lock b/flake.lock index 32b019e..e343a4d 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,21 @@ { "nodes": { + "blobs": { + "flake": false, + "locked": { + "lastModified": 1604995301, + "narHash": "sha256-wcLzgLec6SGJA8fx1OEN1yV/Py5b+U5iyYpksUY/yLw=", + "owner": "simple-nixos-mailserver", + "repo": "blobs", + "rev": "2cccdf1ca48316f2cfd1c9a0017e8de5a7156265", + "type": "gitlab" + }, + "original": { + "owner": "simple-nixos-mailserver", + "repo": "blobs", + "type": "gitlab" + } + }, "clan-core": { "inputs": { "data-mesher": "data-mesher", @@ -73,6 +89,22 @@ "type": "github" } }, + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1767039857, + "narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, "flake-parts": { "inputs": { "nixpkgs-lib": [ @@ -94,6 +126,54 @@ "type": "github" } }, + "git-hooks": { + "inputs": { + "flake-compat": [ + "nixos-mailserver", + "flake-compat" + ], + "gitignore": "gitignore", + "nixpkgs": [ + "nixos-mailserver", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1772893680, + "narHash": "sha256-JDqZMgxUTCq85ObSaFw0HhE+lvdOre1lx9iI6vYyOEs=", + "owner": "cachix", + "repo": "git-hooks.nix", + "rev": "8baab586afc9c9b57645a734c820e4ac0a604af9", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "git-hooks.nix", + "type": "github" + } + }, + "gitignore": { + "inputs": { + "nixpkgs": [ + "nixos-mailserver", + "git-hooks", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1709087332, + "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, "nix-darwin": { "inputs": { "nixpkgs": [ @@ -144,6 +224,30 @@ "type": "github" } }, + "nixos-mailserver": { + "inputs": { + "blobs": "blobs", + "flake-compat": "flake-compat", + "git-hooks": "git-hooks", + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1773912645, + "narHash": "sha256-QHzRqq6gh+t3F/QU9DkP7X63dDDcuIQmaDz12p7ANTg=", + "owner": "simple-nixos-mailserver", + "repo": "nixos-mailserver", + "rev": "25e6dbb8fca3b6e779c5a46fd03bd760b2165bb5", + "type": "gitlab" + }, + "original": { + "owner": "simple-nixos-mailserver", + "ref": "nixos-25.11", + "repo": "nixos-mailserver", + "type": "gitlab" + } + }, "nixpkgs": { "locked": { "lastModified": 1778003029, @@ -163,6 +267,7 @@ "root": { "inputs": { "clan-core": "clan-core", + "nixos-mailserver": "nixos-mailserver", "nixpkgs": [ "clan-core", "nixpkgs" diff --git a/flake.nix b/flake.nix index 8775a14..f95b97d 100644 --- a/flake.nix +++ b/flake.nix @@ -3,6 +3,9 @@ inputs.nixpkgs.follows = "clan-core/nixpkgs"; inputs.treefmt-nix.url = "github:numtide/treefmt-nix"; inputs.treefmt-nix.inputs.nixpkgs.follows = "nixpkgs"; + # Simple NixOS Mailserver, pinned to the branch matching clan-core's nixpkgs. + inputs.nixos-mailserver.url = "gitlab:simple-nixos-mailserver/nixos-mailserver/nixos-25.11"; + inputs.nixos-mailserver.inputs.nixpkgs.follows = "nixpkgs"; outputs = { diff --git a/fmt.nix b/fmt.nix index 9ecfa75..e156b5e 100644 --- a/fmt.nix +++ b/fmt.nix @@ -24,6 +24,7 @@ # No formatter, or reformatting would corrupt them. "*.zone" # Knot zone files + "docs/book.toml" # mdBook config; no TOML formatter enabled "flake.lock" ".envrc" ".gitignore" diff --git a/machines/mx1/configuration.nix b/machines/mx1/configuration.nix index a286d32..9fae840 100644 --- a/machines/mx1/configuration.nix +++ b/machines/mx1/configuration.nix @@ -1,9 +1,11 @@ -{ config, ... }: +{ config, inputs, ... }: let hosts = import ../../modules/hosts.nix; in { imports = [ + inputs.nixos-mailserver.nixosModules.default + ../../modules/mail.nix ../../modules/static-ipv6.nix ../../modules/monitoring/exporters.nix ]; @@ -17,7 +19,4 @@ in }; services.timesyncd.enable = true; - - # Mail host backing the cnx.email MX (mx1.cnx.email -> 5.223.65.38). - # SMTP/IMAP services to be configured. } diff --git a/machines/ns1/configuration.nix b/machines/ns1/configuration.nix index e4f2532..e3f268c 100644 --- a/machines/ns1/configuration.nix +++ b/machines/ns1/configuration.nix @@ -1,4 +1,9 @@ -{ config, lib, pkgs, ... }: +{ + config, + lib, + pkgs, + ... +}: let domains = import ../../modules/dns/domains.nix; mesh = import ../../modules/mesh-hosts.nix { inherit config lib; }; @@ -7,6 +12,7 @@ in { imports = [ ../../modules/dns/authoritative.nix + ../../modules/dns/acme-mx1-secret.nix ../../modules/static-ipv6.nix ../../modules/monitoring/exporters.nix ]; @@ -33,11 +39,9 @@ in time.timeZone = "Etc/GMT-1"; # UTC+1 (fixed offset, no DST) services.timesyncd.enable = true; - # ACME DNS-01 (RFC 2136): a dedicated TSIG key, scoped to ns1 only, that an - # external ACME client uses to write _acme-challenge TXT records. acl_acme - # (referenced by each zone below) limits the key to TXT updates at or under - # _acme-challenge.; Knot then signs the record and transfers it to ns2, - # which never needs this key. Retrieve the secret for the client with: + # ACME DNS-01 (RFC 2136), general key. A dedicated TSIG key scoped by acl_acme + # (referenced by every zone below) to TXT updates at or under _acme-challenge. + # Retrieve the client config with: # clan vars get ns1 dns-acme-tsig/acme.conf clan.core.vars.generators.dns-acme-tsig = { files."acme.conf" = { @@ -51,8 +55,28 @@ in ''; }; + # ACME DNS-01, dedicated mx1 key. A *separate* TSIG key (acme_mx1) that only + # mx1 holds, rendered from the shared secret (generator dns-acme-mx1-secret, + # imported above). acl_acme_mx1 scopes it to TXT updates at exactly + # _acme-challenge.mx1 and _acme-challenge.mta-sts (the mail cert and its + # MTA-STS SAN), and it is attached only to the cnx.email zone below — so this + # credential can write nothing but mx1's own cert challenges. + clan.core.vars.generators.dns-acme-mx1-knot = { + files."acme.conf" = { + secret = true; + owner = "knot"; + group = "knot"; + }; + dependencies = [ "dns-acme-mx1-secret" ]; + script = '' + printf 'key:\n - id: acme_mx1\n algorithm: hmac-sha256\n secret: %s\n' \ + "$(cat "$in"/dns-acme-mx1-secret/secret)" > "$out"/acme.conf + ''; + }; + services.knot.keyFiles = [ config.clan.core.vars.generators.dns-acme-tsig.files."acme.conf".path + config.clan.core.vars.generators.dns-acme-mx1-knot.files."acme.conf".path ]; services.knot.settings.acl = [ @@ -65,6 +89,18 @@ in "update-owner-match" = "sub-or-equal"; "update-owner-name" = [ "_acme-challenge" ]; } + { + id = "acl_acme_mx1"; + key = "acme_mx1"; + action = [ "update" ]; + "update-type" = [ "TXT" ]; + "update-owner" = "name"; + "update-owner-match" = "sub-or-equal"; + "update-owner-name" = [ + "_acme-challenge.mx1" + "_acme-challenge.mta-sts" + ]; + } ]; # Automatic DNSSEC signing policy (primary only). ECDSA P-256/SHA-256 with @@ -93,9 +129,12 @@ in "dnssec-signing" = true; "dnssec-policy" = "cnx"; notify = [ "ns2" ]; + # ns2 transfers; acme_ddns does general DNS-01 updates. The dedicated + # acme_mx1 key is attached only to cnx.email, so it can't touch other zones. acl = [ "acl_ns2" "acl_acme" - ]; # ns2 transfers; acme_ddns key does DNS-01 updates + ] + ++ lib.optionals (d == "cnx.email") [ "acl_acme_mx1" ]; }) domains; } diff --git a/modules/dns/acme-mx1-secret.nix b/modules/dns/acme-mx1-secret.nix new file mode 100644 index 0000000..d29efa2 --- /dev/null +++ b/modules/dns/acme-mx1-secret.nix @@ -0,0 +1,19 @@ +# Shared TSIG secret for the dedicated acme_mx1 key. +# +# This key lets mx1 — and only mx1 — write _acme-challenge.mx1.cnx.email TXT +# records on ns1 to obtain its mail TLS cert via ACME DNS-01. ns1 scopes it with +# acl_acme_mx1 (attached only to the cnx.email zone) so the credential can touch +# nothing else. ns1 renders this secret into a Knot key file; mx1 into a lego +# rfc2136 env file; both must carry the same secret, hence one shared generator +# with a per-host renderer that depends on it. Imported by ns1 and (via mail.nix) +# mx1. +{ pkgs, ... }: +{ + clan.core.vars.generators.dns-acme-mx1-secret = { + share = true; + files."secret".secret = true; + runtimeInputs = [ pkgs.openssl ]; + # 32 random bytes, base64 — a valid hmac-sha256 TSIG secret. + script = ''openssl rand -base64 32 | tr -d '\n' > "$out"/secret''; + }; +} diff --git a/modules/dns/zones/cnx.email.zone b/modules/dns/zones/cnx.email.zone index 82ea682..5d93dd0 100644 --- a/modules/dns/zones/cnx.email.zone +++ b/modules/dns/zones/cnx.email.zone @@ -13,6 +13,27 @@ $TTL 3600 ; ---- Mail ---- mx1 IN A 5.223.65.38 +mx1 IN AAAA 2a01:4ff:2f0:1963::1 @ IN MX 10 mx1.cnx.email. @ IN TXT "v=spf1 mx -all" _dmarc IN TXT "v=DMARC1; p=quarantine; rua=mailto:postmaster@cnx.email" + +; ---- DANE / TLSA ---- +; "3 1 1" = DANE-EE, SPKI, SHA-256: the digest of mx1's certificate public key. +; Valid because the zone is DNSSEC-signed and the lego cert uses --reuse-key, so +; the key (and thus this digest) is stable across renewals. Compute it AFTER the +; first issuance and paste the hex below: +; ssh mx1 'openssl x509 -in /var/lib/acme/mx1.cnx.email/cert.pem -noout -pubkey \ +; | openssl pkey -pubin -outform DER | openssl dgst -sha256 -binary | xxd -p -c256' +_25._tcp.mx1 IN TLSA 3 1 1 bd9a51f60b6d2dd20f18b3553d2795053ac52f87567a46bc892006bb58506404 + +; ---- MTA-STS ---- +; Policy host (A/AAAA point at mx1); the _mta-sts TXT id MUST be bumped whenever +; the policy file in modules/mail.nix changes, or senders keep the cached policy. +mta-sts IN A 5.223.65.38 +mta-sts IN AAAA 2a01:4ff:2f0:1963::1 +_mta-sts IN TXT "v=STSv1; id=2026061801" +mail._domainkey IN TXT ( "v=DKIM1; k=rsa; " + "p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAr9QxTs5dLtY76bo156+Tp0GUoE554rMwIooIYa2MMYHNs8zPb0thFmaCKGAINdHKNIq2phXAlk51iBTfdqXjx7gVWSrs+ftykqO3b5hUjgImsgqPWGUTzy5/bUgcDELiD9KKEyKYD3+ebZEw6d0uvBvEsA6a1CPzOsufoCDtyKjByCuQzkCBrK25TUHFolGvEYcZexR0LSF+8hMss" + "xyw9NYiPpTXVCWQJnrZZpuOBiX0K2l5CAXVyuT/B5RcBXlAUhBTp3390VEhL0wAZMTOnvtvBYK3NnsTIh96fkh6MfWmre7Fi9hEq//xGf40N5/aomMjJrJdqFZJLZpDotb/XwIDAQAB" + ) diff --git a/modules/hetzner-firewall-rules.nix b/modules/hetzner-firewall-rules.nix index 9222486..09fd408 100644 --- a/modules/hetzner-firewall-rules.nix +++ b/modules/hetzner-firewall-rules.nix @@ -24,16 +24,25 @@ let description = "ICMP (ping / PMTUD)"; }; - # Inbound mail only. mx1 is the MX for cnx.email, so other servers deliver on - # 25. Submission (587/465) and IMAP (993) stay closed until the mail stack and - # mailboxes exist — admin access rides the mesh, same as the other hosts. - smtp = { + # Public mail ports for mx1 (MX for cnx.email). 25 is server-to-server + # delivery; 587/465 are client submission; 143/993 are IMAP. 443 serves only the + # MTA-STS policy (https://mta-sts.cnx.email/.well-known/mta-sts.txt); the cert + # itself uses ACME DNS-01 so port 80 stays closed. Admin still rides the mesh. + mailPort = port: description: { direction = "in"; protocol = "tcp"; - port = "25"; + inherit port; source_ips = world; - description = "SMTP (inbound mail)"; + inherit description; }; + mailRules = [ + (mailPort "25" "SMTP (inbound mail)") + (mailPort "587" "Submission (STARTTLS)") + (mailPort "465" "Submission (implicit TLS)") + (mailPort "143" "IMAP (STARTTLS)") + (mailPort "993" "IMAP (implicit TLS)") + (mailPort "443" "MTA-STS policy (HTTPS)") + ]; dnsRules = [ { @@ -61,8 +70,7 @@ in ]; "clan-ns1" = dnsRules; "clan-ns2" = dnsRules; - "clan-mx1" = [ - smtp + "clan-mx1" = mailRules ++ [ zerotier ping ]; diff --git a/modules/mail.nix b/modules/mail.nix new file mode 100644 index 0000000..9b1f4e2 --- /dev/null +++ b/modules/mail.nix @@ -0,0 +1,139 @@ +# Declarative mail stack for mx1 (Simple NixOS Mailserver: Postfix + Dovecot + +# Rspamd + OpenDKIM). Imported by machines/mx1 alongside the SNM flake module. +# +# Mailboxes are virtual (not system users): each address below is a login account +# whose password is auto-generated by a clan vars generator as a four-word +# passphrase with a trailing number (e.g. otter-lantern-cobalt-driftwood-42). The +# generator stores both the passphrase and its sha-512 hash. To add a mailbox: +# append the address to `accounts`, run `clan vars generate mx1`, redeploy mx1, +# then hand the passphrase to the user: +# clan vars get mx1 mail-passwd-/passphrase +# (addr with @ and . replaced by -at- and -, e.g. mail-passwd-postmaster-at-cnx-email) +{ + config, + lib, + pkgs, + ... +}: +let + hosts = import ./hosts.nix; + fqdn = "mx1.cnx.email"; + mtaStsHost = "mta-sts.cnx.email"; + + # MTA-STS policy served at https://mta-sts.cnx.email/.well-known/mta-sts.txt. + # enforce = a sending MTA that fetched this must use a valid, MX-matching TLS + # cert or refuse to deliver. Bump the _mta-sts TXT id (in the zone) whenever + # this changes. + mtaStsPolicy = pkgs.writeText "mta-sts.txt" '' + version: STSv1 + mode: enforce + mx: ${fqdn} + max_age: 604800 + ''; + + # The mailboxes mx1 serves. postmaster is required by RFC 5321. + accounts = [ + "postmaster@cnx.email" + ]; + + genName = addr: "mail-passwd-" + lib.replaceStrings [ "@" "." ] [ "-at-" "-" ] addr; + + passwdGenerators = lib.listToAttrs ( + map (addr: { + name = genName addr; + value = { + files."passphrase".secret = true; # retrievable to hand to the user + files."hash".secret = true; # consumed by SNM's hashedPasswordFile + runtimeInputs = [ + pkgs.xkcdpass + pkgs.mkpasswd + ]; + script = '' + pass="$(xkcdpass --numwords=4 --delimiter=- --case=lower)-$((RANDOM % 90 + 10))" + printf '%s' "$pass" > "$out"/passphrase + printf '%s' "$pass" | mkpasswd -s -m sha-512 > "$out"/hash + ''; + }; + }) accounts + ); + + loginAccounts = lib.listToAttrs ( + map (addr: { + name = addr; + value.hashedPasswordFile = config.clan.core.vars.generators.${genName addr}.files."hash".path; + }) accounts + ); +in +{ + imports = [ ./dns/acme-mx1-secret.nix ]; + + clan.core.vars.generators = passwdGenerators // { + # Render the shared acme_mx1 TSIG secret into a lego rfc2136 env file. lego + # (via security.acme below) uses it to write the _acme-challenge.mx1.cnx.email + # TXT record to ns1, which authorizes the acme_mx1 key for exactly that owner. + dns-acme-rfc2136 = { + files."rfc2136.env".secret = true; # root-owned; systemd reads it as root + dependencies = [ "dns-acme-mx1-secret" ]; + script = '' + printf 'RFC2136_NAMESERVER=${hosts.ns1.ipv4}:53\nRFC2136_TSIG_ALGORITHM=hmac-sha256.\nRFC2136_TSIG_KEY=acme_mx1\nRFC2136_TSIG_SECRET=%s\n' \ + "$(cat "$in"/dns-acme-mx1-secret/secret)" > "$out"/rfc2136.env + ''; + }; + }; + + mailserver = { + enable = true; + # Fresh install: declare the latest layout the nixos-25.11 branch ships (3), + # so SNM uses the current dovecot mail directory layout with nothing to migrate. + stateVersion = 3; + inherit fqdn; + domains = [ "cnx.email" ]; + inherit loginAccounts; + + # Consume a security.acme cert we obtain ourselves via DNS-01 (below); no + # web server and no inbound HTTP needed, so port 80 stays closed. Add the + # MTA-STS host as a SAN so the one cert also covers the policy endpoint. + certificateScheme = "acme"; + certificateDomains = [ mtaStsHost ]; + + dkimSelector = "mail"; + }; + + security.acme = { + acceptTerms = true; + defaults.email = "postmaster@cnx.email"; + certs.${fqdn} = { + dnsProvider = "rfc2136"; + environmentFile = config.clan.core.vars.generators.dns-acme-rfc2136.files."rfc2136.env".path; + # ns1 is the only nameserver that accepts the acme_mx1 UPDATE; check + # propagation against it directly rather than a public resolver. + dnsResolver = "${hosts.ns1.ipv4}:53"; + # Keep the private key fixed across renewals so the DANE TLSA "3 1 1" + # record (public-key digest, published in the zone) stays valid. + extraLegoRenewFlags = [ "--reuse-key" ]; + }; + }; + + # The mail cert is owned group=acme (SNM adds postfix/dovecot); Caddy serves the + # MTA-STS endpoint from the same cert, so it needs to read the key too. + users.users.caddy.extraGroups = [ "acme" ]; + + # MTA-STS policy endpoint, served by Caddy (same web server as control's docs). + # The explicit `tls cert key` points at the lego-issued mail cert (which carries + # mta-sts.cnx.email as a SAN) and disables Caddy's automatic ACME, so no extra + # issuance happens and the DANE TLSA key stays stable. Only :443 is opened. + services.caddy = { + enable = true; + virtualHosts.${mtaStsHost}.extraConfig = '' + tls /var/lib/acme/${fqdn}/cert.pem /var/lib/acme/${fqdn}/key.pem + root * ${pkgs.writeTextDir ".well-known/mta-sts.txt" (builtins.readFile mtaStsPolicy)} + file_server + ''; + }; + + # DKIM private keys are generated on first start under this dir. They're + # regenerable (rotate + republish the TXT), but declaring the path as clan + # state lets a borg client back it up to avoid a needless DNS round-trip on + # restore. Wiring mx1 into the borgbackup instance is a separate step. + clan.core.state.mail-dkim.folders = [ config.mailserver.dkimKeyDirectory ]; +}