Authoritative Internal fzymgc.house Zone (No Leak-by-Default)
Authoritative Internal fzymgc.house Zone (No Leak-by-Default)
Section titled “Authoritative Internal fzymgc.house Zone (No Leak-by-Default)”Status: Design (pre-plan) Bead: hl-zh3w Date: 2026-06-18
⚠️ Superseded approach (2026-06-19). This design proposed making dnsmasq authoritative for the whole zone via
local=/fzymgc.house/. That was abandoned (hl-zh3w.2): zone-widelocal=NXDOMAINs unmanaged names and broke the Tailscale MagicDNS path on the Firewalla’s multi-instance/forwarding pipeline. The shipped fix is per-namelocal=/<fqdn>/+address=/<fqdn>/<ip>emitted natively by router-hosts >=0.10.4 (dnsmasq_conf_path, PR #1338). See ADR hl-qra1 (supersedes hl-2t4o). The Overview/Goals below remain valid; the directive-level design is superseded.
Overview
Section titled “Overview”Internal clients resolving *.fzymgc.house receive the correct internal IPv4
(A) record but a public Cloudflare IPv6 (AAAA) record. The internal IPv6
is unreachable, so strict dual-stack clients stall on the dead path. The
concrete failure that surfaced this: kubectl oidc-login (a Go client) hangs at
OIDC discovery against auth.fzymgc.house because it attempts the leaked
Cloudflare AAAA and blocks until its context cancels, while curl/browsers
survive via Happy-Eyeballs IPv4 fallback. The kubernetes-mcp-server MCP, which
shares the same kubeconfig, is stuck “connecting” for the same reason.
This is a split-horizon completeness defect: the internal DNS overlay
overrides A records but not AAAA, so AAAA (and any other unoverridden
record type) leaks the public Cloudflare answer. The fix makes the Firewalla’s
dnsmasq authoritative for fzymgc.house so it answers the zone only from
local data and never forwards — eliminating leak-by-default for all record
types, not just AAAA.
- Internal clients never receive public Cloudflare records for
fzymgc.house. AAAAfor an internalA-only name returns NODATA (not a forwarded public answer).- No change to
Aresolution: every internal name resolves exactly as today. - Declarative, version-controlled, reversible in seconds.
Non-Goals
Section titled “Non-Goals”- Removing the public/Cloudflare-proxied face of
auth.fzymgc.house. Authentik is intentionally public for Cloudflare Access SSO and stays proxied (tf/cloudflare/tunnel.tf,proxied = true). This design only changes the internal view. fzymgc.net(a second domain with the same latent leak — see Follow-ups).- Full dnsmasq
--auth-zoneSOA/zone-transfer mode (heavier than needed). - Per-name
--filter-AAAA(the rejected targeted alternative; narrower guarantee than zone authority).
Background: current topology and the leak
Section titled “Background: current topology and the leak”DNS authority for fzymgc.house internally is the Firewalla running
dnsmasq, with a Control D (ctrld) proxy as upstream. Two writers inject
internal records into dnsmasq’s overlay (/home/pi/.firewalla/config/dnsmasq_local/):
| Writer | Source | Records |
|---|---|---|
| Hand-deployed bootstrap (live-on-router only; not Ansible-templated) | 00-bootstrap-vault.conf | address=/vault*.fzymgc.house/192.168.20.145 (authoritative form) |
router-hosts daemon ← Terraform + k8s operator | hostsdir → /extdata/router-hosts/hosts/main | 64 A entries (nodes, VIPs, services, *.k8s names) |
00-bootstrap-vault.conf exists only on the router (observed via live
inspection); there is no template for it in the repo — the only .conf.j2 in
the role is local-hosts.conf.j2. It predates / sits beside the Ansible-managed
overlay and is unaffected by this change.
The daemon-managed file is wired via ansible/roles/router-hosts/templates/local-hosts.conf.j2:
hostsdir={{ router_hosts_hosts_dir }}expand-hostsdomain={{ router_hosts_domain }} # fzymgc.houseThere is no local= / server= authority directive. expand-hosts and
domain= only append the domain to unqualified hostsdir names; they do not stop
forwarding. So for a hostsdir name (IPv4-only), dnsmasq answers A from local
data but forwards the AAAA query upstream → Control D → public Cloudflare
AAAA (2606:4700::/32). That forwarded AAAA is the leak.
The vault* names do not leak because address=/name/ip makes dnsmasq
authoritative for them (NODATA on AAAA) — the exact behavior we want for the
whole zone.
Likely trigger: the ctrld overlay file was regenerated on the day the
problem appeared, plausibly changing IPv6/upstream behavior so a long-latent
leak went live. The fix is independent of the trigger.
Design
Section titled “Design”Add a single dnsmasq authority directive to the role-owned config
(local-hosts.conf.j2):
hostsdir={{ router_hosts_hosts_dir }}expand-hostsdomain={{ router_hosts_domain }}local=/{{ router_hosts_domain }}/ # NEW: answer zone locally, never forwardlocal=/fzymgc.house/ is dnsmasq shorthand for server=/fzymgc.house/ with no
upstream: the domain is answered only from local data (hostsdir + address=
overlays) and never forwarded to Control D / upstream. Effects:
Afor a hostsdir name → unchanged (local answer).AAAAfor anA-only name → NODATA (authoritative empty; no forward). ✔- A
fzymgc.housename with no local record → NXDOMAIN (no forward).
The directive sits one layer above Control D, so it is immune to upstream/ctrld behavior.
Placement (chosen: A1). Append to the existing local-hosts.conf.j2 —
co-located with the domain=/hostsdir directives it governs, deployed by the
same task, gated by the same router_hosts_enable_dnsmasq_cutover. Rejected A2
(a separate dnsmasq_local/*.conf + new task) as more surface for one line.
Supporting fix — make the granular tag functional. The role’s
router-hosts-dnsmasq tag is currently a no-op: tasks/main.yml includes
configure.yml via dynamic include_tasks tagged only router-hosts /
router-hosts-configure, so --tags router-hosts-dnsmasq never processes the
include and the dnsmasq tasks inside never run. Add router-hosts-dnsmasq to
the include_tasks: configure.yml tag list in tasks/main.yml so the tag
selects exactly the two dnsmasq tasks (dir + local-hosts.conf) for both apply
and rollback. Without this, --tags router-hosts-dnsmasq silently does nothing.
This is the only change beyond the one template line.
Blast radius and safety
Section titled “Blast radius and safety”The only risk of authoritative mode is the NXDOMAIN case: a fzymgc.house name
that resolves publicly (Cloudflare) and is used internally but is
absent from the local data. Live audit of the 64-entry hostsdir
(/extdata/router-hosts/hosts/main) shows:
- All seven public service names (
vault,auth,grafana,argocd,mealie,longhorn,status) have internalArecords present. - Every
*.k8s.fzymgc.housename is an explicit entry (whoami.k8s,karakeep.k8s,mealie.k8s,prometheus.k8s,traefik.k8s, …). There is no*.k8swildcard dependency, so authoritative mode does not break thek8ssub-zone — the original primary concern. - The Firewalla itself is an internal DNS client: its on-router
otel-collector exports to
otel-gateway.fzymgc.house, which is present in the hostsdir (192.168.20.145). The seven names above are the public-facing subset; the authoritative enumeration is the full 64-entry hostsdir, and the Cloudflare-diff step (below) is what guarantees completeness.
Favorable cross-check (tf/cloudflare/tunnel.tf). Only authentik_services
publish under fzymgc.house; internal_services and ssh_services already use
${svc}.fzymgc.net with the explicit comment that this avoids split-horizon
.house resolution issues. So the fzymgc.house blast radius is bounded to the
proxied authentik_services names plus *.k8s names — all present in the
hostsdir.
The hostsdir is effectively the superset of internal names. NXDOMAIN risk is
empirically near-zero. The plan adds a belt-and-suspenders step: enumerate the
Cloudflare fzymgc.house zone records and diff against the live hostsdir;
pre-create internal overrides for any gap before flipping.
Verification
Section titled “Verification”Before/after on an internal client:
dig A auth.fzymgc.house→192.168.20.145(unchanged).dig AAAA auth.fzymgc.house→ NODATA (no2606:4700::answer). Primary success signal.- Spot-check several names across categories (
grafana,vault, a*.k8sname, a node, a NAS name) still return their internalA. - Confirm no NXDOMAIN regressions for any name in the Cloudflare-zone diff.
dig A nonexistent.fzymgc.house @192.168.20.1→ NXDOMAIN (authoritative empty, not a forwarded public answer) — proves the zone is truly authoritative and the leak path is closed for all unlisted names, not justauth.kubectl get nodessucceeds (OIDC discovery no longer stalls); reconnectkubernetes-mcp-server.
Rollback
Section titled “Rollback”Remove the local= line from local-hosts.conf.j2 first, then reconverge
(the command re-renders the template, so running it against an unchanged
template is a no-op):
ansible-playbook -i inventory/hosts.yml router-playbook.yml --tags router-hosts-dnsmasqdnsmasq reload is seconds; behavior reverts to forwarding. Fully reversible.
Documentation updates
Section titled “Documentation updates”docs/reference/network.md— DNS section: document the authoritative internal zone and theA-internal /AAAA-NODATA contract.docs/operations/router-hosts.md— notelocal=/fzymgc.house/and its NXDOMAIN implication for new names (every internal name must have a local record).
Out of scope / YAGNI
Section titled “Out of scope / YAGNI”--auth-zone, --filter-AAAA, any Cloudflare-side change, and fzymgc.net.
Follow-ups
Section titled “Follow-ups”- File a bead for
fzymgc.net:tunnel.tfinternal_services/ssh_servicespublish${svc}.fzymgc.net; confirm whether the same internalAAAAleak exists and apply the equivalent authority directive if so.
Grounding traces
Section titled “Grounding traces”See bd show hl-zh3w notes: probe (role/template mechanism), live DNS and
router inspection (config + 64-entry hostsdir audit + ctrld), and context7
dnsmasq-man (local=/filter-AAAA/auth-zone semantics).