A complete wrapper around hev-socks5-tunnel that turns it into a system-wide transparent SOCKS5 → TUN setup on macOS:
- All TCP and UDP traffic is forwarded to a SOCKS5 proxy via a
utuninterface - Private RFC1918 networks bypass the tunnel and go via the physical gateway
- The proxy host itself bypasses the tunnel (no routing loop)
- DNS is handled by an in-tunnel MapDNS / fake-IP responder so QUIC and
apps that ignore
/etc/resolveruse the tunnel transparently - launchd starts everything at boot via a
LaunchDaemon
Tested on macOS Tahoe / Apple Silicon (M1).
| File | Purpose |
|---|---|
tunnel-manager.sh |
start / stop / restart / status command |
com.custom.hev-tunnel.plist |
launchd unit that runs tunnel-manager.sh start at boot |
config.yaml |
template for /etc/hev-socks5-tunnel/config.yaml |
The classic problem when forwarding all traffic into a tunnel is that the tunnel itself needs to reach its remote endpoint outside the tunnel — otherwise you get a routing loop. The usual workaround is to overwrite the default route, but on macOS that quickly gets messy and breaks reverse on disconnect.
This script uses two /1 routes instead:
0.0.0.0/1 -> utunN
128.0.0.0/1 -> utunN
::/1 -> utunN
8000::/1 -> utunN
They cover the entire IPv4 (and IPv6) space more specifically than the
real 0.0.0.0/0, so the kernel prefers them — but the actual default
route is left untouched. When you stop the tunnel, you just delete the
/1 routes and the original default route is immediately effective
again. No reboot, no flapping.
For the proxy reachability problem, two patterns work:
- IPv4/IPv6 SOCKS over the same physical link: add the proxy IP to
EXCLUDED_IPSintunnel-manager.sh. The script will install a host route via the original gateway so that proxy packets bypass the tun. - SOCKS over a mesh / overlay network (e.g. Yggdrasil at
200::/7): the mesh interface already has a more-specific route, so no exclusion is needed. Just put the mesh address inconfig.yamland forget.
hev-socks5-tunnel has two modes for forwarding UDP:
udp: 'udp'(default) — standard SOCKS5 UDP-ASSOCIATE. The proxy returns aBND.ADDR:portand the client opens a separate UDP socket to that address. Almost always broken over IPv6 mesh networks because either:- the BND.ADDR is the proxy's public IPv4 (which the client can't reach over an IPv6-only mesh), or
- the BND.ADDR is the mesh address but UDP datagrams over mesh overlays often get rate-limited / dropped / NATed away.
udp: 'tcp'— hev's UDP-over-TCP extension. UDP payloads are encapsulated inside the existing TCP control connection. No second socket, no BND.ADDR rabbit hole. This is the only reliable mode if your proxy is reached over Yggdrasil, Tor, ICMP-tunnel, etc.
Both ends must speak the same UoT framing. Currently this works with:
hev-socks5-server(any version since 2.x) — same author, paired withhev-socks5-tunnelsockstunon Android — Android wrapper around the same engineshadowsocks-libevwith the UoT patch- Not generic SOCKS5 servers like Dante / 3proxy / srelay.
If you skip udp: 'tcp' and leave it commented out, DNS / QUIC / HTTP/3
/ game UDP will silently not work — TCP-only sites will load fine and
you'll spend hours wondering why your video game is stuck on a loading
screen.
hev-socks5-server's main.port defaults to 1081, not 1080. If
your socks5.port in the client config is 1080 and there happens to
be another SOCKS daemon listening on 1080 of the same host (very
common — Tor's SocksPort, ssh -D, leftover Dante install, etc.), the
tunnel will appear to half-work:
- TCP traffic flows (1080-listener speaks SOCKS5)
- UoT silently fails (1080-listener does NOT speak hev's UoT framing)
- Browser opens HTTPS sites OK
- Games stuck on connect, DNS broken, calendar / iCloud broken
Always verify the port matches the main.port in your server config.
hev-socks5-tunnel ships with a built-in fake-IP DNS responder.
Configured under the mapdns: block, it does the following:
- Listens for DNS queries on
address:port(e.g.198.18.0.2:53) - For each FQDN it allocates a synthetic IP from a private pool (e.g.
100.64.0.0/10) - Remembers the FQDN ↔ fake-IP mapping in an LRU cache
- When the client later connects to a fake IP, hev forwards the connection via SOCKS5 by hostname, so the proxy resolves the real domain on the remote side
This eliminates DNS leaks (no UDP/53 to your ISP), works for QUIC/HTTP/3, and avoids the broken UDP-ASSOCIATE path entirely for DNS.
The script switches macOS' system DNS to 198.18.0.2 on start
(via networksetup -setdnsservers on the active service) and restores
the previous setting on stop.
# 1. Install the binary (Homebrew or precompiled release)
# https://github.com/heiher/hev-socks5-tunnel/releases
sudo cp hev-socks5-tunnel /usr/local/bin/
sudo chmod +x /usr/local/bin/hev-socks5-tunnel
# 2. Install the config template and edit it
sudo mkdir -p /etc/hev-socks5-tunnel
sudo cp config.yaml /etc/hev-socks5-tunnel/config.yaml
sudo nano /etc/hev-socks5-tunnel/config.yaml # set proxy host + auth
# 3. Install the manager script
sudo cp tunnel-manager.sh /usr/local/bin/tunnel-manager.sh
sudo chmod +x /usr/local/bin/tunnel-manager.sh
sudo nano /usr/local/bin/tunnel-manager.sh # adjust EXCLUDED_IPS, TUN_INTERFACE
# 4. Test manually
sudo /usr/local/bin/tunnel-manager.sh start
sudo /usr/local/bin/tunnel-manager.sh status
curl -s ifconfig.me # should be the proxy's exit IP
host google.com # should resolve to a 100.64.x.x fake IP
sudo /usr/local/bin/tunnel-manager.sh stop
# 5. Install the launchd unit (autostart at boot)
sudo cp com.custom.hev-tunnel.plist /Library/LaunchDaemons/
sudo chown root:wheel /Library/LaunchDaemons/com.custom.hev-tunnel.plist
sudo chmod 644 /Library/LaunchDaemons/com.custom.hev-tunnel.plist
sudo launchctl bootstrap system /Library/LaunchDaemons/com.custom.hev-tunnel.plistLogs end up in /tmp/hev-tunnel.log,
/tmp/tunnel-manager-boot.log, /tmp/tunnel-manager-boot.err,
and (if misc.log-file: /tmp/hev.log in config) /tmp/hev.log.
Run these on the Mac after start:
# 1. TCP through proxy
curl -s ifconfig.me # should print proxy's exit IP
# 2. MapDNS works (fake IPs from 100.64.0.0/10)
host example.com # should print 100.64.x.x
# 3. System DNS is switched to MapDNS
scutil --dns | head -5 # nameserver[0] : 198.18.0.2
# 4. UDP-over-TCP is happening (sessions appear in hev log)
tail -50 /tmp/hev.log | grep 'udp construct'
# 5. Real-world TCP fetch via the tunnel
curl -sI https://www.google.com # HTTP/2 200If host example.com returns a real public IP — mapdns is misconfigured
or the system DNS wasn't switched. If host returns a fake IP but curl
hangs — udp: 'tcp' is missing or your proxy doesn't speak hev's UoT.
If TCP works but UDP doesn't (browser fine, games and dig broken):
# Watch the hev log while reproducing the broken app:
tail -f /tmp/hev.log | grep 'udp 'You should see lines like socks5 session udp splice after each UDP
packet. If you only see construct → bind → destruct without splice,
the UoT handshake is failing — usually because socks5.port is pointing
to a non-hev SOCKS daemon (see "Common pitfall" below) or udp: 'tcp'
is not set.
In tunnel-manager.sh:
| Var | Default |
|---|---|
CONFIG |
/etc/hev-socks5-tunnel/config.yaml |
TUNNEL_BIN |
/usr/local/bin/hev-socks5-tunnel |
TUN_INTERFACE |
utun5 |
MAPDNS_IP |
198.18.0.2 |
EXCLUDED_IPS |
RFC1918 + your own additions |
TUN_INTERFACE is the name the script expects to see; the actual tun
device is created by hev-socks5-tunnel according to config.yaml's
tunnel.name (default tun0 which on macOS becomes the next free utunN).
If your hev creates utun4, change TUN_INTERFACE accordingly.
MAPDNS_IP must match mapdns.address in config.yaml.
In config.yaml:
tunnel.mtu: 16384 is a reasonable default when the physical path is an Yggdrasil mesh (which itself has MTU 65535 on macOS, so no jumbo cap from the carrier). 8500 also works fine and is a safer fallback if you see weird routing or PMTUD behavior. Values up to 63000 also work. Pick 1400 if the underlying carrier becomes non-jumbo-capable. Throughput benchmarks on a loaded mobile uplink were inconclusive between 8500/16384/63000 — the differences were below the cell-congestion noise floor; 16384 was kept as a round middle-ground.socks5.address: replace with your real proxy host.socks5.udp: keep'tcp'unless you know your proxy supports UDP-ASSOCIATE on the path you're using.socks5.username/socks5.password: comment out if proxy is open.mapdns.network/netmask: the fake-IP pool. Default100.64.0.0/10is the CGNAT range — guaranteed never to overlap with anything real.
sudo launchctl bootout system /Library/LaunchDaemons/com.custom.hev-tunnel.plist
sudo rm /Library/LaunchDaemons/com.custom.hev-tunnel.plist
sudo /usr/local/bin/tunnel-manager.sh stop
sudo rm /usr/local/bin/tunnel-manager.sh
sudo rm /etc/hev-socks5-tunnel/config.yaml
sudo rmdir /etc/hev-socks5-tunnel 2>/dev/null
sudo rm /usr/local/bin/hev-socks5-tunnel- macOS'
pfandrouteare notoriously order-sensitive. If you also use Tunnelblick / OpenVPN / Yggdrasil, the order in which they bring up interfaces may affect which route wins. Boot order: physical → mesh → hev. - The
KeepAlive=falsein the plist is intentional —tunnel-manager.shstarts the daemon in the background and exits, so launchd would otherwise keep restarting it forever. Pair with a separate watchdog (e.g. net-watchdog) if you want auto-restart on health failure. udp: 'tcp'only works if both sides of the SOCKS5 connection understand hev's UoT framing. StandardDante/srelaywill not.- macOS reapplies network settings on Wi-Fi reconnect. The script captures
the original DNS at
startand restores it atstop, but if you change Wi-Fi networks while the tunnel is up the DNS override may be lost. In that case dotunnel-manager.sh restart.
Public domain. Do what you want.