Files
home-network-k3s/docs/09-dns-and-ingress.md
T
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

154 lines
10 KiB
Markdown

# 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 `<service>.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** (`<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](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 `<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](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 `<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](08-tls-and-certificates.md).