Fake DNS Deep Dive
Fake DNS (FakeDNS) is a mechanism that assigns temporary, unique IP addresses from a private pool to domain names. When a connection is later made to one of these fake IPs, the system reverse-maps it back to the original domain. This enables transparent proxy setups where DNS queries arrive before the actual connection, allowing domain-based routing even for opaque IP-destination traffic.
Architecture Overview
flowchart LR
subgraph DNS Resolution
A[Client DNS query for example.com] --> B[FakeDNSServer]
B --> C[Holder.GetFakeIPForDomain]
C --> D[Return 198.18.0.42]
end
subgraph Connection Phase
E[Client connects to 198.18.0.42:443] --> F[Dispatcher sniffing]
F --> G[fakeDNSSniffer]
G --> H[Holder.GetDomainFromFakeDNS]
H --> I[Returns example.com]
I --> J[Override destination to example.com:443]
endCore Components
Holder -- Single Pool
File: app/dns/fakedns/fake.go
The Holder manages a single fake IP pool (either IPv4 or IPv6).
type Holder struct {
domainToIP cache.Lru // bidirectional LRU: domain <-> IP
ipRange *net.IPNet // the CIDR pool (e.g., 198.18.0.0/15)
mu *sync.Mutex
config *FakeDnsPool
}Initialization:
func (fkdns *Holder) initialize(ipPoolCidr string, lruSize int) error {
_, ipRange, _ := net.ParseCIDR(ipPoolCidr)
ones, bits := ipRange.Mask.Size()
rooms := bits - ones
if math.Log2(float64(lruSize)) >= float64(rooms) {
return errors.New("LRU size is bigger than subnet size")
}
fkdns.domainToIP = cache.NewLru(lruSize)
fkdns.ipRange = ipRange
fkdns.mu = new(sync.Mutex)
}The LRU size must be strictly smaller than the subnet size (checked via log2(lruSize) < rooms). Default pool is 198.18.0.0/15 with 65535 entries.
IP Allocation Algorithm
The GetFakeIPForDomain() method allocates IPs using a time-seeded approach:
func (fkdns *Holder) GetFakeIPForDomain(domain string) []net.Address {
fkdns.mu.Lock()
defer fkdns.mu.Unlock()
// 1. Check if domain already has an IP
if v, ok := fkdns.domainToIP.Get(domain); ok {
return []net.Address{v.(net.Address)}
}
// 2. Seed from current time (milliseconds)
currentTimeMillis := uint64(time.Now().UnixNano() / 1e6)
ones, bits := fkdns.ipRange.Mask.Size()
rooms := bits - ones
if rooms < 64 {
currentTimeMillis %= (uint64(1) << rooms)
}
// 3. Compute candidate IP: base + offset
bigIntIP := big.NewInt(0).SetBytes(fkdns.ipRange.IP)
bigIntIP = bigIntIP.Add(bigIntIP, new(big.Int).SetUint64(currentTimeMillis))
// 4. Linear probe for unused IP
for {
ip = net.IPAddress(bigIntIP.Bytes())
if _, ok := fkdns.domainToIP.PeekKeyFromValue(ip); !ok {
break // IP not in use
}
bigIntIP = bigIntIP.Add(bigIntIP, big.NewInt(1))
if !fkdns.ipRange.Contains(bigIntIP.Bytes()) {
bigIntIP = big.NewInt(0).SetBytes(fkdns.ipRange.IP) // wrap around
}
}
// 5. Store and return
fkdns.domainToIP.Put(domain, ip)
return []net.Address{ip}
}The algorithm:
- Returns the cached IP if the domain was seen before
- Uses current time in milliseconds modulo pool size as the starting offset
- Linear probes forward until an unused IP is found
- Wraps around to the pool base if the end is reached
- Stores the bidirectional mapping in the LRU cache
Reverse Lookup
func (fkdns *Holder) GetDomainFromFakeDNS(ip net.Address) string {
if !ip.Family().IsIP() || !fkdns.ipRange.Contains(ip.IP()) {
return ""
}
if k, ok := fkdns.domainToIP.GetKeyFromValue(ip); ok {
return k.(string)
}
return "" // IP in pool but no mapping (evicted from LRU)
}Pool Membership Check
func (fkdns *Holder) IsIPInIPPool(ip net.Address) bool {
if ip.Family().IsDomain() { return false }
return fkdns.ipRange.Contains(ip.IP())
}HolderMulti -- Dual-Stack Support
File: app/dns/fakedns/fake.go
HolderMulti wraps multiple Holder instances (typically one IPv4 and one IPv6 pool).
type HolderMulti struct {
holders []*Holder
config *FakeDnsPoolMulti
}All methods delegate to each holder:
GetFakeIPForDomain()returns IPs from all poolsGetFakeIPForDomain3()filters by IPv4/IPv6 enablement per holderGetDomainFromFakeDNS()returns the first match across poolsIsIPInIPPool()returns true if any pool contains the IP
IPv4 vs IPv6 Filtering
The GetFakeIPForDomain3() method on Holder checks whether the pool's IP family matches the request:
func (fkdns *Holder) GetFakeIPForDomain3(domain string, ipv4, ipv6 bool) []net.Address {
isIPv6 := fkdns.ipRange.IP.To4() == nil
if (isIPv6 && ipv6) || (!isIPv6 && ipv4) {
return fkdns.GetFakeIPForDomain(domain)
}
return []net.Address{}
}The LRU Cache
The cache.Lru type is a bidirectional LRU cache that supports:
Get(key)-- standard key lookupPut(key, value)-- insert with eviction of oldest entryGetKeyFromValue(value)-- reverse lookup (value to key)PeekKeyFromValue(value)-- reverse lookup without updating recency
This bidirectional capability is essential: forward mapping (domain -> IP) is used during DNS resolution, while reverse mapping (IP -> domain) is used during connection sniffing.
When the LRU evicts an entry, the IP becomes available for reuse by a different domain. This means connections to evicted fake IPs cannot be reverse-mapped, which is logged as an informational message.
FakeDNS Server Integration
File: app/dns/nameserver_fakedns.go
The FakeDNSServer plugs into the DNS server system:
type FakeDNSServer struct {
fakeDNSEngine dns.FakeDNSEngine
}
func (f *FakeDNSServer) QueryIP(ctx context.Context, domain string, opt dns.IPOption) ([]net.IP, uint32, error) {
var ips []net.Address
if fkr0, ok := f.fakeDNSEngine.(dns.FakeDNSEngineRev0); ok {
ips = fkr0.GetFakeIPForDomain3(domain, opt.IPv4Enable, opt.IPv6Enable)
} else {
ips = f.fakeDNSEngine.GetFakeIPForDomain(domain)
}
// ... convert to net.IP
return netIP, 1, nil // TTL = 1
}The TTL is always 1 second to prevent downstream caching of fake IPs.
Dispatcher Integration -- Fake DNS Sniffing
File: app/dispatcher/fakednssniffer.go
When sniffing is enabled with "fakedns" in destOverride, the dispatcher creates a fakeDNSSniffer:
func newFakeDNSSniffer(ctx context.Context) (protocolSnifferWithMetadata, error) {
fakeDNSEngine := core.MustFromContext(ctx).GetFeature((*dns.FakeDNSEngine)(nil))
return protocolSnifferWithMetadata{
protocolSniffer: func(ctx context.Context, bytes []byte) (SniffResult, error) {
ob := outbounds[len(outbounds)-1]
domainFromFakeDNS := fakeDNSEngine.GetDomainFromFakeDNS(ob.Target.Address)
if domainFromFakeDNS != "" {
return &fakeDNSSniffResult{domainName: domainFromFakeDNS}, nil
}
// Check if IP is in pool (for fakedns+others mode)
return nil, common.ErrNoClue
},
metadataSniffer: true, // Can sniff without payload data
}, nil
}This is a metadata sniffer -- it works without inspecting payload bytes. It looks up the destination IP in the fake DNS engine and returns the mapped domain.
The fakedns+others Mode
File: app/dispatcher/fakednssniffer.go
The newFakeDNSThenOthers() function implements a composite sniffer:
func newFakeDNSThenOthers(ctx context.Context, fakeDNSSniffer, others []protocolSnifferWithMetadata) {
return protocolSnifferWithMetadata{
protocolSniffer: func(ctx context.Context, bytes []byte) (SniffResult, error) {
// 1. Try fakeDNS lookup first
result, err := fakeDNSSniffer.protocolSniffer(ctx, bytes)
if err == nil { return result, nil }
// 2. If IP is in fake pool but domain not found, try other sniffers
if ipInRange {
for _, v := range others {
if result, err := v.protocolSniffer(ctx, bytes); err == nil {
return DNSThenOthersSniffResult{
domainName: result.Domain(),
protocolOriginalName: result.Protocol(),
}, nil
}
}
}
return nil, common.ErrNoClue
},
}
}This handles the case where a fake IP was assigned but the LRU evicted the mapping. It falls back to TLS SNI or HTTP Host sniffing to recover the domain.
Destination Override in Dispatcher
In app/dispatcher/default.go, the shouldOverride() method checks:
func (d *DefaultDispatcher) shouldOverride(ctx context.Context, result SniffResult, ...) bool {
for _, p := range request.OverrideDestinationForProtocol {
if fkr0, ok := d.fdns.(dns.FakeDNSEngineRev0); ok &&
protocolString != "bittorrent" && p == "fakedns" &&
fkr0.IsIPInIPPool(destination.Address) {
return true // Override even for non-fakedns protocols if IP is fake
}
}
}When the destination is a fake IP and "fakedns" is in the override list, the sniffed domain replaces the fake IP destination regardless of which sniffer found the domain.
Configuration
The FakeDNS engine is configured at the top level of the Xray config:
{
"fakeDns": {
"ipPool": "198.18.0.0/15",
"poolSize": 65535
}
}Or for dual-stack:
{
"fakeDns": {
"pools": [
{ "ipPool": "198.18.0.0/15", "poolSize": 65535 },
{ "ipPool": "fc00::/18", "poolSize": 65535 }
]
}
}The DNS server then references it:
{
"dns": {
"servers": ["fakedns"]
}
}Complete Data Flow
sequenceDiagram
participant App as Application
participant DNS as Xray DNS
participant FakeDNS as FakeDNS Holder
participant Disp as Dispatcher
participant Sniffer as FakeDNS Sniffer
participant Router as Router
App->>DNS: Resolve example.com
DNS->>FakeDNS: GetFakeIPForDomain("example.com")
FakeDNS-->>DNS: 198.18.0.42
DNS-->>App: 198.18.0.42
App->>Disp: Connect to 198.18.0.42:443
Disp->>Sniffer: Sniff metadata
Sniffer->>FakeDNS: GetDomainFromFakeDNS(198.18.0.42)
FakeDNS-->>Sniffer: "example.com"
Sniffer-->>Disp: domain = example.com
Disp->>Disp: Override target to example.com:443
Disp->>Router: Route example.com:443
Router-->>Disp: Select outboundImplementation Notes
The
Holderis registered as bothFakeDnsPoolandFakeDnsPoolMulticonfig types. The core creates the appropriate one based on whether the config has a single pool or multiple pools.FakeDNS is prepended (not appended) to the core config app list:
config.App = append([]*serial.TypedMessage{serial.ToTypedMessage(r)}, config.App...). This ensures it initializes before the DNS system tries to resolvecore.RequireFeatures().The
FakeDNSEngineRev0interface extendsFakeDNSEnginewithGetFakeIPForDomain3()andIsIPInIPPool(). All current implementations satisfy both interfaces.The LRU eviction is silent -- when a domain's mapping is evicted, subsequent connections to that fake IP cannot be reverse-mapped. The
fakedns+otherssniffing mode mitigates this by falling back to TLS/HTTP sniffing.The mutex on
Holderis a regularsync.Mutex(not RWMutex) becauseGetFakeIPForDomain()may write (allocate new IPs), and evenGet()on the LRU updates access order.BitTorrent traffic is explicitly excluded from fake DNS override in
shouldOverride()to prevent tracker issues.