macOS: show the VPN pane only while a VPN is connected (closes #8)
check / check (push) Failing after 4s
check / check (push) Failing after 4s
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
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user