039bcc9af9
Normalize Only/Prefer values (ip4→ipv4, ip6→ipv6) so downstream comparisons match correctly. Fix asymmetric fallback where caller network=ip6 fell through to IPv4 but ip4 didn't fall through to IPv6 — both branches now check hard constraints (server.Only || callerPref) only, allowing soft Prefer to fall back. Fix data race on cache item.ts by reading it inside RLock. Fix Store mutating caller's dns.Msg by copying before TTL override. Add singleflight dedup, semaphore-bounded async refresh, DNS rcode error checking, and bump EDNS0 UDPSize to 4096. Add 25 tests.
186 lines
4.2 KiB
Go
186 lines
4.2 KiB
Go
package resolver
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-gost/core/logger"
|
|
ctxvalue "github.com/go-gost/x/ctx"
|
|
"github.com/miekg/dns"
|
|
)
|
|
|
|
const (
|
|
defaultTTL = 60 * time.Second
|
|
)
|
|
|
|
// CacheKey identifies a cached DNS response by question name, class, and type.
|
|
type CacheKey string
|
|
|
|
// NewCacheKey generates a resolver cache key from a DNS question.
|
|
// Returns an empty key if q is nil.
|
|
func NewCacheKey(q *dns.Question) CacheKey {
|
|
if q == nil {
|
|
return ""
|
|
}
|
|
key := fmt.Sprintf("%s%s.%s", q.Name, dns.Class(q.Qclass).String(), dns.Type(q.Qtype).String())
|
|
return CacheKey(key)
|
|
}
|
|
|
|
type cacheItem struct {
|
|
msg *dns.Msg
|
|
ts time.Time
|
|
ttl time.Duration
|
|
}
|
|
|
|
// Cache stores DNS responses with TTL-based expiration and optional size bounding.
|
|
// A zero maxSize means unbounded (backward compatible).
|
|
//
|
|
// Load returns:
|
|
// - (nil, 0) on cache miss
|
|
// - (msg, ttl > 0) on fresh cache hit
|
|
// - (msg, ttl <= 0) on expired cache hit (for stale-while-revalidate)
|
|
type Cache struct {
|
|
mu sync.RWMutex
|
|
entries map[CacheKey]*cacheItem
|
|
maxSize int // 0 = unlimited
|
|
logger logger.Logger
|
|
}
|
|
|
|
// NewCache creates an unbounded DNS cache.
|
|
func NewCache() *Cache {
|
|
return &Cache{entries: make(map[CacheKey]*cacheItem)}
|
|
}
|
|
|
|
// WithLogger sets the logger for cache debug messages.
|
|
func (c *Cache) WithLogger(logger logger.Logger) *Cache {
|
|
c.logger = logger
|
|
return c
|
|
}
|
|
|
|
// WithMaxSize sets the maximum number of entries. 0 means unlimited.
|
|
// When the limit is reached, expired entries are evicted first, then the oldest.
|
|
func (c *Cache) WithMaxSize(n int) *Cache {
|
|
c.maxSize = n
|
|
return c
|
|
}
|
|
|
|
// Load returns a cached DNS message and its remaining TTL.
|
|
// The returned message is a deep copy safe for caller mutation.
|
|
func (c *Cache) Load(ctx context.Context, key CacheKey) (msg *dns.Msg, ttl time.Duration) {
|
|
var elapsed time.Duration
|
|
c.mu.RLock()
|
|
item, ok := c.entries[key]
|
|
if ok {
|
|
msg = item.msg.Copy()
|
|
elapsed = time.Since(item.ts)
|
|
ttl = item.ttl - elapsed
|
|
}
|
|
c.mu.RUnlock()
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
for i := range msg.Answer {
|
|
d := uint32(elapsed.Seconds())
|
|
if msg.Answer[i].Header().Ttl > d {
|
|
msg.Answer[i].Header().Ttl -= d
|
|
} else {
|
|
msg.Answer[i].Header().Ttl = 1
|
|
}
|
|
}
|
|
|
|
if log := c.logger; log.IsLevelEnabled(logger.DebugLevel) {
|
|
if sid := ctxvalue.SidFromContext(ctx); sid != "" {
|
|
log = log.WithFields(map[string]any{
|
|
"sid": sid,
|
|
})
|
|
}
|
|
log.Debugf("resolver cache hit: %s, ttl: %v", key, ttl)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// Store adds a DNS response to the cache. Negative ttl means "do not cache" and is
|
|
// silently skipped. Zero ttl uses the minimum TTL from the answer records (or
|
|
// defaultTTL if none have a positive TTL). Positive ttl overrides all answer TTLs.
|
|
func (c *Cache) Store(ctx context.Context, key CacheKey, mr *dns.Msg, ttl time.Duration) {
|
|
if key == "" || mr == nil || ttl < 0 {
|
|
return
|
|
}
|
|
|
|
if ttl == 0 {
|
|
for _, answer := range mr.Answer {
|
|
v := time.Duration(answer.Header().Ttl) * time.Second
|
|
if ttl == 0 || ttl > v {
|
|
ttl = v
|
|
}
|
|
}
|
|
if ttl == 0 {
|
|
ttl = defaultTTL
|
|
}
|
|
}
|
|
|
|
cp := mr.Copy()
|
|
if ttl > 0 {
|
|
for i := range cp.Answer {
|
|
cp.Answer[i].Header().Ttl = uint32(ttl.Seconds())
|
|
}
|
|
}
|
|
|
|
c.mu.Lock()
|
|
// Evict if over capacity.
|
|
if c.maxSize > 0 {
|
|
c.cleanupLocked()
|
|
if len(c.entries) >= c.maxSize {
|
|
// Evict the oldest entry.
|
|
var oldestKey CacheKey
|
|
var oldestTS time.Time
|
|
for k, item := range c.entries {
|
|
if oldestKey == "" || item.ts.Before(oldestTS) {
|
|
oldestKey = k
|
|
oldestTS = item.ts
|
|
}
|
|
}
|
|
delete(c.entries, oldestKey)
|
|
}
|
|
}
|
|
c.entries[key] = &cacheItem{
|
|
msg: cp,
|
|
ts: time.Now(),
|
|
ttl: ttl,
|
|
}
|
|
c.mu.Unlock()
|
|
|
|
if log := c.logger; log.IsLevelEnabled(logger.DebugLevel) {
|
|
if sid := ctxvalue.SidFromContext(ctx); sid != "" {
|
|
log = log.WithFields(map[string]any{
|
|
"sid": sid,
|
|
})
|
|
}
|
|
log.Debugf("resolver cache store: %s, ttl: %v", key, ttl)
|
|
}
|
|
}
|
|
|
|
// RefreshTTL resets the timestamp on a cached entry, extending its TTL.
|
|
func (c *Cache) RefreshTTL(key CacheKey) {
|
|
c.mu.Lock()
|
|
item, ok := c.entries[key]
|
|
if ok {
|
|
item.ts = time.Now()
|
|
}
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
// cleanupLocked removes expired entries. Caller must hold c.mu.Lock.
|
|
func (c *Cache) cleanupLocked() {
|
|
now := time.Now()
|
|
for key, item := range c.entries {
|
|
if now.Sub(item.ts) > item.ttl {
|
|
delete(c.entries, key)
|
|
}
|
|
}
|
|
}
|