# ============================================================================= # L0 STATIC — no service declared `internal` can produce a PUBLIC DNS record # Catalog: docs/14 L0 row ("Pre-apply, in CI") — runs WITHOUT a cluster. # # THE DEFECT THIS CLOSES. docs/09 R-DNS-03 says `internal` services are unreachable from the # public internet, that external names resolve to the PUBLIC IP, and that Pi-hole provides the # internal split-horizon answers. schemas/renderer-contract.md §exposure says an `internal` # service renders "no public DNS record". NOTHING ENFORCED EITHER SENTENCE. ExternalDNS # (platform/externaldns/helmrelease.yaml, `sources: [gateway-httproute, service]`, # `policy: sync`, provider cloudflare) considered EVERY HTTPRoute on the shared Gateway, so six # `exposure: internal` services — jackett, pihole, qbittorrent, radarr, semaphore, sonarr — each # published `.home.keithjackson.dev` into the PUBLIC authoritative Cloudflare zone, # answering with the Gateway VIP 192.168.2.20. Verified at the authority on 2026-09-14; the # pre-state is committed at .standup-dns/dns-20260914-pre-externaldns-scope.txt. # # Three things were wrong at once, and none of them was visible from inside the cluster: # * the internal subnet, the Gateway VIP and a complete service inventory were publicly # enumerable by anyone who could guess or brute-force a label; # * a public name answering with an RFC1918 address IS a DNS-rebind primitive, which is why the # SOHO router at 192.168.2.1 strips those answers and LAN clients need per-client dnsmasq # overrides to reach their own services; # * the guarantee was only ever prose. `exposure` was the operator's one declaration of intent # and it had NO effect on DNS whatsoever, so "internal" meant nothing an attacker had to # respect. # # WHY THE FIX NEEDS A GUARD AND NOT JUST A COMMIT. The mechanism is an annotation on a rendered # HTTPRoute. An annotation is exactly the kind of thing that survives review by looking harmless # and then disappears in a refactor of `_route_meta`, a hand-edit of apps/, or a new service added # by copying a public one. Nothing about its ABSENCE is loud: the service keeps working on the LAN, # the Kustomization stays Ready, and the only symptom is a public record nobody is looking at. The # 2026-09-13 ServiceMonitor incident in this same tree is the precedent — twelve monitors pointed # at a port that did not exist for weeks, silently, because the only thing asserting the wiring was # prose. This test makes the DNS guarantee a gate condition instead. # # GIVEN every ServiceDefinition in catalog/ and every HTTPRoute and external-dns-annotated # Service this repo authors, # WHEN each catalog service's rendered routes are resolved in the applied tree (apps/), # THEN (1) every route of an `exposure: internal` service carries ExternalDNS's own controller # annotation set to something OTHER than `dns-controller`, which external-dns v0.21.0 # uses to skip the source object outright (source/gateway.go -> # annotations.IsControllerMismatch, checked before any hostname is resolved), so the # route cannot become an endpoint for ANY name it declares; # (2) NO route of an `exposure: public` service carries it — asserted explicitly, because a # renderer that stamped every route would satisfy (1) perfectly while silently # withdrawing the five public services' records, and `policy: sync` would delete them; # (3) the same holds for the `service` source: an internal service's rendered Service may # not carry an external-dns hostname annotation without the disown annotation beside it. # Two sources are configured, so closing only the route half is half a guarantee; # (4) the annotation is DERIVED FROM `exposure` in catalog/render.py and from nothing else — # the emitting function must read `exposure`, must not name any catalog service, and # must not be able to emit external-dns's own `dns-controller` value (the one value that # CLAIMS a route rather than releasing it, inverting the guarantee while looking # plausible). A per-service list is how twelve ServiceMonitors acquired one identical # wrong port name in a single edit; # (5) THE RENDERER IS PROVEN END-TO-END on synthetic ServiceDefinitions, by invoking the # SHIPPED catalog/render.py as a subprocess: an `internal` fixture must yield the # annotation, a `public` fixture must not, and a fixture that OMITS `exposure` must also # yield it (the contract's default posture is `internal`, and omitting the field is the # likeliest way a new service arrives). Without this the check would still pass if the # renderer stopped working and someone hand-maintained apps/; # (6) every HTTPRoute or annotated Service in the tree that the catalog does NOT render is # DECLARED in KNOWN_NON_CATALOG_PUBLISHERS with what it publishes and why it is out of # scope. Those are hand-authored platform/apps manifests with no `exposure` field for a # renderer to read, and they are the seven names that REMAIN publicly advertised with an # RFC1918 answer after this change. The register is shrink-only: an entry that stops # matching a real object FAILS as stale, so the set of unadjudicated publishers cannot # quietly grow, and adding a new one is a review event; # AND (7) the check is LOAD-BEARING: this Test DECLARES `conformance.home.k3s/scope: whole-repo`, # so ci/l0.sh Stage 3b's `--include-scope whole-repo` unions it into EVERY gate run, and # ci/gate.sh blocks the merge when the l0 stage fails. It deliberately adds NO stage to # ci/l0.sh — tests/l0-static/whole-repo-invariants-wired fails the gate if anyone does. # # WHY SCOPE, NOT REQUIREMENTS: ci/touched-requirements.sh's path map keys apps/* to NOTHING, and # apps/ is exactly where the rendered routes live. A requirement-scoped selection would therefore # filter this check out of the change most likely to break it — a hand-edit of a rendered route. # Same reasoning as its whole-repo siblings. # # STATUS: EXECUTABLE. `check.sh` is an offline assertion script (PyYAML, as catalog/render.py and # the sibling l0-static checks use); conformance/run.sh --layer l0 and ci/l0.sh run it directly, # and this Chainsaw Test wraps the SAME script. # # NOT proven (honest gaps; this is static, and it runs with no cluster and no network): # - That the record is actually GONE from Cloudflare. That is a live read against an external # authority and belongs in L2 beside conformance/tests/l2-behavioral/dns-exposure, which owns # the positive direction (a public name DOES appear). The negative direction was proven # MANUALLY on prod against the deployed v0.21.0 before this landed — a throwaway HTTPRoute # carrying the annotation produced no record at all, and adding the annotation to a route that # already had one produced `action=DELETE` for both its A record and its `a-` TXT registry # entry within one ~60s reconcile — but a manual proof is not a gate. # - That an `internal` name is unreachable from the internet. No DNS record is necessary for # reachability; a port-forward plus a hardcoded IP still reaches the Gateway. This check owns # the NAME axis only. R-NET-04 / the edge port-forward posture owns the other. # - Anything about the seven non-catalog publishers' own correctness. The register records WHAT # each publishes so the claim is reviewable, which is all a static check can offer. # - That the public names RESOLVE to Cloudflare at runtime. check.sh (9) pins the TREE: the # Gateway target is the tunnel UUID in platform/cloudflared/config.yaml, proxying is on, the L4 # `service` publishers opt out, and every name the tunnel 404s is unpublished. Whether Cloudflare # then answers anycast for them is a live read (`dig @1.1.1.1`), not a static one. # - It does not read ../home-network-k3s. The spec is read-only (R-TEST-06). # ============================================================================= apiVersion: chainsaw.kyverno.io/v1alpha1 kind: Test metadata: name: internal-services-publish-no-public-dns labels: conformance.home.k3s/R-DNS-02: "true" conformance.home.k3s/R-DNS-03: "true" annotations: conformance.home.k3s/requirements: "R-DNS-02, R-DNS-03" conformance.home.k3s/layer: "L0" # WHOLE-REPO SCOPE — this invariant quantifies over every ServiceDefinition in catalog/, every # HTTPRoute in apps/wave-* and platform/* and runbooks/, and every external-dns-annotated # Service (apps/vms/unifi, platform/crafty). No single requirement path-maps to that set, and # ci/touched-requirements.sh maps apps/* — where the rendered routes live — to no requirement # at all, so a requirement-scoped selection would skip this check on precisely the changes # that can break it. conformance.home.k3s/scope: "whole-repo" conformance.home.k3s/safety-critical: "true" conformance.home.k3s/executable: "true" conformance.home.k3s/intent: >- A service the operator declared `internal` cannot produce a public DNS record: the guarantee is derived from `exposure` by catalog/render.py, present on every rendered route in the applied tree, absent from every public one, proven end-to-end on synthetic fixtures, and the hand-authored manifests the catalog does not render are enumerated in a shrink-only register so the unadjudicated set cannot grow silently. spec: steps: - name: assert-no-internal-service-can-publish-a-public-dns-record description: >- Resolve every catalog service's rendered HTTPRoutes in apps/, assert the ExternalDNS disown annotation on every internal one and on no public one, extend the same rule to the `service` source, assert catalog/render.py derives the annotation from `exposure` rather than from a service list, prove the shipped renderer on internal/public/default-posture fixtures, and ratchet the register of hand-authored publishers the catalog does not render. try: - script: content: bash ./check.sh