--- date: 2026-11-09 branch: feat/pihole-explicit-lan-names title: PiHole answers an explicit LAN name list instead of a zone wildcard (public names resolve to Cloudflare from the LAN) --- ## What changed **`catalog/pihole.yaml` → `apps/wave-0/pihole.yaml`.** `FTLCONF_misc_dnsmasq_lines` no longer carries `address=/${BASE_DOMAIN}/${GATEWAY_VIP}`. Every name not listed is now forwarded to 8.8.8.8/8.8.4.4 and gets the public answer. The final list (`local-ttl=60` kept): | Name (`.${BASE_DOMAIN}`) | Answer | Why | |---|---|---| | jackett, pihole, plex, qbittorrent, radarr, semaphore, sonarr, headlamp | `${GATEWAY_VIP}` | LAN-only: every HTTPRoute with `external-dns.alpha.kubernetes.io/controller: none`, derived from the tree. No public record, tunnel 404s them; this entry is their only path | | gitea, nextcloud | `${GATEWAY_VIP}` | public, but Cloudflare's proxy caps bodies at 100 MB / responses at 100 s (git push, LFS, uploads) | | keycloak, auth | `${GATEWAY_VIP}` | keep the OIDC flow of the LAN-path apps on one path | | unifi | `${UNIFI_L4_VIP}` | L4 VIP, not behind the Gateway | | crafty-mc | `${CRAFTY_L4_VIP}` | L4 VIP, not behind the Gateway | | pihole-dns | `${PIHOLE_DNS_VIP}` | the resolver's own address | | plex-vip | `${PLEX_L4_VIP}` | kept, see below | Not listed, so they resolve to Cloudflare from the LAN: the apex, grafana, crafty, seerr. **The derived LAN-only set matches the brief's list exactly** (8 names; the tree was walked, not the list trusted). The `runbooks/serving` route has no `hostnames:` but is not disowned (it inherits the apex, public). **plex-vip is kept.** Nothing advertises it any more (`ADVERTISE_IP` is a `plex.direct` name since 2026-10-05), but it is not unused: it is Plex's `customCertificateDomain` (`CERTDOMAIN`, Secret `plex-vip-tls`), the name of the tcp/32400 VIP, and the 404'd name in the tunnel config. It has no public record, so this entry is its only resolution. **New L0 check `conformance/tests/l0-static/pihole-lan-names`** (`scope: whole-repo`, so picked up by the runner's scope selection with no other wiring). Asserts: (a) no zone-wide entry (zone, `#`, or any parent of the zone); (b) every `controller: none` hostname → `${GATEWAY_VIP}`, and a disowned route with no `hostnames:` fails; (c) every tunnel-404 hostname has an entry, with the L4 names (derived from Services carrying both the external-dns hostname and an lbipam VIP) on their own VIP; (d) gitea and nextcloud → `${GATEWAY_VIP}`; plus catalog == rendered value and a built-in mutation self-test. **Stale docs corrected:** `platform/pm-agent/README.md`, `runbooks/pm-agent.md`, `platform/externaldns/README.md`, `platform/cloudflared/README.md`, `platform/gateway/gateway.yaml` (comment), `platform/headlamp/httproute.yaml` (comment), `apps/vms/unifi/README.md`, `README.md`, `schemas/renderer-contract.md`; `runbooks/cloudflare-tunnel.md` gains the two consequences below. `platform/cloudflared/deployment.yaml`: the image entrypoint comment was wrong (comment-only). ## What I verified - **Render:** the baseline `render.py --no-image-automation catalog/pihole.yaml` was byte-identical to the committed `apps/wave-0/pihole.yaml` before the change, and the file was replaced by that command's output after it; `--emit-waves` was not run. - **Mutation, against the real files (10 mutants, each rc 1 with the right assertion, each restored):** (a) zone wildcard restored; (b) headlamp and sonarr entries removed; (b) a disowned route with no `hostnames:`; (c) a new tunnel-404 host with no entry; (c) pihole-dns removed; (c) unifi pointed at the Gateway; (d) gitea removed; (d) nextcloud repointed; (0) rendered value drifted from the catalog. - **Rollout mechanics, measured live (read-only):** StatefulSet `updateStrategy` RollingUpdate partition 0, `podManagementPolicy: Parallel`, no `minReadySeconds`, 2 replicas, PDB minAvailable 1. The last rollout (2026-11-08 06:47–06:48Z) replaced pihole-1 then pihole-0: FTL logged "started" ~1 s after the container, the pod went Ready ~16 s after the container, and pihole-0 started ~9 s later. - **Expected LAN DNS blip:** no total outage; one replica answers throughout. Each of the two pod replacements can lose a few UDP queries for ~1–2 s while the terminating pod leaves the Service (clients retry). For roughly 30–60 s the two pods answer differently for the apex, grafana, crafty and seerr; both answers work. Cached answers age out in 60 s (`local-ttl`). - **Live DNS before the change:** @8.8.8.8 returns Cloudflare (104.21.51.91 / 172.67.178.27) for the apex, grafana, seerr, crafty and gitea, and nothing for jackett; @192.168.2.23 returns 192.168.2.20 for all of them (the wildcard). - **Cloudflared image:** `skopeo inspect --config` on the pinned digest (amd64): Entrypoint `["cloudflared"]`, Cmd `["version"]`, User `65532:65532`. ## What I did NOT verify - **The post-merge behaviour.** Nothing here ran against a live PiHole with the new list; the expected answers are from dnsmasq semantics and the tree. After reconcile, check `dig @192.168.2.23` for one name per row, and grafana returning Cloudflare. - **Whether every LAN client path works via Cloudflare.** Measured 2xx/3xx through Cloudflare by the brief, not re-measured from a LAN client here. - **The FTL DNS-listening moment.** "FTL started" is a log line, not a successful query; the rollout timing above assumes DNS is up shortly after it. **Accepted consequences, stated plainly:** - The apex, grafana, crafty and seerr now depend on the internet and the tunnel from the LAN too. An ISP or Cloudflare outage, or stopping the connectors, makes them unreachable from the LAN where they previously kept working. If grafana in particular should survive an internet outage, add it to the list (one entry; the check permits it). - The tunnel rollback (revert to `wan.`) now also requires restoring the wildcard; recorded in `runbooks/cloudflare-tunnel.md`. **Flagged, not changed:** - The `pihole` container has no readiness probe; only the `oauth2-proxy` sidecar does, so "Ready" says nothing about DNS. The measured margin (FTL ~1 s, Ready ~16 s) makes this benign in a normal rollout but a slow FTL start would not hold the rollout back. A DNS readiness probe needs a schema/renderer decision. **Stale statements left alone:** `platform/coredns/coredns-custom.yaml` header ("both resolve the whole zone ... by wildcard", brief said not to touch CoreDNS); `clusters/base/cluster-vars.yaml` comments (sensitive path); `schemas/renderer-contract.md` §`exposure` first bullets (manual edge port-forward wording); historical `infra/PROD-PROVISIONING.md`, `platform/externaldns/README.md` TODO list.