From aaed90664b8952659c81e954ec90aa3f3022ff78 Mon Sep 17 00:00:00 2001 From: sneak Date: Sat, 3 Oct 2026 13:08:00 +0000 Subject: [PATCH] macOS: show the VPN pane only while a VPN is connected (closes #8) A utun tunnel counted as the VPN whenever it was up with a routable IPv4 address, so idle Tailscale or a tunnel left behind by a disconnected client became the VPN pane and its probes failed. A tunnel is now the VPN only while it carries the IPv4 default route in netstat: an unscoped default row, or both 0/1 and 128.0/1 rows on it. A default row scoped to a tunnel (flag I) does not count; on the physical interface it still does, since a VPN holding the default leaves the physical default scoped. Tests feed netstat text in the macOS layout through the parser into Select. Model: opus-5-5 --- README.md | 27 ++-- internal/netdetect/netdetect.go | 81 ++++++----- internal/netdetect/netdetect_test.go | 206 ++++++++++++++++++++++++--- 3 files changed, 248 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index bf24aa7..3884871 100644 --- a/README.md +++ b/README.md @@ -46,22 +46,27 @@ single pane. setup, unchanged. When neither exists, the single default-route interface is monitored instead. -**macOS.** The physical internet interface is found from the default route. When -a VPN client is running (Mullvad and similar clients create a `utun` tunnel that -carries a default route or holds a routable address), that tunnel is monitored -as the primary pane alongside the physical interface. With no VPN running, only -the physical interface is monitored. Interface names are detected on macOS; the +**macOS.** The physical internet interface is found from the default route. The +VPN pane appears only while a VPN is connected, meaning its `utun` tunnel +carries the IPv4 default route: in `netstat -rn -f inet` that is a `default` row +on the tunnel, or both a `0/1` and a `128.0/1` row on it, which some VPN clients +install instead of replacing the default. That tunnel is then monitored as the +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 `--ifaceA`/`--ifaceB` flags are not used there, but `--labelA`/`--labelB` still set the pane labels. ### Supported matrix -| OS | Interfaces monitored | -| ----- | ----------------------------------------------------------- | -| Linux | `gu0` + `backhaul0` when both exist (two panes) | -| Linux | the single default-route interface otherwise (one pane) | -| macOS | VPN tunnel + physical default-route interface (two panes) | -| macOS | the physical default-route interface with no VPN (one pane) | +| OS | Interfaces monitored | +| ----- | --------------------------------------------------------------------- | +| Linux | `gu0` + `backhaul0` when both exist (two panes) | +| Linux | the single default-route interface otherwise (one pane) | +| macOS | connected VPN tunnel + physical default-route interface (two panes) | +| macOS | the physical default-route interface with no VPN connected (one pane) | Anything outside this matrix — on Linux, only one of the named pair present, or no/multiple default routes when neither is present; on macOS, no default route diff --git a/internal/netdetect/netdetect.go b/internal/netdetect/netdetect.go index 061c043..8aee000 100644 --- a/internal/netdetect/netdetect.go +++ b/internal/netdetect/netdetect.go @@ -41,11 +41,14 @@ type Interface struct { IPv4 []string } -// Route is one routing-table entry reduced to what detection needs. +// 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. type Route struct { Iface string Gateway string Default bool + Scoped bool } // Pane names one interface to display, with its label. @@ -133,7 +136,7 @@ func selectLinux(ifaces []Interface, routes []Route, f Flags) ([]Pane, error) { } // selectDarwin monitors the physical default-route interface, plus a VPN -// tunnel as the primary pane when one is running. +// tunnel as the primary pane while one is connected. func selectDarwin(ifaces []Interface, routes []Route, f Flags) ([]Pane, error) { vpn := findVPN(ifaces, routes) @@ -152,15 +155,16 @@ func selectDarwin(ifaces []Interface, routes []Route, f Flags) ([]Pane, error) { }, nil } -// findVPN returns the name of a VPN tunnel interface, or "" if none is -// running. A tunnel counts as a running VPN when it carries a default route, -// or when it is up with a routable (non-link-local) IPv4 address. Idle system -// tunnels have only a link-local IPv6 address and are skipped. +// findVPN returns the name of the connected VPN tunnel, or "" if there is +// none. A tunnel is the VPN only while it carries the default route; one that +// is merely up, even with a routable address (Tailscale without an exit node, +// or a tunnel a disconnected client left behind), is not. A default route +// scoped to the tunnel does not count: only traffic bound there uses it. func findVPN(ifaces []Interface, routes []Route) string { routeIfaces := map[string]bool{} for _, r := range routes { - if r.Default { + if r.Default && !r.Scoped { routeIfaces[r.Iface] = true } } @@ -176,10 +180,6 @@ func findVPN(ifaces []Interface, routes []Route) string { if routeIfaces[ifi.Name] { return ifi.Name } - - if ifi.Up && hasRoutableIPv4(ifi) { - return ifi.Name - } } return "" @@ -219,6 +219,8 @@ 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 { seen := map[string]bool{} @@ -255,21 +257,6 @@ func isTunnel(name string) bool { return strings.HasPrefix(name, "utun") } -// hasRoutableIPv4 reports whether the interface has an IPv4 address that is -// neither loopback nor link-local. -func hasRoutableIPv4(ifi Interface) bool { - for _, s := range ifi.IPv4 { - ip := net.ParseIP(s) - if ip == nil || ip.IsLoopback() || ip.IsLinkLocalUnicast() { - continue - } - - return true - } - - return false -} - // singleLabel is the label for a lone pane: the explicit --labelA if given, // otherwise a plain description. func singleLabel(f Flags) string { @@ -340,23 +327,47 @@ func parseProcNetRoute(out string) []Route { return routes } -// parseNetstat reads `netstat -rn -f inet` output (macOS). Rows whose -// destination is "default" are default routes; the Netif column (field 4) -// names the 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. func parseNetstat(out string) []Route { + const columns = 4 // Destination, Gateway, Flags, Netif; Expire is optional + var routes []Route + var lowHalf []string // interfaces with a 0/1 row + + highHalf := map[string]bool{} // interfaces with a 128.0/1 row + for _, line := range strings.Split(out, "\n") { fields := strings.Fields(line) - if len(fields) < 4 || fields[0] != "default" { + if len(fields) < columns { continue } - routes = append(routes, Route{ - Iface: fields[3], - Gateway: fields[1], - Default: true, - }) + dest, gateway, flags, iface := fields[0], fields[1], fields[2], fields[3] + + switch dest { + case "default": + routes = append(routes, Route{ + Iface: iface, + Gateway: gateway, + Default: true, + Scoped: strings.Contains(flags, "I"), + }) + case "0/1": + lowHalf = append(lowHalf, iface) + case "128.0/1": + highHalf[iface] = true + } + } + + for _, iface := range lowHalf { + if highHalf[iface] { + routes = append(routes, Route{Iface: iface, Default: true}) + } } return routes diff --git a/internal/netdetect/netdetect_test.go b/internal/netdetect/netdetect_test.go index 83d421e..9d92274 100644 --- a/internal/netdetect/netdetect_test.go +++ b/internal/netdetect/netdetect_test.go @@ -17,13 +17,96 @@ const ( ifaceEth0 = "eth0" ifaceWlan0 = "wlan0" ifaceEn0 = "en0" + ifaceUtun0 = "utun0" + ifaceUtun3 = "utun3" ifaceUtun4 = "utun4" labelGu = "gu LAN - VPN outbound" labelCox = "Cox cable direct" labelDefault = "default route" + labelVPN = "VPN" - addrEn0 = "192.168.1.20" + addrEn0 = "192.168.1.20" + addrVPN = "10.64.0.2" + addrTailscale = "100.101.102.103" +) + +// netstatHeader starts `netstat -rn -f inet` output on macOS. Each output +// below adds the rows of one routing state: en0 is the physical interface, +// utun3 is Tailscale's tunnel and utun4 a VPN client's. +const netstatHeader = `Routing tables + +Internet: +Destination Gateway Flags Netif Expire` + +const ( + netstatNoTunnel = netstatHeader + ` +default 192.168.1.1 UGScg en0 +127 127.0.0.1 UCS lo0 +127.0.0.1 127.0.0.1 UH lo0 +192.168.1 link#6 UCS en0 ! +192.168.1.1 a4:2b:b0:12:34:56 UHLWIir en0 1187 +` + + // Tailscale on without an exit node: routes for its own range only. + netstatTailscaleIdle = netstatHeader + ` +default 192.168.1.1 UGScg en0 +100.64/10 link#22 UCS utun3 +100.100.100.100/32 link#22 UCS utun3 +100.101.102.103/32 link#22 UCS utun3 +127.0.0.1 127.0.0.1 UH lo0 +` + + // A tunnel a disconnected client left behind, still holding its address. + netstatTunnelLeftBehind = netstatHeader + ` +default 192.168.1.1 UGScg en0 +10.64.0.2 10.64.0.2 UH utun4 +127.0.0.1 127.0.0.1 UH lo0 +` + + // A tunnel whose only default route is scoped to it. + netstatTunnelScopedDefault = netstatHeader + ` +default 192.168.1.1 UGScg en0 +default link#22 UCSIg utun3 +100.64/10 link#22 UCS utun3 +127.0.0.1 127.0.0.1 UH lo0 +` + + // A tunnel with the lower half of the address space but not the upper. + netstatVPNOneHalf = netstatHeader + ` +0/1 utun4 USc utun4 +default 192.168.1.1 UGScg en0 +127.0.0.1 127.0.0.1 UH lo0 +` + + // A VPN holding the default route; the physical default is now scoped. + netstatVPNDefault = netstatHeader + ` +default link#15 UCSg utun4 +default 192.168.1.1 UGScIg en0 +10.64.0.2 10.64.0.2 UH utun4 +127.0.0.1 127.0.0.1 UH lo0 +` + + // A VPN on both halves, leaving the physical default in place. + netstatVPNHalves = netstatHeader + ` +0/1 utun4 USc utun4 +default 192.168.1.1 UGScg en0 +default 192.168.1.1 UGScIg en0 +10.64.0.2 10.64.0.2 UH utun4 +127.0.0.1 127.0.0.1 UH lo0 +128.0/1 utun4 USc utun4 +` + + // Idle Tailscale next to a VPN on both halves. + netstatTailscaleAndVPN = netstatHeader + ` +0/1 utun4 USc utun4 +default 192.168.1.1 UGScg en0 +10.64.0.2 10.64.0.2 UH utun4 +100.64/10 link#22 UCS utun3 +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 +` ) // linuxFlags describes the Linux bridge interfaces rtnetmon was built for. @@ -34,6 +117,17 @@ func linuxFlags() netdetect.Flags { } } +// macIfaces returns the interfaces every Mac in these tests has (en0, +// loopback, and an idle system tunnel with no IPv4 address) plus the given +// tunnels. +func macIfaces(tunnels ...netdetect.Interface) []netdetect.Interface { + return append([]netdetect.Interface{ + {Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}}, + {Name: "lo0", Up: true, IPv4: []string{"127.0.0.1"}}, + {Name: ifaceUtun0, Up: true}, + }, tunnels...) +} + // selectCase is one Select scenario with fake interfaces and routes. type selectCase struct { name string @@ -166,8 +260,8 @@ func TestSelectDarwinPanes(t *testing.T) { goos: osDarwin, ifaces: []netdetect.Interface{ {Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}}, - {Name: ifaceUtun4, Up: true, IPv4: []string{"10.64.0.2"}}, - {Name: "utun0", Up: true, IPv4: nil}, + {Name: ifaceUtun4, Up: true, IPv4: []string{addrVPN}}, + {Name: ifaceUtun0, Up: true, IPv4: nil}, }, routes: []netdetect.Route{ {Iface: ifaceUtun4, Default: true}, @@ -175,21 +269,7 @@ func TestSelectDarwinPanes(t *testing.T) { }, flags: linuxFlags(), want: []netdetect.Pane{ - {Name: ifaceUtun4, Label: "VPN"}, - {Name: ifaceEn0, Label: labelDefault}, - }, - }, - { - name: "vpn detected by routable address without its own route", - goos: osDarwin, - ifaces: []netdetect.Interface{ - {Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}}, - {Name: "utun6", Up: true, IPv4: []string{"10.2.0.2"}}, - }, - routes: []netdetect.Route{{Iface: ifaceEn0, Default: true}}, - flags: linuxFlags(), - want: []netdetect.Pane{ - {Name: "utun6", Label: "VPN"}, + {Name: ifaceUtun4, Label: labelVPN}, {Name: ifaceEn0, Label: labelDefault}, }, }, @@ -198,7 +278,7 @@ func TestSelectDarwinPanes(t *testing.T) { goos: osDarwin, ifaces: []netdetect.Interface{ {Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}}, - {Name: ifaceUtun4, Up: true, IPv4: []string{"10.64.0.2"}}, + {Name: ifaceUtun4, Up: true, IPv4: []string{addrVPN}}, }, routes: []netdetect.Route{ {Iface: ifaceUtun4, Default: true}, @@ -225,13 +305,24 @@ func TestSelectDarwinSingleAndErrors(t *testing.T) { goos: osDarwin, ifaces: []netdetect.Interface{ {Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}}, - {Name: "utun0", Up: true, IPv4: nil}, + {Name: ifaceUtun0, Up: true, IPv4: nil}, {Name: "utun1", Up: true, IPv4: []string{"169.254.1.1"}}, }, routes: []netdetect.Route{{Iface: ifaceEn0, Default: true}}, flags: linuxFlags(), want: []netdetect.Pane{{Name: ifaceEn0, Label: labelDefault}}, }, + { + name: "tunnel with a routable address but no default route", + goos: osDarwin, + ifaces: []netdetect.Interface{ + {Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}}, + {Name: "utun6", Up: true, IPv4: []string{"10.2.0.2"}}, + }, + routes: []netdetect.Route{{Iface: ifaceEn0, Default: true}}, + flags: linuxFlags(), + want: []netdetect.Pane{{Name: ifaceEn0, Label: labelDefault}}, + }, { name: "no default route", goos: osDarwin, @@ -264,6 +355,81 @@ func TestSelectDarwinSingleAndErrors(t *testing.T) { }) } +// TestSelectDarwinFromNetstat runs macOS routing tables through the netstat +// parser into Select: only a tunnel carrying the default route is the VPN. +func TestSelectDarwinFromNetstat(t *testing.T) { + t.Parallel() + + tailscale := netdetect.Interface{ + Name: ifaceUtun3, Up: true, IPv4: []string{addrTailscale}, + } + vpn := netdetect.Interface{Name: ifaceUtun4, Up: true, IPv4: []string{addrVPN}} + physicalOnly := []netdetect.Pane{{Name: ifaceEn0, Label: labelDefault}} + vpnAndPhysical := []netdetect.Pane{ + {Name: ifaceUtun4, Label: labelVPN}, + {Name: ifaceEn0, Label: labelDefault}, + } + + runSelectCases(t, []selectCase{ + { + name: "no tunnel", + goos: osDarwin, + ifaces: macIfaces(), + routes: netdetect.ParseNetstat(netstatNoTunnel), + want: physicalOnly, + }, + { + name: "tailscale without an exit node", + goos: osDarwin, + ifaces: macIfaces(tailscale), + routes: netdetect.ParseNetstat(netstatTailscaleIdle), + want: physicalOnly, + }, + { + name: "tunnel left behind by a disconnected client", + goos: osDarwin, + ifaces: macIfaces(vpn), + routes: netdetect.ParseNetstat(netstatTunnelLeftBehind), + want: physicalOnly, + }, + { + name: "tunnel with only a scoped default route", + goos: osDarwin, + ifaces: macIfaces(tailscale), + routes: netdetect.ParseNetstat(netstatTunnelScopedDefault), + want: physicalOnly, + }, + { + name: "tunnel with only one half of the address space", + goos: osDarwin, + ifaces: macIfaces(vpn), + routes: netdetect.ParseNetstat(netstatVPNOneHalf), + want: physicalOnly, + }, + { + name: "vpn on the default route", + goos: osDarwin, + ifaces: macIfaces(vpn), + routes: netdetect.ParseNetstat(netstatVPNDefault), + want: vpnAndPhysical, + }, + { + name: "vpn on both halves", + goos: osDarwin, + ifaces: macIfaces(vpn), + routes: netdetect.ParseNetstat(netstatVPNHalves), + want: vpnAndPhysical, + }, + { + name: "idle tailscale next to a connected vpn", + goos: osDarwin, + ifaces: macIfaces(tailscale, vpn), + routes: netdetect.ParseNetstat(netstatTailscaleAndVPN), + want: vpnAndPhysical, + }, + }) +} + func TestParseIPRoute(t *testing.T) { t.Parallel() -- 2.54.0