From b5173033b6f0ab78b335ee0e01ac7506ecf121a9 Mon Sep 17 00:00:00 2001 From: Frank Date: Wed, 4 Nov 2026 13:45:25 -0800 Subject: [PATCH] fix(caddy): *arr stack and qBittorrent are LAN-only at the edge A missing Porkbun record was never the gate: Caddy issues one certificate per name and every one is in public CT logs, so the *arr hostnames were discoverable and the edge accepted them. Each of the five vhosts now carries a remote_ip source gate (LAN proxies through, everything else gets 404) - the posture the Sonarr comment already claimed. AGENTS.md records the LAN-gate shape as the required form of internal-only, and Caddy.md records why DNS secrecy is fiction. --- AGENTS.md | 12 ++++- dankclaw/Caddyfile | 49 ++++++++++++++++--- serverAppConfigs-docs/Infrastructure/Caddy.md | 4 +- 3 files changed, 56 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index eefd4fc..0021a7e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -231,7 +231,17 @@ Every Caddy-proxied service is in exactly one of three postures: behind the auth chain (Anubis :8923 → Authentik embedded outpost), **exempt by construction** because it authenticates itself (`auth.`, git smart-HTTP, Gitea, OpenWebUI, Jellyfin, Plex, Audiobookshelf, the *arr stack), or internal-only — -no public DNS name, no vhost. New services default to internal-only until guarded. +served to nobody off-LAN. New services default to internal-only until guarded. + +**Internal-only is a source gate, not a missing DNS record.** Caddy answers +every Host it knows, and its per-name certificates are published in Certificate +Transparency logs, so a name with no Porkbun A record is still in a stranger's +reach (`crt.sh` lists it). A household-only service therefore carries an edge +gate — `@lan remote_ip 127.0.0.1/32 ::1/128 192.168.0.0/16 10.0.0.0/8 +172.16.0.0/12`, `handle @lan { reverse_proxy … }`, `handle { respond 404 }` — +which is what the *arr stack and qBittorrent carry (2026-11-04: Frank's law, +"never leaving the home boxes"). A future household-only vhost gets the same +block, not merely an absent record. The guard chain is a **browser-only device**: Anubis demands a JavaScript proof-of-work and the outpost works from a session cookie, so any non-browser diff --git a/dankclaw/Caddyfile b/dankclaw/Caddyfile index 7138b27..88f589e 100644 --- a/dankclaw/Caddyfile +++ b/dankclaw/Caddyfile @@ -303,14 +303,31 @@ jelly.franksdank.cloud { } -# --- *arr Stack (exempt by construction: own auth) --- - +# --- *arr Stack + qBittorrent: LAN-ONLY at the edge (2026-11-04) ----------- +# Frank's law: these never leave the home boxes. DNS secrecy is NOT a gate — +# Caddy issues one certificate per name and every one is in public CT logs +# (crt.sh lists prowlarr/radarr/lidarr/readarr/qbittorrent.franksdank.cloud), +# so a stranger can send the Host. The gate is the source address: a LAN client +# proxies through, anything off-LAN gets 404. `remote_ip` reads the real client +# address because the router DNATs the forward (no proxy hop in between). prowlarr.franksdank.cloud { - reverse_proxy 192.168.0.112:9696 + @lan remote_ip 127.0.0.1/32 ::1/128 192.168.0.0/16 10.0.0.0/8 172.16.0.0/12 + handle @lan { + reverse_proxy 192.168.0.112:9696 + } + handle { + respond 404 + } } radarr.franksdank.cloud { - reverse_proxy 192.168.0.112:7878 + @lan remote_ip 127.0.0.1/32 ::1/128 192.168.0.0/16 10.0.0.0/8 172.16.0.0/12 + handle @lan { + reverse_proxy 192.168.0.112:7878 + } + handle { + respond 404 + } } # --- Sonarr: internal-only by construction (retired 2026-09-04) ----------- @@ -321,15 +338,33 @@ radarr.franksdank.cloud { # zero-auth UI to the internet via manual DNS — retired. lidarr.franksdank.cloud { - reverse_proxy 192.168.0.112:8686 + @lan remote_ip 127.0.0.1/32 ::1/128 192.168.0.0/16 10.0.0.0/8 172.16.0.0/12 + handle @lan { + reverse_proxy 192.168.0.112:8686 + } + handle { + respond 404 + } } readarr.franksdank.cloud { - reverse_proxy 192.168.0.112:8787 + @lan remote_ip 127.0.0.1/32 ::1/128 192.168.0.0/16 10.0.0.0/8 172.16.0.0/12 + handle @lan { + reverse_proxy 192.168.0.112:8787 + } + handle { + respond 404 + } } qbittorrent.franksdank.cloud { - reverse_proxy 192.168.0.112:8080 + @lan remote_ip 127.0.0.1/32 ::1/128 192.168.0.0/16 10.0.0.0/8 172.16.0.0/12 + handle @lan { + reverse_proxy 192.168.0.112:8080 + } + handle { + respond 404 + } } # --- Internal listener: auth decision layer --------------------------------- diff --git a/serverAppConfigs-docs/Infrastructure/Caddy.md b/serverAppConfigs-docs/Infrastructure/Caddy.md index e5975ba..f3a1a1e 100644 --- a/serverAppConfigs-docs/Infrastructure/Caddy.md +++ b/serverAppConfigs-docs/Infrastructure/Caddy.md @@ -217,7 +217,9 @@ Certificates and ACME state live in the caddy data volume (`data/caddy/`). Back - A bare compose container is **not** boot-persistent: after the 2026-08-26 reboot Caddy stayed down for hours because `restart: unless-stopped` does not survive shutdown's explicit stop. It now runs only from its quadlet unit, and the repo has no `dankclaw/compose.yaml` left to re-add it to (`ls`, 2026-10-18). - `admin off` disables the config file watcher: rendered changes then sit on disk while Caddy serves its stale in-memory config until the container is recreated, and nothing logs an error (2026-08-22: a broken basicauth hash survived several deploys this way). `tests/caddyfile.bats` forbids `admin off`. - The basicauth template must stay a bare `REPLACE_WITH_HASH` token (no `$2a$14$` prefix) — the rendered value is the complete hash. A double prefix silently rejects every password with 401; no Caddy error is logged. -- **Exposure has exactly two gates: the router's 80/443 forward to Caddy, and Caddy's vhost list.** Caddy matches on SNI/Host, so *every* name in the Caddyfile already answers a WAN client that sends that name — a public A record buys **discoverability, not access** (measured 2026-11-04). A published name needs a Pi-hole `addn-hosts` line, a Porkbun A record and the Caddyfile block ([[LAN-DNS]]); DNS-01 issuance needs no public record, so an **internal-only** name is simply one with no A record, and that absence is the only thing keeping it out of a scan. Posture measured 2026-11-04: the whole authentik-guarded set is published — `www.`, `search.`, `romm.`, `files.`, `grafana.`, `prometheus.`, `loki.`, `seerr.`, `marreta.`, `notebook.` — while the names still unpublished are the self-authenticating exempt ones (`pihole.`, `hermes.`, `hermesadmin.`, `prowlarr.`, `radarr.`, `lidarr.`, `readarr.`, `qbittorrent.`) and the legacy redirects (`dankclaw.`, `dankserver.`). Every A record is a copy of the WAN address, which is dynamic — republish after an IP change. +- **Exposure has exactly two gates: the router's 80/443 forward to Caddy, and Caddy's vhost list.** Caddy matches on SNI/Host, so *every* name in the Caddyfile already answers a WAN client that sends that name — a public A record buys **discoverability, not access** (measured 2026-11-04). A published name needs a Pi-hole `addn-hosts` line, a Porkbun A record and the Caddyfile block ([[LAN-DNS]]); DNS-01 issuance needs no public record, so an **internal-only** name is one with no A record. Posture measured 2026-11-04: the whole authentik-guarded set is published — `www.`, `search.`, `romm.`, `files.`, `grafana.`, `prometheus.`, `loki.`, `seerr.`, `marreta.`, `notebook.` — while the names with no public record are the self-authenticating exempt ones (`pihole.`, `hermes.`, `hermesadmin.`, `prowlarr.`, `radarr.`, `lidarr.`, `readarr.`, `qbittorrent.`) and the legacy redirects (`dankclaw.`, `dankserver.`). Every A record is a copy of the WAN address, which is dynamic — republish after an IP change. +- **A missing DNS record is not a gate: the names are public anyway.** Caddy issues one certificate per name and every issuance is published in Certificate Transparency logs — `crt.sh` for `%.franksdank.cloud` lists the live *and* retired set (`sonarr.`, `comfyui.`, `chat.`, `jellyfin.`, the whole *arr stack), so "LAN-only because there is no A record" is fiction and a stranger can send the Host. A household-only service needs a **source gate in its vhost**, which is what the *arr stack and qBittorrent now carry: a LAN client proxies through, anything off-LAN gets `404`. Keep those five gated even though they have no A record. +- **The LAN gate is `remote_ip`, and it reads the true client.** The G36 DNATs the 80/443 forward straight to Caddy with no proxy hop, so `@lan remote_ip 127.0.0.1/32 ::1/128 192.168.0.0/16 10.0.0.0/8 172.16.0.0/12` sees either a LAN address or the internet address — never the router's. Verify the split without waiting for a WAN client: a LAN request must reach the upstream (a dead upstream still proves it passed the gate, because the answer is its `502`, not the gate's `404`), and an external fetcher must get the `404`. - **A new record looks like it failed for up to the SOA's negative TTL.** The zone's negative cache is 1800 s, so a resolver that asked for the name *before* the create keeps answering NXDOMAIN (1.1.1.1 did, while Quad9 and the authoritative NS had the record seconds after the create) — check `dig @` before re-creating anything. - The G36 has no NAT loopback, so LAN clients must resolve the name to the LAN IP. That is the Pi-hole role in [[LAN-DNS]]. *(unverified 2026-10-18: hairpin behaviour is only observable from a WAN-side client; nothing on the LAN or in this repo can prove it)* - Caddy serves HTTPS on 443 and HTTP on 80 (redirects). The router needs no 80/443 port forward for LAN use. -- 2.54.0