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
145 lines
3.8 KiB
Go
145 lines
3.8 KiB
Go
package dnscache
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"strings"
|
|
"time"
|
|
|
|
lru "github.com/hashicorp/golang-lru/v2"
|
|
"github.com/miekg/dns"
|
|
)
|
|
|
|
// Cacher is the interface for caching DNS response.
|
|
type Cacher interface {
|
|
Get(Key) *Value
|
|
Add(Key, *Value)
|
|
Purge()
|
|
}
|
|
|
|
// Key is the caching key for DNS message.
|
|
//
|
|
// ECS partitions the cache by EDNS Client Subnet so an answer resolved for one
|
|
// subnet is never served to a client in a different subnet. Answer records are
|
|
// scoped to the network that generated them (RFC 7871 §7.3), so they must not
|
|
// be shared across subnets even when the question is otherwise identical.
|
|
type Key struct {
|
|
Qtype uint16
|
|
Qclass uint16
|
|
Name string
|
|
Upstream string
|
|
ECS string
|
|
}
|
|
|
|
type Value struct {
|
|
Expire time.Time
|
|
Msg *dns.Msg
|
|
}
|
|
|
|
var _ Cacher = (*LRUCache)(nil)
|
|
|
|
// LRUCache implements Cacher interface.
|
|
type LRUCache struct {
|
|
cacher *lru.ARCCache[Key, *Value]
|
|
}
|
|
|
|
// Get looks up key's value from cache.
|
|
func (l *LRUCache) Get(key Key) *Value {
|
|
v, _ := l.cacher.Get(key)
|
|
return v
|
|
}
|
|
|
|
// Add adds a value to cache.
|
|
func (l *LRUCache) Add(key Key, value *Value) {
|
|
l.cacher.Add(key, value)
|
|
}
|
|
|
|
// Purge clears the cache.
|
|
func (l *LRUCache) Purge() {
|
|
l.cacher.Purge()
|
|
}
|
|
|
|
// NewLRUCache creates a new LRUCache instance with given size.
|
|
func NewLRUCache(size int) (*LRUCache, error) {
|
|
cacher, err := lru.NewARC[Key, *Value](size)
|
|
return &LRUCache{cacher: cacher}, err
|
|
}
|
|
|
|
// NewKey creates a new cache key for given DNS message.
|
|
func NewKey(msg *dns.Msg, upstream string) Key {
|
|
q := msg.Question[0]
|
|
return Key{Qtype: q.Qtype, Qclass: q.Qclass, Name: normalizeQname(q.Name), Upstream: upstream, ECS: CanonicalECS(msg)}
|
|
}
|
|
|
|
// CanonicalECS returns a canonical string form of the EDNS Client Subnet (ECS,
|
|
// EDNS option 8) carried by msg, suitable for partitioning cache and
|
|
// singleflight keys. A request with no ECS option returns "", so all ECS-less
|
|
// queries share one partition and behave as before.
|
|
//
|
|
// A request that DOES carry an ECS option is never mapped to "", even at SOURCE
|
|
// PREFIX-LENGTH 0: the /0 query is forwarded with an ECS option, may draw
|
|
// different upstream data than an ECS-less query, and its answer (with the
|
|
// echoed option) must not be served to a client that sent no ECS — RFC 7871
|
|
// §7.3.1 requires /0-cached data to remain distinguishable. The family is part
|
|
// of the token, so an IPv4 /0 and an IPv6 /0 stay distinct too.
|
|
//
|
|
// The address is masked to its source prefix length so only the significant
|
|
// subnet bits contribute to the key: two clients in the same subnet share a
|
|
// partition, while different subnets (or address families) never do. The
|
|
// response-only SCOPE PREFIX-LENGTH is deliberately excluded — it is not part of
|
|
// what the client asked for.
|
|
func CanonicalECS(msg *dns.Msg) string {
|
|
opt := msg.IsEdns0()
|
|
if opt == nil {
|
|
return ""
|
|
}
|
|
for _, o := range opt.Option {
|
|
e, ok := o.(*dns.EDNS0_SUBNET)
|
|
if !ok {
|
|
continue
|
|
}
|
|
bits := int(e.SourceNetmask)
|
|
total := 128
|
|
zero := net.IPv6zero
|
|
if e.Family == 1 {
|
|
total = 32
|
|
zero = net.IPv4zero
|
|
}
|
|
addr := e.Address
|
|
if len(addr) == 0 {
|
|
// A /0 request commonly carries an empty address; normalize it to
|
|
// the family zero so its token is stable regardless of encoding.
|
|
addr = zero
|
|
}
|
|
masked := addr
|
|
if bits <= total {
|
|
if m := addr.Mask(net.CIDRMask(bits, total)); m != nil {
|
|
masked = m
|
|
}
|
|
}
|
|
return fmt.Sprintf("%d/%d/%s", e.Family, e.SourceNetmask, masked.String())
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// NewValue creates a new cache value for given DNS message.
|
|
func NewValue(msg *dns.Msg, expire time.Time) *Value {
|
|
return &Value{
|
|
Expire: expire,
|
|
Msg: msg,
|
|
}
|
|
}
|
|
|
|
func normalizeQname(name string) string {
|
|
var b strings.Builder
|
|
b.Grow(len(name))
|
|
for i := 0; i < len(name); i++ {
|
|
c := name[i]
|
|
if 'A' <= c && c <= 'Z' {
|
|
c += 'a' - 'A'
|
|
}
|
|
b.WriteByte(c)
|
|
}
|
|
return b.String()
|
|
}
|