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
477 lines
14 KiB
Go
477 lines
14 KiB
Go
package netdetect_test
|
|
|
|
import (
|
|
"reflect"
|
|
"testing"
|
|
|
|
"git.eeqj.de/sneak/rtnetmon/internal/netdetect"
|
|
)
|
|
|
|
// Values reused across the selection tests.
|
|
const (
|
|
osLinux = "linux"
|
|
osDarwin = "darwin"
|
|
|
|
ifaceGu0 = "gu0"
|
|
ifaceBackhaul = "backhaul0"
|
|
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"
|
|
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.
|
|
func linuxFlags() netdetect.Flags {
|
|
return netdetect.Flags{
|
|
IfaceA: ifaceGu0, LabelA: labelGu,
|
|
IfaceB: ifaceBackhaul, LabelB: labelCox,
|
|
}
|
|
}
|
|
|
|
// 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
|
|
goos string
|
|
ifaces []netdetect.Interface
|
|
routes []netdetect.Route
|
|
flags netdetect.Flags
|
|
want []netdetect.Pane
|
|
wantErr bool
|
|
}
|
|
|
|
// runSelectCases runs each case as a parallel subtest.
|
|
func runSelectCases(t *testing.T, cases []selectCase) {
|
|
t.Helper()
|
|
|
|
for _, tt := range cases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got, err := netdetect.Select(tt.goos, tt.ifaces, tt.routes, tt.flags)
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Fatalf("Select() expected error, got panes %v", got)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
t.Fatalf("Select() unexpected error: %v", err)
|
|
}
|
|
|
|
if !reflect.DeepEqual(got, tt.want) {
|
|
t.Errorf("Select() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSelectLinuxPanes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runSelectCases(t, []selectCase{
|
|
{
|
|
name: "both bridge interfaces present",
|
|
goos: osLinux,
|
|
ifaces: []netdetect.Interface{
|
|
{Name: ifaceGu0, Up: true},
|
|
{Name: ifaceBackhaul, Up: true},
|
|
{Name: ifaceEth0, Up: true},
|
|
},
|
|
routes: []netdetect.Route{{Iface: ifaceEth0, Default: true}},
|
|
flags: linuxFlags(),
|
|
want: []netdetect.Pane{
|
|
{Name: ifaceGu0, Label: labelGu},
|
|
{Name: ifaceBackhaul, Label: labelCox},
|
|
},
|
|
},
|
|
{
|
|
name: "no bridge interfaces, single default route",
|
|
goos: osLinux,
|
|
ifaces: []netdetect.Interface{
|
|
{Name: ifaceEth0, Up: true},
|
|
{Name: "lo", Up: true},
|
|
},
|
|
routes: []netdetect.Route{{Iface: ifaceEth0, Default: true}},
|
|
flags: linuxFlags(),
|
|
want: []netdetect.Pane{{Name: ifaceEth0, Label: labelDefault}},
|
|
},
|
|
{
|
|
name: "single default route keeps explicit label",
|
|
goos: osLinux,
|
|
ifaces: []netdetect.Interface{{Name: ifaceWlan0, Up: true}},
|
|
routes: []netdetect.Route{{Iface: ifaceWlan0, Default: true}},
|
|
flags: netdetect.Flags{
|
|
IfaceA: ifaceGu0, LabelA: "Home WiFi", LabelASet: true,
|
|
IfaceB: ifaceBackhaul, LabelB: labelCox,
|
|
},
|
|
want: []netdetect.Pane{{Name: ifaceWlan0, Label: "Home WiFi"}},
|
|
},
|
|
})
|
|
}
|
|
|
|
func TestSelectLinuxErrors(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runSelectCases(t, []selectCase{
|
|
{
|
|
name: "only one bridge interface present",
|
|
goos: osLinux,
|
|
ifaces: []netdetect.Interface{
|
|
{Name: ifaceGu0, Up: true},
|
|
{Name: ifaceEth0, Up: true},
|
|
},
|
|
routes: []netdetect.Route{{Iface: ifaceEth0, Default: true}},
|
|
flags: linuxFlags(),
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "no bridge, no default route",
|
|
goos: osLinux,
|
|
ifaces: []netdetect.Interface{{Name: ifaceEth0, Up: true}},
|
|
routes: nil,
|
|
flags: linuxFlags(),
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "no bridge, multiple default routes",
|
|
goos: osLinux,
|
|
ifaces: []netdetect.Interface{
|
|
{Name: ifaceEth0, Up: true},
|
|
{Name: "eth1", Up: true},
|
|
},
|
|
routes: []netdetect.Route{
|
|
{Iface: ifaceEth0, Default: true},
|
|
{Iface: "eth1", Default: true},
|
|
},
|
|
flags: linuxFlags(),
|
|
wantErr: true,
|
|
},
|
|
})
|
|
}
|
|
|
|
func TestSelectDarwinPanes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runSelectCases(t, []selectCase{
|
|
{
|
|
name: "vpn tunnel plus physical default route",
|
|
goos: osDarwin,
|
|
ifaces: []netdetect.Interface{
|
|
{Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}},
|
|
{Name: ifaceUtun4, Up: true, IPv4: []string{addrVPN}},
|
|
{Name: ifaceUtun0, Up: true, IPv4: nil},
|
|
},
|
|
routes: []netdetect.Route{
|
|
{Iface: ifaceUtun4, Default: true},
|
|
{Iface: ifaceEn0, Default: true},
|
|
},
|
|
flags: linuxFlags(),
|
|
want: []netdetect.Pane{
|
|
{Name: ifaceUtun4, Label: labelVPN},
|
|
{Name: ifaceEn0, Label: labelDefault},
|
|
},
|
|
},
|
|
{
|
|
name: "vpn pane honors explicit labels",
|
|
goos: osDarwin,
|
|
ifaces: []netdetect.Interface{
|
|
{Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}},
|
|
{Name: ifaceUtun4, Up: true, IPv4: []string{addrVPN}},
|
|
},
|
|
routes: []netdetect.Route{
|
|
{Iface: ifaceUtun4, Default: true},
|
|
{Iface: ifaceEn0, Default: true},
|
|
},
|
|
flags: netdetect.Flags{
|
|
LabelA: "Mullvad", LabelASet: true,
|
|
LabelB: "Fiber", LabelBSet: true,
|
|
},
|
|
want: []netdetect.Pane{
|
|
{Name: ifaceUtun4, Label: "Mullvad"},
|
|
{Name: ifaceEn0, Label: "Fiber"},
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func TestSelectDarwinSingleAndErrors(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runSelectCases(t, []selectCase{
|
|
{
|
|
name: "no vpn, single physical default route",
|
|
goos: osDarwin,
|
|
ifaces: []netdetect.Interface{
|
|
{Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}},
|
|
{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,
|
|
ifaces: []netdetect.Interface{
|
|
{Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}},
|
|
},
|
|
routes: nil,
|
|
flags: linuxFlags(),
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "two physical default routes",
|
|
goos: osDarwin,
|
|
ifaces: []netdetect.Interface{
|
|
{Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}},
|
|
{Name: "en1", Up: true, IPv4: []string{"192.168.2.20"}},
|
|
},
|
|
routes: []netdetect.Route{
|
|
{Iface: ifaceEn0, Default: true},
|
|
{Iface: "en1", Default: true},
|
|
},
|
|
flags: linuxFlags(),
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "unsupported operating system",
|
|
goos: "windows",
|
|
wantErr: true,
|
|
},
|
|
})
|
|
}
|
|
|
|
// 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()
|
|
|
|
out := "default via 192.168.1.1 dev eth0 proto dhcp metric 100\n"
|
|
got := netdetect.ParseIPRoute(out)
|
|
want := []netdetect.Route{{Iface: ifaceEth0, Gateway: "192.168.1.1", Default: true}}
|
|
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Errorf("ParseIPRoute() = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestParseProcNetRoute(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
out := "Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\n" +
|
|
"eth0\t00000000\t0102A8C0\t0003\t0\t0\t100\t00000000\n" +
|
|
"eth0\t0002A8C0\t00000000\t0001\t0\t0\t0\t00FFFFFF\n"
|
|
got := netdetect.ParseProcNetRoute(out)
|
|
want := []netdetect.Route{{Iface: ifaceEth0, Gateway: "192.168.2.1", Default: true}}
|
|
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Errorf("ParseProcNetRoute() = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestParseNetstat(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
out := "Routing tables\n\nInternet:\n" +
|
|
"Destination Gateway Flags Netif Expire\n" +
|
|
"default 10.0.0.1 UGScg en0\n" +
|
|
"default link#15 UCSg utun4\n" +
|
|
"127.0.0.1 127.0.0.1 UH lo0\n"
|
|
got := netdetect.ParseNetstat(out)
|
|
want := []netdetect.Route{
|
|
{Iface: ifaceEn0, Gateway: "10.0.0.1", Default: true},
|
|
{Iface: ifaceUtun4, Gateway: "link#15", Default: true},
|
|
}
|
|
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Errorf("ParseNetstat() = %v, want %v", got, want)
|
|
}
|
|
}
|