cnx.router.proxy serves <service>.<site><n>.cnx.network with a real Let's Encrypt wildcard obtained via a gateway-scoped TSIG key against ns1; Blocky resolves the names to the router's LAN address, so they exist only internally. First user: Omada UI on gw-cnx-1 (omada.cnx1.cnx.network).
208 lines
7.6 KiB
Nix
208 lines
7.6 KiB
Nix
{
|
|
config,
|
|
lib,
|
|
pkgs,
|
|
...
|
|
}:
|
|
let
|
|
domains = import ../../modules/dns/domains.nix;
|
|
mesh = import ../../modules/mesh-hosts.nix { inherit config lib; };
|
|
hosts = import ../../modules/hosts.nix;
|
|
in
|
|
{
|
|
imports = [
|
|
../../modules/dns/authoritative.nix
|
|
../../modules/dns/acme-mx1-secret.nix
|
|
../../modules/dns/acme-web01-secret.nix
|
|
(import ../../modules/dns/acme-gw-secret.nix "gw-cnx-1")
|
|
../../modules/static-ipv6.nix
|
|
../../modules/monitoring/exporters.nix
|
|
];
|
|
|
|
clan.core.sops.defaultGroups = [ "admins" ];
|
|
|
|
# Knot's state dir holds the non-regenerable DNSSEC key material (KSK/ZSK
|
|
# private keys in the KASP keystore). Declaring it as clan state makes the
|
|
# borgbackup client back it up; losing it forces an emergency DS rollover at
|
|
# the registrar. mode 0700 owned by knot, but borg runs as root so it reads it.
|
|
clan.core.state.knot.folders = [ "/var/lib/knot" ];
|
|
|
|
# The borgbackup repo is addressed as `borg@control`; mesh peers have no name
|
|
# resolution, so map the control machine name to its ZeroTier mesh address.
|
|
networking.hosts.${mesh.hosts.control} = [ "control" ];
|
|
|
|
# Public IPv6 (from modules/hosts.nix; matches the ns1 AAAA glue); SLAAC
|
|
# doesn't bring it up here.
|
|
cnx.staticIPv6 = {
|
|
enable = true;
|
|
address = hosts.${config.networking.hostName}.ipv6;
|
|
};
|
|
|
|
time.timeZone = "Etc/GMT-1"; # UTC+1 (fixed offset, no DST)
|
|
services.chrony.enable = true;
|
|
|
|
# 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" = {
|
|
secret = true;
|
|
owner = "knot";
|
|
group = "knot";
|
|
};
|
|
runtimeInputs = [ pkgs.knot-dns ];
|
|
script = ''
|
|
keymgr -t acme_ddns hmac-sha256 > "$out"/acme.conf
|
|
'';
|
|
};
|
|
|
|
# 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,mta-sts,mail} (the mail cert and its MTA-STS + client-
|
|
# alias SANs), 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
|
|
'';
|
|
};
|
|
|
|
# ACME DNS-01, dedicated web01 key. A *separate* TSIG key (acme_web01) that only
|
|
# web01 holds, rendered from the shared secret (generator dns-acme-web01-secret,
|
|
# imported above). acl_acme_web01 scopes it to TXT updates at _acme-challenge on
|
|
# the cnx.network zone — the owner the wildcard *.cnx.network challenge uses — so
|
|
# this credential can write nothing but web01's own cert challenges.
|
|
clan.core.vars.generators.dns-acme-web01-knot = {
|
|
files."acme.conf" = {
|
|
secret = true;
|
|
owner = "knot";
|
|
group = "knot";
|
|
};
|
|
dependencies = [ "dns-acme-web01-secret" ];
|
|
script = ''
|
|
printf 'key:\n - id: acme_web01\n algorithm: hmac-sha256\n secret: %s\n' \
|
|
"$(cat "$in"/dns-acme-web01-secret/secret)" > "$out"/acme.conf
|
|
'';
|
|
};
|
|
|
|
# ACME DNS-01, dedicated gateway keys. Same pattern as web01: each gateway
|
|
# holds its own TSIG key (acme_gw_<site>_<n>), rendered from the shared
|
|
# secret generator imported above. acl_acme_gw_<site>_<n> scopes it to TXT
|
|
# updates at _acme-challenge.<site><n> on cnx.network — the owner its internal
|
|
# wildcard *.<site><n>.cnx.network challenge uses — and nothing else.
|
|
clan.core.vars.generators.dns-acme-gw-cnx-1-knot = {
|
|
files."acme.conf" = {
|
|
secret = true;
|
|
owner = "knot";
|
|
group = "knot";
|
|
};
|
|
dependencies = [ "dns-acme-gw-cnx-1-secret" ];
|
|
script = ''
|
|
printf 'key:\n - id: acme_gw_cnx_1\n algorithm: hmac-sha256\n secret: %s\n' \
|
|
"$(cat "$in"/dns-acme-gw-cnx-1-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
|
|
config.clan.core.vars.generators.dns-acme-web01-knot.files."acme.conf".path
|
|
config.clan.core.vars.generators.dns-acme-gw-cnx-1-knot.files."acme.conf".path
|
|
];
|
|
|
|
services.knot.settings.acl = [
|
|
{
|
|
id = "acl_acme";
|
|
key = "acme_ddns";
|
|
action = [ "update" ];
|
|
"update-type" = [ "TXT" ];
|
|
"update-owner" = "name";
|
|
"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"
|
|
"_acme-challenge.mail"
|
|
];
|
|
}
|
|
{
|
|
id = "acl_acme_web01";
|
|
key = "acme_web01";
|
|
action = [ "update" ];
|
|
"update-type" = [ "TXT" ];
|
|
"update-owner" = "name";
|
|
"update-owner-match" = "sub-or-equal";
|
|
# Wildcard *.cnx.network places its challenge at _acme-challenge.cnx.network,
|
|
# i.e. _acme-challenge at the cnx.network apex (where this acl is attached).
|
|
"update-owner-name" = [ "_acme-challenge" ];
|
|
}
|
|
{
|
|
id = "acl_acme_gw_cnx_1";
|
|
key = "acme_gw_cnx_1";
|
|
action = [ "update" ];
|
|
"update-type" = [ "TXT" ];
|
|
"update-owner" = "name";
|
|
"update-owner-match" = "sub-or-equal";
|
|
# The internal wildcard *.cnx1.cnx.network places its challenge at
|
|
# _acme-challenge.cnx1.cnx.network.
|
|
"update-owner-name" = [ "_acme-challenge.cnx1" ];
|
|
}
|
|
];
|
|
|
|
# Automatic DNSSEC signing policy (primary only). ECDSA P-256/SHA-256 with
|
|
# Knot's default key management: the ZSK auto-rolls and the KSK is kept stable,
|
|
# so the DS at the registrar only changes on a manual KSK rollover.
|
|
services.knot.settings.policy = [
|
|
{
|
|
id = "cnx";
|
|
algorithm = "ecdsap256sha256";
|
|
}
|
|
];
|
|
|
|
# ns1 = primary (master): loads each zone from its file and serves it to ns2.
|
|
# zonefile-load = difference-no-serial lets us edit records without touching the
|
|
# SOA serial; Knot diffs the file, assigns a unixtime serial, signs the zone,
|
|
# then notifies ns2 and lets it pull the signed zone via AXFR/IXFR. unixtime is
|
|
# strictly monotonic per reload, so two zone versions can never share a serial
|
|
# (the failure mode dateserial's 2-digit daily counter allowed after a journal reset).
|
|
services.knot.settings.zone = map (d: {
|
|
domain = d;
|
|
file = ../../modules/dns/zones + "/${d}.zone";
|
|
"zonefile-load" = "difference-no-serial";
|
|
"zonefile-sync" = "-1";
|
|
"journal-content" = "all"; # required by difference-no-serial; holds the live signed zone
|
|
"serial-policy" = "unixtime";
|
|
"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"
|
|
]
|
|
++ lib.optionals (d == "cnx.email") [ "acl_acme_mx1" ]
|
|
++ lib.optionals (d == "cnx.network") [
|
|
"acl_acme_web01"
|
|
"acl_acme_gw_cnx_1"
|
|
];
|
|
}) domains;
|
|
}
|