DNS Architecture Overview
The Xray DNS subsystem is a fully self-contained DNS resolver that replaces the OS resolver. It supports domain-based routing of DNS queries across multiple upstream servers, static host overrides, parallel/serial query strategies, IP filtering, and integration with Fake DNS.
Core Data Structures
The DNS Struct
The central coordinator lives in app/dns/dns.go. It implements the dns.Client feature interface and orchestrates all query logic.
// app/dns/dns.go
type DNS struct {
sync.Mutex
disableFallback bool
disableFallbackIfMatch bool
enableParallelQuery bool
ipOption *dns.IPOption
hosts *StaticHosts
clients []*Client
ctx context.Context
domainMatcher strmatcher.IndexMatcher
matcherInfos []*DomainMatcherInfo
checkSystem bool
}Key fields:
| Field | Purpose |
|---|---|
clients | Ordered list of *Client wrappers, each holding a Server implementation |
hosts | Static domain-to-IP mappings (the "hosts" config section) |
domainMatcher | A strmatcher.MatcherGroup that indexes domain rules across all clients |
matcherInfos | Maps matcher index back to (clientIdx, domainRuleIdx) |
ipOption | Global query strategy (IPv4/IPv6/both) |
enableParallelQuery | Switches between serial and parallel query modes |
disableFallback / disableFallbackIfMatch | Controls whether unmatched clients are used as fallback |
The Server Interface
Defined in app/dns/nameserver.go, this is the contract for all DNS upstream implementations:
type Server interface {
Name() string
IsDisableCache() bool
QueryIP(ctx context.Context, domain string, option dns.IPOption) ([]net.IP, uint32, error)
}Every server type (UDP, TCP, DoH, DoQ, Local, FakeDNS) implements this interface.
The Client Struct
Client wraps a Server with per-client policy: domain rules, expected/unexpected IP filtering, timeout, tag for routing, and query strategy override.
// app/dns/nameserver.go
type Client struct {
server Server
skipFallback bool
domains []string
expectedIPs router.GeoIPMatcher
unexpectedIPs router.GeoIPMatcher
actPrior bool
actUnprior bool
tag string
timeoutMs time.Duration
finalQuery bool
ipOption *dns.IPOption
checkSystem bool
policyID uint32
}Architecture Diagram
flowchart TD
A[LookupIP called] --> B{Static hosts match?}
B -->|Domain replacement| C[Replace domain, continue]
B -->|IP found| D[Return IPs, TTL=10]
B -->|Not found| E{Parallel query enabled?}
E -->|Yes| F[parallelQuery]
E -->|No| G[serialQuery]
G --> H[sortClients by domain rules]
H --> I[Query clients in order]
I --> J{IPs returned?}
J -->|Yes| K[Apply expectedIPs / unexpectedIPs filter]
J -->|No| L[Try next client or return error]
K --> M[Return filtered IPs]
F --> N[sortClients by domain rules]
N --> O[asyncQueryAll - fire all queries]
O --> P[Collect results by group]
P --> Q{Any group succeeds?}
Q -->|Yes| M
Q -->|No| LInitialization Flow
The New() function in app/dns/dns.go builds the DNS engine:
Parse query strategy -- Maps
QueryStrategy_USE_IP,USE_IP4,USE_IP6,USE_SYStodns.IPOptionflags. TheUSE_SYSstrategy enables runtime route probing viacheckRoutes().Build static hosts -- Either loads from an MPH (minimal perfect hash) cache file or constructs from
config.StaticHosts.Build clients -- For each
NameServerin config, callsNewClient(), which:- Creates a
ServerviaNewServer()(dispatches by URL scheme) - Registers domain rules into the shared
domainMatcher - Builds
expectedIPsandunexpectedIPsGeoIP matchers - Sets per-server cache, stale serving, timeout, and tag
- Creates a
Default client -- If no servers are configured, a
LocalNameServer(system resolver) is added automatically.
Domain-Based Routing
The DNS system routes queries to different upstream servers based on domain matching rules. This is the sortClients() method:
func (s *DNS) sortClients(domain string) []*Client {
// 1. Match domain against domainMatcher (returns indices)
// 2. Sort matched indices, look up (clientIdx, domainRuleIdx) pairs
// 3. Add matched clients in priority order (deduplicating)
// 4. If finalQuery is set on a matched client, stop immediately
// 5. Unless fallback is disabled, append remaining unmatched clients
// 6. If no clients matched and fallback is disabled, use first client
}Each DomainMatcherInfo stores which client and which specific domain rule matched:
type DomainMatcherInfo struct {
clientIdx uint16
domainRuleIdx uint16
}This means the domain "google.com" might match server A's rule "geosite:google", and the DNS query goes to server A first, then falls back to others.
Query Modes
Serial Query
serialQuery() iterates through the sorted client list sequentially. The first client to return a non-empty IP set wins. If a client returns an error, the next client is tried.
func (s *DNS) serialQuery(domain string, option dns.IPOption) ([]net.IP, uint32, error) {
for _, client := range s.sortClients(domain) {
ips, ttl, err := client.QueryIP(s.ctx, domain, option)
if len(ips) > 0 {
return ips, ttl, nil
}
// log error, continue to next client
}
return nil, 0, mergeQueryErrors(domain, errs)
}Parallel Query
parallelQuery() fires queries to all sorted clients simultaneously via asyncQueryAll(), then collects results using a group-based race strategy:
Grouping: Adjacent clients with the same
policyIDform a group. ThemakeGroups()function merges only adjacent, rule-equivalent clients.Group race: Within each group, the first successful result wins (minimum RTT). If all members of a group fail, the next group is tried.
Timeout handling: For servers with caching enabled, the context timeout is doubled (
c.timeoutMs * 2) usingcontext.WithoutCancelto allow background cache updates.
func asyncQueryAll(domain string, option dns.IPOption, clients []*Client, ctx context.Context) chan queryResult {
ch := make(chan queryResult, len(clients))
for i, client := range clients {
go func(i int, c *Client) {
ips, ttl, err := c.QueryIP(qctx, domain, option)
ch <- queryResult{ips: ips, ttl: ttl, err: err, index: i}
}(i, client)
}
return ch
}IP Filtering on Client
After a server returns IPs, the Client.QueryIP() method applies two filters:
- expectedIPs -- If
actPrioris false, only IPs matching the GeoIP set are kept (strict filter). IfactPrioris true, matching IPs are preferred but non-matching IPs are kept as fallback. - unexpectedIPs -- The inverse: IPs matching the "unexpected" set are removed (or deprioritized if
actUnprioris true).
This enables "DNS pollution filtering" where a domestic DNS server might return poisoned IPs for foreign domains.
Route Probing (USE_SYS)
When QueryStrategy is USE_SYS, the DNS system probes actual network connectivity before querying:
func probeRoutes() (ipv4 bool, ipv6 bool) {
// Dial root DNS servers to check IPv4/IPv6 connectivity
if conn, err := net.Dial("udp4", "192.33.4.12:53"); err == nil { ipv4 = true }
if conn, err := net.Dial("udp6", "[2001:500:2::c]:53"); err == nil { ipv6 = true }
}On GUI platforms (Android, iOS, Windows, macOS), this probe is cached for 100ms and re-checked to handle network changes. On server platforms, it runs only once.
Key Source Files
| File | Purpose |
|---|---|
app/dns/dns.go | DNS struct, New(), LookupIP(), sortClients(), serial/parallel query |
app/dns/nameserver.go | Server interface, Client struct, NewServer() factory, NewClient(), IP filtering |
app/dns/dnscommon.go | IPRecord, dnsRequest, buildReqMsgs(), parseResponse(), EDNS0 options |
app/dns/hosts.go | StaticHosts for the "hosts" config mapping |
app/dns/config.go | Domain matcher helpers (toStrMatcher), local TLD rules |
Implementation Notes
The
DNSstruct registers itself viacommon.RegisterConfig((*Config)(nil), ...)ininit(), so the core creates it when the DNS config protobuf appears in the app list.The
tagfield on each Client is crucial: it becomes the inbound tag for the DNS query's routing context (session.ContextWithInbound). This allows routing rules to direct DNS traffic through specific outbounds (e.g., sending DNS-over-HTTPS through a proxy).finalQueryon a nameserver causessortClients()to stop adding clients after that server. This prevents fallback for specific domain rules.The
policyIDis computed at config build time based on a hash of the server's configuration properties (domains, expected IPs, query strategy, tag, etc.). Servers with identical policy IDs are grouped together for parallel racing.When all clients fail,
mergeQueryErrors()consolidates errors. If all errors areerrRecordNotFound(server gave no response), a genericdns.ErrEmptyResponseis returned.Memory management is explicit: after processing domain rules and GeoIP lists during initialization,
runtime.GC()is called to free the temporary protobuf structures.