Some internal services are only reachable through a corporate SOCKS5 proxy. On a laptop, you handle that with a PAC file. On iOS you can't: there's no per-app proxy configuration, and most apps ignore the system proxy anyway.
This is how to make those destinations work transparently on any tailnet device — no PAC file, no proxy settings, no per-app configuration — by putting a Tailscale App Connector in front of a sing-box TUN that translates plain TCP into SOCKS5.
Everything below is anonymized. Substitute your own addresses.
The path
Client (Mac / iOS)
↓ Tailscale decides this destination uses the connector
App Connector node (gw)
↓ tailscale0
↓ policy routing, table 100
singtun0
↓ sing-box
↓ SOCKS5 to 10.0.0.10:1080
Target service
The client sends normal traffic to a normal address. Tailscale routes the destinations you nominate to the connector node. On that node, policy routing pushes exactly those destinations into a TUN interface, where sing-box picks them up and forwards them through the corporate proxy.
Setting it up
1. The connector node
tailscale up --advertise-connector
And in the tailnet policy file, the destinations you want steered:
{"target": ["*"], "app": {"tailscale.com/app-connectors": [{
"name": "corp-proxy",
"connectors": ["tag:proxy-connector"],
"routes": [
"10.10.0.0/24", // internal network
"10.20.0.50/32", // multi-vhost host, see below
"192.0.2.7/32", // single host
],
}]}}
Note that a /32 here beats a broader route learned elsewhere. If 10.20.0.50 is already reachable through some other subnet router advertising 10.20.0.0/16, the more specific /32 wins and steers just that one address to the connector. That's the mechanism, and it's also the fragility: drop the /32 and traffic silently reverts to the old path with no error anywhere.
2. sing-box
TUN inbound, SOCKS5 outbound, and — the key simplification — final pointing at the proxy, so anything routed into the TUN is forwarded without needing a rule of its own:
{
"inbounds": [
{
"type": "tun",
"tag": "tun-in",
"interface_name": "singtun0",
"address": ["172.19.0.1/30"],
"mtu": 1500,
"auto_route": false,
"strict_route": false
}
],
"outbounds": [
{
"type": "socks",
"tag": "corp-socks",
"server": "10.0.0.10",
"server_port": 1080,
"version": "5",
"bind_interface": "tailscale0"
}
],
"route": {
"auto_detect_interface": true,
"rules": [],
"final": "corp-socks"
}
}
auto_route: false is deliberate — the routing is ours, in table 100, not sing-box's.
3. Policy routing
A list file, so adding a destination doesn't mean editing a script. /usr/local/etc/singbox-routes.conf:
# Destinations steered into sing-box → corporate SOCKS5 proxy.
# Keep in sync with the Tailscale App Connector route list.
10.10.0.0/24 # internal network
10.20.0.50/32 # multi-vhost host
192.0.2.7/32 # single host
/usr/local/sbin/singbox-routes:
#!/bin/sh
set -eu
TABLE=100
PRIORITY=100
TUN=singtun0
TS=tailscale0
WAIT_TENTHS=100 # 10s, in 100ms steps
ROUTES_FILE=/usr/local/etc/singbox-routes.conf
log() { echo "singbox-routes: $*" >&2; }
routes() {
[ -r "$ROUTES_FILE" ] || { log "cannot read $ROUTES_FILE"; return 1; }
sed 's/#.*//' "$ROUTES_FILE"
}
wait_for_tun() {
i=0
while [ "$i" -lt "$WAIT_TENTHS" ]; do
if ip link show "$TUN" >/dev/null 2>&1; then
[ "$i" -eq 0 ] || log "$TUN appeared after $((i * 100))ms"
return 0
fi
sleep 0.1
i=$((i + 1))
done
log "$TUN did not appear within $((WAIT_TENTHS / 10))s"
return 1
}
start() {
wait_for_tun
NETS=$(routes)
[ -n "$NETS" ] || { log "no destinations in $ROUTES_FILE"; return 1; }
for NET in $NETS; do
ip route replace "$NET" dev "$TUN" table "$TABLE"
if ! err=$(ip rule add priority "$PRIORITY" iif "$TS" \
to "$NET" lookup "$TABLE" 2>&1); then
case "$err" in
*"File exists"*) ;; # already present
*) log "rule add $NET failed: $err"; return 1 ;;
esac
fi
done
log "$(echo "$NETS" | wc -w) destinations routed via $TUN"
}
stop() {
while ip rule del priority "$PRIORITY" 2>/dev/null; do :; done
ip route flush table "$TABLE" 2>/dev/null || true
}
case "${1:-}" in
start) start ;;
stop) stop ;;
*) echo "Usage: $0 {start|stop}" >&2; exit 1 ;;
esac
Tied to the service lifecycle via a drop-in:
[Service]
ExecStartPost=/usr/local/sbin/singbox-routes start
ExecStopPost=/usr/local/sbin/singbox-routes stop
Four things that cost me time
ExecStartPost fires before the TUN exists
sing-box's unit is Type=simple, so systemd runs ExecStartPost the moment the process is forked — before sing-box has created singtun0. Every ip route add ... dev singtun0 fails with Cannot find device.
The ip rule entries still install, because rules aren't device-bound. So you get rules present, table empty — and a rule pointing at an empty table doesn't terminate the lookup. The kernel falls through to the next rule and out to main, where the traffic takes its old path. Silent, with a running, apparently healthy service.
sing-box doesn't implement sd_notify, so Type=notify isn't available; the script has to poll for the interface itself. In my case the race window was 100ms — one poll interval. Small enough to look fine on an idle box and fail on a loaded one.
Sniffing does not rewrite the destination
If one IP serves several TLS vhosts, an IP-matched rule can't tell them apart, and rewriting with a fixed override_address sends the wrong hostname upstream. The fix is to read the SNI — but not the way the obvious reading of the docs suggests.
{"action": "sniff"} populates the sniffed domain for rule matching only. It does not replace the connection's destination. In route/rule/rule_action.go, NewRuleAction() builds the sniff action with only the sniffer names and timeout; the struct's OverrideDestination field is marked deprecated and is never set from configuration. The rewrite in route/route.go is guarded by if action.OverrideDestination && M.IsDomainName(metadata.Domain), so it never fires. Identical in v1.11.0, v1.12.0, v1.13.0 and current testing.
What does work is that DomainItem.Match() uses the sniffed domain, so domain rules match even though the destination is still an IP. So: sniff, match on domain, and set override_address explicitly per hostname.
{ "inbound": ["tun-in"], "ip_cidr": ["10.20.0.50/32"],
"action": "sniff", "sniffer": ["tls"], "timeout": "500ms" },
{ "inbound": ["tun-in"], "domain": ["app-a.example.com"],
"action": "route", "outbound": "corp-socks",
"override_address": "app-a.example.com" },
{ "inbound": ["tun-in"], "domain": ["app-b.example.com"],
"action": "route", "outbound": "corp-socks",
"override_address": "app-b.example.com" }
Rule order matters: sniff is non-final and evaluation continues to the next rule, so it must come first. Scope it to the one ambiguous IP rather than applying it globally.
Don't add override_port. applyRouteOptionsOverride preserves the original port when only override_address is set, so adding it silently rewrites every port to whatever you specified — I had connections to :8443 arriving at the proxy as :443 for days before noticing.
ip rule add is idempotent; ip route add isn't
iproute2 sets NLM_F_CREATE|NLM_F_EXCL on RTM_NEWRULE, and the kernel's fib_rules.c calls rule_exists() under that flag, returning -EEXIST for a rule matching on action, table, preference and selectors. Repeated starts can't accumulate duplicate rules. Routes have no such protection, hence ip route replace.
Worth matching on the File exists string rather than blanket-suppressing errors with 2>/dev/null || true — otherwise a genuine failure looks exactly like a no-op.
iif tailscale0 is load-bearing
With final pointing at the proxy, the iif tailscale0 condition is what stops sing-box's own outbound connection to the SOCKS server from being routed back into the TUN. That traffic is locally generated, so it doesn't match iif, and there's no loop. Anyone "simplifying" those rules by dropping the iif creates one.
Testing it without touching production
The routing logic can be verified offline: run sing-box with a SOCKS inbound instead of a TUN (route rules behave identically), point its outbound at a throwaway SOCKS5 server that logs what it's asked to connect to, and send a real TLS ClientHello with a chosen SNI to a chosen IP and port.
That gives you a table like this, which is what you actually want to know:
| Probe | Upstream received |
|---|---|
10.20.0.50:8443, SNI app-b.example.com
|
DOMAIN app-b.example.com:8443 |
10.20.0.50:443, SNI app-a.example.com
|
DOMAIN app-a.example.com:443 |
10.20.0.50:8443, no TLS |
IPv4 10.20.0.50:8443 |
192.0.2.7:8078, no rule at all |
IPv4 192.0.2.7:8078 |
Forty lines of Python for the fake proxy and the probe, and it settles questions that reading the documentation does not.
What this can't do
Wildcard domains. *.example.com has no fixed prefix for an ip rule, and sing-box can't use a sniffed SNI as an override_address (that value is a static string). Tailscale App Connectors do support a domains key and learn addresses via DNS — but those dynamically-advertised routes reach the TUN only if they happen to fall inside a prefix you've already listed. For a genuinely open-ended wildcard, keep the PAC file for laptops.
Non-443 ports. Many corporate proxies restrict CONNECT to 443. If a destination on an unusual port fails, test the proxy directly before suspecting the routing.
UDP. Not covered here. SOCKS5 UDP associate exists but corporate proxies rarely permit it.
Why bother
Adding a destination is now two edits: one line in the connector's route list, one line in a conf file. Clients need no configuration at all — which is the entire point, because on iOS "configure the client" was never available.
Top comments (0)