check / check (push) Failing after 0s
Linux runs exactly as before when both bridge interfaces exist. When they do not, the lone default-route interface is monitored, and a single interface draws a single UI pane instead of an empty second one. macOS is newly supported: a running VPN tunnel (utun) is monitored as the primary pane alongside the physical default-route interface, or the physical interface alone when no VPN is up. Detection lives in internal/netdetect: interface/route data types, pure selection logic keyed on OS name, and route parsers, all unit-tested on Linux for both platforms. Only the real route query and the per-platform TCP dial binding are build-tagged. NewMonitor now takes a list of interfaces. Not verified on a real mac: live netstat parsing, IP_BOUND_IF dialing, Mullvad leak protection. Model: opus-4-8 (implementation); fable-5-1 (landing commit)
311 lines
7.9 KiB
Go
311 lines
7.9 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"
|
|
ifaceUtun4 = "utun4"
|
|
|
|
labelGu = "gu LAN - VPN outbound"
|
|
labelCox = "Cox cable direct"
|
|
labelDefault = "default route"
|
|
|
|
addrEn0 = "192.168.1.20"
|
|
)
|
|
|
|
// 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,
|
|
}
|
|
}
|
|
|
|
// 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{"10.64.0.2"}},
|
|
{Name: "utun0", Up: true, IPv4: nil},
|
|
},
|
|
routes: []netdetect.Route{
|
|
{Iface: ifaceUtun4, Default: true},
|
|
{Iface: ifaceEn0, Default: true},
|
|
},
|
|
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: 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{"10.64.0.2"}},
|
|
},
|
|
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: "utun0", 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: "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,
|
|
},
|
|
})
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|