Files
home-network-k3s-impl/conformance/tests/l0-static/internal-services-publish-no-public-dns/chainsaw-test.yaml
T
jakman85andClaude Opus 5.5 92944486d1
gate / gate (pull_request) Successful in 45s
gate / conformance (pull_request) Skipped
feat(dns): publish public names as proxied CNAMEs to the Cloudflare Tunnel
Gateway target -> fc81938b-...cfargotunnel.com, --cloudflare-proxied globally,
L4 service publishers (unifi, crafty-mc) pinned DNS-only, Plex web UI and
Headlamp disowned (LAN/VPN-only). L0 internal-services-publish-no-public-dns
gains assertion (9) tying the Gateway target to the tunnel UUID, requiring
cloudflare-proxied=false on every service-source publisher, and keeping the
tunnel's 404 list and the unpublished set consistent. L2 dns-exposure fixture
opts out of proxying.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 17:55:41 -06:00

138 lines
10 KiB
YAML

# =============================================================================
# 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 `<name>.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