From b11ff75ca6241b0fd4d9c01d99f0980ba0c0cfdb Mon Sep 17 00:00:00 2001 From: Berwn Date: Fri, 31 Jul 2026 10:09:04 +0700 Subject: [PATCH] Add internal reverse proxy for gateways (Caddy, wildcard via DNS-01) cnx.router.proxy serves ..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). --- docs/src/gateways.md | 45 ++++++++- machines/gw-cnx-1/configuration.nix | 12 +++ machines/ns1/configuration.nix | 36 ++++++- modules/dns/acme-gw-secret.nix | 17 ++++ modules/router/default.nix | 1 + modules/router/proxy.nix | 141 ++++++++++++++++++++++++++++ 6 files changed, 246 insertions(+), 6 deletions(-) create mode 100644 modules/dns/acme-gw-secret.nix create mode 100644 modules/router/proxy.nix diff --git a/docs/src/gateways.md b/docs/src/gateways.md index 5d0dc2e..1efe78d 100644 --- a/docs/src/gateways.md +++ b/docs/src/gateways.md @@ -20,6 +20,7 @@ Naming: `gw--`, e.g. `gw-cnx-1`. | IPv6 | DHCPv6-PD on ppp0, /64 per VLAN via SLAAC | | Bans | CrowdSec + nftables bouncer (sshd log parsing) | | Omada | Optional per site: TP-Link Omada controller as a podman container | +| Proxy | Optional: Caddy reverse proxy for internal services under `*..cnx.network` with a real Let's Encrypt wildcard (DNS-01 against ns1) | | Management | ZeroTier mesh: SSH, node_exporter, journald upload — like the fleet | | Storage | Single-disk ZFS (zstd, `/var` its own dataset for snapshots) | @@ -57,17 +58,24 @@ Trust model: mgmt → everything; other VLANs → router DNS/DHCP + internet onl (dynamic PPPoE IP; clan connects over the mesh). 3. Add its node_exporter and Blocky scrape targets in `modules/monitoring/server.nix`. -4. `git add` the new machine directory — flake evaluation only sees +4. If the site uses the internal proxy (`cnx.router.proxy.enable`), wire ns1: + import `(import ../../modules/dns/acme-gw-secret.nix "gw--")` in + **both** the gateway's and ns1's configuration, and on ns1 add the + `dns-acme-gw---knot` generator, its `keyFiles` entry, and an + `acl_acme_gw__` scoped to `_acme-challenge.` on the + `cnx.network` zone (copy the `gw-cnx-1` blocks). Then + `clan vars generate ns1` and redeploy ns1. +5. `git add` the new machine directory — flake evaluation only sees git-tracked files, so an untracked `machines/gw-…/` is silently ignored. -5. `clan vars generate gw--` — prompts for the site's PPPoE +6. `clan vars generate gw--` — prompts for the site's PPPoE credentials, mints the ZeroTier identity etc. (`nix flake check` fails until this has run, because mesh-hosts reads the ZeroTier IP var.) -6. Boot the box from a NixOS installer USB on the local network, then: +7. Boot the box from a NixOS installer USB on the local network, then: `clan machines install gw-- --target-host root@` -7. Check `facter.json` for the real NIC names, fix `wan.interface` / +8. Check `facter.json` for the real NIC names, fix `wan.interface` / `trunkPorts` if the enumeration differs, and `clan machines update gw--` (rides the mesh from then on). -8. Add a row to the site table above and to the machines table in +9. Add a row to the site table above and to the machines table in [Overview](./overview.md). ## Omada controller @@ -80,6 +88,33 @@ gateway's mesh address from an admin machine. Controller state is under `/var/lib/omada`, declared as clan state (`clan.core.state.omada`); wiring it into the borgbackup instance is still a follow-up. +## Internal reverse proxy + +`cnx.router.proxy.enable` puts Caddy on the gateway, terminating TLS for +`..cnx.network` (e.g. `https://omada.cnx1.cnx.network`) and +forwarding to internal backends: + +```nix +cnx.router.proxy = { + enable = true; + services.omada = { + backend = "https://127.0.0.1:8043"; + insecureSkipVerify = true; # Omada's cert is self-signed + }; +}; +``` + +- **Cert**: one real Let's Encrypt wildcard `*..cnx.network` per + gateway, issued via ACME DNS-01 (lego/RFC2136) against ns1 — works behind + PPPoE with zero inbound reachability, and browsers trust it without a CA + install. Each gateway has its own TSIG key (`acme_gw__`) that ns1 + scopes to `_acme-challenge.` TXT records only. +- **Resolution**: the names exist only internally — Blocky answers + `*..cnx.network` with the router's `lan` address; the public + `cnx.network` zone never carries them. +- **Access**: `proxy.allowVlans` (default `mgmt` + `lan`) get 443 (and 80 for + the HTTP→HTTPS redirect). Not exposed to WAN, guest VLANs, or the mesh. + ## Runbook - **PPPoE down**: `systemctl status pppd-wan`, `journalctl -u pppd-wan` on the diff --git a/machines/gw-cnx-1/configuration.nix b/machines/gw-cnx-1/configuration.nix index 1a91225..4b3a43f 100644 --- a/machines/gw-cnx-1/configuration.nix +++ b/machines/gw-cnx-1/configuration.nix @@ -6,6 +6,7 @@ imports = [ ../../modules/router ../../modules/monitoring/exporters.nix + (import ../../modules/dns/acme-gw-secret.nix "gw-cnx-1") ]; clan.core.sops.defaultGroups = [ "admins" ]; @@ -36,6 +37,17 @@ }; # This site runs the Omada controller for its APs/switches. omada.enable = true; + + # Internal reverse proxy: real wildcard cert *.cnx1.cnx.network; Blocky + # resolves the names to the router's LAN address for mgmt+lan clients. + proxy = { + enable = true; + services.omada = { + # Omada's UI is HTTPS with a self-signed cert on the host network. + backend = "https://127.0.0.1:8043"; + insecureSkipVerify = true; + }; + }; }; time.timeZone = "Etc/GMT-7"; # UTC+7 (Thailand, fixed offset, no DST) diff --git a/machines/ns1/configuration.nix b/machines/ns1/configuration.nix index cdafc16..d4a9082 100644 --- a/machines/ns1/configuration.nix +++ b/machines/ns1/configuration.nix @@ -14,6 +14,7 @@ in ../../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 ]; @@ -93,10 +94,29 @@ in ''; }; + # ACME DNS-01, dedicated gateway keys. Same pattern as web01: each gateway + # holds its own TSIG key (acme_gw__), rendered from the shared + # secret generator imported above. acl_acme_gw__ scopes it to TXT + # updates at _acme-challenge. on cnx.network — the owner its internal + # wildcard *..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 = [ @@ -133,6 +153,17 @@ in # 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 @@ -168,6 +199,9 @@ in "acl_acme" ] ++ lib.optionals (d == "cnx.email") [ "acl_acme_mx1" ] - ++ lib.optionals (d == "cnx.network") [ "acl_acme_web01" ]; + ++ lib.optionals (d == "cnx.network") [ + "acl_acme_web01" + "acl_acme_gw_cnx_1" + ]; }) domains; } diff --git a/modules/dns/acme-gw-secret.nix b/modules/dns/acme-gw-secret.nix new file mode 100644 index 0000000..4dfa778 --- /dev/null +++ b/modules/dns/acme-gw-secret.nix @@ -0,0 +1,17 @@ +# Shared TSIG secret for a gateway's dedicated ACME key (function: machine +# name -> module). The acme_gw_ key lets that gateway — and only it — write +# _acme-challenge.