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]>
98 lines
6.6 KiB
Markdown
98 lines
6.6 KiB
Markdown
---
|
||
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.
|