feat(dns): PiHole answers an explicit LAN name list, not a zone wildcard
gate / gate (pull_request) Successful in 1m14s
gate / conformance (pull_request) Skipped

Public names are proxied CNAMEs to the Cloudflare Tunnel since PR #156 and
work from the LAN, so they should resolve publicly there too. Replace the
address=/${BASE_DOMAIN}/ wildcard with explicit entries: the LAN-only names
(derived from every controller:none HTTPRoute), gitea/nextcloud (off
Cloudflare's 100 MB / 100 s limits), keycloak/auth, and the L4 VIPs.

Add L0 check pihole-lan-names, mutation-tested. Correct docs that still
described public names as CNAME to wan. and the cloudflared entrypoint comment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
jakman85andClaude Opus 5.5 committed 2026-11-08 19:51:49 -06:00
1 parent 020e736541
commit 8fe8d5df8a
16 files changed
+513 -60

No files matched your search

@@ -0,0 +1,97 @@
---
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.