fix: partition DNS cache by EDNS Client Subnet

With cache_enable = true, one cache entry was shared by every client
asking the same name against the same upstream: the cache key
({Qtype, Qclass, Name, Upstream}) and the osResolver hot-cache/singleflight
key ("name:qtype:") both ignored the EDNS Client Subnet (ECS). A response
tailored for subnet A was therefore served to subnet B.

A cached answer's records are scoped to the network that generated them
(RFC 7871 §7.3), so sharing them across subnets returns the wrong
CDN/policy answer. Rewriting only the ECS option on the shared answer is
worse: forwarders that validate the echoed ECS (e.g. dnsmasq with
add-subnet) then accept the wrong-subnet answer instead of rejecting it as
a mismatch.

Partition both cache paths by a canonical ECS tuple (family, source-prefix,
masked address) via the new dnscache.CanonicalECS: the LRU key gains an ECS
field and the singleflight/hot-cache key appends the canonical ECS. Same
subnet still shares an entry; different subnets (or address families) never
do. Only a request with no ECS option collapses to the shared empty
partition; a carried /0 keeps its own family-scoped token, since it is
forwarded with an ECS option and must stay distinguishable from a no-ECS
query (RFC 7871 §7.3.1). SetCacheReply no longer touches ECS and only
reconciles the EDNS Cookie.

Adds real cache-path regression tests (LRU and osResolver hot cache) that
serve a different A record per subnet and verify the second subnet never
receives the first's record.

Fixes https://github.com/Control-D-Inc/ctrld/issues/324
This commit is contained in:
Cuong Manh Le
2026-07-28 16:14:15 +07:00
parent 737fc79b58
commit d38538f593
6 changed files with 402 additions and 3 deletions
+6
View File
@@ -14,6 +14,12 @@ func SetCacheReply(answer, msg *dns.Msg, code int) {
// See https://datatracker.ietf.org/doc/html/rfc7873#section-4
sCookie.Cookie = cCookie.Cookie[:16] + sCookie.Cookie[16:]
}
// NOTE: the answer's EDNS Client Subnet (ECS) is intentionally left as the
// upstream returned it. Correctness across clients is guaranteed by
// partitioning the cache and singleflight keys by ECS (see
// dnscache.CanonicalECS), so a cache hit only ever serves an answer that was
// resolved for the requester's own subnet. Rewriting the ECS option here
// without re-scoping the Answer records would violate RFC 7871 §7.3.
}
// getEdns0Cookie returns Edns0 cookie from *dns.OPT if present.