DNS Server Implementations
Xray implements five distinct DNS server types plus a FakeDNS pseudo-server. All implement the Server interface from app/dns/nameserver.go. The server type is selected by the NewServer() factory based on the URL scheme of the nameserver address.
Server Dispatch Logic
The NewServer() function in app/dns/nameserver.go routes server creation:
func NewServer(ctx context.Context, dest net.Destination, dispatcher routing.Dispatcher, ...) (Server, error) {
if address := dest.Address; address.Family().IsDomain() {
u, _ := url.Parse(address.Domain())
switch {
case strings.EqualFold(u.String(), "localhost"): // -> LocalNameServer
case strings.EqualFold(u.Scheme, "https"): // -> DoHNameServer (remote)
case strings.EqualFold(u.Scheme, "h2c"): // -> DoHNameServer (h2c remote)
case strings.EqualFold(u.Scheme, "https+local"): // -> DoHNameServer (local)
case strings.EqualFold(u.Scheme, "h2c+local"): // -> DoHNameServer (h2c local)
case strings.EqualFold(u.Scheme, "quic+local"): // -> QUICNameServer
case strings.EqualFold(u.Scheme, "tcp"): // -> TCPNameServer (remote)
case strings.EqualFold(u.Scheme, "tcp+local"): // -> TCPNameServer (local)
case strings.EqualFold(u.String(), "fakedns"): // -> FakeDNSServer
}
}
if dest.Network == net.Network_UDP { // -> ClassicNameServer
return NewClassicNameServer(dest, dispatcher, ...)
}
}The +local suffix means the server connects directly without going through Xray's routing/dispatcher. Remote variants route DNS traffic through the dispatcher, allowing DNS packets to traverse proxy chains.
Server Type Summary
| Type | Struct | Scheme | Transport | Via Dispatcher |
|---|---|---|---|---|
| UDP | ClassicNameServer | IP address / no scheme | UDP | Yes |
| TCP | TCPNameServer | tcp:// | TCP | Yes (remote) / No (local) |
| DoH | DoHNameServer | https:// or h2c:// | HTTP/2 POST | Yes (remote) / No (local) |
| DoQ | QUICNameServer | quic+local:// | QUIC streams | No (local only) |
| Local | LocalNameServer | localhost | OS resolver | No |
| FakeDNS | FakeDNSServer | fakedns | None (in-memory) | No |
The CachedNameserver Pattern
All real DNS servers (UDP, TCP, DoH, DoQ) share a common query pattern through the CachedNameserver interface defined in app/dns/nameserver_cached.go:
type CachedNameserver interface {
getCacheController() *CacheController
sendQuery(ctx context.Context, noResponseErrCh chan<- error, fqdn string, option dns.IPOption)
}The queryIP() function orchestrates the cache-check-then-fetch flow:
- Check cache for a hit (see caching.md)
- If stale and
serveStaleenabled, return stale result and refresh in background - Otherwise, call
fetch()which usessingleflight.Groupto deduplicate concurrent queries fetch()registers pubsub subscribers, callssendQuery(), and waits for responses
sequenceDiagram
participant Caller
participant queryIP
participant Cache as CacheController
participant PubSub
participant sendQuery
participant Upstream
Caller->>queryIP: QueryIP(domain, option)
queryIP->>Cache: findRecords(fqdn)
alt Cache hit (TTL > 0)
Cache-->>queryIP: cached IPs
queryIP-->>Caller: return IPs
else Cache stale + serveStale
Cache-->>queryIP: stale IPs
queryIP->>sendQuery: background refresh
queryIP-->>Caller: return stale IPs (TTL=1)
else Cache miss
queryIP->>PubSub: registerSubscribers(domain)
queryIP->>sendQuery: sendQuery(domain)
sendQuery->>Upstream: DNS wire protocol
Upstream-->>sendQuery: response
sendQuery->>Cache: updateRecord()
Cache->>PubSub: Publish(domain+"4"|"6")
PubSub-->>queryIP: IPRecord
queryIP-->>Caller: return IPs
endUDP Server (ClassicNameServer)
File: app/dns/nameserver_udp.go
The classic DNS-over-UDP implementation. It manages a map of pending requests keyed by DNS message ID.
type ClassicNameServer struct {
sync.RWMutex
cacheController *CacheController
address *net.Destination
requests map[uint16]*udpDnsRequest
udpServer *udp.Dispatcher
requestsCleanup *task.Periodic
reqID uint32
clientIP net.IP
}Query flow:
sendQuery()builds A and/or AAAA request messages withbuildReqMsgs()- Each message is packed via
dns.PackMessage()and dispatched through audp.Dispatcher - Responses arrive asynchronously via
HandleResponse()callback - On truncation, the query is retried with an EDNS0 OPT resource (UDP payload size 1350)
- Successful responses are passed to
cacheController.updateRecord()
Request cleanup: A task.Periodic runs every minute to evict requests older than 8 seconds.
EDNS0 Client Subnet: If clientIP is configured, an EDNS0 Subnet option is appended with /24 for IPv4 or /96 for IPv6.
TCP Server (TCPNameServer)
File: app/dns/nameserver_tcp.go
DNS-over-TCP per RFC 7766. Two variants exist: remote (dispatched) and local (direct).
type TCPNameServer struct {
cacheController *CacheController
destination *net.Destination
reqID uint32
dial func(context.Context) (net.Conn, error)
clientIP net.IP
}Remote vs Local: The dial function closure is different:
- Remote (
tcp://): Usesdispatcher.Dispatch()to create a routed connection, converted tonet.Connviacnc.NewConnection() - Local (
tcp+local://): Usesinternet.DialSystem()for direct system connection
Wire format: TCP DNS prepends a 2-byte big-endian length prefix before each message. The sendQuery() method:
- Packs the DNS message
- Writes
uint16(length) || messageto the connection - Reads
uint16(length)then the response body - Parses and updates the cache
Each request opens a new TCP connection (no connection pooling).
DoH Server (DoHNameServer)
File: app/dns/nameserver_doh.go
DNS-over-HTTPS using HTTP/2, compatible with RFC 8484 wire format.
type DoHNameServer struct {
cacheController *CacheController
httpClient *http.Client
dohURL string
clientIP net.IP
}Key design decisions:
- Uses
http2.Transportdirectly (not the standardhttp.Transport) for full HTTP/2 control - TLS handshake uses
utls.UClientwithHelloChrome_Autoto mimic Chrome's TLS fingerprint - The h2c variant (
h2c://) skips TLS entirely for cleartext HTTP/2 - EDNS0 padding with random length (100-300 bytes) is applied to mask query size patterns
- HTTP request includes
X-Paddingheader with random base62 padding - Self-resolution detection: if the DoH server tries to resolve its own hostname, an error is raised
Request format: POST to the DoH URL with Content-Type: application/dns-message and Accept: application/dns-message. The body is the raw DNS wire format.
Connection modes:
- Remote (dispatcher != nil): DNS traffic routed through Xray's outbound system
- Local (dispatcher == nil): Direct system connection with access logging
DoQ Server (QUICNameServer)
File: app/dns/nameserver_quic.go
DNS-over-QUIC, local mode only. Uses the quic-go library.
type QUICNameServer struct {
sync.RWMutex
cacheController *CacheController
destination *net.Destination
connection *quic.Conn
clientIP net.IP
}Connection management:
- Maintains a single persistent QUIC connection with lazy reconnection
- Uses ALPN tokens:
"doq","http/1.1","h2" - Default port is 853
- Handshake timeout: 8 seconds
- On connection failure, retries once before returning error
Per-query streams: Each DNS query opens a new QUIC stream via conn.OpenStreamSync(). The message format uses 2-byte length prefix like TCP DNS.
Local Server (LocalNameServer)
File: app/dns/nameserver_local.go
A thin wrapper around the OS system resolver.
type LocalNameServer struct {
client *localdns.Client
}- Cache is always disabled (
IsDisableCache()returnstrue) - Automatically adds
geosite:privatedomain rules (local TLDs and dotless domains) - When added as the only server (default), wraps in a
Clientwith the globalipOption - Uses Go's
net.Resolverunder the hood vialocaldns.Client
FakeDNS Server (FakeDNSServer)
File: app/dns/nameserver_fakedns.go
Not a real DNS server -- it generates fake IP addresses from a configured pool.
type FakeDNSServer struct {
fakeDNSEngine dns.FakeDNSEngine
}- Returns fake IPs with TTL=1 (to prevent caching by downstream)
- Supports dual-stack via
FakeDNSEngineRev0.GetFakeIPForDomain3() - The
dns.FakeDNSEnginefeature is resolved viacore.RequireFeatures() - Cache is always disabled
- Skipped during query if
option.FakeEnableis false
Routing DNS Traffic
DNS queries are routed through Xray's dispatcher using the client's tag field. The toDnsContext() function creates a routing context:
func toDnsContext(ctx context.Context, addr string) context.Context {
dnsCtx := core.ToBackgroundDetachedContext(ctx)
// Preserve inbound tag for routing
dnsCtx = session.ContextWithInbound(dnsCtx, inbound)
dnsCtx = session.ContextWithContent(dnsCtx, ...)
dnsCtx = log.ContextWithAccessMessage(dnsCtx, ...)
return dnsCtx
}The session content sets Protocol: "dns" (or "https" for DoH, "quic" for DoQ) and SkipDNSResolve: true to prevent recursive DNS resolution.
Implementation Notes
All
sendQuery()implementations fire A and AAAA queries in parallel goroutines when both IPv4 and IPv6 are enabled.The
noResponseErrChchannel (capacity 2) allowssendQueryto report transport-level errors back to thedoFetch()caller, preventing indefinite waits on the pubsub subscriber.The request ID generation differs: UDP uses an atomic counter for unique IDs (necessary for multiplexed responses), while DoH and DoQ always use ID 0 (since each query gets its own HTTP request or QUIC stream).
TCP and DoH servers use
context.WithDeadlinefrom the parent context, while UDP uses the periodic cleanup timer (8 seconds) for timeout enforcement.The
IsOwnLink()method on theDNSstruct checks if the current inbound tag matches any DNS client's tag, preventing DNS resolution loops.