macOS: ignore on-link default routes when picking the physical uplink #18
No files matched your search
@@ -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,
|
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
|
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
|
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
|
`--ifaceA`/`--ifaceB` flags are not used there, but `--labelA`/`--labelB` still
|
||||||
set the pane labels.
|
set the pane labels.
|
||||||
|
|
||||||
|
|||||||
@@ -41,9 +41,11 @@ type Interface struct {
|
|||||||
IPv4 []string
|
IPv4 []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route is one routing-table entry reduced to what detection needs. Scoped
|
// Route is one routing-table entry reduced to what detection needs. Gateway
|
||||||
// marks a macOS interface-scoped route (flag I), which only traffic bound to
|
// is the next-hop address, empty for an on-link route that has none: a
|
||||||
// that interface uses.
|
// 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 {
|
type Route struct {
|
||||||
Iface string
|
Iface string
|
||||||
Gateway 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
|
// onlyDefaultRoute returns the single default-route interface, or an error if
|
||||||
// there is not exactly one.
|
// there is not exactly one.
|
||||||
func onlyDefaultRoute(routes []Route) (string, error) {
|
func onlyDefaultRoute(routes []Route) (string, error) {
|
||||||
names := defaultRouteIfaces(routes, "")
|
names := defaultRouteIfaces(routes, func(r Route) bool {
|
||||||
|
return !isTunnel(r.Iface)
|
||||||
|
})
|
||||||
|
|
||||||
switch len(names) {
|
switch len(names) {
|
||||||
case 1:
|
case 1:
|
||||||
@@ -202,9 +206,16 @@ func onlyDefaultRoute(routes []Route) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// onlyPhysicalDefaultRoute returns the single non-tunnel default-route
|
// 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) {
|
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) {
|
switch len(names) {
|
||||||
case 1:
|
case 1:
|
||||||
@@ -218,16 +229,14 @@ func onlyPhysicalDefaultRoute(routes []Route, vpn string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// defaultRouteIfaces returns the sorted, unique interface names that carry a
|
// defaultRouteIfaces returns the sorted, unique interface names that carry a
|
||||||
// default route, excluding the named VPN interface and any other tunnel.
|
// default route accepted by keep.
|
||||||
// Scoped routes count: while a VPN holds the default route, the physical
|
func defaultRouteIfaces(routes []Route, keep func(Route) bool) []string {
|
||||||
// interface keeps only a default route scoped to itself.
|
|
||||||
func defaultRouteIfaces(routes []Route, vpn string) []string {
|
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
|
|
||||||
var names []string
|
var names []string
|
||||||
|
|
||||||
for _, r := range routes {
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,11 +336,20 @@ func parseProcNetRoute(out string) []Route {
|
|||||||
return routes
|
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
|
// parseNetstat reads `netstat -rn -f inet` output (macOS); the Netif column
|
||||||
// (field 4) names the interface. A row whose destination is "default" is a
|
// (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"
|
// default route, scoped when its flags include I. Its Gateway column is the
|
||||||
// row on one interface together also make a default route there: VPN clients
|
// next hop only when the flags include G; otherwise it names the link itself
|
||||||
// that leave the physical default in place send all traffic through them.
|
// (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 {
|
func parseNetstat(out string) []Route {
|
||||||
const columns = 4 // Destination, Gateway, Flags, Netif; Expire is optional
|
const columns = 4 // Destination, Gateway, Flags, Netif; Expire is optional
|
||||||
|
|
||||||
@@ -351,11 +369,15 @@ func parseNetstat(out string) []Route {
|
|||||||
|
|
||||||
switch dest {
|
switch dest {
|
||||||
case "default":
|
case "default":
|
||||||
|
if !strings.Contains(flags, netstatFlagGateway) {
|
||||||
|
gateway = ""
|
||||||
|
}
|
||||||
|
|
||||||
routes = append(routes, Route{
|
routes = append(routes, Route{
|
||||||
Iface: iface,
|
Iface: iface,
|
||||||
Gateway: gateway,
|
Gateway: gateway,
|
||||||
Default: true,
|
Default: true,
|
||||||
Scoped: strings.Contains(flags, "I"),
|
Scoped: strings.Contains(flags, netstatFlagScoped),
|
||||||
})
|
})
|
||||||
case "0/1":
|
case "0/1":
|
||||||
lowHalf = append(lowHalf, iface)
|
lowHalf = append(lowHalf, iface)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const (
|
|||||||
ifaceEth0 = "eth0"
|
ifaceEth0 = "eth0"
|
||||||
ifaceWlan0 = "wlan0"
|
ifaceWlan0 = "wlan0"
|
||||||
ifaceEn0 = "en0"
|
ifaceEn0 = "en0"
|
||||||
|
ifaceBridge = "bridge100"
|
||||||
ifaceUtun0 = "utun0"
|
ifaceUtun0 = "utun0"
|
||||||
ifaceUtun3 = "utun3"
|
ifaceUtun3 = "utun3"
|
||||||
ifaceUtun4 = "utun4"
|
ifaceUtun4 = "utun4"
|
||||||
@@ -27,6 +28,8 @@ const (
|
|||||||
labelVPN = "VPN"
|
labelVPN = "VPN"
|
||||||
|
|
||||||
addrEn0 = "192.168.1.20"
|
addrEn0 = "192.168.1.20"
|
||||||
|
gatewayEn0 = "192.168.1.1"
|
||||||
|
addrBridge = "192.168.64.1"
|
||||||
addrVPN = "10.64.0.2"
|
addrVPN = "10.64.0.2"
|
||||||
addrTailscale = "100.101.102.103"
|
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
|
100.101.102.103/32 link#22 UCS utun3
|
||||||
127.0.0.1 127.0.0.1 UH lo0
|
127.0.0.1 127.0.0.1 UH lo0
|
||||||
128.0/1 utun4 USc utun4
|
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{
|
routes: []netdetect.Route{
|
||||||
{Iface: ifaceUtun4, Default: true},
|
{Iface: ifaceUtun4, Default: true},
|
||||||
{Iface: ifaceEn0, Default: true},
|
{Iface: ifaceEn0, Gateway: gatewayEn0, Default: true, Scoped: true},
|
||||||
},
|
},
|
||||||
flags: linuxFlags(),
|
flags: linuxFlags(),
|
||||||
want: []netdetect.Pane{
|
want: []netdetect.Pane{
|
||||||
@@ -282,7 +311,7 @@ func TestSelectDarwinPanes(t *testing.T) {
|
|||||||
},
|
},
|
||||||
routes: []netdetect.Route{
|
routes: []netdetect.Route{
|
||||||
{Iface: ifaceUtun4, Default: true},
|
{Iface: ifaceUtun4, Default: true},
|
||||||
{Iface: ifaceEn0, Default: true},
|
{Iface: ifaceEn0, Gateway: gatewayEn0, Default: true, Scoped: true},
|
||||||
},
|
},
|
||||||
flags: netdetect.Flags{
|
flags: netdetect.Flags{
|
||||||
LabelA: "Mullvad", LabelASet: true,
|
LabelA: "Mullvad", LabelASet: true,
|
||||||
@@ -308,7 +337,7 @@ func TestSelectDarwinSingleAndErrors(t *testing.T) {
|
|||||||
{Name: ifaceUtun0, Up: true, IPv4: nil},
|
{Name: ifaceUtun0, Up: true, IPv4: nil},
|
||||||
{Name: "utun1", Up: true, IPv4: []string{"169.254.1.1"}},
|
{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(),
|
flags: linuxFlags(),
|
||||||
want: []netdetect.Pane{{Name: ifaceEn0, Label: labelDefault}},
|
want: []netdetect.Pane{{Name: ifaceEn0, Label: labelDefault}},
|
||||||
},
|
},
|
||||||
@@ -319,7 +348,7 @@ func TestSelectDarwinSingleAndErrors(t *testing.T) {
|
|||||||
{Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}},
|
{Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}},
|
||||||
{Name: "utun6", Up: true, IPv4: []string{"10.2.0.2"}},
|
{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(),
|
flags: linuxFlags(),
|
||||||
want: []netdetect.Pane{{Name: ifaceEn0, Label: labelDefault}},
|
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"}},
|
{Name: "en1", Up: true, IPv4: []string{"192.168.2.20"}},
|
||||||
},
|
},
|
||||||
routes: []netdetect.Route{
|
routes: []netdetect.Route{
|
||||||
{Iface: ifaceEn0, Default: true},
|
{Iface: ifaceEn0, Gateway: gatewayEn0, Default: true},
|
||||||
{Iface: "en1", Default: true},
|
{Iface: "en1", Gateway: "192.168.2.1", Default: true},
|
||||||
},
|
},
|
||||||
flags: linuxFlags(),
|
flags: linuxFlags(),
|
||||||
wantErr: true,
|
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) {
|
func TestParseIPRoute(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -463,11 +549,13 @@ func TestParseNetstat(t *testing.T) {
|
|||||||
"Destination Gateway Flags Netif Expire\n" +
|
"Destination Gateway Flags Netif Expire\n" +
|
||||||
"default 10.0.0.1 UGScg en0\n" +
|
"default 10.0.0.1 UGScg en0\n" +
|
||||||
"default link#15 UCSg utun4\n" +
|
"default link#15 UCSg utun4\n" +
|
||||||
|
"default link#45 UCSIg bridge100 !\n" +
|
||||||
"127.0.0.1 127.0.0.1 UH lo0\n"
|
"127.0.0.1 127.0.0.1 UH lo0\n"
|
||||||
got := netdetect.ParseNetstat(out)
|
got := netdetect.ParseNetstat(out)
|
||||||
want := []netdetect.Route{
|
want := []netdetect.Route{
|
||||||
{Iface: ifaceEn0, Gateway: "10.0.0.1", Default: true},
|
{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) {
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
|||||||
Reference in new issue
Block a user