Skip to content

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.

go
// 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:

FieldPurpose
clientsOrdered list of *Client wrappers, each holding a Server implementation
hostsStatic domain-to-IP mappings (the "hosts" config section)
domainMatcherA strmatcher.MatcherGroup that indexes domain rules across all clients
matcherInfosMaps matcher index back to (clientIdx, domainRuleIdx)
ipOptionGlobal query strategy (IPv4/IPv6/both)
enableParallelQuerySwitches between serial and parallel query modes
disableFallback / disableFallbackIfMatchControls 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:

go
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.

go
// 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

mermaid
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| L

Initialization Flow

The New() function in app/dns/dns.go builds the DNS engine:

  1. Parse query strategy -- Maps QueryStrategy_USE_IP, USE_IP4, USE_IP6, USE_SYS to dns.IPOption flags. The USE_SYS strategy enables runtime route probing via checkRoutes().

  2. Build static hosts -- Either loads from an MPH (minimal perfect hash) cache file or constructs from config.StaticHosts.

  3. Build clients -- For each NameServer in config, calls NewClient(), which:

    • Creates a Server via NewServer() (dispatches by URL scheme)
    • Registers domain rules into the shared domainMatcher
    • Builds expectedIPs and unexpectedIPs GeoIP matchers
    • Sets per-server cache, stale serving, timeout, and tag
  4. 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:

go
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:

go
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.

go
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:

  1. Grouping: Adjacent clients with the same policyID form a group. The makeGroups() function merges only adjacent, rule-equivalent clients.

  2. Group race: Within each group, the first successful result wins (minimum RTT). If all members of a group fail, the next group is tried.

  3. Timeout handling: For servers with caching enabled, the context timeout is doubled (c.timeoutMs * 2) using context.WithoutCancel to allow background cache updates.

go
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 actPrior is false, only IPs matching the GeoIP set are kept (strict filter). If actPrior is 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 actUnprior is 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:

go
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

FilePurpose
app/dns/dns.goDNS struct, New(), LookupIP(), sortClients(), serial/parallel query
app/dns/nameserver.goServer interface, Client struct, NewServer() factory, NewClient(), IP filtering
app/dns/dnscommon.goIPRecord, dnsRequest, buildReqMsgs(), parseResponse(), EDNS0 options
app/dns/hosts.goStaticHosts for the "hosts" config mapping
app/dns/config.goDomain matcher helpers (toStrMatcher), local TLD rules

Implementation Notes

  • The DNS struct registers itself via common.RegisterConfig((*Config)(nil), ...) in init(), so the core creates it when the DNS config protobuf appears in the app list.

  • The tag field 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).

  • finalQuery on a nameserver causes sortClients() to stop adding clients after that server. This prevents fallback for specific domain rules.

  • The policyID is 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 are errRecordNotFound (server gave no response), a generic dns.ErrEmptyResponse is returned.

  • Memory management is explicit: after processing domain rules and GeoIP lists during initialization, runtime.GC() is called to free the temporary protobuf structures.

Technical analysis for re-implementation purposes.