OpenSnell
Alpha Branch

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 = true

Run as root (TUN devices need CAP_NET_ADMIN):

sudo ./snell-client --tun -c snell-client.conf

You 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 DNS

Per-platform mechanism

ConcernLinuxmacOS
DNS interceptionnftables DNAT of UDP/TCP :53 to TUN gatewaynetworksetup -setdnsservers override per service (captured + restored on exit)
Outbound TCP to fake-IPsing-tun "system" stack on the TUN devicesame
Outbound TCP to real-IPsing-tun AutoRedirect (nftables REDIRECT)TUN auto-route default-route hijack (8 sub-prefixes)
Bypass for snell-client's own outbound to serverSO_MARK 0x2024 matched in OUTPUT chainsnell server IP installed as Inet4RouteExcludeAddress
Preserving host's inbound servicesprecise (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 bypassnetipx.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 crashesnot needed (nftables table is dropped wholesale on every start)/var/run/opensnell-bypass.state replayed on startup, then truncated
QUIC fast-failICMP Port Unreachable for UDP/443 to fake-IP destinationsICMP Port Unreachable for UDP/443 to any destination caught by the TUN
Cleanup on SIGTERM / SIGINTnftables table dropped, ip rule priority-1 entry removedutun 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:

TrafficBehavior
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 clientLinux: 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 serverLinux: unchanged (SO_MARK 0x2024 matched in OUTPUT). macOS: unchanged (server IP installed as Inet4RouteExcludeAddress)
AAAA DNS queriesEmpty 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 destinationsLinux/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 processesLinux: unchanged. macOS: dropped
FORWARD-chain / IP-forwarded trafficLinux: 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_conntrack modules loaded (Debian 12+ / RHEL 9+ default), nft userspace binary in $PATH, and CAP_NET_ADMIN (in practice: run as root, or grant the capability via systemd AmbientCapabilities).
  • macOS: sudo to create the utun device, install routes, and rewrite the system DNS. networksetup is 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 to upstream-dns instead 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:

PlatformBypass mechanism
LinuxAdds entries to a netipx.IPSet, then calls sing-tun's AutoRedirect.UpdateRouteAddressSet() so nftables republishes its REDIRECT-exclude set on the fly
macOSCalls 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:53

After 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. PID is unstable and process name / comm is 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=realm to the unit (and give it a dedicated useradd -r realm).
  • Then list those users in exclude-uid below.

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

Known 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-domain and 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.

On this page