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).
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| Address | Protocol | Dialed via |
|---|---|---|
1.1.1.1 | classic UDP :53 | routing dispatcher |
tcp://… | DNS over TCP | routing dispatcher |
https://… | DoH, RFC 8484 over h2 | routing dispatcher |
https+local://… | DoH | system dialer, skips routing |
quic+local://… | DoQ, ALPN doq, :853 | system dialer |
localhost | OS resolver | — |
fakedns | fake IP pool | never 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.
{
"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.
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.
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.
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.
Pin tunneled domains to a tunneled resolver
Give the DoH server domains: ["geosite:geolocation-!cn"] with finalQuery: true — sortClients 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.