Skip to content

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-wide local= NXDOMAINs unmanaged names and broke the Tailscale MagicDNS path on the Firewalla’s multi-instance/forwarding pipeline. The shipped fix is per-name local=/<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.

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.
  • AAAA for an internal A-only name returns NODATA (not a forwarded public answer).
  • No change to A resolution: every internal name resolves exactly as today.
  • Declarative, version-controlled, reversible in seconds.
  • 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-zone SOA/zone-transfer mode (heavier than needed).
  • Per-name --filter-AAAA (the rejected targeted alternative; narrower guarantee than zone authority).

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/):

WriterSourceRecords
Hand-deployed bootstrap (live-on-router only; not Ansible-templated)00-bootstrap-vault.confaddress=/vault*.fzymgc.house/192.168.20.145 (authoritative form)
router-hosts daemon ← Terraform + k8s operatorhostsdir/extdata/router-hosts/hosts/main64 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-hosts
domain={{ router_hosts_domain }} # fzymgc.house

There 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.

Add a single dnsmasq authority directive to the role-owned config (local-hosts.conf.j2):

hostsdir={{ router_hosts_hosts_dir }}
expand-hosts
domain={{ router_hosts_domain }}
local=/{{ router_hosts_domain }}/ # NEW: answer zone locally, never forward

local=/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:

  • A for a hostsdir name → unchanged (local answer).
  • AAAA for an A-only name → NODATA (authoritative empty; no forward). ✔
  • A fzymgc.house name 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.

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 internal A records present.
  • Every *.k8s.fzymgc.house name is an explicit entry (whoami.k8s, karakeep.k8s, mealie.k8s, prometheus.k8s, traefik.k8s, …). There is no *.k8s wildcard dependency, so authoritative mode does not break the k8s sub-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.

Before/after on an internal client:

  • dig A auth.fzymgc.house192.168.20.145 (unchanged).
  • dig AAAA auth.fzymgc.houseNODATA (no 2606:4700:: answer). Primary success signal.
  • Spot-check several names across categories (grafana, vault, a *.k8s name, a node, a NAS name) still return their internal A.
  • Confirm no NXDOMAIN regressions for any name in the Cloudflare-zone diff.
  • dig A nonexistent.fzymgc.house @192.168.20.1NXDOMAIN (authoritative empty, not a forwarded public answer) — proves the zone is truly authoritative and the leak path is closed for all unlisted names, not just auth.
  • kubectl get nodes succeeds (OIDC discovery no longer stalls); reconnect kubernetes-mcp-server.

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):

Terminal window
ansible-playbook -i inventory/hosts.yml router-playbook.yml --tags router-hosts-dnsmasq

dnsmasq reload is seconds; behavior reverts to forwarding. Fully reversible.

  • docs/reference/network.md — DNS section: document the authoritative internal zone and the A-internal / AAAA-NODATA contract.
  • docs/operations/router-hosts.md — note local=/fzymgc.house/ and its NXDOMAIN implication for new names (every internal name must have a local record).

--auth-zone, --filter-AAAA, any Cloudflare-side change, and fzymgc.net.

  • File a bead for fzymgc.net: tunnel.tf internal_services/ssh_services publish ${svc}.fzymgc.net; confirm whether the same internal AAAA leak exists and apply the equivalent authority directive if so.

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).