Detect interfaces per platform; add macOS and single-interface support (closes #2)
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)
This commit was merged in pull request #4.
This commit is contained in:
2026-09-21 15:01:59 +02:00
parent 7dd4ac798d
commit 486a47397c
21 changed files with 1092 additions and 93 deletions
+40 -16
View File
@@ -1,5 +1,3 @@
//go:build linux
package monitor_test
import (
@@ -8,26 +6,33 @@ import (
"git.eeqj.de/sneak/rtnetmon/internal/monitor"
)
// Interface names and hosts reused across the monitor package tests.
const (
ifaceTest0 = "test0"
ifaceTest1 = "test1"
ifaceEth0 = "eth0"
host8888 = "8.8.8.8"
)
func TestNewMonitor(t *testing.T) {
t.Parallel()
mon := monitor.NewMonitor("test0", "Test Interface A", "test1",
"Test Interface B", "/tmp/test.log")
mon := monitor.NewMonitor([]monitor.IfaceSpec{
{Name: ifaceTest0, Label: "Test Interface A"},
{Name: ifaceTest1, Label: "Test Interface B"},
}, "/tmp/test.log")
if mon.InterfaceA() == nil {
t.Fatal("interfaceA is nil")
ifaces := mon.Interfaces()
if len(ifaces) != 2 {
t.Fatalf("interfaces = %d, want 2", len(ifaces))
}
if mon.InterfaceB() == nil {
t.Fatal("interfaceB is nil")
if ifaces[0].Name != ifaceTest0 {
t.Errorf("interfaces[0].Name = %q, want %q", ifaces[0].Name, ifaceTest0)
}
if mon.InterfaceA().Name != "test0" {
t.Errorf("interfaceA.Name = %q, want %q", mon.InterfaceA().Name, "test0")
}
if mon.InterfaceB().Name != "test1" {
t.Errorf("interfaceB.Name = %q, want %q", mon.InterfaceB().Name, "test1")
if ifaces[1].Name != ifaceTest1 {
t.Errorf("interfaces[1].Name = %q, want %q", ifaces[1].Name, ifaceTest1)
}
if mon.ICMPTimeout.Milliseconds() != 500 {
@@ -51,12 +56,31 @@ func TestNewMonitor(t *testing.T) {
}
}
func TestNewMonitorSingle(t *testing.T) {
t.Parallel()
mon := monitor.NewMonitor(
[]monitor.IfaceSpec{{Name: ifaceEth0, Label: "default route"}}, "")
ifaces := mon.Interfaces()
if len(ifaces) != 1 {
t.Fatalf("interfaces = %d, want 1", len(ifaces))
}
if ifaces[0].Name != ifaceEth0 {
t.Errorf("interfaces[0].Name = %q, want %q", ifaces[0].Name, ifaceEth0)
}
}
func TestAddHosts(t *testing.T) {
t.Parallel()
mon := monitor.NewMonitor("test0", "Test A", "test1", "Test B", "")
mon := monitor.NewMonitor([]monitor.IfaceSpec{
{Name: ifaceTest0, Label: "Test A"},
{Name: ifaceTest1, Label: "Test B"},
}, "")
mon.AddReachabilityHost("8.8.8.8")
mon.AddReachabilityHost(host8888)
mon.AddReachabilityHost("google.com")
if len(mon.ReachabilityHosts()) != 2 {