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
Context
Section titled “Context”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.
Decision
Section titled “Decision”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
defaultgroup inblocking.clientGroupsBlockcarries a denylist group namedinternal-namespace, whose six entries arefzymgc.houseand*.fzymgc.house,168.192.in-addr.arpaand*.168.192.in-addr.arpa, and7.f.3.7.5.6.6.f.b.d.d.f.ip6.arpaand*.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.housematches 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 indns_vlan_interfacesand 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 underdenylists:with no source, so it registers as a group and matches nothing. Its membership is derived fromblocky_trusted_legsfiltered againstdns_vlan_interfaces, one IPv4 subnet key and one ULA/64key per trusted leg, plus three hand-declared members: the keepalived health client127.0.0.1, the appliance’s forwarding source address192.168.20.1, and the tailnet CGNAT range100.64.0.0/10.
Rationale
Section titled “Rationale”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_legsis 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
blockingdefault, not set tofailOnError. UnderfailOnErrora 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: 32the 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.
Alternatives Considered
Section titled “Alternatives Considered”| 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. |
Consequences
Section titled “Consequences”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.
References
Section titled “References”.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 renderedblocking:section, with the dated in-place comments recording each finding beside the block it justifies.ansible/inventory/group_vars/dns_hosts.yml—blocky_trusted_legs, the estate’s single declaration of which legs are trusted.tf/cloudflare/access.tf— the source of truth publishingid.fzymgc.houseandcluster-docs.docs.fzymgc.house, which is what makes O-1 a real cost rather than a notional one.