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]>
98 lines
7.6 KiB
Plaintext
98 lines
7.6 KiB
Plaintext
# RECOVERY BASELINE — the COMPLETE Cloudflare record set for zone `keithjackson.dev`
|
|
# as it stood IMMEDIATELY BEFORE feat/externaldns-scope-public-only is applied.
|
|
#
|
|
# WHY THIS FILE EXISTS. The branch that adds it changes which resources ExternalDNS considers,
|
|
# and ExternalDNS runs `policy: sync` — a record whose source stops matching is DELETED, not
|
|
# merely left alone. That is a one-way, outward-facing operation against a live authoritative
|
|
# zone, so the pre-state is captured and committed BEFORE the change lands, and any record the
|
|
# reconcile removes can be restored by hand from this file. It is the same discipline as
|
|
# dns-before.txt / dns-verify.txt / dns-post-l2.txt beside it, applied to a destructive-capable
|
|
# change rather than to a migration.
|
|
#
|
|
# CAPTURED 2026-09-14 from the Cloudflare API (GET /zones/<id>/dns_records, all pages) using the
|
|
# SOPS-encrypted token in platform/externaldns/cloudflare-api-token.sops.yaml. Read at the
|
|
# AUTHORITY, not through a resolver: 1.1.1.1 served stale answers for minutes after the
|
|
# 2026-09-13 apex flip and must not be trusted for this. `dig` is on the host but NOT in the
|
|
# devcontainer, which has previously produced false "no records" readings — the API is the only
|
|
# reading that is both complete and authoritative.
|
|
#
|
|
# FORMAT (5 fields, `|`-separated) — wider than the sibling files on purpose: a restore needs the
|
|
# TTL and the proxy flag, not just the value.
|
|
#
|
|
# <type>|<name>|<content>|<ttl>|<proxied>
|
|
#
|
|
# ttl 1 = Cloudflare "Automatic". proxied false = DNS-only (grey cloud), which every record here
|
|
# is; a restore that flips one to proxied would put Cloudflare's edge in front of a LAN address.
|
|
#
|
|
# 39 records. 36 of them are ExternalDNS-managed (18 A + their 18 `a-`-prefixed TXT registry
|
|
# entries), every one registered `external-dns/owner=prod` — i.e. THIS cluster owns them, which
|
|
# is why this cluster's reconcile is able to delete them. The remaining 3 belong to the legacy
|
|
# non-k3s stack and carry NO TXT registry entry, so no owner id claims them and ExternalDNS
|
|
# cannot touch them regardless of what this branch does:
|
|
#
|
|
# *.keithjackson.dev -> 136.51.115.252 legacy stack (the zone wildcard)
|
|
# brickbreak.keithjackson.dev -> 20.236.242.14 unrelated, off-prem
|
|
# dev.brickbreak… -> 20.236.242.14 unrelated, off-prem
|
|
#
|
|
# NOTE there is NO `*.home.keithjackson.dev` wildcard in the zone any more (the comment in
|
|
# clusters/prod/kustomization.yaml still says the zone has both wildcards; it does not, as of
|
|
# this capture). A single-label wildcard does not match two labels, so `*.keithjackson.dev` does
|
|
# NOT cover `<svc>.home.keithjackson.dev`: when a specific `<svc>.home` record is removed, that
|
|
# name goes to NXDOMAIN publicly rather than falling back to the legacy IP. That is the intended
|
|
# end state for `internal` services and it is also precisely why Pi-hole must already be
|
|
# answering the LAN authoritatively before this branch merges.
|
|
#
|
|
# WHICH OF THESE THIS BRANCH DELETES — the 12 rows for the six `exposure: internal` catalog
|
|
# services (jackett, pihole, qbittorrent, radarr, semaphore, sonarr): each one's A record and its
|
|
# `a-`-prefixed TXT registry entry. Nothing else. Verified live on prod against the deployed
|
|
# v0.21.0 with a throwaway hostname before any of this was authored: adding the annotation to an
|
|
# HTTPRoute that already had a published record produced
|
|
# `action=DELETE record=... type=A` and `action=DELETE record=a-... type=TXT` one reconcile
|
|
# interval (~60s) later, and nothing else in the zone moved.
|
|
#
|
|
# TO RESTORE ONE ROW BY HAND (both halves, or ExternalDNS will not recognise the record as its
|
|
# own): recreate the A record with its value/ttl/proxied from this file, then recreate the
|
|
# matching `a-<name>` TXT with its content VERBATIM — the `heritage=…,external-dns/owner=prod,
|
|
# external-dns/resource=…` string is what re-establishes ownership. An A record restored without
|
|
# its TXT is unowned and will not be reconciled; a TXT restored with a wrong `resource=` value
|
|
# points the registry at a source object that does not exist.
|
|
A|*.keithjackson.dev|136.51.115.252|1|false
|
|
A|auth.home.keithjackson.dev|192.168.2.20|1|false
|
|
A|brickbreak.keithjackson.dev|20.236.242.14|1|false
|
|
A|crafty-mc.home.keithjackson.dev|192.168.2.22|1|false
|
|
A|crafty.home.keithjackson.dev|192.168.2.20|1|false
|
|
A|dev.brickbreak.keithjackson.dev|20.236.242.14|1|false
|
|
A|gitea.home.keithjackson.dev|192.168.2.20|1|false
|
|
A|grafana.home.keithjackson.dev|192.168.2.20|1|false
|
|
A|headlamp.home.keithjackson.dev|192.168.2.20|1|false
|
|
A|home.keithjackson.dev|192.168.2.20|1|false
|
|
A|jackett.home.keithjackson.dev|192.168.2.20|1|false
|
|
A|keycloak.home.keithjackson.dev|192.168.2.20|1|false
|
|
A|nextcloud.home.keithjackson.dev|192.168.2.20|1|false
|
|
A|pihole.home.keithjackson.dev|192.168.2.20|1|false
|
|
A|plex.home.keithjackson.dev|192.168.2.20|1|false
|
|
A|qbittorrent.home.keithjackson.dev|192.168.2.20|1|false
|
|
A|radarr.home.keithjackson.dev|192.168.2.20|1|false
|
|
A|seerr.home.keithjackson.dev|192.168.2.20|1|false
|
|
A|semaphore.home.keithjackson.dev|192.168.2.20|1|false
|
|
A|sonarr.home.keithjackson.dev|192.168.2.20|1|false
|
|
A|unifi.home.keithjackson.dev|192.168.2.21|1|false
|
|
TXT|a-auth.home.keithjackson.dev|"heritage=external-dns,external-dns/owner=prod,external-dns/resource=httproute/oauth2-proxy/oauth2-proxy"|1|false
|
|
TXT|a-crafty-mc.home.keithjackson.dev|"heritage=external-dns,external-dns/owner=prod,external-dns/resource=service/crafty/crafty-l4-tcp"|1|false
|
|
TXT|a-crafty.home.keithjackson.dev|"heritage=external-dns,external-dns/owner=prod,external-dns/resource=httproute/crafty/crafty"|1|false
|
|
TXT|a-gitea.home.keithjackson.dev|"heritage=external-dns,external-dns/owner=prod,external-dns/resource=httproute/gitea/gitea-3000"|1|false
|
|
TXT|a-grafana.home.keithjackson.dev|"heritage=external-dns,external-dns/owner=prod,external-dns/resource=httproute/monitoring/grafana"|1|false
|
|
TXT|a-headlamp.home.keithjackson.dev|"heritage=external-dns,external-dns/owner=prod,external-dns/resource=httproute/headlamp/headlamp"|1|false
|
|
TXT|a-home.keithjackson.dev|"heritage=external-dns,external-dns/owner=prod,external-dns/resource=httproute/homepage/homepage"|1|false
|
|
TXT|a-jackett.home.keithjackson.dev|"heritage=external-dns,external-dns/owner=prod,external-dns/resource=httproute/jackett/jackett-9117"|1|false
|
|
TXT|a-keycloak.home.keithjackson.dev|"heritage=external-dns,external-dns/owner=prod,external-dns/resource=httproute/keycloak/keycloak"|1|false
|
|
TXT|a-nextcloud.home.keithjackson.dev|"heritage=external-dns,external-dns/owner=prod,external-dns/resource=httproute/nextcloud/nextcloud-80"|1|false
|
|
TXT|a-pihole.home.keithjackson.dev|"heritage=external-dns,external-dns/owner=prod,external-dns/resource=httproute/pihole/pihole-80"|1|false
|
|
TXT|a-plex.home.keithjackson.dev|"heritage=external-dns,external-dns/owner=prod,external-dns/resource=httproute/plex/plex-32400"|1|false
|
|
TXT|a-qbittorrent.home.keithjackson.dev|"heritage=external-dns,external-dns/owner=prod,external-dns/resource=httproute/qbittorrent/qbittorrent-8080"|1|false
|
|
TXT|a-radarr.home.keithjackson.dev|"heritage=external-dns,external-dns/owner=prod,external-dns/resource=httproute/radarr/radarr-7878"|1|false
|
|
TXT|a-seerr.home.keithjackson.dev|"heritage=external-dns,external-dns/owner=prod,external-dns/resource=httproute/seerr/seerr-5055"|1|false
|
|
TXT|a-semaphore.home.keithjackson.dev|"heritage=external-dns,external-dns/owner=prod,external-dns/resource=httproute/semaphore/semaphore-3000"|1|false
|
|
TXT|a-sonarr.home.keithjackson.dev|"heritage=external-dns,external-dns/owner=prod,external-dns/resource=httproute/sonarr/sonarr-8989"|1|false
|
|
TXT|a-unifi.home.keithjackson.dev|"heritage=external-dns,external-dns/owner=prod,external-dns/resource=service/unifi/unifi-l4-tcp"|1|false
|