DNS Client and Router
Source: dns/client.go, dns/router.go, dns/rcode.go, dns/client_truncate.go, dns/client_log.go, dns/extension_edns0_subnet.go
DNS Client
Structure
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{}]
}Two cache modes:
- Shared cache (
cache): Keyed bydns.Question(Name + Qtype + Qclass) - Independent cache (
transportCache): Keyed bytransportCacheKey(Question + transport tag), so each transport has its own cache namespace
The cache uses github.com/sagernet/sing/contrab/freelru (a sharded LRU cache). Default capacity is 1024 entries.
Exchange
The core Exchange method handles the full query lifecycle:
func (c *Client) Exchange(ctx context.Context, transport adapter.DNSTransport,
message *dns.Msg, options adapter.DNSQueryOptions,
responseChecker func(responseAddrs []netip.Addr) bool) (*dns.Msg, error)Step 1: Strategy Filtering
Immediately returns empty success for mismatched strategies:
if question.Qtype == dns.TypeA && options.Strategy == C.DomainStrategyIPv6Only ||
question.Qtype == dns.TypeAAAA && options.Strategy == C.DomainStrategyIPv4Only {
return FixedResponseStatus(message, dns.RcodeSuccess), nil
}Step 2: Client Subnet
clientSubnet := options.ClientSubnet
if !clientSubnet.IsValid() {
clientSubnet = c.clientSubnet
}
if clientSubnet.IsValid() {
message = SetClientSubnet(message, clientSubnet)
}Step 3: Cache Check
Only "simple requests" are cacheable (single question, no extra records except OPT, no client subnet in options):
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()Cache deduplication prevents concurrent identical queries:
cond, loaded := c.cacheLock.LoadOrStore(question, make(chan struct{}))
if loaded {
select {
case <-cond: // Wait for first query to complete
case <-ctx.Done(): return nil, ctx.Err()
}
}Step 4: Cache Load with TTL Adjustment
func (c *Client) loadResponse(question dns.Question, transport adapter.DNSTransport) (*dns.Msg, int) {
response, expireAt, loaded = c.cache.GetWithLifetime(question)
// Calculate remaining TTL
nowTTL := int(expireAt.Sub(timeNow).Seconds())
// Adjust record TTLs: subtract elapsed time
duration := uint32(originTTL - nowTTL)
for _, record := range recordList {
record.Header().Ttl = record.Header().Ttl - duration
}
return response, nowTTL
}Cached responses are copied (response.Copy()) to prevent mutation. TTLs are adjusted to reflect time elapsed since caching.
Step 5: RDRC Check
if c.rdrc != nil {
rejected := c.rdrc.LoadRDRC(transport.Tag(), question.Name, question.Qtype)
if rejected {
return nil, ErrResponseRejectedCached
}
}Step 6: Transport Exchange
ctx, cancel := context.WithTimeout(ctx, c.timeout)
response, err := transport.Exchange(ctx, message)
cancel()Default timeout is C.DNSTimeout.
Step 7: Response Validation
If a responseChecker is provided, the response addresses are validated:
if responseChecker != nil {
var rejected bool
if response.Rcode != dns.RcodeSuccess && response.Rcode != dns.RcodeNameError {
rejected = true
} else if len(response.Answer) == 0 {
rejected = !responseChecker(nil)
} else {
rejected = !responseChecker(MessageToAddresses(response))
}
if rejected {
c.rdrc.SaveRDRCAsync(transport.Tag(), question.Name, question.Qtype, c.logger)
return response, ErrResponseRejected
}
}Step 8: TTL Normalization
All records in the response are set to the minimum TTL found. If options.RewriteTTL is set, that value overrides.
For negative responses (NXDOMAIN with no answers), the SOA minimum TTL is used:
func extractNegativeTTL(response *dns.Msg) (uint32, bool) {
for _, record := range response.Ns {
if soa, isSOA := record.(*dns.SOA); isSOA {
return min(soa.Header().Ttl, soa.Minttl), true
}
}
return 0, false
}Step 9: HTTPS Record Filtering
For HTTPS queries with domain strategy, address hints are filtered:
if question.Qtype == dns.TypeHTTPS {
if options.Strategy == C.DomainStrategyIPv4Only {
// Remove IPv6 hints
} else if options.Strategy == C.DomainStrategyIPv6Only {
// Remove IPv4 hints
}
}Lookup
Parallel A/AAAA queries:
func (c *Client) Lookup(ctx context.Context, transport adapter.DNSTransport,
domain string, options adapter.DNSQueryOptions, responseChecker func([]netip.Addr) bool) ([]netip.Addr, error) {
if strategy == C.DomainStrategyIPv4Only {
return c.lookupToExchange(ctx, transport, dnsName, dns.TypeA, options, responseChecker)
} else if strategy == C.DomainStrategyIPv6Only {
return c.lookupToExchange(ctx, transport, dnsName, dns.TypeAAAA, options, responseChecker)
}
var group task.Group
group.Append("exchange4", func(ctx context.Context) error { ... })
group.Append("exchange6", func(ctx context.Context) error { ... })
err := group.Run(ctx)
return sortAddresses(response4, response6, strategy), nil
}sortAddresses orders results by strategy: PreferIPv6 puts AAAA first, everything else puts A first.
DNS Router
Rule Matching
func (r *Router) matchDNS(ctx context.Context, allowFakeIP bool, ruleIndex int,
isAddressQuery bool, options *adapter.DNSQueryOptions) (adapter.DNSTransport, adapter.DNSRule, int) {
for ; currentRuleIndex < len(r.rules); currentRuleIndex++ {
currentRule := r.rules[currentRuleIndex]
if currentRule.WithAddressLimit() && !isAddressQuery {
continue // Skip address-limit rules for non-address queries
}
metadata.ResetRuleCache()
if currentRule.Match(metadata) {
switch action := currentRule.Action().(type) {
case *R.RuleActionDNSRoute:
transport, loaded := r.transport.Transport(action.Server)
// Apply strategy, cache, TTL, client subnet options
return transport, currentRule, currentRuleIndex
case *R.RuleActionDNSRouteOptions:
// Modify options and continue matching
case *R.RuleActionReject:
return nil, currentRule, currentRuleIndex
case *R.RuleActionPredefined:
return nil, currentRule, currentRuleIndex
}
}
}
return r.transport.Default(), nil, -1
}Rules with address limits are only evaluated for address queries (A, AAAA, HTTPS).
Reverse Mapping
When enabled, the router stores IP-to-domain mappings with TTL-based expiration:
if r.dnsReverseMapping != nil && transport.Type() != C.DNSTypeFakeIP {
for _, answer := range response.Answer {
switch record := answer.(type) {
case *mDNS.A:
r.dnsReverseMapping.AddWithLifetime(
M.AddrFromIP(record.A),
FqdnToDomain(record.Hdr.Name),
time.Duration(record.Hdr.Ttl)*time.Second)
case *mDNS.AAAA:
r.dnsReverseMapping.AddWithLifetime(...)
}
}
}FakeIP responses are excluded from reverse mapping since they return synthetic addresses.
Network Reset
On network changes, the router clears all caches and resets all transports:
func (r *Router) ResetNetwork() {
r.ClearCache()
for _, transport := range r.transport.Transports() {
transport.Reset()
}
}Helper Types
RcodeError
type RcodeError int
var RcodeNameError = RcodeError(dns.RcodeNameError)
func (e RcodeError) Error() string {
return dns.RcodeToString[int(e)]
}MessageToAddresses
Extracts IP addresses from a DNS response, including HTTPS SVCB hints:
func MessageToAddresses(response *dns.Msg) []netip.Addr {
for _, rawAnswer := range response.Answer {
switch answer := rawAnswer.(type) {
case *dns.A: addresses = append(addresses, M.AddrFromIP(answer.A))
case *dns.AAAA: addresses = append(addresses, M.AddrFromIP(answer.AAAA))
case *dns.HTTPS:
for _, value := range answer.SVCB.Value {
if value.Key() == dns.SVCB_IPV4HINT || value.Key() == dns.SVCB_IPV6HINT {
addresses = append(addresses, common.Map(strings.Split(value.String(), ","), M.ParseAddr)...)
}
}
}
}
}