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
This commit is contained in:
2026-09-21 06:53:39 +00:00
parent cf9dc39053
commit ef3c373a12
18 changed files with 933 additions and 85 deletions
+47 -6
View File
@@ -1,18 +1,17 @@
//go:build linux
// +build linux
package cli
import (
"context"
"os"
"os/signal"
"runtime"
"syscall"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"git.eeqj.de/sneak/rtnetmon/internal/monitor"
"git.eeqj.de/sneak/rtnetmon/internal/netdetect"
)
// Config holds the application configuration
@@ -73,12 +72,17 @@ func init() {
_ = viper.BindPFlag("logfile", rootCmd.Flags().Lookup("logfile"))
}
func runMonitor(cmd *cobra.Command, args []string) error {
func runMonitor(cmd *cobra.Command, _ []string) error {
monitor.Logf(cfg.LogFile, "Starting rtnetmon")
monitor.Logf(cfg.LogFile, "Monitoring interfaces %s and %s", cfg.IfaceA, cfg.IfaceB)
// Detect which interfaces to monitor for this platform.
specs, err := detectInterfaces(cmd)
if err != nil {
return err
}
// Create the monitor
mon := monitor.NewMonitor(cfg.IfaceA, cfg.LabelA, cfg.IfaceB, cfg.LabelB, cfg.LogFile)
mon := monitor.NewMonitor(specs, cfg.LogFile)
// Add reachability hosts
for _, host := range cfg.Hosts {
@@ -112,6 +116,43 @@ func runMonitor(cmd *cobra.Command, args []string) error {
return mon.Run(ctx)
}
// detectInterfaces enumerates the host, reads its default routes, and applies
// the platform selection rules to decide which interfaces to monitor.
func detectInterfaces(cmd *cobra.Command) ([]monitor.IfaceSpec, error) {
ifaces, err := netdetect.Interfaces()
if err != nil {
return nil, err
}
routes, err := netdetect.DefaultRoutes()
if err != nil {
return nil, err
}
flags := netdetect.Flags{
IfaceA: cfg.IfaceA,
LabelA: cfg.LabelA,
IfaceB: cfg.IfaceB,
LabelB: cfg.LabelB,
LabelASet: cmd.Flags().Changed("labelA"),
LabelBSet: cmd.Flags().Changed("labelB"),
}
panes, err := netdetect.Select(runtime.GOOS, ifaces, routes, flags)
if err != nil {
return nil, err
}
specs := make([]monitor.IfaceSpec, len(panes))
for i, p := range panes {
specs[i] = monitor.IfaceSpec{Name: p.Name, Label: p.Label}
}
monitor.Logf(cfg.LogFile, "Monitoring %d interface(s)", len(specs))
return specs, nil
}
// Execute runs the root command
func Execute() error {
return rootCmd.Execute()