Skip to content

Instantly share code, notes, and snippets.

@1d10t
Last active April 8, 2026 13:09
Show Gist options
  • Select an option

  • Save 1d10t/3ee48da74224ed0e0d19b394980f52ed to your computer and use it in GitHub Desktop.

Select an option

Save 1d10t/3ee48da74224ed0e0d19b394980f52ed to your computer and use it in GitHub Desktop.
macOS hev-socks5-tunnel: split-default routing wrapper + launchd autostart

macOS hev-socks5-tunnel: split-default routing + MapDNS + launchd autostart

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 utun interface
  • 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/resolver use the tunnel transparently
  • launchd starts everything at boot via a LaunchDaemon

Tested on macOS Tahoe / Apple Silicon (M1).

Files

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

How it works

1. Split default route (the /1 trick)

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.

2. Proxy reachability (the exclusion problem)

For the proxy reachability problem, two patterns work:

  1. IPv4/IPv6 SOCKS over the same physical link: add the proxy IP to EXCLUDED_IPS in tunnel-manager.sh. The script will install a host route via the original gateway so that proxy packets bypass the tun.
  2. 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 in config.yaml and forget.

3. UDP-over-TCP and why udp: 'tcp' is mandatory

hev-socks5-tunnel has two modes for forwarding UDP:

  • udp: 'udp' (default) — standard SOCKS5 UDP-ASSOCIATE. The proxy returns a BND.ADDR:port and 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 with hev-socks5-tunnel
  • sockstun on Android — Android wrapper around the same engine
  • shadowsocks-libev with 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.

Common pitfall: wrong port number

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.

4. MapDNS / fake-IP

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.

Install

# 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.plist

Logs 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.

Verify it really works

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 200

If 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.

Configuration knobs

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. Default 100.64.0.0/10 is the CGNAT range — guaranteed never to overlap with anything real.

Uninstall

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

Caveats

  • macOS' pf and route are 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=false in the plist is intentional — tunnel-manager.sh starts 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. Standard Dante / srelay will not.
  • macOS reapplies network settings on Wi-Fi reconnect. The script captures the original DNS at start and restores it at stop, but if you change Wi-Fi networks while the tunnel is up the DNS override may be lost. In that case do tunnel-manager.sh restart.

License

Public domain. Do what you want.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.custom.hev-tunnel</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/tunnel-manager.sh</string>
<string>start</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<false/>
<key>StandardOutPath</key>
<string>/tmp/tunnel-manager-boot.log</string>
<key>StandardErrorPath</key>
<string>/tmp/tunnel-manager-boot.err</string>
</dict>
</plist>
tunnel:
name: tun0
# MTU of the hev tun interface. The underlying transport here is
# Yggdrasil (utun0), which has MTU 65535 on macOS, so there is no
# physical cap — jumbo frames inside the tun are fine. 16384 is a
# round-number upgrade from the common 8500 default and has worked
# stably in testing; values up to 63000 also work. Benchmarks on a
# loaded mobile uplink were inconclusive between 8500 / 16384 / 63000
# — the differences fell inside the cell-congestion noise floor.
# Drop to 1400 if your physical path ever becomes non-jumbo-capable.
mtu: 16384
#mtu: 1400
multi-queue: false
ipv4: 198.18.0.1
ipv6: 'fc00::1'
socks5:
# Your SOCKS5 proxy server.
# `address` can be an IPv4 like '203.0.113.10', a public IPv6,
# or a mesh address (e.g. an Yggdrasil node under 200::/7).
#
# IMPORTANT: `port` MUST match the TCP listen port on your SOCKS5 server.
# hev-socks5-server defaults to 1081 (NOT 1080). If you put 1080 here and
# there is some other SOCKS daemon on 1080, you will get a half-working
# tunnel where TCP works but UDP/UoT silently fails — because the other
# daemon does not understand hev's UoT framing. Always double-check the
# `main.port` value in your /etc/hev-socks5-server/config.yml.
port: 1081
address: 'PROXY_HOST_OR_IPv6_HERE'
# IMPORTANT: 'tcp' enables hev's UDP-over-TCP extension, which is the only
# reliable way to forward UDP (DNS, QUIC, HTTP/3, game traffic) over a
# SOCKS5 proxy when the proxy is reached over a mesh network or a
# NAT-restricted path.
# Your proxy server must also support this extension. As of writing only
# hev-socks5-server, sockstun (Android), and shadowsocks-libev with the
# UoT patch implement it. Default 'udp' falls back to standard SOCKS5
# UDP-ASSOCIATE which usually does not work over IPv6/mesh because the
# BND.ADDR returned by the server is unreachable from the client.
udp: 'tcp'
#pipeline: true
# Override BND.ADDR returned by the server. Only useful in non-UoT
# mode if your server returns an unreachable address. Leave commented
# out when udp: 'tcp' (UoT) is in use.
# udp-address: 'YOUR_PROXY_HOST'
# If your proxy requires auth:
username: 'YOUR_USERNAME'
password: 'YOUR_PASSWORD'
# Fake-IP / MapDNS responder.
# Acts as a local DNS server inside the tunnel: incoming DNS queries to
# `address:port` get answered with synthetic IPs from `network/netmask`,
# and the original FQDN is remembered. When something later connects to
# one of those fake IPs, hev forwards it via SOCKS5 *by hostname* to the
# proxy — so the proxy resolves the real domain.
#
# Combined with `tunnel-manager.sh` switching system DNS to 198.18.0.2:
# - no DNS leaks to your ISP
# - QUIC / HTTP/3 work transparently
# - works for browsers, ping (with fake IP), curl, scp, anything libc
mapdns:
address: 198.18.0.2
port: 53
network: 100.64.0.0
netmask: 255.192.0.0
cache-size: 10000
#misc:
# log-level: info
# log-file: stderr
misc:
log-level: debug
log-file: /tmp/hev.log
#!/bin/bash
# tunnel-manager.sh — Split-default routing through hev-socks5-tunnel on macOS.
#
# Wraps hev-socks5-tunnel into start/stop/restart/status commands and sets up:
# - "split default route" via /1 prefixes (no clobbering of the real default)
# - exclusion routes for RFC1918 + user-supplied IPs (proxy host etc.)
# - automatic switch of system DNS to the in-tunnel MapDNS responder
# (so QUIC and apps that bypass /etc/resolver actually use the tunnel)
# - rollback of all of the above on stop
#
# Tested on macOS Tahoe / Apple Silicon.
# --- SETTINGS ---
CONFIG="/etc/hev-socks5-tunnel/config.yaml"
TUNNEL_BIN="/usr/local/bin/hev-socks5-tunnel"
TUN_INTERFACE="utun5"
PIDFILE="/tmp/hev-socks5-tunnel.pid"
# Address of the in-tunnel MapDNS responder. Must match `mapdns.address` in
# config.yaml. Hardcoded here so we can switch system DNS to it.
MAPDNS_IP="198.18.0.2"
# Excluded networks. Traffic to these IPs/subnets goes via the physical
# gateway and bypasses the tunnel. Add the SOCKS proxy host here if it's
# reachable directly (not via another tunnel like Yggdrasil).
EXCLUDED_IPS=(
"10.0.0.0/8" # Private network
"172.16.0.0/12" # Private network
"192.168.0.0/16" # Private network
# "203.0.113.10/32" # Example: your SOCKS proxy public IP
# "198.51.100.5/32" # Example: another exclusion
)
# ----------------
start() {
echo "Starting tunnel..."
if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
echo "Tunnel already running (PID: $(cat "$PIDFILE"))"
exit 1
fi
# 1. Wait for network (gateway) at boot — launchd may start us before
# Wi-Fi associates.
echo "Waiting for network..."
for i in {1..30}; do
GATEWAY=$(route -n get default 2>/dev/null | awk '/gateway:/ {print $2}')
if [ -n "$GATEWAY" ]; then
break
fi
sleep 2
done
if [ -z "$GATEWAY" ]; then
echo "Error: no IPv4 default gateway after 60s. Bailing out."
exit 1
fi
echo "Original gateway: $GATEWAY"
# 2. Launch hev-socks5-tunnel (no extra sudo wrapper)
$TUNNEL_BIN $CONFIG > /tmp/hev-tunnel.log 2>&1 &
echo $! > $PIDFILE
# 3. Wait for the tun interface to appear
echo "Waiting for $TUN_INTERFACE..."
for i in {1..10}; do
if ifconfig $TUN_INTERFACE >/dev/null 2>&1; then
break
fi
sleep 1
done
# 4. Install IPv4 exclusions (route them to the physical gateway)
echo "Installing exclusions..."
for ip in "${EXCLUDED_IPS[@]}"; do
route add -net "$ip" "$GATEWAY" >/dev/null 2>&1
done
# NOTE: if your SOCKS proxy address is an IPv6 mesh address (e.g. via
# Yggdrasil under 200::/7), it will be routed automatically through the
# mesh interface (utun0) and does NOT need an exclusion entry here.
# 5. Steal default route via the /1 split trick.
# Two /1 routes cover all of v4/v6 more specifically than 0.0.0.0/0,
# so the kernel prefers them — but the actual default route is left
# untouched and reappears instantly when we delete these on stop.
echo "Redirecting traffic into $TUN_INTERFACE..."
route add -net 0.0.0.0/1 -interface $TUN_INTERFACE >/dev/null 2>&1
route add -net 128.0.0.0/1 -interface $TUN_INTERFACE >/dev/null 2>&1
route add -inet6 ::/1 -interface $TUN_INTERFACE >/dev/null 2>&1
route add -inet6 8000::/1 -interface $TUN_INTERFACE >/dev/null 2>&1
# 6. Switch system DNS to the in-tunnel MapDNS responder.
# Without this, apps keep talking to the LAN router (which is in the
# exclusion list) and DNS queries leak / get blocked. With it,
# everything (including QUIC) is resolved via fake-IPs from MapDNS
# and routed through the proxy by name.
DEFAULT_IF=$(route -n get default 2>/dev/null | awk '/interface:/ {print $2}')
SERVICE_NAME=$(networksetup -listallhardwareports 2>/dev/null \
| grep -B 1 "Device: $DEFAULT_IF" \
| awk -F': ' '/Hardware Port/ {print $2}')
if [ -n "$SERVICE_NAME" ]; then
echo "Switching system DNS to MapDNS ($SERVICE_NAME$MAPDNS_IP)..."
echo "$SERVICE_NAME" > /tmp/tunnel-service-name
ORIG_DNS=$(networksetup -getdnsservers "$SERVICE_NAME" | tr '\n' ' ' | sed 's/ *$//')
if [[ "$ORIG_DNS" == *"There aren't any DNS Servers"* ]]; then
echo "Empty" > /tmp/tunnel-orig-dns
else
echo "$ORIG_DNS" > /tmp/tunnel-orig-dns
fi
networksetup -setdnsservers "$SERVICE_NAME" "$MAPDNS_IP"
fi
echo "✅ Tunnel up, routes installed."
}
stop() {
echo "Stopping tunnel and cleaning up routes..."
GATEWAY=$(route -n get default 2>/dev/null | awk '/gateway:/ {print $2}')
# 1. Remove split-default routes
route delete -net 0.0.0.0/1 -interface $TUN_INTERFACE >/dev/null 2>&1
route delete -net 128.0.0.0/1 -interface $TUN_INTERFACE >/dev/null 2>&1
route delete -inet6 ::/1 -interface $TUN_INTERFACE >/dev/null 2>&1
route delete -inet6 8000::/1 -interface $TUN_INTERFACE >/dev/null 2>&1
# 2. Remove exclusion routes
if [ -n "$GATEWAY" ]; then
for ip in "${EXCLUDED_IPS[@]}"; do
route delete -net "$ip" "$GATEWAY" >/dev/null 2>&1
done
fi
# 3. Restore original system DNS
if [ -f /tmp/tunnel-service-name ] && [ -f /tmp/tunnel-orig-dns ]; then
SERVICE_NAME=$(cat /tmp/tunnel-service-name)
ORIG_DNS=$(cat /tmp/tunnel-orig-dns)
echo "Restoring DNS for $SERVICE_NAME..."
if [ "$ORIG_DNS" = "Empty" ]; then
networksetup -setdnsservers "$SERVICE_NAME" "Empty"
else
networksetup -setdnsservers "$SERVICE_NAME" $ORIG_DNS
fi
rm -f /tmp/tunnel-service-name /tmp/tunnel-orig-dns
fi
# 4. Kill the tunnel process
if [ -f "$PIDFILE" ]; then
kill "$(cat "$PIDFILE")" 2>/dev/null
rm -f "$PIDFILE"
else
pkill hev-socks5-tunnel
fi
echo "✅ Tunnel stopped, original routes restored."
}
status() {
if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
echo "🟢 Tunnel RUNNING (PID: $(cat "$PIDFILE"))"
ifconfig $TUN_INTERFACE 2>/dev/null | grep -E "inet |inet6 "
else
echo "🔴 Tunnel NOT running."
fi
}
case "$1" in
start) start ;;
stop) stop ;;
restart) stop; sleep 2; start ;;
status) status ;;
*) echo "Usage: $0 {start|stop|restart|status}"; exit 1 ;;
esac
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment