From 48bf7fb25011781603c3f56c89e5acdc23f6f7ee Mon Sep 17 00:00:00 2001 From: Berwn Date: Sun, 21 Jun 2026 03:05:54 +0700 Subject: [PATCH] Add web01 public reverse proxy with DNS-01 wildcard TLS web01 terminates TLS for grafana.cnx.network and proxies to Grafana on control over the mesh. Caddy serves a *.cnx.network wildcard cert obtained via ACME DNS-01, using a dedicated acme_web01 TSIG key scoped on ns1 to _acme-challenge on the cnx.network zone only. Ports 80/443 are the only public exposure (80 just redirects); admin and the backend ride ZeroTier. Also reload Caddy on cert renewal for both web01 and mx1, since both reference the cert via explicit tls file paths and would otherwise keep serving a stale cert after a silent renewal. --- clan.nix | 1 + machines/ns1/configuration.nix | 34 +++++++++++++- machines/web01/configuration.nix | 18 +++++++- machines/web01/disko.nix | 2 +- modules/dns/acme-web01-secret.nix | 19 ++++++++ modules/dns/zones/cnx.network.zone | 7 +++ modules/hetzner-firewall-rules.nix | 24 ++++++++++ modules/hosts.nix | 4 ++ modules/mail.nix | 4 ++ modules/web-proxy.nix | 73 ++++++++++++++++++++++++++++++ 10 files changed, 182 insertions(+), 4 deletions(-) create mode 100644 modules/dns/acme-web01-secret.nix create mode 100644 modules/web-proxy.nix diff --git a/clan.nix b/clan.nix index df1d690..584b47f 100644 --- a/clan.nix +++ b/clan.nix @@ -24,6 +24,7 @@ in ns1 = { }; ns2 = { }; mx1 = { }; + web01 = { }; }; inventory.instances = { diff --git a/machines/ns1/configuration.nix b/machines/ns1/configuration.nix index 32cc1fc..578ec21 100644 --- a/machines/ns1/configuration.nix +++ b/machines/ns1/configuration.nix @@ -13,6 +13,7 @@ in imports = [ ../../modules/dns/authoritative.nix ../../modules/dns/acme-mx1-secret.nix + ../../modules/dns/acme-web01-secret.nix ../../modules/static-ipv6.nix ../../modules/monitoring/exporters.nix ]; @@ -74,9 +75,28 @@ in ''; }; + # 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 + ''; + }; + 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 ]; services.knot.settings.acl = [ @@ -102,6 +122,17 @@ in "_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" ]; + } ]; # Automatic DNSSEC signing policy (primary only). ECDSA P-256/SHA-256 with @@ -136,6 +167,7 @@ in "acl_ns2" "acl_acme" ] - ++ lib.optionals (d == "cnx.email") [ "acl_acme_mx1" ]; + ++ lib.optionals (d == "cnx.email") [ "acl_acme_mx1" ] + ++ lib.optionals (d == "cnx.network") [ "acl_acme_web01" ]; }) domains; } diff --git a/machines/web01/configuration.nix b/machines/web01/configuration.nix index 090666a..c00df31 100644 --- a/machines/web01/configuration.nix +++ b/machines/web01/configuration.nix @@ -1,7 +1,21 @@ +{ config, ... }: +let + hosts = import ../../modules/hosts.nix; +in { imports = [ - + ../../modules/static-ipv6.nix + ../../modules/monitoring/exporters.nix + ../../modules/web-proxy.nix ]; - # New machine! + clan.core.sops.defaultGroups = [ "admins" ]; + + # Public IPv6 (from modules/hosts.nix); SLAAC doesn't bring it up here. + cnx.staticIPv6 = { + enable = true; + address = hosts.${config.networking.hostName}.ipv6; + }; + + services.timesyncd.enable = true; } diff --git a/machines/web01/disko.nix b/machines/web01/disko.nix index a6acc4d..85162fa 100644 --- a/machines/web01/disko.nix +++ b/machines/web01/disko.nix @@ -1,7 +1,7 @@ # --- # schema = "single-disk" # [placeholders] -# mainDisk = "/dev/disk/by-id/scsi-0QEMU_QEMU_HARDDISK_108706511" +# mainDisk = "/dev/disk/by-id/scsi-0QEMU_QEMU_HARDDISK_108706511" # --- # This file was automatically generated! # CHANGING this configuration requires wiping and reinstalling the machine diff --git a/modules/dns/acme-web01-secret.nix b/modules/dns/acme-web01-secret.nix new file mode 100644 index 0000000..8f46850 --- /dev/null +++ b/modules/dns/acme-web01-secret.nix @@ -0,0 +1,19 @@ +# Shared TSIG secret for the dedicated acme_web01 key. +# +# This key lets web01 — and only web01 — write _acme-challenge.cnx.network TXT +# records on ns1 to obtain its wildcard (*.cnx.network) TLS cert via ACME DNS-01. +# ns1 scopes it with acl_acme_web01 (attached only to the cnx.network zone) so the +# credential can touch nothing else. ns1 renders this secret into a Knot key file; +# web01 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 web-proxy.nix) web01. +{ pkgs, ... }: +{ + clan.core.vars.generators.dns-acme-web01-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.network.zone b/modules/dns/zones/cnx.network.zone index a4e5802..e298084 100644 --- a/modules/dns/zones/cnx.network.zone +++ b/modules/dns/zones/cnx.network.zone @@ -25,3 +25,10 @@ control IN AAAA fd06:1bad:ece2:92ad:ba99:9306:1bad:ece2 ;@ IN A ;www IN CNAME cnx.network. monitor IN A 5.223.66.36 + +; ---- web01 (public reverse proxy / TLS termination) ---- +; Serves a wildcard *.cnx.network TLS cert (ACME DNS-01) and forwards to internal +; services over the mesh. Add a vhost in modules/web-proxy.nix and a CNAME here. +web01 IN A 5.223.55.246 +web01 IN AAAA 2a01:4ff:2f0:2d8f::1 +grafana IN CNAME web01.cnx.network. diff --git a/modules/hetzner-firewall-rules.nix b/modules/hetzner-firewall-rules.nix index 09fd408..1baf373 100644 --- a/modules/hetzner-firewall-rules.nix +++ b/modules/hetzner-firewall-rules.nix @@ -44,6 +44,26 @@ let (mailPort "443" "MTA-STS policy (HTTPS)") ]; + # web01 is a public reverse proxy with TLS termination. 443 serves the proxy; + # 80 only carries Caddy's HTTP->HTTPS redirect (the cert uses ACME DNS-01, not + # HTTP-01). Admin rides the mesh. + webRules = [ + { + direction = "in"; + protocol = "tcp"; + port = "80"; + source_ips = world; + description = "HTTP (redirect to HTTPS)"; + } + { + direction = "in"; + protocol = "tcp"; + port = "443"; + source_ips = world; + description = "HTTPS (reverse proxy / TLS termination)"; + } + ]; + dnsRules = [ { direction = "in"; @@ -74,4 +94,8 @@ in zerotier ping ]; + "clan-web01" = webRules ++ [ + zerotier + ping + ]; } diff --git a/modules/hosts.nix b/modules/hosts.nix index 07e9280..5aec512 100644 --- a/modules/hosts.nix +++ b/modules/hosts.nix @@ -25,4 +25,8 @@ ipv4 = "5.223.65.38"; ipv6 = "2a01:4ff:2f0:1963::1"; }; + web01 = { + ipv4 = "5.223.55.246"; + ipv6 = "2a01:4ff:2f0:2d8f::1"; + }; } diff --git a/modules/mail.nix b/modules/mail.nix index 2b55389..5b5e9b6 100644 --- a/modules/mail.nix +++ b/modules/mail.nix @@ -117,6 +117,10 @@ in # 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" ]; + # Caddy serves the MTA-STS endpoint from explicit cert file paths, so it + # won't notice a renewal on its own — reload it whenever the cert changes. + # (Merges with the postfix/dovecot reloads SNM wires up for this cert.) + reloadServices = [ "caddy.service" ]; }; }; diff --git a/modules/web-proxy.nix b/modules/web-proxy.nix new file mode 100644 index 0000000..b7b4d50 --- /dev/null +++ b/modules/web-proxy.nix @@ -0,0 +1,73 @@ +# Public reverse proxy with TLS termination for web01. Caddy fronts internal +# services and forwards to them over the ZeroTier mesh, never the public net. +# The cert is a single wildcard (*.cnx.network) obtained via ACME DNS-01, so +# adding a vhost needs no new issuance. Public ports: 443 for the proxy and 80 +# only for Caddy's HTTP->HTTPS redirect (issuance never uses inbound HTTP). +{ + config, + lib, + pkgs, + ... +}: +let + mesh = import ./mesh-hosts.nix { inherit config lib; }; + hosts = import ./hosts.nix; + certName = "cnx.network"; +in +{ + imports = [ ./dns/acme-web01-secret.nix ]; + + # Render the shared acme_web01 TSIG secret into a lego rfc2136 env file. lego + # (via security.acme below) uses it to write _acme-challenge.cnx.network TXT + # records on ns1, which authorizes the acme_web01 key for exactly that owner. + clan.core.vars.generators.dns-acme-web01-rfc2136 = { + files."rfc2136.env".secret = true; # root-owned; systemd reads it as root + dependencies = [ "dns-acme-web01-secret" ]; + script = '' + printf 'RFC2136_NAMESERVER=${hosts.ns1.ipv4}:53\nRFC2136_TSIG_ALGORITHM=hmac-sha256.\nRFC2136_TSIG_KEY=acme_web01\nRFC2136_TSIG_SECRET=%s\n' \ + "$(cat "$in"/dns-acme-web01-secret/secret)" > "$out"/rfc2136.env + ''; + }; + + security.acme = { + acceptTerms = true; + defaults.email = "postmaster@cnx.email"; + # One wildcard cert for every vhost this proxy serves, via DNS-01 (so issuance + # never depends on inbound HTTP). Port 80 is open only for Caddy's + # HTTP->HTTPS redirect, not for ACME. + certs.${certName} = { + domain = "*.cnx.network"; + extraDomainNames = [ "cnx.network" ]; + dnsProvider = "rfc2136"; + environmentFile = config.clan.core.vars.generators.dns-acme-web01-rfc2136.files."rfc2136.env".path; + # ns1 is the only nameserver that accepts the acme_web01 UPDATE; check + # propagation against it directly rather than a public resolver. + dnsResolver = "${hosts.ns1.ipv4}:53"; + # Caddy reads the cert from explicit file paths (tls directive below), so it + # won't notice a renewal on its own — reload it whenever the cert changes. + reloadServices = [ "caddy.service" ]; + }; + }; + + # The lego-issued cert is owned group=acme; Caddy needs to read the key. + users.users.caddy.extraGroups = [ "acme" ]; + + # Reverse proxy. The explicit `tls cert key` points Caddy at the wildcard cert + # and disables its automatic ACME, so no extra issuance happens. Backends are + # dialed over the mesh by their ZeroTier address (mesh.hosts.). + services.caddy = { + enable = true; + virtualHosts."grafana.cnx.network".extraConfig = '' + tls /var/lib/acme/${certName}/cert.pem /var/lib/acme/${certName}/key.pem + reverse_proxy http://[${mesh.hosts.control}]:3000 + ''; + }; + + # 443 serves the proxy; 80 only carries Caddy's automatic HTTP->HTTPS redirect + # (the Hetzner cloud firewall also scopes these in + # modules/hetzner-firewall-rules.nix). Admin still rides the mesh. + networking.firewall.allowedTCPPorts = [ + 80 + 443 + ]; +}