# 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)](../decisions/adr-0002-cni-cilium.md), [ADR-0016 (Cloudflare Tunnel public ingress)](../decisions/adr-0016-public-ingress-cloudflare-tunnel.md), [ADR-0021 (inbound remote access)](../decisions/adr-0021-inbound-remote-access-wireguard.md). ## Naming model - All services live at `.home.keithjackson.dev` (e.g. `plex.home.keithjackson.dev`). - The **root** `home.keithjackson.dev` serves the **Command & Control UI** ([12](12-command-center-ui.md)). - `*.home.keithjackson.dev` is the platform's zone, in Cloudflare DNS. `public` services are published as **proxied CNAMEs to the Cloudflare Tunnel** (`.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** (`.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](08-tls-and-certificates.md)). - **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`](../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](../decisions/adr-0002-cni-cilium.md)). - A shared **Gateway** defines HTTPS listeners on `*.home.keithjackson.dev` terminating TLS with the cert-manager cert ([08](08-tls-and-certificates.md)). - 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](https://gitea.home.keithjackson.dev/Home-Network/home-network-k3s-impl). - **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](08-tls-and-certificates.md)). - **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](03-networking-isolation-and-egress.md)). - `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 `.home.keithjackson.dev` to the internal Gateway VIP (split-horizon). This is the majority of services. - **`public` services** need a public record for `.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](08-tls-and-certificates.md)). 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](03-networking-isolation-and-egress.md)). 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 `.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](08-tls-and-certificates.md).