Skip to content

Untrusted clients lose the whole fzymgc.house apex, its real public names included — Blocky's per-client denylist is the only tier that can withhold it, denylist-then-allowlist rejected because it fails quietly, and the appliance's whole aggregated population trusted as one client

Date: 2026-08-28 Status: Accepted Deciders: Sean Brandt

The resolver pair sandiego/portland runs Blocky as the client-facing tier and PowerDNS as the authoritative tier for fzymgc.house. Guest (VLAN 3100) and Telework (VLAN 3200) are about to be cut onto that pair. Before they are, the pair needs a way to hand the internal namespace to some clients and withhold it from others. Four facts govern what that mechanism can be, and a reader arriving at this decision cold will not have any of them.

fzymgc.house IS A REAL PUBLIC ZONE, not an invented internal-only suffix. tf/cloudflare/ is its source of truth, and tf/cloudflare/access.tf publishes id.fzymgc.house (the Keycloak issuer) and cluster-docs.docs.fzymgc.house (this documentation site) as names that resolve on the public internet, behind Cloudflare Access. Withholding the apex from a client therefore withholds names that a phone on cellular resolves without difficulty. That is the whole substance of this decision, and it is why the decision exists as a record rather than as a commit message.

BLOCKY v0.34.0 BUILDS A FIXED TWENTY-RESOLVER CHAIN, and only one position in it can withhold a name per client. Re-verified 2026-08-28 against server/server.go:528-569 at the pinned tag: rateLimit is position 2, blocking is position 13, caching is position 15, and condUpstream is position 18. blocking sits above condUpstream, so blocking.clientGroupsBlock can withhold a .house name before the conditional mapping to PowerDNS is ever consulted. It also sits above caching, whose keys are question-keyed rather than client-keyed and which would therefore serve one client’s answer to another. No other position in the chain is both client-aware and above the mapping.

THE TWO OBVIOUS ALTERNATIVE TIERS ARE STRUCTURALLY UNABLE TO DO THIS, not merely inconvenient. upstreams.groups is per-client, but it sits below condUpstream at position 18 and therefore never sees a .house query at all — those queries leave the chain at the conditional mapping. PowerDNS 5.1 Views is per-client on paper, but Blocky reaches PowerDNS over 127.0.0.1:1053, so every query PowerDNS sees arrives from loopback with no client address to key a view on.

GUEST AND TELEWORK ISOLATION TODAY IS ACCIDENTAL, and the accident is about to be removed. Those legs are isolated only because the Firewalla’s FW_PREROUTING_DNS_DEFAULT DNAT still catches their routed queries — the same DNAT they stop using the moment their Primary DNS Server field is repointed at the pair. Isolation supplied by a rule nobody chose for this purpose is not a posture. This decision has to land before that cut, which is why it is authored in a phase that cuts nothing.

Untrusted clients are handed a default client group that carries a denylist covering the whole internal namespace, and the exempt set is enumerated rather than inferred. A client the configuration does not name is untrusted, and an untrusted client receives NXDOMAIN for every name under fzymgc.house and under both reverse zones — public names included.

Concretely:

  • The default group in blocking.clientGroupsBlock carries a denylist group named internal-namespace, whose six entries are fzymgc.house and *.fzymgc.house, 168.192.in-addr.arpa and *.168.192.in-addr.arpa, and 7.f.3.7.5.6.6.f.b.d.d.f.ip6.arpa and *.7.f.3.7.5.6.6.f.b.d.d.f.ip6.arpa. The wildcard form is the load-bearing entry — Blocky strips the *. and stores the remainder in a trie whose lookup reports a hit for the key or one of its parents, so *.fzymgc.house matches the apex and every subdomain. The plain form matches the exact name only and is carried as a deliberate hedge, because a malformed wildcard line is logged and silently dropped.
  • Every client not in the enumerated exempt set is handed default. There is no third state: a leg present in dns_vlan_interfaces and absent from the trust list renders no exempt key at all and is denied by absence rather than by an edit somebody has to remember to make.
  • The exempt set is a named, source-less group — trusted-clients, declared under denylists: with no source, so it registers as a group and matches nothing. Its membership is derived from blocky_trusted_legs filtered against dns_vlan_interfaces, one IPv4 subnet key and one ULA /64 key per trusted leg, plus three hand-declared members: the keepalived health client 127.0.0.1, the appliance’s forwarding source address 192.168.20.1, and the tailnet CGNAT range 100.64.0.0/10.

Three rulings, taken together on 2026-08-28. They are recorded as one document because they only make sense as one posture — recorded separately they read as three unrelated concessions.

O-1 — untrusted clients lose the whole apex, public names included

Section titled “O-1 — untrusted clients lose the whole apex, public names included”

THIS IS AN ACCEPTED COST, RULED BY THE OPERATOR ON 2026-08-28, AND IT IS NOT A DEFECT AND NOT A TEMPORARY STATE. It is accepted cost 2 of the three this milestone carries.

Stated plainly, because the plain statement is the point: a Guest client that asks for id.fzymgc.house receives NXDOMAIN. So does a Guest client that asks for cluster-docs.docs.fzymgc.house. Both of those names exist in public DNS, both are published by tf/cloudflare/access.tf, and both resolve from the same handset the moment it drops off Wi-Fi and onto cellular. A guest who wants the documentation site or the login page from the guest network does not get it.

The mechanism forces this. blocking withholds by NAME, and the only names it can be given that cover the internal estate are the zone apex and its wildcard — which is also every public name the zone publishes, because they are the same zone. There is no partition of fzymgc.house into “the internal part” and “the public part” that Blocky’s denylist can express, because the split is not in the namespace; it is in which answer PowerDNS would return.

O-5 — the fail-closed asymmetry: leak one segment rather than take the estate down

Section titled “O-5 — the fail-closed asymmetry: leak one segment rather than take the estate down”

Where the two failure directions are not symmetric, the configuration is written to fail toward leaking one segment’s view of the namespace rather than toward the whole estate losing DNS. A segment that can see names it should not is a bounded, diagnosable, reversible problem. An estate with no working resolver is an outage in which nothing — including the tooling used to diagnose it — resolves anything.

This ruling is applied in three named places in the shipped configuration, and it is worth naming them because in every one the other branch is the one that looks more rigorous:

  • A leg absent from blocky_trusted_legs is denied by ABSENCE, not raised at render. The alternative — go red at render on any leg the trust list does not classify — is the stricter-looking choice, and it would block a future merge that adds a leg until somebody edits this role. Deny-by- absence gets the same safety with none of that coupling: a new leg is untrusted with no edit at all.
  • The denylist source loading strategy is left at its blocking default, not set to failOnError. Under failOnError a broken source is a daemon exit — estate down. Under the default it is a silent no-op — one segment sees names it should not. O-5 points at the default. It is pinned explicitly rather than left unset, per this repository’s convention of pinning a default so an upstream release cannot change it unnoticed.
  • The appliance’s forwarding source is allowlisted out of the rate limiter, not capped with everyone else (added 2026-08-29, operator ruling — see § O-6). The alternative — cap every client uniformly, appliance included, with no exceptions — is the stricter-looking choice, and it is the wrong one here: that client is an AGGREGATE. At ipv4Prefix: 32 the cap is one bucket per source address, so a per-client cap on the appliance is a single shared ceiling for its whole population rather than an abuse cap on one host. The cost taken instead is that this population has no abuse protection at this tier at all — a leak of protection for one segment, in preference to an outage ceiling for it.

O-6 — the appliance is trusted, and therefore so is everything behind it

Section titled “O-6 — the appliance is trusted, and therefore so is everything behind it”

ACCEPTED COST 3, ruled 2026-08-28. After the Phase-04 repoint the Firewalla’s dnsmasq forwards to the resolver pair from its OWN source address, 192.168.20.1. Every client it still serves — every un-renewed lease, wg0, and wg_ap — therefore arrives at Blocky as ONE client, wearing one address, in one client group. Blocky cannot distinguish among them, because by the time the query reaches it there is nothing left to distinguish them by.

The consequence is that trusting the appliance trusts its entire remaining population wholesale. The VPN legs wg0 and wg_ap inherit the whole internal namespace through that forward and cannot be told apart from it. This is recorded, not to be re-litigated, and it is the reason the appliance’s address is given its own exempt key with its own comment even though the Main leg’s /22 already covers it: O-6 must be independently revocable. If Main ever stops being trusted, the appliance’s trust must not silently vanish along with it — or silently survive.

A SECOND CONSEQUENCE OF THE SAME PREMISE, added 2026-08-29. This section originally drew only the trust consequence. The same one-client aggregation that makes trust wholesale also makes the per-client rate limit wholesale, and that was missed when the section was written. At ipv4Prefix: 32 the limiter’s bucket is keyed on source address, so 192.168.20.1 is ONE bucket — and 100 qps shared across every un-renewed lease plus wg0 plus wg_ap is an outage ceiling for that whole population, not an abuse cap on a host. That is the direction § O-5 rules against.

This was found independently by code review as WR-05 and by phase verification as a flagged prohibition. The operator ruled on 2026-08-29 that the address is added to rateLimit.allowlist. Both alternatives were declined: sizing the cap against the aggregate, and leaving the configuration alone while amending only this record. The accepted cost, stated plainly, is that the appliance’s aggregated population has no rate-limit protection at this tier at all — accepted because the compensating position is that the appliance is a managed estate device which already holds every exempt key for that population, so this tier was never its trust boundary.

The exemption has no effect until the appliance forwards to the pair. It arms with the Phase-04 repoint.

Alternative Why it is out
Denylist the zone, then allowlist the real public names REJECTED, and it is the alternative this record exists to name — because it fails QUIETLY rather than loudly. An allowlisted name is no longer blocked, so the query continues down the chain, reaches conditional.mapping at position 18, and is answered by PowerDNS with the internal address for that name. A guest client cannot route to that address. So the query returns NOERROR with an answer, the name looks fixed, every spot check reads green, and the connection times out with no DNS-shaped symptom anywhere. A loud failure — NXDOMAIN — would have been strictly better, because it is legible. This is not a future improvement to revisit; it is worse than the accepted cost it appears to fix.
Per-client upstreams.groups Structurally cannot work. upstreams.groups is position 18’s neighbour, below condUpstream, and a .house query leaves the chain at the conditional mapping before it ever arrives. The tier is client-aware and never sees the traffic.
PowerDNS 5.1 Views Structurally cannot work. Blocky reaches PowerDNS over 127.0.0.1:1053, so every query PowerDNS sees has source 127.0.0.1. There is no client to key a view on.
Accept the tailnet loss as a fourth accepted cost REJECTED on 2026-08-28 in favour of exempting 100.64.0.0/10 wholesale. Deny-by-absence works correctly on the tailnet — a 100.x source is in no leg CIDR, is not the health client and is not the appliance, so it resolves to no group and is handed default. The listener that exists specifically so a remote client can resolve would still answer, and would answer NXDOMAIN. See Consequences for what the chosen exemption costs instead.
Carry only the wildcard denylist entries, dropping the plain apex forms Rejected. Three fewer lines, but a malformed wildcard line is logged and silently dropped, and the plain entry is a cheap hedge that still holds the apex if a later edit breaks the wildcard. The cost of the alternative is a comment that has to explain an absence.

Untrusted clients get NXDOMAIN, not NOERROR with an empty answer, and NXDOMAIN here covers EVERY query type unconditionally — A, AAAA, PTR, SOA, TXT, MX, SRV and HTTPS alike. Blocky’s nxDomain block handler contains no branch on question type, unlike zeroIP, which does. There is no type carve-out and none is possible.

Reverse enumeration by PTR fails for the same reason and by the same mechanism. A real PTR question is a subdomain of its reverse zone — 12.20.168.192.in-addr.arpa. — which is why the denylist carries the wildcard forms of both reverse zones and not only their apexes. A forward-only or apex-only denylist would have let an untrusted client walk the estate’s hostnames by PTR while every forward-direction check read green.

The tailnet is exempt WHOLESALE, and that is a deliberate widening of the trusted set. 100.64.0.0/10 is every tailnet address there is; this exemption therefore trusts every tailnet peer, not a chosen subset. It is the same posture the node firewall already takes — its own ruleset comment records that it cannot distinguish among tailnet peers either — so the exemption does not widen the trust boundary beyond where the packet filter already put it. Narrowing which peers may reach port 53 is a change to tailscale/policy.hujson, owned outside this milestone.

The keepalived health client is exempt by its own key AND is never rate-limited, and those are two separate protections against the same failure. keepalived_health_name is dns-health.fzymgc.house — a name under the withheld apex — and it is dug from 127.0.0.1 on each node. A group that catches it blackholes the health check on both nodes at once, and after the Phase-04 repoint there is no appliance behind them to fall back to. Its failure class is total estate outage rather than leak, which is why it gets its own exempt key, its own rate-limit allowlist entry, and its own render assertion kept deliberately separate from the others.

A client that roams from an untrusted leg to a trusted one keeps its NXDOMAIN for blockTTL, in its own stub cache, where no converge on the resolver can clear it. Under nxDomain that value lands in both the SOA header TTL and its MINTTL field, which is exactly what a downstream stub reads as the negative-caching lifetime. It is pinned short, away from the six-hour default, for that reason alone.

Reversing this later is not free. The trust list becomes the estate’s statement of which legs are handed the internal namespace; collapsing it back into “all legs” would silently re-exempt everything added since.

  • .planning/workstreams/dns-serving-plane/ROADMAP.md § Phase 01 — the phase goal, its five success criteria and the three accepted costs this record ratifies.
  • .planning/workstreams/dns-serving-plane/REQUIREMENTS.md — VIEW-01 … VIEW-09, the requirements this decision satisfies; VIEW-07 is the requirement for this record itself.
  • Internal DNS runbook — the operational view: which clients are trusted, which are not, what the untrusted see, and the one file that changes it.
  • ansible/roles/blocky/templates/blocky-config.yml.j2 — the rendered blocking: section, with the dated in-place comments recording each finding beside the block it justifies.
  • ansible/inventory/group_vars/dns_hosts.ymlblocky_trusted_legs, the estate’s single declaration of which legs are trusted.
  • tf/cloudflare/access.tf — the source of truth publishing id.fzymgc.house and cluster-docs.docs.fzymgc.house, which is what makes O-1 a real cost rather than a notional one.