--- date: 2026-10-03 branch: feat/public-dns-wan-target title: Public DNS now points at the WAN — Gateway-published names CNAME to an in-cluster DDNS record --- ## What changed The operator closed the open "public-target gap" recorded in `platform/externaldns/helmrelease.yaml`. The WAN NAT / port-forwarding (80/443 → Gateway VIP 192.168.2.20) was done by the operator outside this repo. Operator decisions (2026-10-03): 1. **Scope: every Gateway-published name goes public.** That covers the five `exposure: public` apps (plex, gitea, keycloak, nextcloud, seerr) and also the hand-authored admin UIs (auth, crafty, grafana, headlamp, the `home.keithjackson.dev` apex). `internal` services still publish nothing: their `controller: none` route annotation is checked before the target is read. 2. **The WAN IP is kept current in-cluster**, not hardcoded. Implementation: - `platform/gateway/gateway.yaml`: `external-dns.alpha.kubernetes.io/target: wan.${BASE_DOMAIN}` on the shared Gateway. Every gateway-sourced record changes from `A 192.168.2.20` to `CNAME wan.home.keithjackson.dev`. - `platform/externaldns/ddns.yaml` (new): `favonia/cloudflare-ddns:1.17.1` Deployment in the `external-dns` namespace. It reuses `cloudflare-api-token` and keeps `wan.${BASE_DOMAIN}` an unproxied IPv4 A record at the WAN IP (detected via Cloudflare trace, checked every 5 min, `DELETE_ON_STOP=false`). external-dns has no TXT registry entry for it, so it never owns or deletes it. - The helmrelease comment block and the README now describe the decision instead of the gap. Known consequence: **k3s nodes resolve through public DNS** (`node_baseline_dns_servers` = 8.8.8.8 / 1.1.1.1). The kube-apiserver's OIDC issuer `keycloak.home.keithjackson.dev` therefore resolves to the WAN IP from the nodes, and kubectl SSO works only if the router hairpins WAN:443 back to the Gateway. A LAN-side probe of `136.51.115.252:443` on 2026-10-03 returned a **self-signed placeholder cert**, not the Gateway's Let's Encrypt wildcard. That suggests no hairpin. The cert-based admin kubeconfig is unaffected. ## What I verified - Live prod, 2026-10-03, before the change: Gateway `gateway/shared` ADDRESS `192.168.2.20`. External-dns args include `--source=gateway-httproute --source=service --policy=sync --txt-owner-id=prod --domain-filter=keithjackson.dev`, and it logs "All records are already up to date". - Public answers at 1.1.1.1: plex/gitea/keycloak/grafana/auth/apex → `192.168.2.20`; `crafty-mc` → `.22` and `unifi` → `.21` (service source, not affected by this change); jackett → none (internal, correctly unpublished). - The authority (betty.ns.cloudflare.com) has no unprefixed TXT, AAAA or MX at `plex.` or the apex, so the A→CNAME switch has no co-existing record to collide with. - WAN IP from the workstation: `136.51.115.252`. - No CiliumNetworkPolicy or NetworkPolicy in `external-dns`. The Kyverno label and default-deny policies match only `app-namespace` namespaces. The vpn egress policy selects only `egress-class: vpn` pods. - Toolbox gate result: see the PR description (run after this commit). ## What I did NOT verify - **The outside-in path.** I have no vantage point outside the LAN. Whether `https://.home.keithjackson.dev` from the internet reaches the Gateway with the LE cert is UNVERIFIED. - **Hairpin / kubectl SSO after the flip.** Likely broken per the probe above. Fix candidates: a node `/etc/hosts` pin for `keycloak.${BASE_DOMAIN}` → Gateway VIP via `node_baseline`, or router NAT reflection. - **The record-type flip itself**, until Flux reconciles: whether Cloudflare accepts external-dns's create-CNAME in the same batch as the delete-A, or converges one interval later. - `favonia/cloudflare-ddns` under this pod securityContext. The README warns that `no-new-privileges` misbehaves on some Proxmox-style hosts. These are bare-metal nodes, but it is untested until the pod runs. - Whether the L4 service-source names (crafty-mc, unifi) should also go public. Not decided, not changed. - `conformance/tests/l0-static/internal-services-publish-no-public-dns/check.sh` header lines 39–40 still describe the target as open. That is a sensitive path, so it is left for a follow-up.