Phase 2 of the Cloudflare Tunnel. This moves public DNS onto the tunnel in one atomic PR, so the conformance guard lands together with the flip.
Change
Gateway target: platform/gateway/gateway.yamlexternal-dns.alpha.kubernetes.io/target changes from wan.${BASE_DOMAIN} to the literal fc81938b-5855-47f1-8a27-6286f56dda93.cfargotunnel.com.
Proxying: platform/externaldns/helmrelease.yaml gets extraArgs: [--cloudflare-proxied]. It has to be global: v0.23.0 reads per-object cloudflare-proxied from the route or Service, never from the Gateway.
L4 Services stay DNS-only: unifi-l4-tcp and crafty-l4-tcp get cloudflare-proxied: "false". These are the only service-source publishers, both in the tree and live.
Plex web UI is LAN/VPN-only: catalog/plex.yaml changes from exposure: public to internal. The golden was re-rendered with the canonical command into a scratch directory, and only apps/wave-0/plex.yaml was copied back. The diff is +2 lines: controller: none on HTTPRoute plex-32400. vm-demo and keycloak were not onboarded and no other golden moved. ADVERTISE_IP (plex.direct), the L4 VIP .24, plex-remote.yaml and tcp/32400 are untouched.
Headlamp is LAN/VPN-only: controller: none on platform/headlamp/httproute.yaml.
L0 internal-services-publish-no-public-dns gets a new assertion (9):
(9a) The Gateway target must equal <tunnel: from platform/cloudflared/config.yaml>.cfargotunnel.com, and --cloudflare-proxied must be set.
(9b) Every service-source publisher must carry cloudflare-proxied: "false", and no HTTPRoute may opt out of proxying.
(9c) A hostname the tunnel 404s may be named only by disowned routes or by the declared L4 DNS-only set {unifi, crafty-mc}. The reverse also holds: every disowned route's hostname must be 404'd by the tunnel, so the two lists stay consistent.
(9d) plex and headlamp must be disowned.
The header and register prose are updated.
L2 dns-exposure fixture: gets cloudflare-proxied: "false". Without it the global flag proxies the probe record and the exact-IP poll against 1.1.1.1 can never match.
Docs: ddns.yaml header (wan. is now WireGuard-only), the externaldns and gateway READMEs, runbooks/plex-remote-access.md, and runbooks/cloudflare-tunnel.md. The tunnel runbook marks Phase 2 done, adds the router operator step, makes credential rotation swap the Gateway UUID too, and covers rollback.
Measured on prod at ~23:55Z: 17 HTTPRoutes, Gateway target wan.home.keithjackson.dev.
UPDATE: CNAME wan. changes to a proxied CNAME fc81938b-…cfargotunnel.com (8 names): auth, crafty, gitea, grafana, keycloak, nextcloud, seerr, and the apex home.keithjackson.dev (homepage; the runbooks route shares the apex).
DELETE (record and its TXT): plex.home.keithjackson.dev and headlamp.home.keithjackson.dev.
Unchanged:
unifi (A 192.168.2.21, DNS-only) and crafty-mc (A 192.168.2.22, DNS-only).
wan. (cloudflare-ddns, DNS-only, used by WireGuard udp/51820).
The six internal names (jackett, pihole, qbittorrent, radarr, semaphore, sonarr) are already controller: none and stay unpublished.
Checks:
kubectl --context k3s-prod -n external-dns logs deploy/external-dns should show UPDATE for the 8 names and DELETE for the plex and headlamp CNAMEs and TXTs.
dig @1.1.1.1 gitea.home.keithjackson.dev A and AAAA should return Cloudflare anycast. That AAAA is Cloudflare's edge, not an origin address.
dig @1.1.1.1 plex.home.keithjackson.dev should return NXDOMAIN or empty.
The LAN is unaffected: the Pi-hole wildcard still answers 192.168.2.20. Trimming Pi-hole is a later PR.
Operator step after verifying: close the router's WAN tcp/80 and tcp/443 forwards. Keep udp/51820 and tcp/32400 (see runbooks/cloudflare-tunnel.md).
Risk
If the annotation prefix stops being honoured at runtime again (the 2026-11-07 incident class), target and controller: none are both ignored. Every name would then become a proxied A record at 192.168.2.20, and the internal names would publish too.
The guard is assertion (8): (8a) values.annotationPrefix must equal the prefix used by every annotation in the tree and by the renderer, and (8b) no prefix flags are allowed in extraArgs.
That only proves the tree is consistent. It cannot catch a controller that ignores the flag after a chart bump, and no runtime alert exists for this.
A second effect to know about: nodes resolve through public DNS, so kube-apiserver OIDC discovery for keycloak. now goes via Cloudflare and the tunnel. No oidc-ca-file is set, so system roots apply and the edge cert should validate. This is untested.
Gate (pinned toolbox, from the worktree, with DEV_KUBECONFIG=/dev/null and the .git mount shim)
rc 3. The only stage that did not run is gitea-settings (no SETTINGS_API_TOKEN locally).
spec-immutability, decisions-log, lint, l0, render-golden, l1 and approval-policy are green (approval-policy is N/A offline).
The L0 check passes 116 assertions, and each new assertion was mutation-tested to fail.
L2/L3 conformance does not run (no cluster), so the dns-exposure fixture change is untested live.
Phase 2 of the Cloudflare Tunnel. This moves public DNS onto the tunnel in one atomic PR, so the conformance guard lands together with the flip.
## Change
- **Gateway target**: `platform/gateway/gateway.yaml` `external-dns.alpha.kubernetes.io/target` changes from `wan.${BASE_DOMAIN}` to the literal `fc81938b-5855-47f1-8a27-6286f56dda93.cfargotunnel.com`.
- **Proxying**: `platform/externaldns/helmrelease.yaml` gets `extraArgs: [--cloudflare-proxied]`. It has to be global: v0.23.0 reads per-object `cloudflare-proxied` from the route or Service, never from the Gateway.
- **L4 Services stay DNS-only**: `unifi-l4-tcp` and `crafty-l4-tcp` get `cloudflare-proxied: "false"`. These are the only `service`-source publishers, both in the tree and live.
- **Plex web UI is LAN/VPN-only**: `catalog/plex.yaml` changes from `exposure: public` to `internal`. The golden was re-rendered with the canonical command into a scratch directory, and only `apps/wave-0/plex.yaml` was copied back. The diff is **+2 lines**: `controller: none` on HTTPRoute `plex-32400`. vm-demo and keycloak were not onboarded and no other golden moved. ADVERTISE_IP (plex.direct), the L4 VIP .24, `plex-remote.yaml` and tcp/32400 are untouched.
- **Headlamp is LAN/VPN-only**: `controller: none` on `platform/headlamp/httproute.yaml`.
- **L0 `internal-services-publish-no-public-dns`** gets a new assertion (9):
- (9a) The Gateway target must equal `<tunnel: from platform/cloudflared/config.yaml>.cfargotunnel.com`, and `--cloudflare-proxied` must be set.
- (9b) Every `service`-source publisher must carry `cloudflare-proxied: "false"`, and no HTTPRoute may opt out of proxying.
- (9c) A hostname the tunnel 404s may be named only by disowned routes or by the declared L4 DNS-only set {unifi, crafty-mc}. The reverse also holds: every disowned route's hostname must be 404'd by the tunnel, so the two lists stay consistent.
- (9d) plex and headlamp must be disowned.
- The header and register prose are updated.
- **L2 `dns-exposure` fixture**: gets `cloudflare-proxied: "false"`. Without it the global flag proxies the probe record and the exact-IP poll against 1.1.1.1 can never match.
- **Docs**: `ddns.yaml` header (wan. is now WireGuard-only), the externaldns and gateway READMEs, `runbooks/plex-remote-access.md`, and `runbooks/cloudflare-tunnel.md`. The tunnel runbook marks Phase 2 done, adds the router operator step, makes credential rotation swap the Gateway UUID too, and covers rollback.
- `decisions/2026-11-08-dns-via-cloudflare-tunnel.md`.
## Expected live effect after merge (please verify)
Measured on prod at ~23:55Z: 17 HTTPRoutes, Gateway target `wan.home.keithjackson.dev`.
**UPDATE: CNAME `wan.` changes to a proxied CNAME `fc81938b-…cfargotunnel.com`** (8 names):
`auth`, `crafty`, `gitea`, `grafana`, `keycloak`, `nextcloud`, `seerr`, and the apex `home.keithjackson.dev` (homepage; the runbooks route shares the apex).
**DELETE (record and its TXT)**: `plex.home.keithjackson.dev` and `headlamp.home.keithjackson.dev`.
**Unchanged**:
- `unifi` (A 192.168.2.21, DNS-only) and `crafty-mc` (A 192.168.2.22, DNS-only).
- `wan.` (cloudflare-ddns, DNS-only, used by WireGuard udp/51820).
- The six internal names (jackett, pihole, qbittorrent, radarr, semaphore, sonarr) are already `controller: none` and stay unpublished.
**Checks**:
- `kubectl --context k3s-prod -n external-dns logs deploy/external-dns` should show UPDATE for the 8 names and DELETE for the plex and headlamp CNAMEs and TXTs.
- `dig @1.1.1.1 gitea.home.keithjackson.dev A` and `AAAA` should return Cloudflare anycast. That AAAA is Cloudflare's edge, not an origin address.
- `dig @1.1.1.1 plex.home.keithjackson.dev` should return NXDOMAIN or empty.
The LAN is unaffected: the Pi-hole wildcard still answers 192.168.2.20. Trimming Pi-hole is a later PR.
**Operator step after verifying**: close the router's WAN tcp/80 and tcp/443 forwards. Keep udp/51820 and tcp/32400 (see `runbooks/cloudflare-tunnel.md`).
## Risk
If the annotation prefix stops being honoured at runtime again (the 2026-11-07 incident class), `target` and `controller: none` are both ignored. Every name would then become a **proxied A record at 192.168.2.20**, and the internal names would publish too.
- The guard is assertion **(8)**: (8a) `values.annotationPrefix` must equal the prefix used by every annotation in the tree and by the renderer, and (8b) no prefix flags are allowed in extraArgs.
- That only proves the tree is consistent. It cannot catch a controller that ignores the flag after a chart bump, and no runtime alert exists for this.
A second effect to know about: nodes resolve through public DNS, so kube-apiserver OIDC discovery for `keycloak.` now goes via Cloudflare and the tunnel. No `oidc-ca-file` is set, so system roots apply and the edge cert should validate. This is untested.
## Gate (pinned toolbox, from the worktree, with DEV_KUBECONFIG=/dev/null and the .git mount shim)
- **rc 3**. The only stage that did not run is **gitea-settings** (no SETTINGS_API_TOKEN locally).
- spec-immutability, decisions-log, lint, l0, render-golden, l1 and approval-policy are green (approval-policy is N/A offline).
- The L0 check passes 116 assertions, and each new assertion was mutation-tested to fail.
- L2/L3 conformance does not run (no cluster), so the dns-exposure fixture change is untested live.
## Sensitive paths (needs operator `/approve-override`)
- `conformance/tests/l0-static/internal-services-publish-no-public-dns/check.sh`
- `conformance/tests/l0-static/internal-services-publish-no-public-dns/chainsaw-test.yaml`
- `conformance/tests/l2-behavioral/dns-exposure/chainsaw-test.yaml`
## Follow-ups
- Spec requirement IDs (proposed R-DNS-04/05, R-SEC-04) once the spec ADR merges. They are not referenced here.
- `platform/pm-agent/README.md` and a comment in `catalog/pihole.yaml` still describe the wan. CNAME.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Gateway target -> fc81938b-...cfargotunnel.com, --cloudflare-proxied globally,
L4 service publishers (unifi, crafty-mc) pinned DNS-only, Plex web UI and
Headlamp disowned (LAN/VPN-only). L0 internal-services-publish-no-public-dns
gains assertion (9) tying the Gateway target to the tunnel UUID, requiring
cloudflare-proxied=false on every service-source publisher, and keeping the
tunnel's 404 list and the unpublished set consistent. L2 dns-exposure fixture
opts out of proxying.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Phase 2 of the Cloudflare Tunnel. This moves public DNS onto the tunnel in one atomic PR, so the conformance guard lands together with the flip.
Change
platform/gateway/gateway.yamlexternal-dns.alpha.kubernetes.io/targetchanges fromwan.${BASE_DOMAIN}to the literalfc81938b-5855-47f1-8a27-6286f56dda93.cfargotunnel.com.platform/externaldns/helmrelease.yamlgetsextraArgs: [--cloudflare-proxied]. It has to be global: v0.23.0 reads per-objectcloudflare-proxiedfrom the route or Service, never from the Gateway.unifi-l4-tcpandcrafty-l4-tcpgetcloudflare-proxied: "false". These are the onlyservice-source publishers, both in the tree and live.catalog/plex.yamlchanges fromexposure: publictointernal. The golden was re-rendered with the canonical command into a scratch directory, and onlyapps/wave-0/plex.yamlwas copied back. The diff is +2 lines:controller: noneon HTTPRouteplex-32400. vm-demo and keycloak were not onboarded and no other golden moved. ADVERTISE_IP (plex.direct), the L4 VIP .24,plex-remote.yamland tcp/32400 are untouched.controller: noneonplatform/headlamp/httproute.yaml.internal-services-publish-no-public-dnsgets a new assertion (9):<tunnel: from platform/cloudflared/config.yaml>.cfargotunnel.com, and--cloudflare-proxiedmust be set.service-source publisher must carrycloudflare-proxied: "false", and no HTTPRoute may opt out of proxying.dns-exposurefixture: getscloudflare-proxied: "false". Without it the global flag proxies the probe record and the exact-IP poll against 1.1.1.1 can never match.ddns.yamlheader (wan. is now WireGuard-only), the externaldns and gateway READMEs,runbooks/plex-remote-access.md, andrunbooks/cloudflare-tunnel.md. The tunnel runbook marks Phase 2 done, adds the router operator step, makes credential rotation swap the Gateway UUID too, and covers rollback.decisions/2026-11-08-dns-via-cloudflare-tunnel.md.Expected live effect after merge (please verify)
Measured on prod at ~23:55Z: 17 HTTPRoutes, Gateway target
wan.home.keithjackson.dev.UPDATE: CNAME
wan.changes to a proxied CNAMEfc81938b-…cfargotunnel.com(8 names):auth,crafty,gitea,grafana,keycloak,nextcloud,seerr, and the apexhome.keithjackson.dev(homepage; the runbooks route shares the apex).DELETE (record and its TXT):
plex.home.keithjackson.devandheadlamp.home.keithjackson.dev.Unchanged:
unifi(A 192.168.2.21, DNS-only) andcrafty-mc(A 192.168.2.22, DNS-only).wan.(cloudflare-ddns, DNS-only, used by WireGuard udp/51820).controller: noneand stay unpublished.Checks:
kubectl --context k3s-prod -n external-dns logs deploy/external-dnsshould show UPDATE for the 8 names and DELETE for the plex and headlamp CNAMEs and TXTs.dig @1.1.1.1 gitea.home.keithjackson.dev AandAAAAshould return Cloudflare anycast. That AAAA is Cloudflare's edge, not an origin address.dig @1.1.1.1 plex.home.keithjackson.devshould return NXDOMAIN or empty.The LAN is unaffected: the Pi-hole wildcard still answers 192.168.2.20. Trimming Pi-hole is a later PR.
Operator step after verifying: close the router's WAN tcp/80 and tcp/443 forwards. Keep udp/51820 and tcp/32400 (see
runbooks/cloudflare-tunnel.md).Risk
If the annotation prefix stops being honoured at runtime again (the 2026-11-07 incident class),
targetandcontroller: noneare both ignored. Every name would then become a proxied A record at 192.168.2.20, and the internal names would publish too.values.annotationPrefixmust equal the prefix used by every annotation in the tree and by the renderer, and (8b) no prefix flags are allowed in extraArgs.A second effect to know about: nodes resolve through public DNS, so kube-apiserver OIDC discovery for
keycloak.now goes via Cloudflare and the tunnel. Nooidc-ca-fileis set, so system roots apply and the edge cert should validate. This is untested.Gate (pinned toolbox, from the worktree, with DEV_KUBECONFIG=/dev/null and the .git mount shim)
Sensitive paths (needs operator
/approve-override)conformance/tests/l0-static/internal-services-publish-no-public-dns/check.shconformance/tests/l0-static/internal-services-publish-no-public-dns/chainsaw-test.yamlconformance/tests/l2-behavioral/dns-exposure/chainsaw-test.yamlFollow-ups
platform/pm-agent/README.mdand a comment incatalog/pihole.yamlstill describe the wan. CNAME.🤖 Generated with Claude Code