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:
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:
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
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):
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:
LoadOrStoreatomically checks if a channel already exists for this question- If
loadedis true, another goroutine is already executing the query. The current goroutine blocks on the channel - If
loadedis false, the current goroutine proceeds with the query. On completion, it deletes the entry and closes the channel, unblocking all waiters - After being unblocked, waiters fall through to
loadResponsewhich 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":
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.DisableCacheA 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:
disableCache = disableCache || (response.Rcode != dns.RcodeSuccess && response.Rcode != dns.RcodeNameError)Cache Storage
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
disableExpireis true, entries are added without a lifetime (they persist until evicted by LRU) - When
disableExpireis 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:
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:
- Find the minimum TTL across all records (
originTTL) -- this was the TTL when the entry was stored - Compute
nowTTLas the remaining seconds until expiration - Compute
duration = originTTL - nowTTL(time elapsed since caching) - Subtract
durationfrom each record's TTL, so clients see decreasing TTLs over time - If
originTTLis 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:
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:
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:
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
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:
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:
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":
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:
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:
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:
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:
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:
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:
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 = trueThe 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:
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:
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:
clientSubnet := options.ClientSubnet
if !clientSubnet.IsValid() {
clientSubnet = c.clientSubnet // Fall back to global setting
}
if clientSubnet.IsValid() {
message = SetClientSubnet(message, clientSubnet)
}Implementation
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:
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:
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
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:
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:
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:
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:
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:
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
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 messagesErrNotCached-- cache miss (used internally byquestionCache)ErrResponseRejected-- response failed address-limit checkErrResponseRejectedCached-- extendsErrResponseRejected, indicates the rejection was served from RDRC
Configuration
{
"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"
}
}
}| Field | Default | Description |
|---|---|---|
disable_cache | false | Disable all DNS response caching |
disable_expire | false | Cache entries never expire (evicted only by LRU) |
independent_cache | false | Separate cache namespace per transport |
cache_capacity | 1024 | Maximum cache entries (minimum 1024) |
client_subnet | none | Default EDNS0 Client Subnet prefix |
store_rdrc | false | Enable RDRC persistence to cache file |
rdrc_timeout | 168h (7 days) | RDRC entry expiration duration |