macOS: show the VPN pane only while a VPN is connected (closes #8)
check / check (push) Successful in 3m1s

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. Not run on a real Mac.

Model: opus-5-5
This commit was merged in pull request #15.
This commit is contained in:
2026-10-03 15:26:39 +02:00
parent 66fb8bf149
commit 3578d18777
3 changed files with 248 additions and 66 deletions
+46 -35
View File
@@ -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