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.
This commit is contained in:
Berwn
2026-06-18 14:47:20 +07:00
parent 026a26dd53
commit 1cb6f39ea2
9 changed files with 353 additions and 19 deletions
+3 -4
View File
@@ -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.
}
+46 -7
View File
@@ -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.<zone>; 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;
}