macOS: ignore on-link default routes when picking the physical uplink
check / check (push) Successful in 2m32s

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.
This commit is contained in:
sneak committed 2026-11-06 19:08:12 -08:00
1 parent 7686440108
commit 6dc3b9e395
3 files changed
+138 -23

No files matched your search

+37 -15
View File
@@ -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)