Files
home-network-k3s/docs/09-dns-and-ingress.md
jakman85andClaude Opus 5.5 250fb54154 docs: bring the specification in line with the accepted implementation
The operator ruled the implementation repo's main (50c8719) the latest
and accepted state. Sync the spec to it.

Accepted changes are reflected: one bare-metal environment, TrueNAS bulk
storage, R2 off-cluster backup, CloudNativePG, single-node VPN gateway
with Cilium fail-closed, inbound WireGuard, per-app auth sidecars,
Renovate, the CI gate as approver, and the pm-agent. New ADR-0017..0025;
ADR-0016 corrected and accepted; R-* IDs unchanged, eight amended.

Where the implementation has simply not met a requirement, the
requirement stands and the shortfall is recorded in docs/16-known-gaps.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:46:00 -06:00

10 KiB

09 — DNS & Ingress

Serves: R-DNS-01/02/03/04/05, R-C2-01, R-SEC-04, works with R-TLS-*. Related: ADR-0002 (Cilium/Gateway API), ADR-0016 (Cloudflare Tunnel public ingress), ADR-0021 (inbound remote access).

Naming model

  • All services live at <service>.home.keithjackson.dev (e.g. plex.home.keithjackson.dev).
  • The root home.keithjackson.dev serves the Command & Control UI (12).
  • *.home.keithjackson.dev is the platform's zone, in Cloudflare DNS. public services are published as proxied CNAMEs to the Cloudflare Tunnel (<tunnel-uuid>.cfargotunnel.com, ADR-0016), not as records pointing at the network's public IP.

DNS automation (R-DNS-02)

  • ExternalDNS watches Gateway/HTTPRoute (and Service) resources and manages records in the authoritative DNS provider (Cloudflare, per OQ-4) automatically — a new service's DNS appears from its declared intent, no manual record editing.
  • Records for *.home.keithjackson.dev are created/updated/removed as services come and go.
  • Public records are created only for services declared public (see the exposure class below, R-DNS-05). The non-HTTP L4 names unifi and crafty-mc are the one exception: they hold DNS-only records at their LAN addresses, and are never proxied or tunnelled. The DNS-only (unproxied) wan. record for WireGuard is kept current by cloudflare-ddns, outside this flow.

Split-horizon resolution (R-DNS-03)

Internal and external answers differ:

  • External (<service>.home.keithjackson.dev from the internet): a public service resolves to Cloudflare's edge (proxied CNAME to the tunnel). Cloudflare forwards the request down the outbound-only tunnel to cloudflared in the cluster, which forwards it to the Gateway (ADR-0016). internal services have no public answer, apart from the L4 exception above.

  • Internal (LAN clients): Pi-hole answers with the internal Gateway address (or the service's L4 VIP) for the names it holds local records for.

  • Correctness does not depend on split horizon for public web apps (R-DNS-04). The ISP router cannot hairpin NAT and advertises a public IPv6 resolver via RA, so some LAN clients will get the public answer. That answer goes through Cloudflare and works from the LAN too.

  • PiHole (itself an onboarded service) provides internal LAN DNS. It keeps explicit local records for:

    • the internal names;
    • the L4 VIPs;
    • the names that must avoid Cloudflare's 100 MB body / 100 s timeout limits or must work during an internet outage: gitea, nextcloud, keycloak/auth.

    It holds no zone-wide entry. Every other name is forwarded upstream, so the remaining public names resolve to Cloudflare from the LAN too and depend on the internet and the tunnel. Its DNS address accepts queries only from the LAN and WireGuard clients. Pi-hole runs in the cluster, so LAN DNS depends on cluster health; an off-cluster resolver is an operator choice not taken.

    A trusted certificate is served either way: Cloudflare's edge certificate on the tunnel path, the Let's Encrypt wildcard on the LAN path (08).

  • In-cluster resolution keeps its own split horizon (CoreDNS) and the node /etc/hosts pins (OIDC issuer, Gitea registry), so cluster-internal callers never traverse Cloudflare.

DNS is thus both a platform capability and a hosted service. Bootstrapping order matters: the platform needs working DNS before PiHole is fully managed — handled by the bring-up sequence in roadmap/phases.md.

Ingress (north-south) via Gateway API

  • Gateway API implemented by Cilium is the ingress layer (no separate ingress controller; reuses the CNI, ADR-0002).
  • A shared Gateway defines HTTPS listeners on *.home.keithjackson.dev terminating TLS with the cert-manager cert (08).
  • Each service contributes an HTTPRoute mapping its hostname to its Service — rendered from the ServiceDefinition, not hand-authored.
  • Exposure class is declared per service:
    • internal — reachable only on the LAN/VPN (refused at the tunnel, no public record; internal DNS only).
    • public — additionally reachable from the internet through the Cloudflare Tunnel: cloudflared → the Gateway over HTTPS (ADR-0016). A service is public by catalog exposure: public, or as a registered platform route: auth, Grafana, Crafty's web UI, and the apex with its /runbooks. Platform routes have no catalog entry, so they are held in a shrink-only register, enforced by the static check conformance/tests/l0-static/internal-services-publish-no-public-dns in the implementation repo.
  • Never public: jackett, pihole, semaphore, qbittorrent, radarr, sonarr, headlamp (a front end to the Kubernetes API), plex's web UI, and the non-HTTP L4 services unifi and crafty-mc (which hold only the DNS-only LAN records above).
  • The Kubernetes API is never published — no tunnel route, no edge forward, no public record (R-SEC-04).

Public ingress: Cloudflare Tunnel (ADR-0016)

  • cloudflared runs in-cluster as a Flux-managed workload and holds an outbound-only connection to Cloudflare. No inbound 80/443 reaches the WAN IP.
  • Its ingress rules are a deny-list plus a zone wildcard. Each LAN/VPN-only hostname maps to http_status:404; the apex and *.home.keithjackson.dev then go to the Gateway (HTTPS, origin certificate validated with the hostname as SNI); a catch-all http_status:404 closes the list for hosts outside the zone (R-DNS-05). A static check keeps the deny-list and the set of unpublished routes identical in both directions, so a LAN/VPN-only name cannot be published or tunnelled by omission.
  • Edge TLS is Cloudflare ACM/Total TLS (Universal SSL does not cover two-label-deep names); origin TLS is the Let's Encrypt wildcard at the Gateway (08).
  • Limits and trade-offs:
    • 100 MB request bodies and 100 s responses through the proxy;
    • Cloudflare terminates TLS and sees plaintext;
    • a Cloudflare outage takes public access down (the LAN keeps working through Pi-hole's local records);
    • the client IP arrives as CF-Connecting-IP / X-Forwarded-For, not as the TCP source.

Edge integration (single SOHO router — OQ-1 resolved)

  • The environment has one SOHO edge router: no VLANs, no VPN, no services, no API/IaC. It does not forward 80/443 (public web ingress is the tunnel, ADR-0016). Its only remaining inbound forwards are WireGuard udp/51820 (remote access, ADR-0021, via the DNS-only wan. record) and Plex tcp/32400 (remote streaming via plex.tv; Cloudflare's terms restrict proxied video). This is a documented manual edge step (the SOHO device has no automation), the analogue of the old pfSense manual-config exception. The WAN IP therefore stays public.
  • No multi-WAN inbound rules — single WAN (03).
  • internal services are never port-forwarded or tunnelled; they rely on internal DNS + Gateway (or their L4 VIP) on the LAN, or WireGuard from outside.
  • Ingress VIP mechanism — resolved: Cilium L2 announcement (ARP/NDP) on the flat LAN. BGP is not available on a SOHO router, and a single flat subnet makes L2 announcement the natural choice.

DNS record depth (note for *.home.keithjackson.dev)

A wildcard *.keithjackson.dev matches only one label deep — it covers home.keithjackson.dev but not plex.home.keithjackson.dev. Consequences:

  • internal services need no public record — PiHole resolves <svc>.home.keithjackson.dev to the internal Gateway VIP (split-horizon). This is the majority of services.
  • public services need a public record for <svc>.home.keithjackson.dev: a proxied CNAME to the tunnel (ADR-0016). Per OQ-4 (resolved), ExternalDNS manages these automatically via the scoped Cloudflare API token — no manual record editing (see 08). The same depth limit is why edge TLS needs ACM/Total TLS rather than Universal SSL.

Interaction with default-deny

Ingress opens north-south paths to the specific service; it does not relax east-west default-deny (03). A service being publicly reachable does not let other in-cluster services reach it unless an explicit allow rule exists. Cilium's Gateway re-evaluates the calling pod's egress policy against the real backend, so any in-cluster client of the Gateway, cloudflared included, needs an explicit egress allow to the backends it reaches.

Acceptance gates

  • A new service's <service>.home.keithjackson.dev record appears automatically and routes to it.
  • internal services are unreachable from the public internet; public services are reachable and TLS-valid.
  • Internal clients resolving through Pi-hole reach the internal Gateway for the names it holds local records for, and get a trusted cert.
  • A LAN client resolving a public name through a public resolver reaches it, TLS-valid (R-DNS-04). The WAN IP does not answer on 80/443.
  • A request at the tunnel for a LAN/VPN-only hostname returns 404, and no internal service has a public DNS record, except the DNS-only L4 names (R-DNS-05).
  • The Kubernetes API is unreachable from the internet: no tunnel route, no edge forward, no public record (R-SEC-04).
  • The root home.keithjackson.dev serves the C2 UI.

Resolved

  • OQ-1 — single SOHO edge; ingress VIP via Cilium L2 announcement. Public web ingress amended 2026-11-08 to a Cloudflare Tunnel (ADR-0016); the edge keeps only udp/51820 and tcp/32400.
  • OQ-4 — scoped Cloudflare API token drives ExternalDNS (records) and cert-manager DNS-01 (certs) automatically; see 08.