TUN Mode (Transparent TCP Capture)
Transparently capture every new outbound TCP connection on the box and tunnel it through the snell server, with fake-IP DNS, direct bypass controls, IPv6 probing, and QUIC fast-fail behavior.
In addition to the SOCKS5 listener, alpha-branch snell-client can
transparently capture every new outbound TCP connection on the
machine and tunnel it through the snell server. No SOCKS5 awareness
required from the application side, and with the original hostname
preserved end-to-end so the snell server does its own (clean) DNS
resolution. This is the same general architecture clash / mihomo /
sing-box use.
This page covers the capture and routing side. The DNS-specific details are split out into Fake-IP DNS — read both to get the full picture.
Quick start
Add a [snell-tun] section alongside the existing [snell-client]:
[snell-tun]
enable = trueRun as root (TUN devices need CAP_NET_ADMIN):
sudo ./snell-client --tun -c snell-client.confYou can keep [snell-client] listen = 127.0.0.1:1080 to run the
SOCKS5 listener and the TUN inbound at the same time, or set
listen = off to run TUN only.
Architecture overview
The two platforms have different kernel hooks for DNS interception and outbound TCP capture, but both funnel into the same userspace fake-IP pipeline:
┌──────────────── Linux ─────────────────┐ ┌──────────────── macOS ────────────────┐
│ nftables DNAT UDP/TCP :53 │ │ programmatic system DNS override: │
│ → 198.18.128.1:53 (TUN gateway) │ │ networksetup -setdnsservers <svc> │
│ │ │ 198.18.128.1 (restored on exit) │
└────────────────────┬───────────────────┘ └────────────────────┬──────────────────┘
▼ ▼
┌─────────────────────────────────────────────────┐
│ in-process fake-IP DNS server │
│ A → allocate 198.18.128.N for hostname, │
│ return; pool is LRU, bidirectional │
│ AAAA → empty NOERROR (apps fall back to A) │
│ other types → SERVFAIL (we don't forward) │
└────────────────────┬────────────────────────────┘
▼
┌──────────────── Linux ─────────────────┐ ┌──────────────── macOS ────────────────┐
│ outbound TCP capture: │ │ outbound TCP capture: │
│ sing-tun "system" stack on TUN │ │ TUN auto-route with 8 sub-prefixes │
│ handles fake-IP TCP; │ │ (1/8, 2/7, 4/6, 8/5, 16/4, 32/3, │
│ sing-tun AutoRedirect (nftables │ │ 64/2, 128/1) covering all of v4, │
│ REDIRECT) handles real-IP TCP │ │ snell server IP excluded │
└────────────────────┬───────────────────┘ └────────────────────┬──────────────────┘
▼ ▼
┌─────────────────────────────────────────────────┐
│ shared handler: │
│ dst ∈ fake-IP prefix? → reverse-lookup pool │
│ → DialTCP(name, port) │
│ with AtypDomainName │
│ else → DialTCP(ip, port) │
│ with AtypIPv4 │
└────────────────────┬────────────────────────────┘
▼
snell server (its own resolver)
│
▼
real destination, clean DNSPer-platform mechanism
| Concern | Linux | macOS |
|---|---|---|
| DNS interception | nftables DNAT of UDP/TCP :53 to TUN gateway | networksetup -setdnsservers override per service (captured + restored on exit) |
| Outbound TCP to fake-IP | sing-tun "system" stack on the TUN device | same |
| Outbound TCP to real-IP | sing-tun AutoRedirect (nftables REDIRECT) | TUN auto-route default-route hijack (8 sub-prefixes) |
| Bypass for snell-client's own outbound to server | SO_MARK 0x2024 matched in OUTPUT chain | snell server IP installed as Inet4RouteExcludeAddress |
| Preserving host's inbound services | precise (conntrack-based PREROUTING bypass) | works for typical workstations; do not run on hosts that accept public inbound connections |
Per-process exclusion (realm/gost etc.) | exclude-uid (nftables UID match) | not supported (macOS has no equivalent kernel hook) |
| Direct IP / Direct Domain bypass | netipx.IPSet fed into RouteExcludeAddressSet; live republished via AutoRedirect.UpdateRouteAddressSet() | /sbin/route add -host/-net … -interface <iface>; longest-prefix-match wins over the utun auto-route half-prefixes |
| Stale-route reclaim across crashes | not needed (nftables table is dropped wholesale on every start) | /var/run/opensnell-bypass.state replayed on startup, then truncated |
| QUIC fast-fail | ICMP Port Unreachable for UDP/443 to fake-IP destinations | ICMP Port Unreachable for UDP/443 to any destination caught by the TUN |
Cleanup on SIGTERM / SIGINT | nftables table dropped, ip rule priority-1 entry removed | utun destroyed, auto-routes deleted, system DNS restored, direct routes removed |
Why the two platforms differ on DNS interception
macOS's mDNSResponder uses per-network-service DNS scoping,
binding queries to the Wi-Fi / Ethernet source interface so they
egress that interface directly — a default-route hijack alone never
sees them. Replacing the configured DNS with the TUN gateway IP (which
only exists on the utun device) is what makes scoped routing send the
queries into our TUN.
Linux has no such scoping; nftables DNAT at the OUTPUT chain catches everything regardless of the configured resolver.
Why the two platforms differ on TCP capture
Linux can use nftables REDIRECT in the OUTPUT chain to retarget any
outbound TCP socket to a local userspace listener while preserving the
original destination via SO_ORIGINAL_DST — that's how sing-tun's
AutoRedirect mode handles real-IP-destination traffic without
needing a full L3 reassembly path.
macOS has no equivalent. Instead, the TUN device installs eight
sub-prefixes that collectively cover all of v4 (1.0.0.0/8,
2.0.0.0/7, 4.0.0.0/6, 8.0.0.0/5, …, 128.0.0.0/1), with the
snell server's own IP installed as an exclude, so the kernel routes
everything that isn't the snell server into the TUN — at which
point userspace reads packets off the TUN, reassembles flows, and
dials upstream the same way the Linux path does.
IPv6 strategy
The fake-IP path is IPv4-only: AAAA queries get an empty NOERROR reply so resolvers fall back to A; the snell server then resolves the hostname against its own DNS and connects v4.
That alone does not catch traffic that bypasses fake-DNS — for example
Chrome Secure DNS / Firefox DoH, services that cache AAAA, and apps
that connect to literal v6 addresses. For those, the TUN handler runs
an in-tunnel IPv6 reachability probe at startup and every 5 minutes:
it dials [2606:4700:4700::1111]:443 (Cloudflare DNS) through the
snell tunnel.
If the probe succeeds, the server has working v6 egress and real-v6 destinations arriving at the handler are forwarded normally. If the probe fails, real-v6 destinations on the handler are dropped immediately so Happy Eyeballs apps fall back to A in milliseconds instead of stalling for the kernel's connect timeout.
The ipv6 = false knob under [snell-tun] forces v6 off regardless of
the probe. Use it on hosts whose v6 connectivity is broken in ways the
probe target cannot detect, or when you want deterministic v4-only
behavior.
Behavior by traffic class
Per-traffic-type cheat sheet, with platform notes called out where they differ:
| Traffic | Behavior |
|---|---|
| Inbound TCP/UDP to local services (sshd, nginx, caddy, realm, …) | Linux: unchanged (PREROUTING excludes local destinations). macOS: unchanged for the typical workstation case — see caveat below |
| Reply packets from those services back to the client | Linux: unchanged (ct mark bypasses redirect). macOS: same caveat as above |
| New outbound TCP from local processes (by hostname) | Redirected via fake-IP → snell server with AtypDomainName |
| New outbound TCP from local processes (literal IP) | Redirected via REDIRECT (Linux) / TUN default route (macOS) → snell server with AtypIPv4 |
snell-client's own connection to the snell server | Linux: unchanged (SO_MARK 0x2024 matched in OUTPUT). macOS: unchanged (server IP installed as Inet4RouteExcludeAddress) |
| AAAA DNS queries | Empty NOERROR (apps fall back to A) for proxied names; real records for Direct Domains. No IPv6 fake-IP path |
| Outbound IPv6 traffic to real v6 destinations | Linux/macOS: gated by the v6 probe — accepted only when the snell server has working v6 egress; otherwise dropped so apps fall back to A |
| Outbound UDP/443 (web QUIC) | ICMP Port Unreachable injected back to the app so the QUIC stack abandons immediately and falls back to TCP |
| Other UDP from local processes (other than DNS, 443) | Linux: unchanged for real IPs (we capture DNS only); fake-IP UDP dropped silently. macOS: dropped (TUN catches all v4 UDP; v2 only handles DNS) |
| ICMP from local processes | Linux: unchanged. macOS: dropped |
FORWARD-chain / IP-forwarded traffic | Linux: unchanged (we don't touch FORWARD). macOS: not applicable |
Linux behavior in practice
On Linux, new SSH sessions from any source can still connect, existing
TCP services on the box keep working, and a SIGTERM / SIGINT to
snell-client removes the TUN interface, the nftables table, and the
one ip rule priority-1 entry sing-tun installs — leaving the host
exactly as it was before. Verified end-to-end including docker pull
of Docker Hub official images on a box with poisoned ISP DNS.
macOS behavior in practice
On macOS, the same SIGTERM cleanup additionally restores every network service's DNS to its pre-override state. The auto-route default-route hijack is bluntier than Linux's nftables REDIRECT, so inbound services running on the macOS host that need to reply out to a remote client will not work while TUN is up (reply packets get pulled into the TUN and forwarded through snell, which has no back-channel to the original client).
For typical macOS desktop usage — i.e. nothing on the box accepts inbound connections — this is invisible. If you run a public-facing service on your Mac, keep TUN off. Verified end-to-end including HTTPS to Google on a host with ISP-poisoned DHCP DNS.
Requirements
- Linux: kernel with
nf_tables+nf_conntrackmodules loaded (Debian 12+ / RHEL 9+ default),nftuserspace binary in$PATH, andCAP_NET_ADMIN(in practice: run as root, or grant the capability via systemdAmbientCapabilities). - macOS:
sudoto create the utun device, install routes, and rewrite the system DNS.networksetupis part of the base system. - Plain SOCKS5 mode remains usable without root from the same binary on both platforms.
Direct IP / Direct Domain bypass
In addition to "everything goes through snell", you can mark specific destinations as direct: the host's own routing stack handles them, and snell never sees them. This is useful for LAN ranges, corporate intranets, or third-party services that misbehave behind a proxy.
Two scopes are configured under [snell-tun]:
direct-ip— comma-separated CIDRs (10.0.0.0/8, 192.168.0.0/16) or bare IPs (1.1.1.1, 8.8.8.8). Static, loaded once at startup.direct-domain— comma-separated DNS-suffix list (internal.example.com, intranet.local). When the fake-IP DNS server sees a matching query, it forwards it verbatim toupstream-dnsinstead of synthesizing a fake IP, then dynamically registers each returned A/AAAA address in the bypass set with the record's own TTL. A 30-second reaper sweeps expired entries.
Both knobs flow into the same per-platform bypass mechanism:
| Platform | Bypass mechanism |
|---|---|
| Linux | Adds entries to a netipx.IPSet, then calls sing-tun's AutoRedirect.UpdateRouteAddressSet() so nftables republishes its REDIRECT-exclude set on the fly |
| macOS | Calls route add -host <ip> -interface <default-iface> so the kernel's longest-prefix-match wins over sing-tun's auto-route half-prefixes. The default interface is detected once at startup via route -n get default |
The Direct-Domain DNS forwarder uses snell-client's OutputMark
(SO_MARK 0x2024) on Linux so its upstream UDP query escapes sing-tun's
nft DNS hijack. Without that mark, the forwarder would loop straight
back into OpenSnell's own fake-IP DNS server.
Persistence across crashes on macOS: every route the bypass manager
installs is also recorded in /var/run/opensnell-bypass.state using an
atomic temp+rename write. On startup the state file is replayed — each
entry gets route deleted before the file is truncated — so a
SIGKILLed previous run does not leave orphan /32 routes in the kernel
routing table. Linux does not need an equivalent because sing-tun's
nftables table is dropped wholesale on every start.
Example:
[snell-tun]
enable = true
direct-ip = 10.0.0.0/8, 192.168.0.0/16, 1.1.1.1
direct-domain = corp.example.com, lan.local
upstream-dns = 223.5.5.5:53After startup, curl https://corp.example.com/internal hits the real
internal host via the host's native route, while
curl https://www.cloudflare.com/ still tunnels through snell.
Excluding specific services (Linux only)
If you run transparent forwarders (realm, gost, socat-style
port-forwarders) on the same box, you usually want them to keep
egressing direct — the snell server is not the target of the
forwarder; the forwarder is the target of someone else.
The Linux mechanism is UID-based:
- nftables can only match by UID or GID of the socket owner.
PIDis unstable and process name /commis not exposed to netfilter at all (a hard kernel-level limitation that applies to every transparent proxy on Linux — clash, sing-box, v2ray-redir, ss-redir, etc.). - The standard fix is to make the forwarder run under its own user.
For a systemd-managed service, add
User=realmto the unit (and give it a dedicateduseradd -r realm). - Then list those users in
exclude-uidbelow.
On macOS this knob does not exist — there is no kernel hook to match outbound sockets by UID. If you need to bypass a specific process on Mac, you would have to configure that process to dial via a different mechanism (SOCKS5 directly, network namespace, etc.) instead of relying on the TUN capture.
Full [snell-tun] configuration
[snell-tun]
; Master switch. Defaults to false — without this, snell-client behaves
; exactly like the SOCKS5-only build it always has. Can also be forced
; on with the --tun command-line flag.
enable = true
; TUN interface name. Default "snell0".
;interface = snell0
; Fake-IP CIDR. Default 198.18.128.0/17 (RFC 2544 benchmark-reserved
; sub-range, picked to *not* collide with clash's 198.18.0.0/16 or
; sing-box's 198.18.0.0/15 defaults so opensnell can coexist on the
; same box). Override only if you know you have a conflict.
;fake-ip-range = 198.18.128.0/17
; MTU. Default 9000.
;mtu = 9000
; Comma-separated UIDs and/or usernames whose outbound TCP should
; bypass the redirect and egress direct. Usernames are resolved via
; the system passwd database at startup. Linux only — macOS has no
; equivalent kernel hook. Typical use case: transparent TCP forwarders
; running as their own user.
;exclude-uid = realm, gost
; IPv6 master switch. Default true. When true (or unset), snell-client
; probes the server's v6 reachability at startup and every 5 minutes by
; dialing a known v6 endpoint through the tunnel; real-v6 destinations
; are accepted at the TUN handler only when the probe says the server
; can reach v6. Set to false to force-disable v6 handling regardless of
; the probe.
;ipv6 = true
; Override the v6 reachability probe target. Format: "[v6]:port".
; Default [2606:4700:4700::1111]:443 (Cloudflare DNS).
;ipv6-probe-target = [2606:4700:4700::1111]:443
; How often to re-run the v6 probe. Accepts Go duration strings.
; Default 5 minutes.
;ipv6-probe-interval = 5m
; Direct IP — comma-separated CIDRs (or bare IPs, treated as /32 or
; /128) that should bypass the proxy entirely and use the host's normal
; routing. Static; loaded once at startup.
;direct-ip = 10.0.0.0/8, 192.168.0.0/16, 1.1.1.1
; Direct Domain — comma-separated DNS-suffix list. Matching queries are
; forwarded to upstream-dns (real records, not fake IPs), and each
; returned A/AAAA address is registered in the bypass set with the
; record's own TTL. Suffix match: "example.com" matches "example.com"
; and "foo.example.com" but not "notexample.com". Requires upstream-dns.
;direct-domain = corp.example.com, lan.local
; Upstream DNS used by direct-domain query forwarding. Format
; "ip:port"; the :53 port may be omitted. Only consulted for names
; matching a direct-domain suffix; other queries continue to use the
; fake-IP layer. No default — required when direct-domain is set.
;upstream-dns = 223.5.5.5:53Known limitations / future work
- Fake-IPv6 pool: AAAA queries currently return an empty NOERROR so
apps fall back to A. OpenSnell never allocates fake-IPv6 addresses.
A v6-only destination (no A record) will not work through the proxy
unless you list the name under
direct-domainand have direct v6 reachability. A future version may add a ULA fake-IPv6 pool when the v6 probe succeeds. - Linux IPv6 UDP intercept: on Linux only IPv4 UDP/443 flows reach the handler. Real-v6 UDP traffic, including v6 QUIC, goes via the host's native interface and does not get the ICMP fallback.
- macOS default-interface hot-switch: the bypass manager detects the default physical interface once at startup. If you switch from Wi-Fi to Ethernet while snell-client is running, existing direct routes remain pinned to the old interface until restart.
- QUIC over non-443 UDP ports: only UDP/443 gets ICMP unreachable injection. Custom QUIC apps on other ports still drop silently; doing this on every UDP port would break games, VoIP, and mDNS.
- UDP application traffic proxying: still not implemented. DNS and UDP/443 ICMP injection are the only UDP handling in this alpha. WebRTC and other UDP-native apps egress direct on Linux and get dropped on macOS.
- cgroup-v2 based exclusion: currently bypass users are matched by
UID. A systemd unit's
User=is the workaround. Native cgroup-v2 path classification is not implemented yet. - Windows: not supported.
TCP Brutal Congestion Control
A per-connection, rate-pinned kernel congestion control algorithm. Useful on high-loss long-fat paths where cubic/bbr collapse — and a sharp footgun on snell, which has no mux.
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.