Fake-IP DNS
Why the snell server resolves your hostnames instead of your local OS, how the userspace fake-IP server hands out 198.18.128.0/17 shadow addresses, and how Direct Domains bypass fake-IP with real records.
Fake-IP DNS is the DNS half of the TUN inbound feature — the TUN mode page covers the capture half. Read both to get the full picture.
This page explains why fake-IP exists at all, exactly what the userspace DNS server returns for each query type, how Direct Domains are forwarded as real DNS records, and how the 198.18.128.0/17 prefix gets reverse-mapped back to the original name when a TCP connection eventually arrives.
Why fake-IP exists
A first-principles question: when an app on your machine wants to
reach docker.io, why doesn't the snell client just resolve the name
locally, then send the resulting IP to the snell server?
Because many users live behind ISP DNS that poisons docker.io, github.com, googlevideo.com, etc. If the local host resolves and only an IP reaches the snell server, the IP is already wrong and snell can do nothing about it.
The fix needs the snell server itself to do the resolution from
its presumably uncontaminated upstream (and you can pin that upstream
via the server's dns = …).
But the snell protocol carries (host, port) in CONNECT requests, not
just (ip, port). So all we need is a way to make local apps' TCP
connections arrive with the hostname intact, even though apps call
connect(2) with an IP address.
That's what fake-IP does:
- Intercept the app's DNS query.
- Allocate a fake IP from a reserved prefix and return it as the A record.
- Record
fake_ip → hostnamein a bidirectional pool. - When a TCP connection eventually arrives for that fake IP, look up the hostname and send it (not the fake IP) to the snell server as the CONNECT target.
- The snell server resolves the hostname through its own DNS.
End to end, the original hostname makes it from the app's
getaddrinfo() call to the snell server's resolver, while the kernel
between them sees a perfectly normal-looking TCP connection to an IP.
The DNS server's response policy
The userspace DNS server (bound to 198.18.128.1:53 on Linux via
nftables DNAT, or installed as the system DNS on macOS) responds to
these query classes:
| Query class | Response |
|---|---|
Ordinary A query | Allocate a fresh 198.18.128.N from the pool (or return an existing one for repeats), set it as the answer, record both directions in the pool. |
Ordinary AAAA query | Empty NOERROR. Apps with Happy Eyeballs fall back to A on this reply (they treat it as "no v6 address for this name, but the name resolved fine"). |
A / AAAA query matching direct-domain | Forward the query verbatim to upstream-dns, return the real records, and register each returned address in the direct-bypass set with the record's own TTL. |
| anything else (MX, TXT, SRV, …) | SERVFAIL. We don't forward, because forwarding leaks the query to the local resolver and defeats the whole point. Apps that need MX/TXT/SRV via the proxy are not the target workload of this feature. |
Apps that depend on TXT records to bootstrap connectivity (some WireGuard / Tailscale flows, k8s service discovery) will not work through fake-IP DNS — they need to use SOCKS5 with their own resolver, or be excluded from capture.
Direct Domain forwarding
direct-domain is the escape hatch for names that should not be
converted into fake IPs. It accepts comma-separated DNS suffixes under
[snell-tun]:
[snell-tun]
direct-domain = corp.example.com, lan.local
upstream-dns = 223.5.5.5:53When a query matches one of those suffixes, OpenSnell forwards the query
to upstream-dns and returns real A/AAAA records to the application.
Each returned address is also registered in the direct-bypass set with
the DNS record's own TTL, so the subsequent TCP connection uses the
host's native route instead of the snell tunnel.
Suffix matching is exact on DNS labels: example.com matches
example.com and foo.example.com, but not notexample.com.
On Linux, the upstream UDP query is sent with snell-client's
OutputMark (SO_MARK 0x2024) so it escapes sing-tun's nft DNS
hijack. Without that mark, the forwarder would loop back into the same
fake-IP DNS server.
Why 198.18.128.0/17 specifically
198.18.0.0/15 is the RFC 2544 benchmark-reserved range. It's
intentionally unroutable on the public Internet: any RFC-compliant
router will drop traffic to it, so a fake IP from this range can never
collide with a real destination.
Within that:
- clash defaults to
198.18.0.0/16 - sing-box defaults to
198.18.0.0/15 - OpenSnell uses
198.18.128.0/17(the upper half of the198.18.0.0/16half-block) precisely so it does not collide with either of the above. You can run OpenSnell on a box that's also running clash or sing-box without their fake-IP pools fighting over the same addresses.
The configurable knob is fake-ip-range in [snell-tun]; override
only if you've verified you have a conflict.
The allocation pool
The pool is structurally simple: a bidirectional LRU map between fake IPs and hostnames, sized at startup to the available count in the configured prefix (≈32 K addresses for the default /17).
- New hostname → take the LRU slot, assign its fake IP, record both directions.
- Repeat hostname before TTL expiry → return the same fake IP. This matters for apps that cache DNS at the OS layer and then open multiple connections in sequence — they all hit the same fake IP and get reverse-mapped to the same name, so the snell server sees a consistent CONNECT target.
- Pool exhaustion → evict the LRU entry. In practice the /17 is big enough that this never happens on a workstation; on a server with thousands of distinct destinations per minute, you'd see entries recycled and an occasional cold-start re-resolution.
End-to-end packet flow
To make the abstract concrete, here's what happens when you run
curl https://docker.io/v2/:
- curl calls
getaddrinfo("docker.io"). - The local resolver sends an A query for
docker.ioto its configured DNS server. On Linux this is whatever's in/etc/resolv.conf; on macOS it's per-network-service. - DNS interception kicks in.
- Linux: nftables DNAT in the OUTPUT chain rewrites the destination
from the configured resolver to
198.18.128.1:53(the TUN gateway). - macOS: the system DNS for the active service was already swapped
to
198.18.128.1at TUN startup, so mDNSResponder sends it there directly via the utun.
- Linux: nftables DNAT in the OUTPUT chain rewrites the destination
from the configured resolver to
- The fake-IP server allocates
198.18.128.42fordocker.io(some fake IP from the pool) and returns it as the A answer. - curl gets back
198.18.128.42as a normal-looking A record and callsconnect(198.18.128.42:443). - TCP capture kicks in.
- Linux: the destination falls inside the TUN's routed subnet, so the kernel sends the SYN into the TUN device. sing-tun reads it and reassembles a TCP flow.
- macOS: the destination falls inside one of the 8 sub-prefixes installed as TUN routes (and isn't the snell server's IP, which is excluded), so the kernel sends the SYN into the utun.
- The userspace handler sees the destination
198.18.128.42, recognizes it as a fake IP, reverse-looks-up the pool →docker.io, and opens a snell CONNECT withAtypDomainNamecarryingdocker.ioas the hostname. - The snell server receives the CONNECT, calls its own resolver
(or the configured
dns = …upstream) fordocker.io, gets the real IP, and dials it. - Bytes flow. From curl's perspective it's talking to
198.18.128.42:443over TCP; from the upstream's perspective it's talking to the snell server's egress IP. The fake IP never leaves the local host.
If at step 7 the destination isn't in the fake-IP prefix — i.e., the
app passed a literal IP to connect(2) rather than going through
getaddrinfo() — the handler falls back to AtypIPv4 and the snell
server dials the IP directly. No fake-IP indirection happens in that
case; we just transport the raw IP.
Why this is better than letting the OS resolve
Three concrete failure modes get fixed:
- ISP DNS poisoning. The classic case: ISP returns wrong A records
for politically inconvenient hostnames. With fake-IP, the local DNS
never gets queried for these names — the userspace server intercepts
first, and the real resolution happens on the snell server. So even
on a box whose
/etc/resolv.confpoints at a poisoned resolver,docker pullworks. - DNS cache pollution. If the OS-level DNS caches the wrong IP, you can be stuck with a bad answer for the TTL. With fake-IP, the OS cache only sees the fake IP, which is meaningless to retain — and the actual resolution happens fresh on the server side each time the snell server's resolver TTL expires.
- Geo-correct CDN routing. When the snell server resolves the name, the CDN gets the snell server's IP as the source — so it picks an edge close to the snell server, not close to the client. For a proxy whose entire job is to act as the network endpoint, this is exactly the right behavior.
Limits
- The ordinary fake-IP path is IPv4-only. AAAA gets empty NOERROR for proxied names so apps fall back to A. Direct Domains are the exception: they can return real AAAA records because they bypass the proxy path. See TUN mode → IPv6 strategy for the full behavior.
- Only the query classes above are handled. Everything else is SERVFAIL.
- DNSSEC validation, if your OS attempts it, will fail on fake-IP answers because the records are unsigned. Most consumer setups don't validate DNSSEC end-to-end so this rarely matters, but it does mean fake-IP is not usable in a setup that hard-requires validated resolution at the client side.
- Apps that bypass the system resolver (e.g., DoH-in-browser with a
hardcoded resolver IP, or apps that ship their own resolver pointed
at
1.1.1.1over UDP) need additional handling. On Linux this is partially mitigated by the OUTPUT-chain DNAT also catching UDP:53to any destination, but a browser doing DoH over:443to a literal IP will sail right through and use its own DNS without touching the fake-IP path.