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

+5 -3
View File
@@ -17,6 +17,8 @@ outbound-only from the cluster (no router port forward, no hairpin, no split-DNS
| `networkpolicy.yaml` | default-deny (single owner — not an app namespace) + Prometheus scrape allow + egress CNP: world 7844 tcp/udp and 443, the Gateway (`ingress` entity), and Gateway backends by `gateway-access=shared` minus the LAN-only namespaces. |
| `servicemonitor.yaml`, `alerts.yaml` | Headless metrics Service + ServiceMonitor scraping :2000, and the two alerts, both written to fire (not go quiet) when their series vanish. |
**Phase 1 only.** This component changes no live traffic: Cloudflare sends a hostname through the
tunnel only when its DNS record is a CNAME to `<UUID>.cfargotunnel.com`, and nothing here creates
one. Moving DNS (external-dns, Gateway annotations, Pi-hole/CoreDNS split horizon) is a later change.
Cloudflare sends a hostname through the tunnel only when its DNS record is a proxied CNAME to
`<UUID>.cfargotunnel.com`; nothing in this directory creates one. Those records are published by
external-dns from the shared Gateway's target annotation (Phase 2, PR #156; see
`platform/externaldns/README.md`). LAN resolution: `catalog/pihole.yaml` (explicit names only since
2026-11-09), so public names reach Cloudflare from the LAN too.
+3 -2
View File
@@ -70,8 +70,9 @@ spec:
- name: cloudflared
# Tag + digest: Renovate's `kubernetes` manager (renovate.json5, platform/**) bumps both.
image: docker.io/cloudflare/cloudflared:2026.10.7@sha256:4955524aeaa379e154023d5a027c250f7b35041a2e7e950cf13f144bc9b9c5ba
# The image ENTRYPOINT is `cloudflared --no-autoupdate`; these replace its CMD. The tunnel
# UUID, credentials path, metrics address and ingress rules all come from the file.
# The image ENTRYPOINT is `cloudflared` and its CMD is `version` (read from the pinned
# digest's image config, 2026-11-09); these args replace that CMD. `no-autoupdate`, the
# tunnel UUID, credentials path, metrics address and ingress rules all come from the file.
args:
- tunnel
- --config
+7 -2
View File
@@ -44,8 +44,13 @@ wan.home.keithjackson.dev A (DNS-only) <current WAN IP> <- cloudflare-ddn
- `internal` routes (`controller: none`) still publish nothing. Plex's web UI and Headlamp joined that
set on 2026-11-08 (LAN/VPN-only); the tunnel also 404s every such name (9c/9d).
- Public answers are Cloudflare anycast **A and AAAA** — Cloudflare's edge, not an origin address.
- LAN and pods resolve `*.home.keithjackson.dev` to the Gateway VIP via PiHole and CoreDNS
(split-horizon) and never use the public path.
- **Pods** resolve the whole zone to the Gateway VIP via CoreDNS (split-horizon) and never use the
public path. **LAN clients** use PiHole, which since 2026-11-09 answers an EXPLICIT list
(`catalog/pihole.yaml`): the LAN-only names, plus gitea/nextcloud/keycloak/auth (kept off
Cloudflare's 100 MB / 100 s limits and on one OIDC path), go to the Gateway VIP and the L4 names to
their own VIPs; every other public name (apex, grafana, crafty, seerr) resolves to Cloudflare from
the LAN exactly as from anywhere else, so no split-DNS is needed for them. Guard:
`conformance/tests/l0-static/pihole-lan-names`.
- **Nodes resolve through public DNS** (`node_baseline_dns_servers`), so from a node the apiserver's
OIDC issuer `keycloak.home.keithjackson.dev` now resolves to Cloudflare and its discovery/JWKS
fetches go through the tunnel (publicly trusted edge cert; no `oidc-ca-file` is configured). The
+4 -2
View File
@@ -30,8 +30,10 @@ metadata:
# applies BEFORE it reads this target. Those same names map to `http_status:404` in the tunnel's
# ingress rules, so even a hand-made record cannot reach them through the tunnel.
#
# LAN and in-cluster clients are unaffected: PiHole and CoreDNS answer ${BASE_DOMAIN} with
# ${GATEWAY_VIP} directly (split-horizon), so they never take this public path.
# In-cluster clients are unaffected: CoreDNS answers ${BASE_DOMAIN} with ${GATEWAY_VIP} directly
# (split-horizon). LAN clients use PiHole's EXPLICIT list (catalog/pihole.yaml, since 2026-11-09):
# LAN-only names and gitea/nextcloud/keycloak/auth get ${GATEWAY_VIP}; every other published
# name takes this public path through Cloudflare, from the LAN as from anywhere.
#
# `wan.${BASE_DOMAIN}` (platform/externaldns/ddns.yaml) is NO LONGER a target of anything here;
# it stays only as the WireGuard udp/51820 endpoint.
+1 -1
View File
@@ -20,7 +20,7 @@ metadata:
homelab.keithjackson.dev/auth-groups: platform-admins
# LAN/VPN-ONLY (operator decision 2026-11-08). Disowned from external-dns, so the public
# headlamp record and its TXT are DELETED and nothing republishes them; LAN/VPN clients keep
# resolving the name via PiHole's split-horizon wildcard (-> the Gateway VIP). The Cloudflare
# resolving the name via PiHole's explicit entry for it (-> the Gateway VIP). The Cloudflare
# Tunnel also maps headlamp.${BASE_DOMAIN} to http_status:404 (platform/cloudflared/config.yaml).
# Same mechanism the renderer emits for `exposure: internal` services; asserted by
# conformance/tests/l0-static/internal-services-publish-no-public-dns (9c).
+7 -4
View File
@@ -72,11 +72,14 @@ Both are deliberate, and both fail **visibly** rather than silently:
- **The nodes resolve the registry hostname to the WAN, so the TLS they fail to verify is
the router's — DIAGNOSED 2026-10-06, and fixed in this change.** containerd pulls as a
*node* process, so it resolves through the node's public upstreams
(`node_baseline_dns_servers` = the LAN gateway) rather than through Pi-hole. The public
answer for every name in this zone is a CNAME to `wan.home.keithjackson.dev` — the WAN
IP — which this router does not hairpin; it answers :443 with its own placeholder
(`node_baseline_dns_servers` = the LAN gateway) rather than through Pi-hole. At the time,
the public answer for every name in this zone was a CNAME to `wan.home.keithjackson.dev`
— the WAN IP — which this router does not hairpin; it answered :443 with its own placeholder
certificate `CN=%D` carrying **no extensions, therefore no SANs**, which is precisely
`x509: certificate is not valid for any names`. The Gateway, the wildcard certificate
`x509: certificate is not valid for any names`. (Since 2026-11-08 the public answer is
Cloudflare's edge via the Tunnel, not the WAN IP — see `platform/externaldns/README.md`.
The pin is still the right path: it keeps registry pulls on the LAN and off Cloudflare's
proxy.) The Gateway, the wildcard certificate
and the digest were never implicated. The fix is the pin this repository already uses
for the Keycloak issuer, for the identical reason: `gitea.{{ k3s_base_domain }}` is now
in `node_baseline_hosts_pins` — node configuration is Ansible's, declared in the