docs/09 R-DNS-03 and schemas/renderer-contract.md both said an `internal` service renders no public DNS record. Nothing enforced either sentence. ExternalDNS's `gateway-httproute` source admitted EVERY HTTPRoute on the shared Gateway, so six `exposure: internal` services — jackett, pihole, qbittorrent, radarr, semaphore, sonarr — published `<name>.home.keithjackson.dev` into the public authoritative Cloudflare zone answering with the Gateway VIP 192.168.2.20. That published the internal subnet, the Gateway VIP and a full service inventory to anyone who could guess a label, and made each of those names a DNS-rebind primitive — which is why the SOHO router strips RFC1918 answers and LAN clients need per-client dnsmasq overrides to reach their own services. THE MECHANISM, established against the DEPLOYED version rather than from memory. The live prod Deployment runs registry.k8s.io/external-dns/external-dns:v0.21.0. In v0.21.0, source/annotations/processors.go IsControllerMismatch() skips a source object whose `external-dns.alpha.kubernetes.io/controller` annotation is present and != its own `dns-controller`, and source/gateway.go applies that check inside Endpoints() BEFORE any hostname is resolved; source/service.go applies the identical check, covering the second configured source. catalog/render.py now emits that annotation on every HTTPRoute it renders for an `internal` service, derived from `exposure` alone — no per-service list — so a service that omits `exposure` inherits the internal default and is unpublishable too. Verified LIVE on prod on a throwaway hostname with no existing record at risk: the annotated route published nothing, and adding the annotation to a route that already had a record produced action=DELETE for both its A record and its `a-` TXT registry entry one ~60s reconcile later, moving nothing else in the zone. WHY PER-RESOURCE AND NOT A CLUSTER-WIDE OPT-IN FILTER. `labelFilter` is the stronger default posture and v0.21.0 supports it for both configured sources, but nine publishing objects here are hand-authored platform/apps manifests with no `exposure` field, seven of which hold live records (auth, crafty, crafty-mc, grafana, headlamp, the apex, unifi). An opt-in filter deletes all seven at once, and whether each should be publicly resolvable is an operator decision per name. Per-resource exclusion has exactly the blast radius of the defect; the seven are enumerated in a shrink-only register so the unadjudicated set cannot grow silently, and the filter stays available as hardening afterwards. `policy: sync`, `txtOwnerId` and `domainFilters` are UNTOUCHED. Per-resource exclusion only ever removes an object from this instance's view, so nothing here makes prod capable of deleting a record it does not own. WHAT THIS DELETES, and it is not reversible by a revert alone: 12 records — the A record and the `a-`-prefixed TXT registry entry for each of jackett, pihole, qbittorrent, radarr, semaphore and sonarr. The zone has no `*.home.keithjackson.dev` wildcard and `*.keithjackson.dev` does not match two labels, so each becomes a public NXDOMAIN with no fallback. The complete pre-state is captured at the authority and committed as a recovery baseline (.standup-dns/dns-20260914-pre-externaldns-scope.txt), with the restore procedure for both halves of a record. MERGE ORDER IS NOT OPTIONAL: feat/pihole-lan-dns-authority must land and be verified first. Removing these records while the LAN still depends on them, with the router stripping RFC1918 and no internal authority yet, breaks name resolution outright. The five `exposure: public` services are LEFT UNCHANGED and still answer with the Gateway VIP, so they remain publicly unreachable. That is two open operator decisions, both written up in platform/externaldns/helmrelease.yaml: what address they should answer with (136.51.115.252 is the legacy non-k3s stack serving a placeholder cert, not established as correct for this cluster), and how to separate them from the non-public names that share the Gateway — because in v0.21.0 the target annotation is read only from the GATEWAY and a ListenerSet, never from an HTTPRoute, so a per-service override is not expressible while they all attach to `shared`. No knob is added for it yet: one that cannot take effect invites someone to set it and expect one. Guard: conformance/tests/l0-static/internal-services-publish-no-public-dns, a whole-repo-scoped L0 static check (no ci/l0.sh stage — the whole-repo-invariants-wired meta-test forbids one). It asserts both directions (present on internal, absent on public), the `service`-source half, that the renderer DERIVES the annotation from `exposure` and cannot emit external-dns's own claiming value, proves the shipped renderer end-to-end on internal/public/omitted-field fixtures, and ratchets the non-catalog register. Proven to fail on the pre-fix tree (9 of 53 assertions, naming all six services) and pass after (56/56). Schema 1.6.0 -> 1.7.0: no field changes, a mapping change to `exposure`. Co-Authored-By: Claude Opus 5 <[email protected]>
137 lines
10 KiB
YAML
137 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.
|
|
# - Anything about the five `public` services' TARGET. They still resolve to the Gateway VIP
|
|
# rather than a routable address; that is an open operator decision recorded in
|
|
# platform/externaldns/helmrelease.yaml, and asserting a target here would hard-code a guess.
|
|
# - 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
|