Skip to content

DNS Caching and Response Processing

Source: dns/client.go, dns/client_truncate.go, dns/client_log.go, dns/extension_edns0_subnet.go, dns/rcode.go, experimental/cachefile/rdrc.go, experimental/cachefile/cache.go, common/compatible/map.go

Cache Architecture

The DNS client uses freelru (a sharded LRU cache from github.com/sagernet/sing/contrab/freelru) for response caching. Two mutually exclusive cache modes are available:

go
type Client struct {
    timeout            time.Duration
    disableCache       bool
    disableExpire      bool
    independentCache   bool
    clientSubnet       netip.Prefix
    rdrc               adapter.RDRCStore
    initRDRCFunc       func() adapter.RDRCStore
    logger             logger.ContextLogger
    cache              freelru.Cache[dns.Question, *dns.Msg]
    cacheLock          compatible.Map[dns.Question, chan struct{}]
    transportCache     freelru.Cache[transportCacheKey, *dns.Msg]
    transportCacheLock compatible.Map[dns.Question, chan struct{}]
}

Shared Cache (Default)

Keyed by dns.Question (Name + Qtype + Qclass). All transports share the same cache namespace, meaning a cached response from transport A can serve a query that would go to transport B.

Independent Cache

When independentCache is true, the cache is keyed by transportCacheKey:

go
type transportCacheKey struct {
    dns.Question
    transportTag string
}

Each transport gets its own cache namespace, preventing cross-transport cache hits. This is important when different transports return different results for the same domain (e.g., a domestic DNS vs. a foreign DNS returning different IPs).

Initialization

go
func NewClient(options ClientOptions) *Client {
    cacheCapacity := options.CacheCapacity
    if cacheCapacity < 1024 {
        cacheCapacity = 1024
    }
    if !client.disableCache {
        if !client.independentCache {
            client.cache = common.Must1(freelru.NewSharded[dns.Question, *dns.Msg](
                cacheCapacity, maphash.NewHasher[dns.Question]().Hash32))
        } else {
            client.transportCache = common.Must1(freelru.NewSharded[transportCacheKey, *dns.Msg](
                cacheCapacity, maphash.NewHasher[transportCacheKey]().Hash32))
        }
    }
}

Minimum capacity is 1024 entries. The freelru.NewSharded constructor creates a sharded LRU cache with a hash function generated by maphash.NewHasher. Only one of the two caches (cache or transportCache) is ever created, depending on the independentCache flag.

Cache Deduplication

The client prevents concurrent identical queries from causing a thundering herd using channel-based locking via compatible.Map (a generic wrapper around sync.Map):

go
if c.cache != nil {
    cond, loaded := c.cacheLock.LoadOrStore(question, make(chan struct{}))
    if loaded {
        // Another goroutine is already querying this question
        select {
        case <-cond:           // Wait for the in-flight query to complete
        case <-ctx.Done():     // Or context cancellation
            return nil, ctx.Err()
        }
    } else {
        // This goroutine wins the race; clean up when done
        defer func() {
            c.cacheLock.Delete(question)
            close(cond)  // Signal all waiters
        }()
    }
}

The mechanism works as follows:

  1. LoadOrStore atomically checks if a channel already exists for this question
  2. If loaded is true, another goroutine is already executing the query. The current goroutine blocks on the channel
  3. If loaded is false, the current goroutine proceeds with the query. On completion, it deletes the entry and closes the channel, unblocking all waiters
  4. After being unblocked, waiters fall through to loadResponse which retrieves the now-cached result

The same pattern is used for transportCacheLock when independent cache mode is active.

Cacheability Determination

Not all DNS messages are cached. A request is cacheable only if it is a "simple request":

go
isSimpleRequest := len(message.Question) == 1 &&
    len(message.Ns) == 0 &&
    (len(message.Extra) == 0 || len(message.Extra) == 1 &&
        message.Extra[0].Header().Rrtype == dns.TypeOPT &&
        message.Extra[0].Header().Class > 0 &&
        message.Extra[0].Header().Ttl == 0 &&
        len(message.Extra[0].(*dns.OPT).Option) == 0) &&
    !options.ClientSubnet.IsValid()

disableCache := !isSimpleRequest || c.disableCache || options.DisableCache

A simple request has:

  • Exactly one question
  • No authority records
  • No extra records (or exactly one OPT record with no options, positive UDP size, and zero extended rcode)
  • No per-query client subnet override

Additionally, responses with error codes other than SUCCESS and NXDOMAIN are never cached:

go
disableCache = disableCache || (response.Rcode != dns.RcodeSuccess && response.Rcode != dns.RcodeNameError)

Cache Storage

go
func (c *Client) storeCache(transport adapter.DNSTransport, question dns.Question, message *dns.Msg, timeToLive uint32) {
    if timeToLive == 0 {
        return
    }
    if c.disableExpire {
        if !c.independentCache {
            c.cache.Add(question, message)
        } else {
            c.transportCache.Add(transportCacheKey{
                Question:     question,
                transportTag: transport.Tag(),
            }, message)
        }
    } else {
        if !c.independentCache {
            c.cache.AddWithLifetime(question, message, time.Second*time.Duration(timeToLive))
        } else {
            c.transportCache.AddWithLifetime(transportCacheKey{
                Question:     question,
                transportTag: transport.Tag(),
            }, message, time.Second*time.Duration(timeToLive))
        }
    }
}

Key behaviors:

  • Zero TTL responses are never cached
  • When disableExpire is true, entries are added without a lifetime (they persist until evicted by LRU)
  • When disableExpire is false, entries expire based on the response's TTL

Cache Retrieval and TTL Adjustment

When loading a cached response, TTLs are adjusted to reflect elapsed time:

go
func (c *Client) loadResponse(question dns.Question, transport adapter.DNSTransport) (*dns.Msg, int) {
    if c.disableExpire {
        // No expiration: return cached response as-is (copied)
        response, loaded = c.cache.Get(question)
        if !loaded { return nil, 0 }
        return response.Copy(), 0
    }

    // With expiration: get entry with lifetime info
    response, expireAt, loaded = c.cache.GetWithLifetime(question)
    if !loaded { return nil, 0 }

    // Manual expiration check (belt-and-suspenders)
    timeNow := time.Now()
    if timeNow.After(expireAt) {
        c.cache.Remove(question)
        return nil, 0
    }

    // Calculate remaining TTL
    var originTTL int
    for _, recordList := range [][]dns.RR{response.Answer, response.Ns, response.Extra} {
        for _, record := range recordList {
            if originTTL == 0 || record.Header().Ttl > 0 && int(record.Header().Ttl) < originTTL {
                originTTL = int(record.Header().Ttl)
            }
        }
    }
    nowTTL := int(expireAt.Sub(timeNow).Seconds())
    if nowTTL < 0 { nowTTL = 0 }

    response = response.Copy()
    if originTTL > 0 {
        duration := uint32(originTTL - nowTTL)
        for _, recordList := range [][]dns.RR{response.Answer, response.Ns, response.Extra} {
            for _, record := range recordList {
                record.Header().Ttl = record.Header().Ttl - duration
            }
        }
    } else {
        for _, recordList := range [][]dns.RR{response.Answer, response.Ns, response.Extra} {
            for _, record := range recordList {
                record.Header().Ttl = uint32(nowTTL)
            }
        }
    }
    return response, nowTTL
}

The TTL adjustment logic:

  1. Find the minimum TTL across all records (originTTL) -- this was the TTL when the entry was stored
  2. Compute nowTTL as the remaining seconds until expiration
  3. Compute duration = originTTL - nowTTL (time elapsed since caching)
  4. Subtract duration from each record's TTL, so clients see decreasing TTLs over time
  5. If originTTL is 0 (all records had zero TTL), set all TTLs to the remaining lifetime

Responses are always .Copy()-ed before return to prevent callers from mutating cached entries.

TTL Normalization

Before caching, all record TTLs in a response are normalized to a single value:

go
var timeToLive uint32
if len(response.Answer) == 0 {
    // Negative response: use SOA minimum TTL
    if soaTTL, hasSOA := extractNegativeTTL(response); hasSOA {
        timeToLive = soaTTL
    }
}
if timeToLive == 0 {
    // Find minimum TTL across all sections
    for _, recordList := range [][]dns.RR{response.Answer, response.Ns, response.Extra} {
        for _, record := range recordList {
            if timeToLive == 0 || record.Header().Ttl > 0 && record.Header().Ttl < timeToLive {
                timeToLive = record.Header().Ttl
            }
        }
    }
}
if options.RewriteTTL != nil {
    timeToLive = *options.RewriteTTL
}
// Apply uniform TTL to all records
for _, recordList := range [][]dns.RR{response.Answer, response.Ns, response.Extra} {
    for _, record := range recordList {
        record.Header().Ttl = timeToLive
    }
}

Negative TTL Extraction

For NXDOMAIN responses with no answer records, the TTL is derived from the SOA record in the authority section:

go
func extractNegativeTTL(response *dns.Msg) (uint32, bool) {
    for _, record := range response.Ns {
        if soa, isSOA := record.(*dns.SOA); isSOA {
            soaTTL := soa.Header().Ttl
            soaMinimum := soa.Minttl
            if soaTTL < soaMinimum {
                return soaTTL, true
            }
            return soaMinimum, true
        }
    }
    return 0, false
}

The function returns min(soa.Header().Ttl, soa.Minttl), following RFC 2308 guidance on negative caching.

Lookup Cache Fast Path

The Lookup method (domain to addresses) has a fast path that checks the cache before constructing a full DNS message:

go
func (c *Client) lookupToExchange(ctx context.Context, transport adapter.DNSTransport,
    name string, qType uint16, options adapter.DNSQueryOptions,
    responseChecker func(responseAddrs []netip.Addr) bool) ([]netip.Addr, error) {
    question := dns.Question{Name: name, Qtype: qType, Qclass: dns.ClassINET}
    disableCache := c.disableCache || options.DisableCache
    if !disableCache {
        cachedAddresses, err := c.questionCache(question, transport)
        if err != ErrNotCached {
            return cachedAddresses, err
        }
    }
    // ... proceed with full Exchange
}

func (c *Client) questionCache(question dns.Question, transport adapter.DNSTransport) ([]netip.Addr, error) {
    response, _ := c.loadResponse(question, transport)
    if response == nil {
        return nil, ErrNotCached
    }
    if response.Rcode != dns.RcodeSuccess {
        return nil, RcodeError(response.Rcode)
    }
    return MessageToAddresses(response), nil
}

This bypasses the deduplication mechanism and directly checks the cache. If a cached NXDOMAIN response exists, it returns the appropriate RcodeError without making a network request.

RDRC (Response Domain Rejection Cache)

The RDRC caches domain/qtype/transport combinations that have been rejected by address-limit rules. This prevents repeatedly querying a transport that is known to return unacceptable addresses.

Interface

go
type RDRCStore interface {
    LoadRDRC(transportName string, qName string, qType uint16) (rejected bool)
    SaveRDRC(transportName string, qName string, qType uint16) error
    SaveRDRCAsync(transportName string, qName string, qType uint16, logger logger.Logger)
}

Initialization

The RDRC store is lazily initialized from the cache file at client start:

go
func (c *Client) Start() {
    if c.initRDRCFunc != nil {
        c.rdrc = c.initRDRCFunc()
    }
}

In the router, the init function checks if the cache file supports RDRC:

go
RDRC: func() adapter.RDRCStore {
    cacheFile := service.FromContext[adapter.CacheFile](ctx)
    if cacheFile == nil {
        return nil
    }
    if !cacheFile.StoreRDRC() {
        return nil
    }
    return cacheFile
},

Storage Backend (bbolt)

The RDRC is persisted using bbolt (a fork of BoltDB) in a bucket named "rdrc2":

go
var bucketRDRC = []byte("rdrc2")

Key Format

Keys are [2-byte qType (big-endian)][qName bytes], stored under a sub-bucket named by the transport tag:

go
key := buf.Get(2 + len(qName))
binary.BigEndian.PutUint16(key, qType)
copy(key[2:], qName)

Value Format

Values are 8-byte Unix timestamps (big-endian) representing the expiration time:

go
expiresAt := buf.Get(8)
binary.BigEndian.PutUint64(expiresAt, uint64(time.Now().Add(c.rdrcTimeout).Unix()))
return bucket.Put(key, expiresAt)

Default Timeout

RDRC entries expire after 7 days by default:

go
if options.StoreRDRC {
    if options.RDRCTimeout > 0 {
        rdrcTimeout = time.Duration(options.RDRCTimeout)
    } else {
        rdrcTimeout = 7 * 24 * time.Hour
    }
}

Async Save with In-Memory Cache

To avoid blocking the query path on disk writes, RDRC entries are saved asynchronously with an in-memory write-ahead cache:

go
type CacheFile struct {
    // ...
    saveRDRCAccess sync.RWMutex
    saveRDRC       map[saveRDRCCacheKey]bool
}

func (c *CacheFile) SaveRDRCAsync(transportName string, qName string, qType uint16, logger logger.Logger) {
    saveKey := saveRDRCCacheKey{transportName, qName, qType}
    c.saveRDRCAccess.Lock()
    c.saveRDRC[saveKey] = true        // Immediately visible to reads
    c.saveRDRCAccess.Unlock()
    go func() {
        err := c.SaveRDRC(transportName, qName, qType)    // Persist to bbolt
        if err != nil {
            logger.Warn("save RDRC: ", err)
        }
        c.saveRDRCAccess.Lock()
        delete(c.saveRDRC, saveKey)   // Remove from write-ahead cache
        c.saveRDRCAccess.Unlock()
    }()
}

On load, the in-memory cache is checked first before reading from bbolt:

go
func (c *CacheFile) LoadRDRC(transportName string, qName string, qType uint16) (rejected bool) {
    c.saveRDRCAccess.RLock()
    rejected, cached := c.saveRDRC[saveRDRCCacheKey{transportName, qName, qType}]
    c.saveRDRCAccess.RUnlock()
    if cached {
        return
    }
    // Fall through to bbolt read...
}

Expiration

When loading from bbolt, expired entries are detected and lazily cleaned up:

go
content := bucket.Get(key)
expiresAt := time.Unix(int64(binary.BigEndian.Uint64(content)), 0)
if time.Now().After(expiresAt) {
    deleteCache = true   // Mark for deletion
    return nil           // Not rejected
}
rejected = true

The deletion happens in a separate Update transaction to avoid holding a read transaction lock during the write.

Integration with Exchange

RDRC is checked after cache deduplication but before the transport exchange:

go
if !disableCache && responseChecker != nil && c.rdrc != nil {
    rejected := c.rdrc.LoadRDRC(transport.Tag(), question.Name, question.Qtype)
    if rejected {
        return nil, ErrResponseRejectedCached
    }
}

And saved when a response is rejected by the address-limit checker:

go
if rejected {
    if !disableCache && c.rdrc != nil {
        c.rdrc.SaveRDRCAsync(transport.Tag(), question.Name, question.Qtype, c.logger)
    }
    return response, ErrResponseRejected
}

The router's retry loop uses ErrResponseRejected and ErrResponseRejectedCached to skip to the next matching rule.

EDNS0 Client Subnet

The client injects EDNS0 Client Subnet (ECS) options into DNS messages before exchange:

go
clientSubnet := options.ClientSubnet
if !clientSubnet.IsValid() {
    clientSubnet = c.clientSubnet      // Fall back to global setting
}
if clientSubnet.IsValid() {
    message = SetClientSubnet(message, clientSubnet)
}

Implementation

go
func SetClientSubnet(message *dns.Msg, clientSubnet netip.Prefix) *dns.Msg {
    return setClientSubnet(message, clientSubnet, true)
}

func setClientSubnet(message *dns.Msg, clientSubnet netip.Prefix, clone bool) *dns.Msg {
    var (
        optRecord    *dns.OPT
        subnetOption *dns.EDNS0_SUBNET
    )
    // Search for existing OPT record and EDNS0_SUBNET option
    for _, record := range message.Extra {
        if optRecord, isOPTRecord = record.(*dns.OPT); isOPTRecord {
            for _, option := range optRecord.Option {
                subnetOption, isEDNS0Subnet = option.(*dns.EDNS0_SUBNET)
                if isEDNS0Subnet { break }
            }
        }
    }
    // Create OPT record if not found
    if optRecord == nil {
        exMessage := *message
        message = &exMessage
        optRecord = &dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}}
        message.Extra = append(message.Extra, optRecord)
    } else if clone {
        return setClientSubnet(message.Copy(), clientSubnet, false)
    }
    // Create or update subnet option
    if subnetOption == nil {
        subnetOption = new(dns.EDNS0_SUBNET)
        subnetOption.Code = dns.EDNS0SUBNET
        optRecord.Option = append(optRecord.Option, subnetOption)
    }
    if clientSubnet.Addr().Is4() {
        subnetOption.Family = 1
    } else {
        subnetOption.Family = 2
    }
    subnetOption.SourceNetmask = uint8(clientSubnet.Bits())
    subnetOption.Address = clientSubnet.Addr().AsSlice()
    return message
}

Key details:

  • The first call uses clone = true, which copies the message if an OPT record already exists (to avoid mutating the original)
  • If no OPT record exists, a shallow copy of the message is made and a new OPT record is appended
  • Family 1 = IPv4, Family 2 = IPv6
  • Messages with per-query client subnet set (options.ClientSubnet.IsValid()) are excluded from caching

EDNS0 Version Downgrade

After receiving a response, the client handles EDNS0 version mismatches:

go
requestEDNSOpt := message.IsEdns0()
responseEDNSOpt := response.IsEdns0()
if responseEDNSOpt != nil && (requestEDNSOpt == nil || requestEDNSOpt.Version() < responseEDNSOpt.Version()) {
    response.Extra = common.Filter(response.Extra, func(it dns.RR) bool {
        return it.Header().Rrtype != dns.TypeOPT
    })
    if requestEDNSOpt != nil {
        response.SetEdns0(responseEDNSOpt.UDPSize(), responseEDNSOpt.Do())
    }
}

If the response's EDNS0 version is higher than the request's (or the request had no EDNS0), the OPT record is stripped and optionally replaced with a version-compatible one.

DNS Message Truncation

For UDP DNS responses that exceed the maximum message size, truncation is applied respecting EDNS0:

go
func TruncateDNSMessage(request *dns.Msg, response *dns.Msg, headroom int) (*buf.Buffer, error) {
    maxLen := 512
    if edns0Option := request.IsEdns0(); edns0Option != nil {
        if udpSize := int(edns0Option.UDPSize()); udpSize > 512 {
            maxLen = udpSize
        }
    }
    responseLen := response.Len()
    if responseLen > maxLen {
        response = response.Copy()
        response.Truncate(maxLen)
    }
    buffer := buf.NewSize(headroom*2 + 1 + responseLen)
    buffer.Resize(headroom, 0)
    rawMessage, err := response.PackBuffer(buffer.FreeBytes())
    if err != nil {
        buffer.Release()
        return nil, err
    }
    buffer.Truncate(len(rawMessage))
    return buffer, nil
}
  • Default maximum is 512 bytes (standard DNS UDP limit)
  • If the request has an EDNS0 OPT record with a larger UDP size, that size is used
  • Truncation is performed on a copy to avoid mutating the cached response
  • The buffer includes headroom for protocol framing (e.g., UDP headers)

Cache Clearing

go
func (c *Client) ClearCache() {
    if c.cache != nil {
        c.cache.Purge()
    } else if c.transportCache != nil {
        c.transportCache.Purge()
    }
}

Called by the router on network changes:

go
func (r *Router) ResetNetwork() {
    r.ClearCache()
    for _, transport := range r.transport.Transports() {
        transport.Reset()
    }
}

func (r *Router) ClearCache() {
    r.client.ClearCache()
    if r.platformInterface != nil {
        r.platformInterface.ClearDNSCache()
    }
}

This also clears the platform-level DNS cache (e.g., on Android/iOS) if a platform interface is available.

Strategy Filtering

Before any cache or transport interaction, queries that conflict with the domain strategy are immediately answered with empty success:

go
if question.Qtype == dns.TypeA && options.Strategy == C.DomainStrategyIPv6Only ||
   question.Qtype == dns.TypeAAAA && options.Strategy == C.DomainStrategyIPv4Only {
    return FixedResponseStatus(message, dns.RcodeSuccess), nil
}

This prevents unnecessary cache entries and network round-trips for mismatched query types.

HTTPS Record Filtering

For HTTPS (SVCB type 65) queries, address hints are filtered based on domain strategy:

go
if question.Qtype == dns.TypeHTTPS {
    if options.Strategy == C.DomainStrategyIPv4Only || options.Strategy == C.DomainStrategyIPv6Only {
        for _, rr := range response.Answer {
            https, isHTTPS := rr.(*dns.HTTPS)
            if !isHTTPS { continue }
            content := https.SVCB
            content.Value = common.Filter(content.Value, func(it dns.SVCBKeyValue) bool {
                if options.Strategy == C.DomainStrategyIPv4Only {
                    return it.Key() != dns.SVCB_IPV6HINT
                } else {
                    return it.Key() != dns.SVCB_IPV4HINT
                }
            })
            https.SVCB = content
        }
    }
}

IPv4-only strategy removes IPv6 hints; IPv6-only strategy removes IPv4 hints. This filtering happens after transport exchange but before caching, so cached HTTPS responses are already filtered.

Loop Detection

DNS query loops are detected by tagging the context with the current transport:

go
contextTransport, loaded := transportTagFromContext(ctx)
if loaded && transport.Tag() == contextTransport {
    return nil, E.New("DNS query loopback in transport[", contextTransport, "]")
}
ctx = contextWithTransportTag(ctx, transport.Tag())

This prevents infinite recursion when a transport needs to resolve its server's hostname (e.g., DoH transport for dns.example.com trying to resolve dns.example.com via itself).

Logging

Three log functions provide structured output for DNS events:

go
func logCachedResponse(logger, ctx, response, ttl)    // "cached example.com NOERROR 42"
func logExchangedResponse(logger, ctx, response, ttl)  // "exchanged example.com NOERROR 300"
func logRejectedResponse(logger, ctx, response)         // "rejected A example.com 1.2.3.4"

Each logs the domain at DEBUG level and individual records at INFO level. The FormatQuestion helper normalizes miekg/dns record strings by stripping semicolons, collapsing whitespace, and trimming.

Error Types

go
type RcodeError int

const (
    RcodeSuccess     RcodeError = mDNS.RcodeSuccess
    RcodeFormatError RcodeError = mDNS.RcodeFormatError
    RcodeNameError   RcodeError = mDNS.RcodeNameError
    RcodeRefused     RcodeError = mDNS.RcodeRefused
)

func (e RcodeError) Error() string {
    return mDNS.RcodeToString[int(e)]
}

Sentinel errors:

  • ErrNoRawSupport -- transport does not support raw DNS messages
  • ErrNotCached -- cache miss (used internally by questionCache)
  • ErrResponseRejected -- response failed address-limit check
  • ErrResponseRejectedCached -- extends ErrResponseRejected, indicates the rejection was served from RDRC

Configuration

json
{
  "dns": {
    "client_options": {
      "disable_cache": false,
      "disable_expire": false,
      "independent_cache": false,
      "cache_capacity": 1024,
      "client_subnet": "1.2.3.0/24"
    }
  },
  "experimental": {
    "cache_file": {
      "enabled": true,
      "path": "cache.db",
      "store_rdrc": true,
      "rdrc_timeout": "168h"
    }
  }
}
FieldDefaultDescription
disable_cachefalseDisable all DNS response caching
disable_expirefalseCache entries never expire (evicted only by LRU)
independent_cachefalseSeparate cache namespace per transport
cache_capacity1024Maximum cache entries (minimum 1024)
client_subnetnoneDefault EDNS0 Client Subnet prefix
store_rdrcfalseEnable RDRC persistence to cache file
rdrc_timeout168h (7 days)RDRC entry expiration duration