Summary
With cache_enable = true, ctrld stores the upstream reply with the querying
client's EDNS Client Subnet (ECS, EDNS option 8) still embedded, then replays
that option verbatim to every subsequent client that hits the cache entry until
TTL expiry.
Any downstream forwarder that validates the echoed ECS per RFC 7871 §9.2 —
dnsmasq with add-subnet does — discards every replayed reply whose subnet
doesn't match what it sent (discarding DNS reply: subnet option mismatch).
The client gets no answer at all; retries receive the same stale option and do
not self-heal until the cache entry expires.
Two asymmetries make this worse than a warning:
- ctrld only echoes ECS for IPv6 client subnets; IPv4-subnet cache misses
return no ECS. So an IPv4 client handed a cached-from-IPv6 entry receives an
IPv6 subnet it can never match.
- Whichever client populates an entry first decides its fate: IPv4-first →
clean for everyone until TTL; IPv6-first → poisoned for every other client.
This is the same cache behavior as #242 ("Bad EDNS Cookie with Cached
Responses", fixed for option COOKIE in v1.4.4): the cached answer is copied
without rewriting/stripping the per-client EDNS option. ECS needs the same
treatment.
Environment
- ctrld v1.5.4 (also reproduced identically on v1.5.1 before upgrading)
- UniFi Dream Machine Pro (UniFi OS), installed via the ControlD router installer
- dnsmasq forwards to ctrld on
127.0.0.1:5354 with add-subnet=32,128
(this is the stock ctrld UniFi integration — /run/dnsmasq.dhcp.conf.d/zzzctrld.conf)
- Listener policy routes ~11 networks (IPv4 CIDRs + IPv6 /64s) to per-network
DoH upstreams (https://dns.controld.com/<resolver-id>)
- ControlD-side ECS is unset on all profiles (confirmed via API) — the
echoed option is synthesized locally by ctrld
Reproduction (4 queries, one fresh name)
Fresh, never-cached names via nip.io; dig @127.0.0.1 -p 5354 +subnet=<s> <name> A.
Output below from v1.5.4 (client subnets anonymized to the 2001:db8 documentation prefix):
# 1. IPv6 "client A" populates the cache — MISS, echoes its own subnet (OK)
$ dig +subnet=2001:db8:0:20::1111/128 fresh1.10.9.8.7.nip.io A
;; CLIENT-SUBNET: 2001:db8:0:20::1111/128/0 Query time: 80 msec
# 2. IPv6 "client B", different subnet, same name — HIT, gets A's subnet (BUG)
$ dig +subnet=2001:db8:0:20::2222/128 fresh1.10.9.8.7.nip.io A
;; CLIENT-SUBNET: 2001:db8:0:20::1111/128/0 Query time: 10 msec
# 3. IPv4 client, same name — HIT, gets A's IPv6 subnet (BUG, can never match)
$ dig +subnet=192.168.20.77/32 fresh1.10.9.8.7.nip.io A
;; CLIENT-SUBNET: 2001:db8:0:20::1111/128/0 Query time: 10 msec
# 4. IPv4 control, second fresh name — MISS, no ECS echoed (OK; dnsmasq accepts)
$ dig +subnet=192.168.20.77/32 fresh2.10.9.8.7.nip.io A
;; (no CLIENT-SUBNET in reply) Query time: 740 msec
Steps 2 and 3 are the bug. When the client is dnsmasq (rather than dig), RFC
7871 §9.2 validation rejects both: dnsmasq logs
discarding DNS reply: subnet option mismatch and returns nothing to the
querying client.
Expected behavior
Cached replies should either strip the stored ECS option or rewrite it to
the current client's subnet — exactly as the v1.4.4 fix for #242 did for EDNS
COOKIE. A reply carrying another client's (wrong-family) subnet violates the
echo expectation that RFC 7871 validators enforce.
Measured impact
On a home network (~23 networks, ~3–5 q/s through dnsmasq → ctrld):
- 19.6% of all queries discarded (journald discard count ÷ tcpdump query
count, synchronized 5-min windows); independently corroborated at 20.5%
by wire-capture ECS-mismatch counting (484 paired query/replies), and up to
28.3% in evening windows.
- IPv4 clients matched 0% of the time when handed any replayed ECS.
- Controlled toggle:
cache_enable on → 90 discards / off → 0 / on → 86
(per equal window).
- Ran for weeks looking like "intermittent slow DNS", since whichever protocol
family populated a name first decided whether that name worked.
Workaround
cache_enable = false — discards drop to zero immediately (verified, plus the
4-query probe above then shows every reply carrying the client's own subnet or
none). Costs the cache-hit latency, so we'd love to re-enable once fixed.
Summary
With
cache_enable = true, ctrld stores the upstream reply with the queryingclient's EDNS Client Subnet (ECS, EDNS option 8) still embedded, then replays
that option verbatim to every subsequent client that hits the cache entry until
TTL expiry.
Any downstream forwarder that validates the echoed ECS per RFC 7871 §9.2 —
dnsmasq with
add-subnetdoes — discards every replayed reply whose subnetdoesn't match what it sent (
discarding DNS reply: subnet option mismatch).The client gets no answer at all; retries receive the same stale option and do
not self-heal until the cache entry expires.
Two asymmetries make this worse than a warning:
return no ECS. So an IPv4 client handed a cached-from-IPv6 entry receives an
IPv6 subnet it can never match.
clean for everyone until TTL; IPv6-first → poisoned for every other client.
This is the same cache behavior as #242 ("Bad EDNS Cookie with Cached
Responses", fixed for option COOKIE in v1.4.4): the cached answer is copied
without rewriting/stripping the per-client EDNS option. ECS needs the same
treatment.
Environment
127.0.0.1:5354withadd-subnet=32,128(this is the stock ctrld UniFi integration —
/run/dnsmasq.dhcp.conf.d/zzzctrld.conf)DoH upstreams (
https://dns.controld.com/<resolver-id>)echoed option is synthesized locally by ctrld
Reproduction (4 queries, one fresh name)
Fresh, never-cached names via nip.io;
dig @127.0.0.1 -p 5354 +subnet=<s> <name> A.Output below from v1.5.4 (client subnets anonymized to the 2001:db8 documentation prefix):
Steps 2 and 3 are the bug. When the client is dnsmasq (rather than dig), RFC
7871 §9.2 validation rejects both: dnsmasq logs
discarding DNS reply: subnet option mismatchand returns nothing to thequerying client.
Expected behavior
Cached replies should either strip the stored ECS option or rewrite it to
the current client's subnet — exactly as the v1.4.4 fix for #242 did for EDNS
COOKIE. A reply carrying another client's (wrong-family) subnet violates the
echo expectation that RFC 7871 validators enforce.
Measured impact
On a home network (~23 networks, ~3–5 q/s through dnsmasq → ctrld):
count, synchronized 5-min windows); independently corroborated at 20.5%
by wire-capture ECS-mismatch counting (484 paired query/replies), and up to
28.3% in evening windows.
cache_enableon → 90 discards / off → 0 / on → 86(per equal window).
family populated a name first decided whether that name worked.
Workaround
cache_enable = false— discards drop to zero immediately (verified, plus the4-query probe above then shows every reply carrying the client's own subnet or
none). Costs the cache-hit latency, so we'd love to re-enable once fixed.