From 6dc3b9e3952614722bb5b1b460e9b50a0051042d Mon Sep 17 00:00:00 2001 From: sneak Date: Fri, 6 Nov 2026 19:08:12 -0800 Subject: [PATCH] macOS: ignore on-link default routes when picking the physical uplink A VM with NAT networking makes vmnet install a default route scoped to its bridge (bridge100) with no gateway. The detector counted it as a second physical default route and refused to start: multiple physical default-route interfaces found (bridge100, en0) Scoped routes cannot simply be dropped: while a VPN holds the default route, the physical interface keeps only a default route scoped to itself. That route still names its gateway (flag G). The on-link one does not, so a physical default route now requires a next-hop gateway. parseNetstat records a gateway only when the G flag is set; a link#N gateway column is the interface itself, not a next hop. --- README.md | 7 +- internal/netdetect/netdetect.go | 52 ++++++++++---- internal/netdetect/netdetect_test.go | 102 +++++++++++++++++++++++++-- 3 files changed, 138 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 3884871..a856e31 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,12 @@ primary pane alongside the physical interface. A tunnel that is up without the default route is ignored, whatever its address: Tailscale without an exit node, or a tunnel a disconnected client left behind. A default route scoped to the tunnel alone (flag `I`) does not count either. With no VPN connected, only the -physical interface is monitored. Interface names are detected on macOS; the +physical interface is monitored. The physical interface is the one non-tunnel +`default` row with a next-hop gateway (flag `G`); it keeps that gateway even +while a VPN holds the default route and its own default is scoped. An on-link +`default` row with no gateway, such as the one vmnet or Internet Sharing +installs on `bridge100` for a VM's subnet, is ignored. Interface names are +detected on macOS; the `--ifaceA`/`--ifaceB` flags are not used there, but `--labelA`/`--labelB` still set the pane labels. diff --git a/internal/netdetect/netdetect.go b/internal/netdetect/netdetect.go index 8aee000..c90b1cd 100644 --- a/internal/netdetect/netdetect.go +++ b/internal/netdetect/netdetect.go @@ -41,9 +41,11 @@ type Interface struct { IPv4 []string } -// Route is one routing-table entry reduced to what detection needs. Scoped -// marks a macOS interface-scoped route (flag I), which only traffic bound to -// that interface uses. +// Route is one routing-table entry reduced to what detection needs. Gateway +// is the next-hop address, empty for an on-link route that has none: a +// tunnel, or the pseudo-default that vmnet and Internet Sharing install on a +// bridge so the hosted subnet works. Scoped marks a macOS interface-scoped +// route (flag I), which only traffic bound to that interface uses. type Route struct { Iface string Gateway string @@ -188,7 +190,9 @@ func findVPN(ifaces []Interface, routes []Route) string { // onlyDefaultRoute returns the single default-route interface, or an error if // there is not exactly one. func onlyDefaultRoute(routes []Route) (string, error) { - names := defaultRouteIfaces(routes, "") + names := defaultRouteIfaces(routes, func(r Route) bool { + return !isTunnel(r.Iface) + }) switch len(names) { case 1: @@ -202,9 +206,16 @@ func onlyDefaultRoute(routes []Route) (string, error) { } // onlyPhysicalDefaultRoute returns the single non-tunnel default-route -// interface (ignoring the VPN), or an error if there is not exactly one. +// interface (ignoring the VPN), or an error if there is not exactly one. Only +// a default route with a next-hop gateway counts: scoped routes do, since +// while a VPN holds the default route the physical interface keeps only a +// default route scoped to itself, but that route still names the gateway. An +// on-link default without one (vmnet's on bridge100) reaches nothing beyond +// its own subnet and is not an uplink. func onlyPhysicalDefaultRoute(routes []Route, vpn string) (string, error) { - names := defaultRouteIfaces(routes, vpn) + names := defaultRouteIfaces(routes, func(r Route) bool { + return r.Iface != vpn && !isTunnel(r.Iface) && r.Gateway != "" + }) switch len(names) { case 1: @@ -218,16 +229,14 @@ func onlyPhysicalDefaultRoute(routes []Route, vpn string) (string, error) { } // defaultRouteIfaces returns the sorted, unique interface names that carry a -// default route, excluding the named VPN interface and any other tunnel. -// Scoped routes count: while a VPN holds the default route, the physical -// interface keeps only a default route scoped to itself. -func defaultRouteIfaces(routes []Route, vpn string) []string { +// default route accepted by keep. +func defaultRouteIfaces(routes []Route, keep func(Route) bool) []string { seen := map[string]bool{} var names []string for _, r := range routes { - if !r.Default || r.Iface == vpn || isTunnel(r.Iface) || seen[r.Iface] { + if !r.Default || !keep(r) || seen[r.Iface] { continue } @@ -327,11 +336,20 @@ func parseProcNetRoute(out string) []Route { return routes } +// netstat route flags (see `man netstat`). +const ( + netstatFlagGateway = "G" // destination is reached through a next-hop gateway + netstatFlagScoped = "I" // route is scoped to its interface +) + // parseNetstat reads `netstat -rn -f inet` output (macOS); the Netif column // (field 4) names the interface. A row whose destination is "default" is a -// default route, scoped when its flags include I. A "0/1" row and a "128.0/1" -// row on one interface together also make a default route there: VPN clients -// that leave the physical default in place send all traffic through them. +// default route, scoped when its flags include I. Its Gateway column is the +// next hop only when the flags include G; otherwise it names the link itself +// (link#N) and the route is on-link, with no gateway. A "0/1" row and a +// "128.0/1" row on one interface together also make a default route there: +// VPN clients that leave the physical default in place send all traffic +// through them. func parseNetstat(out string) []Route { const columns = 4 // Destination, Gateway, Flags, Netif; Expire is optional @@ -351,11 +369,15 @@ func parseNetstat(out string) []Route { switch dest { case "default": + if !strings.Contains(flags, netstatFlagGateway) { + gateway = "" + } + routes = append(routes, Route{ Iface: iface, Gateway: gateway, Default: true, - Scoped: strings.Contains(flags, "I"), + Scoped: strings.Contains(flags, netstatFlagScoped), }) case "0/1": lowHalf = append(lowHalf, iface) diff --git a/internal/netdetect/netdetect_test.go b/internal/netdetect/netdetect_test.go index 9d92274..156f629 100644 --- a/internal/netdetect/netdetect_test.go +++ b/internal/netdetect/netdetect_test.go @@ -17,6 +17,7 @@ const ( ifaceEth0 = "eth0" ifaceWlan0 = "wlan0" ifaceEn0 = "en0" + ifaceBridge = "bridge100" ifaceUtun0 = "utun0" ifaceUtun3 = "utun3" ifaceUtun4 = "utun4" @@ -27,6 +28,8 @@ const ( labelVPN = "VPN" addrEn0 = "192.168.1.20" + gatewayEn0 = "192.168.1.1" + addrBridge = "192.168.64.1" addrVPN = "10.64.0.2" addrTailscale = "100.101.102.103" ) @@ -106,6 +109,32 @@ default 192.168.1.1 UGScg en0 100.101.102.103/32 link#22 UCS utun3 127.0.0.1 127.0.0.1 UH lo0 128.0/1 utun4 USc utun4 +` + + // A VM with NAT networking: vmnet installs an on-link default route, + // scoped to its bridge and with no gateway, next to the real one. + netstatVMBridge = netstatHeader + ` +default 192.168.1.1 UGScg en0 +default link#45 UCSIg bridge100 ! +127.0.0.1 127.0.0.1 UH lo0 +192.168.64 link#45 UC bridge100 ! +` + + // The same VM bridge while a VPN holds the default route. + netstatVMBridgeAndVPN = netstatHeader + ` +default link#15 UCSg utun4 +default 192.168.1.1 UGScIg en0 +default link#45 UCSIg bridge100 ! +10.64.0.2 10.64.0.2 UH utun4 +127.0.0.1 127.0.0.1 UH lo0 +192.168.64 link#45 UC bridge100 ! +` + + // Only the VM bridge's on-link default route, no uplink at all. + netstatVMBridgeOnly = netstatHeader + ` +default link#45 UCSIg bridge100 ! +127.0.0.1 127.0.0.1 UH lo0 +192.168.64 link#45 UC bridge100 ! ` ) @@ -265,7 +294,7 @@ func TestSelectDarwinPanes(t *testing.T) { }, routes: []netdetect.Route{ {Iface: ifaceUtun4, Default: true}, - {Iface: ifaceEn0, Default: true}, + {Iface: ifaceEn0, Gateway: gatewayEn0, Default: true, Scoped: true}, }, flags: linuxFlags(), want: []netdetect.Pane{ @@ -282,7 +311,7 @@ func TestSelectDarwinPanes(t *testing.T) { }, routes: []netdetect.Route{ {Iface: ifaceUtun4, Default: true}, - {Iface: ifaceEn0, Default: true}, + {Iface: ifaceEn0, Gateway: gatewayEn0, Default: true, Scoped: true}, }, flags: netdetect.Flags{ LabelA: "Mullvad", LabelASet: true, @@ -308,7 +337,7 @@ func TestSelectDarwinSingleAndErrors(t *testing.T) { {Name: ifaceUtun0, Up: true, IPv4: nil}, {Name: "utun1", Up: true, IPv4: []string{"169.254.1.1"}}, }, - routes: []netdetect.Route{{Iface: ifaceEn0, Default: true}}, + routes: []netdetect.Route{{Iface: ifaceEn0, Gateway: gatewayEn0, Default: true}}, flags: linuxFlags(), want: []netdetect.Pane{{Name: ifaceEn0, Label: labelDefault}}, }, @@ -319,7 +348,7 @@ func TestSelectDarwinSingleAndErrors(t *testing.T) { {Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}}, {Name: "utun6", Up: true, IPv4: []string{"10.2.0.2"}}, }, - routes: []netdetect.Route{{Iface: ifaceEn0, Default: true}}, + routes: []netdetect.Route{{Iface: ifaceEn0, Gateway: gatewayEn0, Default: true}}, flags: linuxFlags(), want: []netdetect.Pane{{Name: ifaceEn0, Label: labelDefault}}, }, @@ -341,8 +370,8 @@ func TestSelectDarwinSingleAndErrors(t *testing.T) { {Name: "en1", Up: true, IPv4: []string{"192.168.2.20"}}, }, routes: []netdetect.Route{ - {Iface: ifaceEn0, Default: true}, - {Iface: "en1", Default: true}, + {Iface: ifaceEn0, Gateway: gatewayEn0, Default: true}, + {Iface: "en1", Gateway: "192.168.2.1", Default: true}, }, flags: linuxFlags(), wantErr: true, @@ -430,6 +459,63 @@ func TestSelectDarwinFromNetstat(t *testing.T) { }) } +// TestSelectDarwinVMBridge covers a VM's NAT bridge: vmnet installs a default +// route scoped to the bridge with no gateway, which is not an uplink. +func TestSelectDarwinVMBridge(t *testing.T) { + t.Parallel() + + vpn := netdetect.Interface{Name: ifaceUtun4, Up: true, IPv4: []string{addrVPN}} + bridge := netdetect.Interface{Name: ifaceBridge, Up: true, IPv4: []string{addrBridge}} + physicalOnly := []netdetect.Pane{{Name: ifaceEn0, Label: labelDefault}} + vpnAndPhysical := []netdetect.Pane{ + {Name: ifaceUtun4, Label: labelVPN}, + {Name: ifaceEn0, Label: labelDefault}, + } + + runSelectCases(t, []selectCase{ + { + name: "on-link default route is not an uplink", + goos: osDarwin, + ifaces: macIfaces(bridge), + routes: []netdetect.Route{ + {Iface: ifaceEn0, Gateway: gatewayEn0, Default: true}, + {Iface: ifaceBridge, Default: true, Scoped: true}, + }, + flags: linuxFlags(), + want: physicalOnly, + }, + { + name: "only an on-link default route", + goos: osDarwin, + ifaces: macIfaces(bridge), + routes: []netdetect.Route{{Iface: ifaceBridge, Default: true, Scoped: true}}, + flags: linuxFlags(), + wantErr: true, + }, + { + name: "netstat: vm bridge next to the physical default", + goos: osDarwin, + ifaces: macIfaces(bridge), + routes: netdetect.ParseNetstat(netstatVMBridge), + want: physicalOnly, + }, + { + name: "netstat: vm bridge next to a connected vpn", + goos: osDarwin, + ifaces: macIfaces(bridge, vpn), + routes: netdetect.ParseNetstat(netstatVMBridgeAndVPN), + want: vpnAndPhysical, + }, + { + name: "netstat: vm bridge with no uplink", + goos: osDarwin, + ifaces: macIfaces(bridge), + routes: netdetect.ParseNetstat(netstatVMBridgeOnly), + wantErr: true, + }, + }) +} + func TestParseIPRoute(t *testing.T) { t.Parallel() @@ -463,11 +549,13 @@ func TestParseNetstat(t *testing.T) { "Destination Gateway Flags Netif Expire\n" + "default 10.0.0.1 UGScg en0\n" + "default link#15 UCSg utun4\n" + + "default link#45 UCSIg bridge100 !\n" + "127.0.0.1 127.0.0.1 UH lo0\n" got := netdetect.ParseNetstat(out) want := []netdetect.Route{ {Iface: ifaceEn0, Gateway: "10.0.0.1", Default: true}, - {Iface: ifaceUtun4, Gateway: "link#15", Default: true}, + {Iface: ifaceUtun4, Default: true}, + {Iface: ifaceBridge, Default: true, Scoped: true}, } if !reflect.DeepEqual(got, want) { -- 2.54.0