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) } }