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:
commit
b184a42729
13 files changed
+992
-4
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user