Files
rtnetmon/internal/netdetect/netdetect_test.go
T
sneak 71774015db
check / check (push) Failing after 1s
Detect interfaces per platform; add macOS and single-interface support (closes #2)
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 now
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 (source address on Linux, IP_BOUND_IF on macOS) are build-tagged. Ping
argument construction is a pure, OS-keyed function. NewMonitor now takes a list
of interfaces.

Model: opus-4-8
2026-09-21 07:52:50 +00:00

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