feat(dns): public names via the Cloudflare Tunnel (Phase 2) — proxied CNAMEs, Plex/Headlamp LAN-only #156

Merged
jakman85 merged 1 commits from feat/dns-via-cloudflare-tunnel into main 2026-11-09 01:27:07 +00:00
Owner

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

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)
gitea-admin added 1 commit 2026-11-09 01:24:01 +00:00
feat(dns): publish public names as proxied CNAMEs to the Cloudflare Tunnel
gate / gate (pull_request) Successful in 45s
gate / conformance (pull_request) Skipped
92944486d1
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]>
jakman85 approved these changes 2026-11-09 01:26:14 +00:00
jakman85 scheduled this pull request to auto merge when all checks succeed 2026-11-09 01:26:32 +00:00
jakman85 merged commit 020e736541 into main 2026-11-09 01:27:07 +00:00
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Home-Network/home-network-k3s-impl#156