1 Commits
Author SHA1 Message Date
sneak ef3c373a12 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 06:53:39 +00:00
18 changed files with 933 additions and 85 deletions
+64 -18
View File
@@ -1,33 +1,70 @@
# rtnetmon # rtnetmon
Real-time network monitoring dashboard for Linux systems with dual-interface support. Real-time network monitoring dashboard for Linux and macOS, with one- or
two-interface support.
## Overview ## Overview
rtnetmon is a terminal-based network monitoring tool that provides real-time rtnetmon is a terminal-based network monitoring tool that provides real-time
visibility into network health, packet loss, and latency across two network visibility into network health, packet loss, and latency across one or two
interfaces simultaneously. It's designed for Linux systems and uses ncurses network interfaces simultaneously. It runs on Linux and macOS and uses a
for a clean, real-time dashboard interface. terminal dashboard interface.
## Features ## Features
- **Dual Interface Monitoring**: Monitor two network interfaces simultaneously - **Interface Monitoring**: Monitor one or two network interfaces simultaneously
- **Real-time Updates**: Live dashboard with sub-second updates - **Real-time Updates**: Live dashboard with sub-second updates
- **Comprehensive Metrics**: - **Comprehensive Metrics**:
- ICMP reachability tests - ICMP reachability tests
- Packet loss percentage - Packet loss percentage
- TCP connection latency - TCP connection latency
- Interface health status - Interface health status
- **Visual Indicators**: Color-coded status, spinners, and meters for quick status assessment - **Visual Indicators**: Color-coded status, spinners, and meters for quick
- Packet loss meter uses reverse coloring (empty/green = good, full/red = bad) status assessment
- Packet loss meter uses reverse coloring (empty/green = good, full/red =
bad)
- **Detailed Logging**: Optional logging to file for debugging and analysis - **Detailed Logging**: Optional logging to file for debugging and analysis
## Requirements ## Requirements
- Linux operating system - Linux or macOS
- Go 1.21 or later - Go 1.21 or later
- Root/sudo access (for raw ICMP packets) - Root/sudo access (for raw ICMP packets)
- `ping` command available in PATH - `ping` command available in PATH
- On Linux: `ip` (with `/proc/net/route` as a fallback)
- On macOS: `netstat`
## Platform support and interface detection
rtnetmon monitors either one or two interfaces, chosen automatically for the
platform it runs on. When only one interface is detected, the dashboard shows a
single pane.
**Linux.** The two named interfaces (`--ifaceA`/`--ifaceB`, default
`gu0`/`backhaul0`) are used when both exist — this is the original dual-bridge
setup, unchanged. When neither exists, the single default-route interface is
monitored instead.
**macOS.** The physical internet interface is found from the default route. When
a VPN client is running (Mullvad and similar clients create a `utun` tunnel that
carries a default route or holds a routable address), that tunnel is monitored
as the primary pane alongside the physical interface. With no VPN running, only
the physical interface is monitored. Interface names are detected on macOS; the
`--ifaceA`/`--ifaceB` flags are not used there, but `--labelA`/`--labelB` still
set the pane labels.
### Supported matrix
| OS | Interfaces monitored |
| ----- | ----------------------------------------------------------- |
| Linux | `gu0` + `backhaul0` when both exist (two panes) |
| Linux | the single default-route interface otherwise (one pane) |
| macOS | VPN tunnel + physical default-route interface (two panes) |
| macOS | the physical default-route interface with no VPN (one pane) |
Anything outside this matrix — on Linux, only one of the named pair present, or
no/multiple default routes when neither is present; on macOS, no default route
or more than one physical default route — exits with a clear error.
## Installation ## Installation
@@ -65,11 +102,17 @@ rtnetmon/
├── internal/ ├── internal/
│ ├── cli/ # Command-line interface using Cobra │ ├── cli/ # Command-line interface using Cobra
│ │ └── root.go │ │ └── root.go
│ ├── netdetect/ # Per-platform interface/route detection and selection
│ │ ├── netdetect.go # Selection logic and route parsers (pure)
│ │ ├── routes_linux.go # Linux default-route query (build-tagged)
│ │ └── routes_darwin.go # macOS default-route query (build-tagged)
│ └── monitor/ # Core monitoring functionality │ └── monitor/ # Core monitoring functionality
│ ├── monitor.go # Main monitoring types and functions │ ├── monitor.go # Main monitoring types and functions
│ ├── loops.go # Monitoring loops (reachability, loss, TCP) │ ├── loops.go # Monitoring loops (reachability, loss, TCP)
│ ├── styles.go # Terminal color styles │ ├── styles.go # Terminal color styles
── ui.go # User interface rendering ── ui.go # User interface rendering
│ ├── dial_linux.go # TCP source binding (build-tagged)
│ └── dial_darwin.go # TCP IP_BOUND_IF binding (build-tagged)
├── go.mod ├── go.mod
├── go.sum ├── go.sum
├── Makefile ├── Makefile
@@ -103,8 +146,11 @@ The monitor package provides an object-oriented API for programmatic use:
```go ```go
import "git.eeqj.de/sneak/rtnetmon/internal/monitor" import "git.eeqj.de/sneak/rtnetmon/internal/monitor"
// Create a new monitor // Create a new monitor for one or two interfaces
mon := monitor.NewMonitor("eth0", "Primary", "wlan0", "Backup", "/tmp/monitor.log") mon := monitor.NewMonitor([]monitor.IfaceSpec{
{Name: "eth0", Label: "Primary"},
{Name: "wlan0", Label: "Backup"},
}, "/tmp/monitor.log")
// Configure timing parameters (optional - defaults are sensible) // Configure timing parameters (optional - defaults are sensible)
mon.ICMPTimeout = 1 * time.Second mon.ICMPTimeout = 1 * time.Second
+1 -4
View File
@@ -1,7 +1,4 @@
//go:build linux // netmon network dashboard (curses) for Linux and macOS
// +build linux
// netmon dual-interface network dashboard (curses)
// WTFPL 2025-05-16 sneak@sneak.berlin // WTFPL 2025-05-16 sneak@sneak.berlin
package main package main
+1 -1
View File
@@ -6,6 +6,7 @@ require (
github.com/gdamore/tcell/v2 v2.8.1 github.com/gdamore/tcell/v2 v2.8.1
github.com/spf13/cobra v1.8.0 github.com/spf13/cobra v1.8.0
github.com/spf13/viper v1.18.2 github.com/spf13/viper v1.18.2
golang.org/x/sys v0.29.0
) )
require ( require (
@@ -29,7 +30,6 @@ require (
go.uber.org/atomic v1.9.0 // indirect go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/term v0.28.0 // indirect golang.org/x/term v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect golang.org/x/text v0.21.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect
+47 -6
View File
@@ -1,18 +1,17 @@
//go:build linux
// +build linux
package cli package cli
import ( import (
"context" "context"
"os" "os"
"os/signal" "os/signal"
"runtime"
"syscall" "syscall"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper" "github.com/spf13/viper"
"git.eeqj.de/sneak/rtnetmon/internal/monitor" "git.eeqj.de/sneak/rtnetmon/internal/monitor"
"git.eeqj.de/sneak/rtnetmon/internal/netdetect"
) )
// Config holds the application configuration // Config holds the application configuration
@@ -73,12 +72,17 @@ func init() {
_ = viper.BindPFlag("logfile", rootCmd.Flags().Lookup("logfile")) _ = 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, "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 // 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 // Add reachability hosts
for _, host := range cfg.Hosts { for _, host := range cfg.Hosts {
@@ -112,6 +116,43 @@ func runMonitor(cmd *cobra.Command, args []string) error {
return mon.Run(ctx) 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 // Execute runs the root command
func Execute() error { func Execute() error {
return rootCmd.Execute() return rootCmd.Execute()
-3
View File
@@ -1,6 +1,3 @@
//go:build linux
// +build linux
package cli package cli
import ( import (
+36
View File
@@ -0,0 +1,36 @@
//go:build darwin
package monitor
import (
"net"
"syscall"
"golang.org/x/sys/unix"
)
// bindControl returns a socket control hook that pins the connection to the
// given interface with IP_BOUND_IF. On macOS a bound source address is not
// enough: without this the kernel still routes the packets over the VPN's
// default route, so traffic would not leave the interface we are measuring.
func bindControl(iface string) func(network, address string, c syscall.RawConn) error {
ifi, err := net.InterfaceByName(iface)
if err != nil {
return nil
}
idx := ifi.Index
return func(_, _ string, c syscall.RawConn) error {
var setErr error
ctrlErr := c.Control(func(fd uintptr) {
setErr = unix.SetsockoptInt(int(fd), unix.IPPROTO_IP, unix.IP_BOUND_IF, idx)
})
if ctrlErr != nil {
return ctrlErr
}
return setErr
}
}
+12
View File
@@ -0,0 +1,12 @@
//go:build linux
package monitor
import "syscall"
// bindControl returns no socket control hook on Linux: binding the dialer's
// local source address (as tcpDuration already does) is enough to send
// traffic out of the chosen interface.
func bindControl(_ string) func(network, address string, c syscall.RawConn) error {
return nil
}
-3
View File
@@ -1,6 +1,3 @@
//go:build linux
// +build linux
package monitor package monitor
import ( import (
+54 -23
View File
@@ -1,6 +1,3 @@
//go:build linux
// +build linux
package monitor package monitor
import ( import (
@@ -11,6 +8,7 @@ import (
"net" "net"
"os" "os"
"os/exec" "os/exec"
"runtime"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -53,9 +51,8 @@ type Monitor struct {
packetLossHosts []string packetLossHosts []string
tcpHosts []string tcpHosts []string
// Interfaces // Interfaces to monitor (one or two)
interfaceA *InterfaceStatus interfaces []*InterfaceStatus
interfaceB *InterfaceStatus
// Logging // Logging
logFile string logFile string
@@ -68,9 +65,16 @@ type Monitor struct {
mu sync.RWMutex mu sync.RWMutex
} }
// NewMonitor creates a new Monitor instance with default settings // IfaceSpec names one interface to monitor and its display label.
func NewMonitor(ifaceA, labelA, ifaceB, labelB, logFile string) *Monitor { type IfaceSpec struct {
return &Monitor{ Name string
Label string
}
// NewMonitor creates a new Monitor instance with default settings, monitoring
// the given interfaces (one or two).
func NewMonitor(ifaces []IfaceSpec, logFile string) *Monitor {
m := &Monitor{
// Default timing configuration // Default timing configuration
ICMPTimeout: 500 * time.Millisecond, ICMPTimeout: 500 * time.Millisecond,
TCPTimeout: 500 * time.Millisecond, TCPTimeout: 500 * time.Millisecond,
@@ -94,15 +98,17 @@ func NewMonitor(ifaceA, labelA, ifaceB, labelB, logFile string) *Monitor {
packetLossHosts: []string{}, packetLossHosts: []string{},
tcpHosts: []string{}, tcpHosts: []string{},
// Initialize interfaces
interfaceA: NewInterfaceStatus(ifaceA, labelA),
interfaceB: NewInterfaceStatus(ifaceB, labelB),
// Logging // Logging
logFile: logFile, logFile: logFile,
startTime: time.Now(), startTime: time.Now(),
} }
for _, spec := range ifaces {
m.interfaces = append(m.interfaces, NewInterfaceStatus(spec.Name, spec.Label))
}
return m
} }
// AddReachabilityHost adds a host for reachability monitoring // AddReachabilityHost adds a host for reachability monitoring
@@ -159,12 +165,11 @@ func (m *Monitor) Run(ctx context.Context) error {
tcpHosts := append([]string{}, m.tcpHosts...) tcpHosts := append([]string{}, m.tcpHosts...)
m.mu.RUnlock() m.mu.RUnlock()
go m.reachLoop(ctx, m.interfaceA, reachHosts) for _, st := range m.interfaces {
go m.reachLoop(ctx, m.interfaceB, reachHosts) go m.reachLoop(ctx, st, reachHosts)
go m.lossLoop(ctx, m.interfaceA, lossHosts) go m.lossLoop(ctx, st, lossHosts)
go m.lossLoop(ctx, m.interfaceB, lossHosts) go m.tcpLoop(ctx, st, tcpHosts)
go m.tcpLoop(ctx, m.interfaceA, tcpHosts) }
go m.tcpLoop(ctx, m.interfaceB, tcpHosts)
// Run UI loop // Run UI loop
m.logf("Starting UI loop") m.logf("Starting UI loop")
@@ -252,19 +257,45 @@ func fetchIPInfo(iface string) string {
return fmt.Sprintf("%s [%s] %s", r.IP, r.Hostname, r.Org) return fmt.Sprintf("%s [%s] %s", r.IP, r.Hostname, r.Org)
} }
// pingArgs builds the arguments for a single reachability ping on the given
// OS. Linux binds the interface with -I and takes -W in seconds; macOS binds
// with -b and takes -W in milliseconds.
func pingArgs(goos, iface, host string) []string {
if goos == "darwin" {
return []string{"-b", iface, "-c1", "-W1000", host}
}
return []string{"-I", iface, "-c1", "-W1", host}
}
// lossArgs builds the arguments for a packet-loss ping burst of count pings.
func lossArgs(goos, iface, host string, count int) []string {
c := strconv.Itoa(count)
if goos == "darwin" {
return []string{"-q", "-i", "0.05", "-c", c, "-W1000", "-b", iface, host}
}
return []string{"-q", "-i", "0.05", "-c", c, "-W1", "-I", iface, host}
}
// pingOnce performs a single ping // pingOnce performs a single ping
func (m *Monitor) pingOnce(iface, host string) bool { func (m *Monitor) pingOnce(iface, host string) bool {
ctx, cancel := context.WithTimeout(context.Background(), m.ICMPTimeout) ctx, cancel := context.WithTimeout(context.Background(), m.ICMPTimeout)
defer cancel() defer cancel()
return exec.CommandContext(ctx, "ping", "-I", iface, "-c1", "-W1", host).Run() == nil
args := pingArgs(runtime.GOOS, iface, host)
return exec.CommandContext(ctx, "ping", args...).Run() == nil
} }
// lossPercent calculates packet loss percentage // lossPercent calculates packet loss percentage
func (m *Monitor) lossPercent(iface, host string) float64 { func (m *Monitor) lossPercent(iface, host string) float64 {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel() defer cancel()
out, err := exec.CommandContext(ctx, "ping", "-q", "-i", "0.05",
"-c", fmt.Sprint(m.PacketLossPings), "-W1", "-I", iface, host).CombinedOutput() args := lossArgs(runtime.GOOS, iface, host, m.PacketLossPings)
out, err := exec.CommandContext(ctx, "ping", args...).CombinedOutput()
if err != nil { if err != nil {
return 1.0 return 1.0
} }
@@ -302,7 +333,7 @@ func (m *Monitor) tcpDuration(iface, hp string) time.Duration {
if err != nil { if err != nil {
return m.TCPTimeout return m.TCPTimeout
} }
d := net.Dialer{Timeout: m.TCPTimeout, LocalAddr: la} d := net.Dialer{Timeout: m.TCPTimeout, LocalAddr: la, Control: bindControl(iface)}
st := time.Now() st := time.Now()
c, err := d.Dial("tcp", hp) c, err := d.Dial("tcp", hp)
if err != nil { if err != nil {
+27 -15
View File
@@ -1,6 +1,3 @@
//go:build linux
// +build linux
package monitor package monitor
import ( import (
@@ -10,27 +7,26 @@ import (
// TestNewMonitor tests the creation of a new Monitor // TestNewMonitor tests the creation of a new Monitor
func TestNewMonitor(t *testing.T) { func TestNewMonitor(t *testing.T) {
// Test that we can create a monitor without errors // Test that we can create a monitor without errors
mon := NewMonitor("test0", "Test Interface A", "test1", "Test Interface B", "/tmp/test.log") mon := NewMonitor([]IfaceSpec{
{Name: "test0", Label: "Test Interface A"},
{Name: "test1", Label: "Test Interface B"},
}, "/tmp/test.log")
if mon == nil { if mon == nil {
t.Fatal("NewMonitor returned nil") t.Fatal("NewMonitor returned nil")
} }
// Check interfaces // Check interfaces
if mon.interfaceA == nil { if len(mon.interfaces) != 2 {
t.Fatal("interfaceA is nil") t.Fatalf("Expected 2 interfaces, got %d", len(mon.interfaces))
} }
if mon.interfaceB == nil { if mon.interfaces[0].Name != "test0" {
t.Fatal("interfaceB is nil") t.Errorf("Expected interfaces[0].Name to be 'test0', got '%s'", mon.interfaces[0].Name)
} }
if mon.interfaceA.Name != "test0" { if mon.interfaces[1].Name != "test1" {
t.Errorf("Expected interfaceA.Name to be 'test0', got '%s'", mon.interfaceA.Name) t.Errorf("Expected interfaces[1].Name to be 'test1', got '%s'", mon.interfaces[1].Name)
}
if mon.interfaceB.Name != "test1" {
t.Errorf("Expected interfaceB.Name to be 'test1', got '%s'", mon.interfaceB.Name)
} }
// Check default configuration // Check default configuration
@@ -56,9 +52,25 @@ func TestNewMonitor(t *testing.T) {
} }
} }
// TestNewMonitorSingle verifies a monitor can be built with one interface.
func TestNewMonitorSingle(t *testing.T) {
mon := NewMonitor([]IfaceSpec{{Name: "eth0", Label: "default route"}}, "")
if len(mon.interfaces) != 1 {
t.Fatalf("Expected 1 interface, got %d", len(mon.interfaces))
}
if mon.interfaces[0].Name != "eth0" {
t.Errorf("Expected interfaces[0].Name to be 'eth0', got '%s'", mon.interfaces[0].Name)
}
}
// TestAddHosts tests adding hosts to the monitor // TestAddHosts tests adding hosts to the monitor
func TestAddHosts(t *testing.T) { func TestAddHosts(t *testing.T) {
mon := NewMonitor("test0", "Test A", "test1", "Test B", "") mon := NewMonitor([]IfaceSpec{
{Name: "test0", Label: "Test A"},
{Name: "test1", Label: "Test B"},
}, "")
// Test adding reachability hosts // Test adding reachability hosts
mon.AddReachabilityHost("8.8.8.8") mon.AddReachabilityHost("8.8.8.8")
+48
View File
@@ -0,0 +1,48 @@
package monitor
import (
"reflect"
"testing"
)
func TestPingArgs(t *testing.T) {
tests := []struct {
goos string
want []string
}{
{"linux", []string{"-I", "eth0", "-c1", "-W1", "8.8.8.8"}},
{"darwin", []string{"-b", "eth0", "-c1", "-W1000", "8.8.8.8"}},
}
for _, tt := range tests {
t.Run(tt.goos, func(t *testing.T) {
got := pingArgs(tt.goos, "eth0", "8.8.8.8")
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("pingArgs(%q) = %v, want %v", tt.goos, got, tt.want)
}
})
}
}
func TestLossArgs(t *testing.T) {
tests := []struct {
goos string
want []string
}{
{"linux", []string{
"-q", "-i", "0.05", "-c", "20", "-W1", "-I", "eth0", "8.8.8.8",
}},
{"darwin", []string{
"-q", "-i", "0.05", "-c", "20", "-W1000", "-b", "eth0", "8.8.8.8",
}},
}
for _, tt := range tests {
t.Run(tt.goos, func(t *testing.T) {
got := lossArgs(tt.goos, "eth0", "8.8.8.8", 20)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("lossArgs(%q) = %v, want %v", tt.goos, got, tt.want)
}
})
}
}
-3
View File
@@ -1,6 +1,3 @@
//go:build linux
// +build linux
package monitor package monitor
import tcell "github.com/gdamore/tcell/v2" import tcell "github.com/gdamore/tcell/v2"
-3
View File
@@ -1,6 +1,3 @@
//go:build linux
// +build linux
package monitor package monitor
import ( import (
+4 -6
View File
@@ -1,6 +1,3 @@
//go:build linux
// +build linux
package monitor package monitor
import ( import (
@@ -345,10 +342,11 @@ func (m *Monitor) uiLoop(ctx context.Context) {
// Draw the runtime // Draw the runtime
Put(m.screen, 0, 3, "Runtime: "+time.Since(m.startTime).Round(time.Second).String(), CDefault) Put(m.screen, 0, 3, "Runtime: "+time.Since(m.startTime).Round(time.Second).String(), CDefault)
// Draw interfaces // Draw interfaces (one pane each; a single interface draws one pane)
y := 5 y := 5
y = m.DrawInterface(m.screen, y, w, m.interfaceA) for _, st := range m.interfaces {
_ = m.DrawInterface(m.screen, y, w, m.interfaceB) y = m.DrawInterface(m.screen, y, w, st)
}
// Show the screen // Show the screen
m.screen.Show() m.screen.Show()
+359
View File
@@ -0,0 +1,359 @@
// Package netdetect chooses which network interfaces rtnetmon should monitor.
//
// The host-specific queries (enumerating interfaces, reading the routing
// table) live behind small data types so the selection logic and the route
// parsers are pure functions that can be unit-tested on any OS with fake
// data. Only the real routing-table query is build-tagged per platform.
package netdetect
import (
"fmt"
"net"
"sort"
"strconv"
"strings"
)
// Interface is a network interface reduced to what detection needs.
type Interface struct {
Name string
Up bool
IPv4 []string
}
// Route is one routing-table entry reduced to what detection needs.
type Route struct {
Iface string
Gateway string
Default bool
}
// Pane names one interface to display, with its label.
type Pane struct {
Name string
Label string
}
// Flags carries the user's --iface/--label choices and whether each label was
// set explicitly on the command line.
type Flags struct {
IfaceA string
LabelA string
IfaceB string
LabelB string
LabelASet bool
LabelBSet bool
}
// Interfaces enumerates the host's interfaces and their IPv4 addresses. This
// uses the standard library and is the same on every platform.
func Interfaces() ([]Interface, error) {
ifs, err := net.Interfaces()
if err != nil {
return nil, fmt.Errorf("listing interfaces: %w", err)
}
out := make([]Interface, 0, len(ifs))
for _, ifi := range ifs {
var v4 []string
addrs, _ := ifi.Addrs()
for _, a := range addrs {
if n, ok := a.(*net.IPNet); ok && n.IP.To4() != nil {
v4 = append(v4, n.IP.String())
}
}
out = append(out, Interface{
Name: ifi.Name,
Up: ifi.Flags&net.FlagUp != 0,
IPv4: v4,
})
}
return out, nil
}
// Select decides which one or two interfaces to monitor for the given OS.
// See the README's supported matrix for the exact rules.
func Select(goos string, ifaces []Interface, routes []Route, f Flags) ([]Pane, error) {
switch goos {
case "linux":
return selectLinux(ifaces, routes, f)
case "darwin":
return selectDarwin(ifaces, routes, f)
default:
return nil, fmt.Errorf("unsupported operating system %q", goos)
}
}
// selectLinux keeps today's behavior: if both configured interfaces exist,
// monitor them as two panes; if neither exists, monitor the single
// default-route interface; anything else is an error.
func selectLinux(ifaces []Interface, routes []Route, f Flags) ([]Pane, error) {
haveA := hasInterface(ifaces, f.IfaceA)
haveB := hasInterface(ifaces, f.IfaceB)
switch {
case haveA && haveB:
return []Pane{
{Name: f.IfaceA, Label: f.LabelA},
{Name: f.IfaceB, Label: f.LabelB},
}, nil
case !haveA && !haveB:
name, err := onlyDefaultRoute(routes)
if err != nil {
return nil, err
}
return []Pane{{Name: name, Label: singleLabel(f)}}, nil
default:
return nil, fmt.Errorf(
"found only one of the configured interfaces (%q, %q); "+
"rtnetmon needs both, or neither (default route only)",
f.IfaceA, f.IfaceB)
}
}
// selectDarwin monitors the physical default-route interface, plus a VPN
// tunnel as the primary pane when one is running.
func selectDarwin(ifaces []Interface, routes []Route, f Flags) ([]Pane, error) {
vpn := findVPN(ifaces, routes)
phys, err := onlyPhysicalDefaultRoute(routes, vpn)
if err != nil {
return nil, err
}
if vpn == "" {
return []Pane{{Name: phys, Label: singleLabel(f)}}, nil
}
return []Pane{
{Name: vpn, Label: labelOr(f.LabelA, f.LabelASet, "VPN")},
{Name: phys, Label: labelOr(f.LabelB, f.LabelBSet, "default route")},
}, nil
}
// findVPN returns the name of a VPN tunnel interface, or "" if none is
// running. A tunnel counts as a running VPN when it carries a default route,
// or when it is up with a routable (non-link-local) IPv4 address. Idle system
// tunnels have only a link-local IPv6 address and are skipped.
func findVPN(ifaces []Interface, routes []Route) string {
routeIfaces := map[string]bool{}
for _, r := range routes {
if r.Default {
routeIfaces[r.Iface] = true
}
}
sorted := append([]Interface(nil), ifaces...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
for _, ifi := range sorted {
if !isTunnel(ifi.Name) {
continue
}
if routeIfaces[ifi.Name] {
return ifi.Name
}
if ifi.Up && hasRoutableIPv4(ifi) {
return ifi.Name
}
}
return ""
}
// onlyDefaultRoute returns the single default-route interface, or an error if
// there is not exactly one.
func onlyDefaultRoute(routes []Route) (string, error) {
names := defaultRouteIfaces(routes, "")
switch len(names) {
case 1:
return names[0], nil
case 0:
return "", fmt.Errorf(
"no default route found; rtnetmon needs one internet interface")
default:
return "", fmt.Errorf(
"multiple default-route interfaces found (%s); rtnetmon supports only one",
strings.Join(names, ", "))
}
}
// onlyPhysicalDefaultRoute returns the single non-tunnel default-route
// interface (ignoring the VPN), or an error if there is not exactly one.
func onlyPhysicalDefaultRoute(routes []Route, vpn string) (string, error) {
names := defaultRouteIfaces(routes, vpn)
switch len(names) {
case 1:
return names[0], nil
case 0:
return "", fmt.Errorf(
"no physical default-route interface found; " +
"rtnetmon needs one internet interface")
default:
return "", fmt.Errorf(
"multiple physical default-route interfaces found (%s); "+
"rtnetmon supports only one",
strings.Join(names, ", "))
}
}
// defaultRouteIfaces returns the sorted, unique interface names that carry a
// default route, excluding the named VPN interface and any other tunnel.
func defaultRouteIfaces(routes []Route, vpn string) []string {
seen := map[string]bool{}
var names []string
for _, r := range routes {
if !r.Default || r.Iface == vpn || isTunnel(r.Iface) || seen[r.Iface] {
continue
}
seen[r.Iface] = true
names = append(names, r.Iface)
}
sort.Strings(names)
return names
}
// hasInterface reports whether an interface with the given name exists.
func hasInterface(ifaces []Interface, name string) bool {
for _, ifi := range ifaces {
if ifi.Name == name {
return true
}
}
return false
}
// isTunnel reports whether the interface name is a macOS userspace tunnel
// (utunN), which is what Mullvad and other WireGuard/OpenVPN clients use.
func isTunnel(name string) bool {
return strings.HasPrefix(name, "utun")
}
// hasRoutableIPv4 reports whether the interface has an IPv4 address that is
// neither loopback nor link-local.
func hasRoutableIPv4(ifi Interface) bool {
for _, s := range ifi.IPv4 {
ip := net.ParseIP(s)
if ip == nil || ip.IsLoopback() || ip.IsLinkLocalUnicast() {
continue
}
return true
}
return false
}
// singleLabel is the label for a lone pane: the explicit --labelA if given,
// otherwise a plain description.
func singleLabel(f Flags) string {
return labelOr(f.LabelA, f.LabelASet, "default route")
}
// labelOr returns value when it was set explicitly, otherwise fallback.
func labelOr(value string, set bool, fallback string) string {
if set {
return value
}
return fallback
}
// parseIPRoute reads `ip -4 route show default` output. Every printed line is
// a default route.
func parseIPRoute(out string) []Route {
var routes []Route
for _, line := range strings.Split(out, "\n") {
fields := strings.Fields(line)
if len(fields) == 0 || fields[0] != "default" {
continue
}
r := Route{Default: true}
for i := 0; i < len(fields)-1; i++ {
switch fields[i] {
case "dev":
r.Iface = fields[i+1]
case "via":
r.Gateway = fields[i+1]
}
}
if r.Iface != "" {
routes = append(routes, r)
}
}
return routes
}
// parseProcNetRoute reads /proc/net/route. A default route has destination
// 00000000; the gateway is a little-endian hex IPv4 address.
func parseProcNetRoute(out string) []Route {
var routes []Route
for i, line := range strings.Split(out, "\n") {
if i == 0 { // column header
continue
}
fields := strings.Fields(line)
if len(fields) < 3 || fields[1] != "00000000" {
continue
}
routes = append(routes, Route{
Iface: fields[0],
Gateway: hexToIP(fields[2]),
Default: true,
})
}
return routes
}
// parseNetstat reads `netstat -rn -f inet` output (macOS). Rows whose
// destination is "default" are default routes; the Netif column (field 4)
// names the interface.
func parseNetstat(out string) []Route {
var routes []Route
for _, line := range strings.Split(out, "\n") {
fields := strings.Fields(line)
if len(fields) < 4 || fields[0] != "default" {
continue
}
routes = append(routes, Route{
Iface: fields[3],
Gateway: fields[1],
Default: true,
})
}
return routes
}
// hexToIP converts a little-endian hex IPv4 address (as found in
// /proc/net/route) to dotted-quad form.
func hexToIP(h string) string {
v, err := strconv.ParseUint(h, 16, 32)
if err != nil {
return ""
}
return fmt.Sprintf("%d.%d.%d.%d", v&0xff, (v>>8)&0xff, (v>>16)&0xff, (v>>24)&0xff)
}
+238
View File
@@ -0,0 +1,238 @@
package netdetect
import (
"reflect"
"testing"
)
// gu and backhaul are the Linux bridge interfaces rtnetmon was built for.
func linuxFlags() Flags {
return Flags{
IfaceA: "gu0", LabelA: "gu LAN - VPN outbound",
IfaceB: "backhaul0", LabelB: "Cox cable direct",
}
}
func TestSelect(t *testing.T) {
tests := []struct {
name string
goos string
ifaces []Interface
routes []Route
flags Flags
want []Pane
wantErr bool
}{
{
name: "linux both bridge interfaces present",
goos: "linux",
ifaces: []Interface{
{Name: "gu0", Up: true},
{Name: "backhaul0", Up: true},
{Name: "eth0", Up: true},
},
routes: []Route{{Iface: "eth0", Default: true}},
flags: linuxFlags(),
want: []Pane{
{Name: "gu0", Label: "gu LAN - VPN outbound"},
{Name: "backhaul0", Label: "Cox cable direct"},
},
},
{
name: "linux no bridge interfaces, single default route",
goos: "linux",
ifaces: []Interface{{Name: "eth0", Up: true}, {Name: "lo", Up: true}},
routes: []Route{{Iface: "eth0", Default: true}},
flags: linuxFlags(),
want: []Pane{{Name: "eth0", Label: "default route"}},
},
{
name: "linux only one bridge interface present is an error",
goos: "linux",
ifaces: []Interface{{Name: "gu0", Up: true}, {Name: "eth0", Up: true}},
routes: []Route{{Iface: "eth0", Default: true}},
flags: linuxFlags(),
wantErr: true,
},
{
name: "linux no bridge, no default route is an error",
goos: "linux",
ifaces: []Interface{{Name: "eth0", Up: true}},
routes: nil,
flags: linuxFlags(),
wantErr: true,
},
{
name: "linux no bridge, multiple default routes is an error",
goos: "linux",
ifaces: []Interface{{Name: "eth0", Up: true}, {Name: "eth1", Up: true}},
routes: []Route{{Iface: "eth0", Default: true}, {Iface: "eth1", Default: true}},
flags: linuxFlags(),
wantErr: true,
},
{
name: "linux single default route keeps explicit label",
goos: "linux",
ifaces: []Interface{
{Name: "wlan0", Up: true},
},
routes: []Route{{Iface: "wlan0", Default: true}},
flags: Flags{
IfaceA: "gu0", LabelA: "Home WiFi", LabelASet: true,
IfaceB: "backhaul0", LabelB: "Cox cable direct",
},
want: []Pane{{Name: "wlan0", Label: "Home WiFi"}},
},
{
name: "macos vpn tunnel plus physical default route",
goos: "darwin",
ifaces: []Interface{
{Name: "en0", Up: true, IPv4: []string{"192.168.1.20"}},
{Name: "utun4", Up: true, IPv4: []string{"10.64.0.2"}},
{Name: "utun0", Up: true, IPv4: nil},
},
routes: []Route{
{Iface: "utun4", Default: true},
{Iface: "en0", Default: true},
},
flags: linuxFlags(),
want: []Pane{
{Name: "utun4", Label: "VPN"},
{Name: "en0", Label: "default route"},
},
},
{
name: "macos vpn detected by routable address without its own route",
goos: "darwin",
ifaces: []Interface{
{Name: "en0", Up: true, IPv4: []string{"192.168.1.20"}},
{Name: "utun6", Up: true, IPv4: []string{"10.2.0.2"}},
},
routes: []Route{{Iface: "en0", Default: true}},
flags: linuxFlags(),
want: []Pane{
{Name: "utun6", Label: "VPN"},
{Name: "en0", Label: "default route"},
},
},
{
name: "macos no vpn, single physical default route",
goos: "darwin",
ifaces: []Interface{
{Name: "en0", Up: true, IPv4: []string{"192.168.1.20"}},
{Name: "utun0", Up: true, IPv4: nil},
{Name: "utun1", Up: true, IPv4: []string{"169.254.1.1"}},
},
routes: []Route{{Iface: "en0", Default: true}},
flags: linuxFlags(),
want: []Pane{{Name: "en0", Label: "default route"}},
},
{
name: "macos vpn pane honors explicit labels",
goos: "darwin",
ifaces: []Interface{
{Name: "en0", Up: true, IPv4: []string{"192.168.1.20"}},
{Name: "utun4", Up: true, IPv4: []string{"10.64.0.2"}},
},
routes: []Route{
{Iface: "utun4", Default: true},
{Iface: "en0", Default: true},
},
flags: Flags{
LabelA: "Mullvad", LabelASet: true,
LabelB: "Fiber", LabelBSet: true,
},
want: []Pane{
{Name: "utun4", Label: "Mullvad"},
{Name: "en0", Label: "Fiber"},
},
},
{
name: "macos no default route is an error",
goos: "darwin",
ifaces: []Interface{{Name: "en0", Up: true, IPv4: []string{"192.168.1.20"}}},
routes: nil,
flags: linuxFlags(),
wantErr: true,
},
{
name: "macos two physical default routes is an error",
goos: "darwin",
ifaces: []Interface{
{Name: "en0", Up: true, IPv4: []string{"192.168.1.20"}},
{Name: "en1", Up: true, IPv4: []string{"192.168.2.20"}},
},
routes: []Route{
{Iface: "en0", Default: true},
{Iface: "en1", Default: true},
},
flags: linuxFlags(),
wantErr: true,
},
{
name: "unsupported operating system is an error",
goos: "windows",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := 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 TestParseIPRoute(t *testing.T) {
out := "default via 192.168.1.1 dev eth0 proto dhcp metric 100\n"
got := parseIPRoute(out)
want := []Route{{Iface: "eth0", 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) {
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 := parseProcNetRoute(out)
want := []Route{{Iface: "eth0", 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) {
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 := parseNetstat(out)
want := []Route{
{Iface: "en0", Gateway: "10.0.0.1", Default: true},
{Iface: "utun4", Gateway: "link#15", Default: true},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("parseNetstat() = %v, want %v", got, want)
}
}
+16
View File
@@ -0,0 +1,16 @@
//go:build darwin
package netdetect
import "os/exec"
// DefaultRoutes returns the host's IPv4 default routes, read from the macOS
// routing table.
func DefaultRoutes() ([]Route, error) {
out, err := exec.Command("netstat", "-rn", "-f", "inet").Output()
if err != nil {
return nil, err
}
return parseNetstat(string(out)), nil
}
+26
View File
@@ -0,0 +1,26 @@
//go:build linux
package netdetect
import (
"os"
"os/exec"
)
// DefaultRoutes returns the host's IPv4 default routes. It prefers the `ip`
// command and falls back to reading /proc/net/route.
func DefaultRoutes() ([]Route, error) {
out, err := exec.Command("ip", "-4", "route", "show", "default").Output()
if err == nil {
if routes := parseIPRoute(string(out)); len(routes) > 0 {
return routes, nil
}
}
data, err := os.ReadFile("/proc/net/route")
if err != nil {
return nil, err
}
return parseProcNetRoute(string(data)), nil
}