OpenSnell
Alpha Branch

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:

  1. Intercept the app's DNS query.
  2. Allocate a fake IP from a reserved prefix and return it as the A record.
  3. Record fake_ip → hostname in a bidirectional pool.
  4. 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.
  5. 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 classResponse
Ordinary A queryAllocate 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 queryEmpty 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-domainForward 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:53

When 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 the 198.18.0.0/16 half-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/:

  1. curl calls getaddrinfo("docker.io").
  2. The local resolver sends an A query for docker.io to its configured DNS server. On Linux this is whatever's in /etc/resolv.conf; on macOS it's per-network-service.
  3. 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.1 at TUN startup, so mDNSResponder sends it there directly via the utun.
  4. The fake-IP server allocates 198.18.128.42 for docker.io (some fake IP from the pool) and returns it as the A answer.
  5. curl gets back 198.18.128.42 as a normal-looking A record and calls connect(198.18.128.42:443).
  6. 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.
  7. 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 with AtypDomainName carrying docker.io as the hostname.
  8. The snell server receives the CONNECT, calls its own resolver (or the configured dns = … upstream) for docker.io, gets the real IP, and dials it.
  9. Bytes flow. From curl's perspective it's talking to 198.18.128.42:443 over 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:

  1. 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.conf points at a poisoned resolver, docker pull works.
  2. 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.
  3. 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.1 over UDP) need additional handling. On Linux this is partially mitigated by the OUTPUT-chain DNAT also catching UDP :53 to any destination, but a browser doing DoH over :443 to a literal IP will sail right through and use its own DNS without touching the fake-IP path.

On this page