mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-07-29 01:18:48 +02:00
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
37 lines
1.2 KiB
Go
37 lines
1.2 KiB
Go
package ctrld
|
|
|
|
import (
|
|
"github.com/miekg/dns"
|
|
)
|
|
|
|
// SetCacheReply extracts and stores the necessary data from the message for a cached answer.
|
|
func SetCacheReply(answer, msg *dns.Msg, code int) {
|
|
answer.SetRcode(msg, code)
|
|
cCookie := getEdns0Cookie(msg.IsEdns0())
|
|
sCookie := getEdns0Cookie(answer.IsEdns0())
|
|
if cCookie != nil && sCookie != nil {
|
|
// Client cookie is fixed size 8 bytes, Server cookie is variable size 8 -> 32 bytes.
|
|
// 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.
|
|
func getEdns0Cookie(opt *dns.OPT) *dns.EDNS0_COOKIE {
|
|
if opt == nil {
|
|
return nil
|
|
}
|
|
for _, o := range opt.Option {
|
|
if e, ok := o.(*dns.EDNS0_COOKIE); ok {
|
|
return e
|
|
}
|
|
}
|
|
return nil
|
|
}
|