Summary
On macOS, NativeTun.setRoutes()'s EEXIST recovery deletes the host's pre-existing global default route whenever a route range is a literal 0.0.0.0/0, and unsetRoutes() never restores it. Observed via a sing-box system: true WireGuard endpoint carrying full-tunnel: the machine loses its default route on start, and after the tunnel stops it has no default route → no internet until the network is re-kicked manually (e.g. toggling Wi‑Fi).
Environment
- sing-tun (current
main), via sing-box 1.14.0-alpha
- macOS 26.x, Apple Silicon (arm64)
- Repro config (sing-box): a TUN inbound with
auto_route: true plus a system: true wireguard endpoint whose peer has allowed_ips: ["0.0.0.0/0", "::/0"].
Root cause — tun_darwin.go
setRoutes() installs each range from BuildAutoRouteRanges() via execRoute(RTM_ADD, …). With auto_route, 0.0.0.0/0 is split into reserved-excluding sub-ranges, so a literal 0.0.0.0/0 is never installed. But the system WireGuard interface installs its allowed_ips verbatim, so the range list contains a literal 0.0.0.0/0.
// setRoutes(), ~L448
err = execRoute(unix.RTM_ADD, t.options.InterfaceScope, interfaceIndex, destination, gateway)
if err != nil {
if errors.Is(err, unix.EEXIST) {
err = execRoute(unix.RTM_DELETE, false, 0, destination, gateway) // deletes the EXISTING 0.0.0.0/0 = the system default
...
err = execRoute(unix.RTM_ADD, t.options.InterfaceScope, ...) // re-add as ifscope
RTM_ADD 0.0.0.0/0 returns EEXIST (the system's global default already exists); the recovery RTM_DELETE 0.0.0.0/0 removes the system's global default (macOS matches by destination), then re-adds the WG's as interface-scoped. On teardown, unsetRoutes() (~L471-493) deletes the endpoint's own ranges but never restores the default it displaced.
Evidence (route -n monitor, single sing-box pid)
Start:
RTM_ADD default <wg-gateway> ifscope <utunN>
RTM_DELETE default <en0-gateway> (GLOBAL, no ifscope) <-- system default deleted
Stop: only RTM_DELETE of the endpoint's own ranges; no re-add of the original default. netstat -rn -f inet afterwards has no default entry -> no connectivity.
Why the auto_route TUN does not hit this
The auto_route TUN goes through BuildAutoRouteRanges(), which splits 0.0.0.0/0 into reserved-excluding sub-ranges — it never installs a literal 0.0.0.0/0, so RTM_ADD never collides with the system default and the EEXIST branch is never taken for 0/0.
Expected
The EEXIST recovery should not delete (or should restore) the system's primary default route. Options: split 0.0.0.0/0 for system interfaces the same way auto_route does; or have setRoutes() remember any pre-existing default it displaced and unsetRoutes() restore it.
Workaround (downstream)
Capture the default gateway before start and restore it via route add -inet default <gw> after stop, deriving the current gateway from scutil State:/Network/Global/IPv4 so it survives mid-session network changes.
Summary
On macOS,
NativeTun.setRoutes()'sEEXISTrecovery deletes the host's pre-existing global default route whenever a route range is a literal0.0.0.0/0, andunsetRoutes()never restores it. Observed via a sing-boxsystem: trueWireGuard endpoint carrying full-tunnel: the machine loses its default route on start, and after the tunnel stops it has no default route → no internet until the network is re-kicked manually (e.g. toggling Wi‑Fi).Environment
main), via sing-box 1.14.0-alphaauto_route: trueplus asystem: truewireguard endpoint whose peer hasallowed_ips: ["0.0.0.0/0", "::/0"].Root cause —
tun_darwin.gosetRoutes()installs each range fromBuildAutoRouteRanges()viaexecRoute(RTM_ADD, …). Withauto_route,0.0.0.0/0is split into reserved-excluding sub-ranges, so a literal0.0.0.0/0is never installed. But the system WireGuard interface installs itsallowed_ipsverbatim, so the range list contains a literal0.0.0.0/0.RTM_ADD 0.0.0.0/0returnsEEXIST(the system's global default already exists); the recoveryRTM_DELETE 0.0.0.0/0removes the system's global default (macOS matches by destination), then re-adds the WG's as interface-scoped. On teardown,unsetRoutes()(~L471-493) deletes the endpoint's own ranges but never restores the default it displaced.Evidence (
route -n monitor, single sing-box pid)Start:
Stop: only
RTM_DELETEof the endpoint's own ranges; no re-add of the original default.netstat -rn -f inetafterwards has nodefaultentry -> no connectivity.Why the auto_route TUN does not hit this
The
auto_routeTUN goes throughBuildAutoRouteRanges(), which splits0.0.0.0/0into reserved-excluding sub-ranges — it never installs a literal0.0.0.0/0, soRTM_ADDnever collides with the system default and the EEXIST branch is never taken for0/0.Expected
The EEXIST recovery should not delete (or should restore) the system's primary default route. Options: split
0.0.0.0/0for system interfaces the same wayauto_routedoes; or havesetRoutes()remember any pre-existing default it displaced andunsetRoutes()restore it.Workaround (downstream)
Capture the default gateway before start and restore it via
route add -inet default <gw>after stop, deriving the current gateway fromscutilState:/Network/Global/IPv4so it survives mid-session network changes.