Skip to content

macOS: setRoutes EEXIST-recovery deletes the system default route (literal 0.0.0.0/0); unsetRoutes never restores it #80

Description

@Sway-Chan

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions