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 + ]; +}