Contents

DNS

The built-in DNS server: hosts, per-domain nameservers, fakedns, and query strategies.

If your apps resolve domains through the operating system, the censor sees every lookup in plaintext — and Xray’s router sees only destination IPs, so your domain: rules can’t match unless sniffing recovers the name later. The built-in DNS server fixes both: queries are answered inside the core, and remote nameservers are reached through your own outbounds.

A query walks hosts, then a sorted list of servers

LookupIP is a pipeline, not a single lookup — static hosts get first refusal, then sortClients orders the configured nameservers per domain and tries them in sequence (or in parallel groups when enableParallelQuery is set).

app/dns/dns.go app/dns/nameserver.go
Query resolution: hosts short-circuit, domain rules pick servers, remote servers dial through the dispatcher

sortClients puts servers whose domains rules match first, then appends the rest as round-robin fallback — unless disableFallback is set globally (disableFallbackIfMatch drops the fallback pool only for domains that hit a rule), the server opted out with skipFallback, or a server declared finalQuery, which truncates the list on the spot. After a response, the client can still discard answers whose IPs fall outside expectedIPs — a defense against poisoned responses that return bogus addresses.

Nameserver addresses choose a transport

The URL scheme of a server address selects an entirely different implementation, and — the part that matters for anti-censorship — whether the query is routed like ordinary proxy traffic or sent directly.

app/dns/nameserver_doh.go app/dns/nameserver_quic.go
AddressProtocolDialed via
1.1.1.1classic UDP :53routing dispatcher
tcp://…DNS over TCProuting dispatcher
https://…DoH, RFC 8484 over h2routing dispatcher
https+local://…DoHsystem dialer, skips routing
quic+local://…DoQ, ALPN doq, :853system dialer
localhostOS resolver
fakednsfake IP poolnever touches the network

Remote-mode servers hand their connection to dispatcher.Dispatch() — the same code path your proxied traffic takes. Point https://1.1.1.1/dns-query at a config whose routing sends it out a VLESS outbound and your DNS is tunneled, encrypted, and invisible to the local network. +local variants skip the dispatcher: DoH and TCP call internet.DialSystem, DoQ opens its own socket via quic.DialAddr — for when the resolver itself must not loop through routing.

Hosts can answer, replace, or refuse

A hosts entry is not just a domain→IP pin. A value that is itself a domain rewrites the query (unwrapped up to 5 levels deep), and a #<rcode> value returns a raw DNS error — "#3" answers NXDOMAIN, making hosts a zero-network ad blackhole. IP answers carry a fixed TTL of 10.

app/dns/hosts.go
{
	"dns": {
		"hosts": {
			"ads.tracker.example": "#3",
			"domain:corp.example": "10.0.0.5",
			"legacy.example": "current.example"
		},
		"servers": [
			{
				"address": "https://1.1.1.1/dns-query",
				"domains": ["geosite:geolocation-!cn"],
				"skipFallback": true,
				"finalQuery": true
			},
			"223.5.5.5"
		],
		"queryStrategy": "UseIP"
	}
}

FakeDNS makes domain rules work on IP-only flows

FakeDNS answers instantly with an address it invented, so the router can recover the domain later from the IP alone. GetFakeIPForDomain hands out addresses from a configured CIDR pool and remembers the mapping in an LRU; GetDomainFromFakeDNS reverses it. When a tun device or SOCKS client connects to a fake IP, Xray looks the domain back up and your domain:/geosite: routing rules apply to a flow that never carried a domain at all.

app/dns/fakedns/fake.go app/dns/nameserver_fakedns.go

Fake answers get TTL 1 so apps re-ask constantly and the LRU stays fresh. Each new allocation starts at the pool base plus the current unix-millisecond count (mod pool size), probing forward past addresses still mapped in the LRU — so a restarted core hands out from wherever the clock is now instead of re-mapping the pool’s first IPs to different domains apps may still have cached. Queries only reach the FakeDNS server when the caller sets FakeEnable; both query loops (serialQuery, asyncQueryAll) skip it otherwise.

Query strategies gate address families — UseSys probes real routes

queryStrategy decides which record types exist at all. UseIPv4 never sends AAAA queries, UseIPv6 never sends A. UseSys is dynamic: checkRoutes literally dials 192.33.4.12:53 over udp4 and [2001:500:2::c]:53 over udp6 to test which families are routable. The result is cached for 100 ms on GUI platforms (Windows, macOS, Android, iOS, or Linux/BSD with a display) and probed exactly once on headless servers, where routes don’t hop between Wi-Fi and cellular.

app/dns/dns.go app/dns/cache_controller.go

Caching lives per-nameserver in a CacheController: concurrent lookups for the same name are deduplicated through singleflight, and with serveStale an expired record is returned immediately with TTL 1 while a background pull refreshes it — stale-but-instant beats fresh-but-blocked when your resolver sits behind a flaky tunnel.

Don't

Leave routed domains on the fallback pool

A bare server list means every server may answer every domain — your domestic resolver sees queries for the domains you tunnel, leaking intent even if traffic is proxied.

Do

Pin tunneled domains to a tunneled resolver

Give the DoH server domains: ["geosite:geolocation-!cn"] with finalQuery: truesortClients ends the candidate list right there, so a matched domain never falls back to the plaintext UDP server even when the DoH query fails. Add skipFallback: true to close the reverse leak: domestic domains never ride the tunnel.