Skip to content

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:

go
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

TypeStructSchemeTransportVia Dispatcher
UDPClassicNameServerIP address / no schemeUDPYes
TCPTCPNameServertcp://TCPYes (remote) / No (local)
DoHDoHNameServerhttps:// or h2c://HTTP/2 POSTYes (remote) / No (local)
DoQQUICNameServerquic+local://QUIC streamsNo (local only)
LocalLocalNameServerlocalhostOS resolverNo
FakeDNSFakeDNSServerfakednsNone (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:

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:

  1. Check cache for a hit (see caching.md)
  2. If stale and serveStale enabled, return stale result and refresh in background
  3. Otherwise, call fetch() which uses singleflight.Group to deduplicate concurrent queries
  4. fetch() registers pubsub subscribers, calls sendQuery(), and waits for responses
mermaid
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
    end

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

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

  1. sendQuery() builds A and/or AAAA request messages with buildReqMsgs()
  2. Each message is packed via dns.PackMessage() and dispatched through a udp.Dispatcher
  3. Responses arrive asynchronously via HandleResponse() callback
  4. On truncation, the query is retried with an EDNS0 OPT resource (UDP payload size 1350)
  5. 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).

go
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://): Uses dispatcher.Dispatch() to create a routed connection, converted to net.Conn via cnc.NewConnection()
  • Local (tcp+local://): Uses internet.DialSystem() for direct system connection

Wire format: TCP DNS prepends a 2-byte big-endian length prefix before each message. The sendQuery() method:

  1. Packs the DNS message
  2. Writes uint16(length) || message to the connection
  3. Reads uint16(length) then the response body
  4. 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.

go
type DoHNameServer struct {
    cacheController *CacheController
    httpClient      *http.Client
    dohURL          string
    clientIP        net.IP
}

Key design decisions:

  • Uses http2.Transport directly (not the standard http.Transport) for full HTTP/2 control
  • TLS handshake uses utls.UClient with HelloChrome_Auto to 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-Padding header 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.

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

go
type LocalNameServer struct {
    client *localdns.Client
}
  • Cache is always disabled (IsDisableCache() returns true)
  • Automatically adds geosite:private domain rules (local TLDs and dotless domains)
  • When added as the only server (default), wraps in a Client with the global ipOption
  • Uses Go's net.Resolver under the hood via localdns.Client

FakeDNS Server (FakeDNSServer)

File: app/dns/nameserver_fakedns.go

Not a real DNS server -- it generates fake IP addresses from a configured pool.

go
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.FakeDNSEngine feature is resolved via core.RequireFeatures()
  • Cache is always disabled
  • Skipped during query if option.FakeEnable is 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:

go
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 noResponseErrCh channel (capacity 2) allows sendQuery to report transport-level errors back to the doFetch() 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.WithDeadline from the parent context, while UDP uses the periodic cleanup timer (8 seconds) for timeout enforcement.

  • The IsOwnLink() method on the DNS struct checks if the current inbound tag matches any DNS client's tag, preventing DNS resolution loops.

Technical analysis for re-implementation purposes.