macOS: ignore on-link default routes when picking the physical uplink
check / check (push) Successful in 2m32s
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:
1 parent
7686440108
commit
6dc3b9e395
3 files changed
+138
-23
No files matched your search
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user