BuildNot yet confirmed elsewhere1 publisher3 min readPublished
iOS has nowhere to put a proxy setting, so the proxy moves into the network
A Tailscale App Connector fronting a sing-box TUN turns plain TCP into SOCKS5 for any tailnet device. The steering is longest-prefix match, and it fails without complaining.
The Engineer · Build desk
What happened
- iOS offers no per-app proxy configuration and most apps ignore the system proxy, so the PAC file that solves this on a laptop has nowhere to live.
- The published design instead puts a Tailscale App Connector in front of a sing-box TUN that converts plain TCP into SOCKS5, with nothing configured on the client.
- On the connector node, a rule at priority 100 matching traffic arriving on tailscale0 sends nominated prefixes to table 100, whose routes point at singtun0.
- sing-box carries no route rules at all: final sends everything entering the TUN to a SOCKS5 outbound at 10.0.0.10:1080, bound to tailscale0.
Why it matters
- capability Devices with no place to configure a proxy get one anyway, because the translation sits on a node the operator controls rather than on the handset.
- constraint Access is bounded by an enumerated prefix list held in two files, so adding an internal service is a change on the connector rather than a setting a user can flip.
- exposure The bad state looks like the good state: flows keep completing after the steering is lost, so the usual signal of breakage never fires.
- decision With no readiness signal from a Type=simple unit, correctness has to be bought with polling inside the helper script instead of an ordering guarantee from systemd.
Longest-prefix match is the whole steering language in this design, and it does more work than the config files suggest. A /32 in the connector's route list beats the same address learned from another subnet router advertising 10.20.0.0/16, so one host gets carved out of an existing network without renumbering anything [10]. The author is straight about the other side of that: delete the /32 and traffic reverts to the old path with no error anywhere [11]. Connections still complete. They just stop going through the corporate proxy, and no client sees a difference.
The destination list also exists twice. Once in the tailnet policy file, where 10.10.0.0/24, 10.20.0.50/32 and 192.0.2.7/32 are nominated for the connector [9], and once in /usr/local/etc/singbox-routes.conf on the node, carrying the same three entries under a comment telling you to keep them in sync [13]. One copy decides what arrives at the connector, the other decides what the connector does with it, and neither validates the other [17].
The rule that does the steering matches on input interface tailscale0 [14], which means packets the connector node originates itself never match it [19]. That is load-bearing in two directions. The SOCKS5 session sing-box opens to 10.0.0.10:1080 is pinned to tailscale0 by explicit bind_interface [2] and cannot be recaptured by the node's own rules, and in any case 10.0.0.10 falls outside all three steered prefixes [18]. It also means a curl from a shell on the connector exercises none of the path you just built.
The ordering bug is the part of the writeup worth keeping. sing-box's unit is Type=simple, so systemd fires ExecStartPost the moment the process forks, before singtun0 exists, and every route add fails with Cannot find device [3]. The ip rule entries install regardless, because rules are not device-bound, so the node ends up with rules present and table 100 empty [4]. Half the configuration lands, which is worse than none of it landing, and the shape of that failure comes directly from Type=simple giving systemd nothing to wait for. The mitigation is a poll loop in the script, checking for the interface in 100ms steps for up to ten seconds and logging how late it showed up [16].
What this buys is a proxy for devices that have no place to configure one, since iOS offers no per-app proxy setting and most apps ignore the system proxy anyway [5]. What it costs is that the translation now lives in a routing table on one Linux box, with auto_route deliberately switched off so sing-box does not manage that table either [12]. Every question a client might ask about whether its traffic went through the proxy has to be answered somewhere else.
What to watch
- Whether sing-box ships or documents a unit that signals readiness, which would let the route install be a dependency instead of a ten-second poll loop.
- Whether Tailscale exposes the connector's effective route set on the node, so the policy-file list and the local list can be diffed rather than trusted.
- What the path does when a competing subnet router's broader advertisement is withdrawn or re-added while the more specific /32 is in place.
Clarity's read
What the record supports and how the coverage leans. The claims behind it follow.
Reality
- Evidence58
- Adoption10
- Hype gap−8
- Incentives22
- Confidence45
Claim ledger
Ranked by verification strength, evidence, and original report placement.
- [1]
The sing-box inbound is a TUN tagged tun-in with interface_name singtun0, address 172.19.0.1/30, mtu 1500, auto_route false and strict_route false.
- [2]
The sing-box outbound is type socks, version 5, server 10.0.0.10 port 1080, with bind_interface tailscale0; the route block has empty rules and final set to that outbound, so anything routed into the TUN is forwarded without needing a rule of its own.
- [3]
sing-box's unit is Type=simple, so systemd runs ExecStartPost the moment the process is forked, before sing-box has created singtun0, and every `ip route add ... dev singtun0` fails with Cannot find device.
- [4]
The ip rule entries still install, because rules are not device-bound, leaving rules present and table 100 empty.
- [5]
iOS has no per-app proxy configuration and most apps ignore the system proxy; on a laptop the same problem is handled with a PAC file.
- [6]
Some internal services are only reachable through a corporate SOCKS5 proxy.
- [7]
The design makes those destinations work transparently on any tailnet device, with no PAC file, no proxy settings and no per-app configuration, by putting a Tailscale App Connector in front of a sing-box TUN that translates plain TCP into SOCKS5.
- [8]
The connector node is enabled with `tailscale up --advertise-connector`.
- [9]
The tailnet policy file nominates the steered destinations under tailscale.com/app-connectors as connector "corp-proxy" on tag:proxy-connector, with routes 10.10.0.0/24 (internal network), 10.20.0.50/32 (multi-vhost host) and 192.0.2.7/32 (single host).
- [10]
A /32 in the connector route list beats a broader route learned elsewhere: if 10.20.0.50 is already reachable through another subnet router advertising 10.20.0.0/16, the more specific /32 wins and steers just that one address to the connector.
- [11]
Per the author, dropping the /32 means traffic silently reverts to the old path with no error anywhere.
- [12]
auto_route: false is deliberate, because the routing is done by the operator in table 100 rather than by sing-box.
- [13]
The same destinations are listed in /usr/local/etc/singbox-routes.conf, which carries the three entries 10.10.0.0/24, 10.20.0.50/32 and 192.0.2.7/32 under a comment to keep them in sync with the Tailscale App Connector route list.
- [14]
For each destination the script runs `ip route replace $NET dev singtun0 table 100` and `ip rule add priority 100 iif tailscale0 to $NET lookup 100`.
- [15]
The script is tied to the sing-box service lifecycle by a systemd drop-in using ExecStartPost=/usr/local/sbin/singbox-routes start and ExecStopPost=/usr/local/sbin/singbox-routes stop.
- [16]
The script's wait_for_tun function polls `ip link show singtun0` in 100ms steps for up to 10 seconds (WAIT_TENTHS=100), logging when the device appears late or fails to appear.
- [17]
The steered destination list is maintained in two separate places, the tailnet policy file and singbox-routes.conf on the connector node, so each added or removed destination requires two edits and neither list checks the other.
- [18]
The proxy address 10.0.0.10 is not contained in any of the three steered prefixes (10.10.0.0/24, 10.20.0.50/32, 192.0.2.7/32), so packets to the proxy are not themselves steered into the TUN.
- [19]
Because the ip rule predicate requires iif tailscale0, packets originating on the connector node itself do not match the rule and are not steered into the TUN.
Sources
1 independent publisher whose own reporting we read for this story.
- dev.toTransparent SOCKS5 access from iOS with a Tailscale App Connector and sing-box
1 article · August 25, 2026
Topics and entities
Follow any of these and your For You feed starts watching them — no settings page required.