merge: internal services stop advertising themselves in public DNS

Six exposure:internal services published A records into the public Cloudflare
zone answering 192.168.2.20, disclosing the internal subnet, the Gateway VIP
and a full service inventory, and forming a DNS-rebind primitive that is why
the SOHO router strips these answers in the first place.

The renderer now stamps external-dns.alpha.kubernetes.io/controller: none on
every HTTPRoute it renders for an internal service, derived from the exposure
field alone — so a service that omits exposure defaults to internal and is
likewise unpublishable. Established against the deployed v0.21.0's own source,
where the controller check runs before hostname resolution in both configured
sources, then proven live on a throwaway hostname including that adding the
annotation to an existing record triggers the DELETE.

Deletes exactly 12 records: the A and TXT pair for jackett, pihole,
qbittorrent, radarr, semaphore and sonarr. A 39-record recovery baseline is
committed alongside.

Safe to land before the DHCP change, contrary to my earlier caution: the
router returns NOTHING for all six names today, so these records serve nobody
on the LAN, while Pi-hole at 192.168.2.23 already answers for anyone pointed
at it.

The five exposure:public services are untouched. They remain unreachable
publicly, which is a separate problem: the legacy edge at 136.51.115.252
returns HTTP 403 for our hostnames and serves a placeholder certificate, so
nothing is reverse-proxying them today.
This commit is contained in:
jakman85 committed 2026-09-18 20:50:43 -05:00
commit b184a42729
13 files changed
+992 -4

No files matched your search

+84
View File
@@ -26,10 +26,94 @@ spec:
provider:
name: cloudflare
# Watch Gateway API routes (Cilium Gateway) + Services. docs/09: records rendered from intent.
#
# BOTH SOURCES ARE UNFILTERED HERE, AND THAT IS DELIBERATE — see the WHICH RESOURCES block
# below. Scoping is done PER RESOURCE by the renderer, not cluster-wide by this list.
sources:
- gateway-httproute
- service
# ---------------------------------------------------------------------------------------------
# WHICH RESOURCES MAY PUBLISH — `internal` services no longer can (R-DNS-03, 2026-09-14)
#
# THE DEFECT. `sources` above admits EVERY HTTPRoute on the shared Gateway, and each one's
# address is the Gateway VIP. docs/09 R-DNS-03 says `internal` services are unreachable from the
# public internet and that Pi-hole serves the internal split-horizon answers; the renderer
# contract said an `internal` service renders "no public DNS record". Neither sentence was
# enforced by anything, so six `exposure: internal` services (jackett, pihole, qbittorrent,
# radarr, semaphore, sonarr) published `<name>.home.keithjackson.dev` into this PUBLIC
# authoritative zone answering 192.168.2.20. That exposed the internal subnet, the Gateway VIP
# and a complete service inventory to anyone who could guess a label, and made every one of
# those names a DNS-rebind primitive — which is exactly why the SOHO router at 192.168.2.1
# strips RFC1918 answers and LAN clients need per-client dnsmasq overrides to reach their own
# services. Pre-state captured at the AUTHORITY and committed:
# .standup-dns/dns-20260914-pre-externaldns-scope.txt.
#
# THE MECHANISM — per-resource exclusion, emitted by catalog/render.py from `exposure`.
# An `internal` service's rendered HTTPRoute carries
# external-dns.alpha.kubernetes.io/controller: none
# ExternalDNS v0.21.0 (the DEPLOYED app version; read off the live prod Deployment, image
# registry.k8s.io/external-dns/external-dns:v0.21.0) skips any source object whose `controller`
# annotation is present and != its own `dns-controller`:
# source/annotations/processors.go IsControllerMismatch()
# source/gateway.go Endpoints(): if annotations.IsControllerMismatch(meta, src.rtKind) { continue }
# source/service.go Endpoints(): the identical check
# The gateway check runs BEFORE any hostname is resolved, so such a route cannot become an
# endpoint for any name it declares. PROVEN LIVE on prod before this landed, with a throwaway
# hostname and no existing record at risk: an annotated route published nothing at all, and
# ADDING the annotation to a route that already had a record produced
# action=DELETE record=<name> type=A and action=DELETE record=a-<name> type=TXT
# one ~60s reconcile later, and moved nothing else in the zone.
#
# WHY PER-RESOURCE AND NOT A CLUSTER-WIDE OPT-IN FILTER. The chart's `labelFilter`
# (--label-filter) is the stronger default posture — publish only what is labelled — and
# v0.21.0's own --help confirms BOTH configured sources support it ("currently supported by
# source types crd, gateway-httproute, ... service and ambassador-host"). It is NOT set here
# because its blast radius is not this defect's. Nine publishing objects in this repo are
# hand-authored platform/apps manifests with no `exposure` field for the renderer to read, and
# seven of them hold live records: auth (oauth2-proxy), crafty, crafty-mc, grafana, headlamp,
# the apex (homepage), unifi. Setting an opt-in filter DELETES all seven on the next reconcile,
# and whether each should be publicly resolvable at all is an OPERATOR decision per name, not a
# renderer one. They are enumerated in the shrink-only register in
# conformance/tests/l0-static/internal-services-publish-no-public-dns/check.sh, so the set
# cannot grow silently — adjudicate them there, then this filter becomes the right hardening.
#
# DELIBERATELY UNTOUCHED: `policy: sync`, `txtOwnerId`, `domainFilters`. Nothing above widens
# what this instance can see or own; per-resource exclusion only ever REMOVES an object from
# this instance's view, so it cannot make prod capable of deleting a record it does not own.
# ---------------------------------------------------------------------------------------------
# THE PUBLIC-TARGET GAP — STILL OPEN, AND NOT FIXABLE HERE ON v0.21.0 (operator decision)
#
# The five `exposure: public` services (plex, gitea, seerr, keycloak, nextcloud) publish records
# that point at 192.168.2.20 — the Gateway VIP, an unroutable RFC1918 address — so they are not
# actually reachable from the internet either. Their records are LEFT EXACTLY AS THEY ARE by
# this change, for two independent reasons:
#
# 1. THE VALUE IS NOT ESTABLISHED. The one public IP this repo knows is 136.51.115.252
# (clusters/prod/kustomization.yaml, where the zone's `*.keithjackson.dev` wildcard also
# points). It belongs to the LEGACY non-k3s stack and currently serves a placeholder
# certificate. Whether prod's public services belong behind it, behind a new forward, or
# are not meant to be public at all is an operator decision. Breaking five services' DNS
# on a guess is worse than leaving a known-wrong-but-stable state.
# 2. THE MECHANISM CANNOT BE SCOPED TO THEM. `external-dns.alpha.kubernetes.io/target` is a
# real override, but in v0.21.0's gateway-httproute source it is read ONLY from the
# GATEWAY (source/gateway.go: `overrides: annotations.TargetsFromTargetAnnotation(
# gw.Annotations)`) and from a ListenerSet listener — NEVER from the HTTPRoute. So there is
# no per-service target override while every public service shares the `shared` Gateway.
# Annotating the Gateway would move EVERY gateway-sourced record, which after this change
# still includes auth, crafty, grafana, headlamp and the apex — i.e. it would publish a
# routable address for admin UIs. That is strictly worse than the status quo.
#
# SO THE DECISION THE OPERATOR OWNS IS TWO-PART, and no knob is added here until the first half
# is answered, because a knob that cannot take effect invites someone to set it and expect one:
# (a) WHAT address should the five public names answer with — 136.51.115.252 as-is, a new
# port-forward/WAN address, or nothing (drop `exposure: public` to `internal` instead)?
# (b) HOW should they be separated from the non-public names that share the Gateway? Options,
# roughly in ascending cost: a SECOND Gateway (or a ListenerSet) carrying the target
# annotation, with only the public services' routes attached; or publishing the five names
# from the `crd` source (DNSEndpoint objects the renderer emits for `public` services),
# which restores per-service targeting without a second Gateway.
# Only (b) is a manifest change; (a) is a fact about the home WAN that has to be checked.
# ---------------------------------------------------------------------------------------------
# CROSS-CLUSTER DNS OWNERSHIP. Read before changing either knob below.
#
# On 2026-09-04 prod's external-dns DELETED EVERY ONE of dev's public records. Both clusters ran