Merge pull request 'fix(caddy): the *arr stack and qBittorrent are LAN-only at the edge' (#151) from fix/arr-lan-only into main

This commit was merged in pull request #151.
This commit is contained in:
dank committed 2026-11-04 13:49:33 -08:00
commit a3ddb4c65a
3 files changed
+56 -9

No files matched your search

+11 -1
View File
@@ -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
+42 -7
View File
@@ -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 ---------------------------------
@@ -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 @<authoritative NS>` 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.