Files
home-network-k3s-impl/decisions/2026-10-03-public-dns-wan-target.md
jakman85andClaude Opus 5.5 a1e808e697
gate / gate (pull_request) Successful in 1m46s
gate / conformance (pull_request) Has been skipped
feat(dns): publish Gateway names to the WAN via in-cluster DDNS
Gateway-published records now CNAME to wan.${BASE_DOMAIN}, which a
favonia/cloudflare-ddns Deployment keeps at the home WAN's current public
IP. Operator decision: every Gateway-published name goes public; internal
services (controller: none) remain unpublished.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-03 16:59:53 -05:00

4.2 KiB
Raw Permalink Blame History

date, branch, title
date branch title
2026-10-03 feat/public-dns-wan-target 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://<svc>.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.