1 Commits
Author SHA1 Message Date
clawbot 486a47397c 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)
2026-09-21 15:01:59 +02:00
21 changed files with 1092 additions and 93 deletions
+63 -18
View File
@@ -2,34 +2,70 @@
rtnetmon is a WTFPL-licensed Go terminal (CLI/TUI) network monitor by rtnetmon is a WTFPL-licensed Go terminal (CLI/TUI) network monitor by
[@sneak](https://sneak.berlin) that shows real-time network health, packet [@sneak](https://sneak.berlin) that shows real-time network health, packet
loss, and latency across two Linux network interfaces at once. loss, and latency across one or two network interfaces on Linux and macOS.
## 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
@@ -67,11 +103,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 # monitor types, probes, logging │ ├── monitor.go # monitor types, probes, logging
│ ├── 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)
├── script/ # Scripts to Rule Them All entrypoints ├── script/ # Scripts to Rule Them All entrypoints
├── .gitea/workflows/ # CI (runs script/cibuild) ├── .gitea/workflows/ # CI (runs script/cibuild)
├── Dockerfile # lint + test gate phases and the build ├── Dockerfile # lint + test gate phases and the build
@@ -133,8 +175,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
+2 -4
View File
@@ -1,7 +1,5 @@
//go:build linux // Command rtnetmon is a real-time network monitoring dashboard for Linux
// and macOS. WTFPL, sneak@sneak.berlin.
// Command rtnetmon is a dual-interface real-time network monitoring
// dashboard for Linux. WTFPL, sneak@sneak.berlin.
package main package main
import ( import (
+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
-2
View File
@@ -1,5 +1,3 @@
//go:build linux
package cli package cli
import "github.com/spf13/cobra" import "github.com/spf13/cobra"
+52 -10
View File
@@ -1,5 +1,3 @@
//go:build linux
// Package cli wires command-line flags to the network monitor and runs it. // Package cli wires command-line flags to the network monitor and runs it.
package cli package cli
@@ -7,12 +5,14 @@ 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.
@@ -59,8 +59,8 @@ func newRootCmd() *cobra.Command {
Short: "Real-time network monitoring dashboard", Short: "Real-time network monitoring dashboard",
Long: `rtnetmon is a dual-interface network monitoring dashboard that provides Long: `rtnetmon is a dual-interface network monitoring dashboard that provides
real-time visibility into network health, packet loss, and latency.`, real-time visibility into network health, packet loss, and latency.`,
RunE: func(_ *cobra.Command, _ []string) error { RunE: func(cmd *cobra.Command, _ []string) error {
return runMonitor(cfg) return runMonitor(cmd, cfg)
}, },
} }
registerFlags(cmd, cfg) registerFlags(cmd, cfg)
@@ -86,14 +86,17 @@ func registerFlags(cmd *cobra.Command, cfg *config) {
} }
} }
// runMonitor constructs and runs the monitor from cfg. // runMonitor detects the interfaces to monitor, then constructs and runs the
func runMonitor(cfg *config) error { // monitor from cfg.
func runMonitor(cmd *cobra.Command, cfg *config) 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)
mon := monitor.NewMonitor(cfg.IfaceA, cfg.LabelA, cfg.IfaceB, cfg.LabelB, specs, err := detectInterfaces(cmd, cfg)
cfg.LogFile) if err != nil {
return err
}
mon := monitor.NewMonitor(specs, cfg.LogFile)
for _, host := range cfg.Hosts { for _, host := range cfg.Hosts {
mon.AddReachabilityHost(host) mon.AddReachabilityHost(host)
@@ -122,6 +125,45 @@ func runMonitor(cfg *config) 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, cfg *config,
) ([]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 builds the root command and runs it. // Execute builds the root command and runs it.
func Execute() error { func Execute() error {
return newRootCmd().Execute() return newRootCmd().Execute()
-2
View File
@@ -1,5 +1,3 @@
//go:build linux
package cli_test package cli_test
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
}
+9 -6
View File
@@ -1,5 +1,3 @@
//go:build linux
package monitor package monitor
// Test-only accessors exposing unexported state for white-box assertions. // Test-only accessors exposing unexported state for white-box assertions.
@@ -13,8 +11,13 @@ func (m *Monitor) PacketLossHosts() []string { return m.packetLossHosts }
// TCPHosts returns the configured TCP hosts. // TCPHosts returns the configured TCP hosts.
func (m *Monitor) TCPHosts() []string { return m.tcpHosts } func (m *Monitor) TCPHosts() []string { return m.tcpHosts }
// InterfaceA returns the first monitored interface. // Interfaces returns the monitored interfaces.
func (m *Monitor) InterfaceA() *InterfaceStatus { return m.interfaceA } func (m *Monitor) Interfaces() []*InterfaceStatus { return m.interfaces }
// InterfaceB returns the second monitored interface. // PingArgs exposes pingArgs for external tests.
func (m *Monitor) InterfaceB() *InterfaceStatus { return m.interfaceB } func PingArgs(goos, iface, host string) []string { return pingArgs(goos, iface, host) }
// LossArgs exposes lossArgs for external tests.
func LossArgs(goos, iface, host string, count int) []string {
return lossArgs(goos, iface, host, count)
}
-2
View File
@@ -1,5 +1,3 @@
//go:build linux
package monitor package monitor
import ( import (
+55 -24
View File
@@ -1,8 +1,6 @@
//go:build linux // Package monitor implements the real-time network monitoring dashboard:
// ICMP reachability, packet loss, TCP latency, and the terminal UI that
// Package monitor implements the dual-interface real-time network // renders them. It monitors one or two interfaces.
// monitoring dashboard: ICMP reachability, packet loss, TCP latency, and
// the terminal UI that renders them.
package monitor package monitor
import ( import (
@@ -14,6 +12,7 @@ import (
"net" "net"
"os" "os"
"os/exec" "os/exec"
"runtime"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -88,9 +87,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
@@ -104,9 +102,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{
ICMPTimeout: defaultICMPTimeout, ICMPTimeout: defaultICMPTimeout,
TCPTimeout: defaultTCPTimeout, TCPTimeout: defaultTCPTimeout,
PacketLossPings: defaultPacketLossPings, PacketLossPings: defaultPacketLossPings,
@@ -127,13 +132,16 @@ func NewMonitor(ifaceA, labelA, ifaceB, labelB, logFile string) *Monitor {
packetLossHosts: []string{}, packetLossHosts: []string{},
tcpHosts: []string{}, tcpHosts: []string{},
interfaceA: NewInterfaceStatus(ifaceA, labelA),
interfaceB: NewInterfaceStatus(ifaceB, labelB),
logFile: logFile, logFile: logFile,
startTime: time.Now(), startTime: time.Now(),
uiUpdate: make(chan struct{}, uiUpdateBuffer), uiUpdate: make(chan struct{}, uiUpdateBuffer),
} }
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.
@@ -194,12 +202,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)
m.logf("Starting UI loop") m.logf("Starting UI loop")
m.uiLoop(ctx) m.uiLoop(ctx)
@@ -306,13 +313,36 @@ 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 over the named interface. // pingOnce performs a single ping over the named interface.
func (m *Monitor) pingOnce(ctx context.Context, iface, host string) bool { func (m *Monitor) pingOnce(ctx context.Context, iface, host string) bool {
ctx, cancel := context.WithTimeout(ctx, m.ICMPTimeout) ctx, cancel := context.WithTimeout(ctx, m.ICMPTimeout)
defer cancel() defer cancel()
args := pingArgs(runtime.GOOS, iface, host)
cmd := exec.CommandContext( //nolint:gosec // G204: fixed argv, operator CLI input cmd := exec.CommandContext( //nolint:gosec // G204: fixed argv, operator CLI input
ctx, "ping", "-I", iface, "-c1", "-W1", host) ctx, "ping", args...)
return cmd.Run() == nil return cmd.Run() == nil
} }
@@ -322,9 +352,10 @@ func (m *Monitor) lossPercent(ctx context.Context, iface, host string) float64 {
ctx, cancel := context.WithTimeout(ctx, lossQueryTimeout) ctx, cancel := context.WithTimeout(ctx, lossQueryTimeout)
defer cancel() defer cancel()
args := lossArgs(runtime.GOOS, iface, host, m.PacketLossPings)
cmd := exec.CommandContext( //nolint:gosec // G204: fixed argv, operator CLI input cmd := exec.CommandContext( //nolint:gosec // G204: fixed argv, operator CLI input
ctx, "ping", "-q", "-i", "0.05", ctx, "ping", args...)
"-c", strconv.Itoa(m.PacketLossPings), "-W1", "-I", iface, host)
out, err := cmd.CombinedOutput() out, err := cmd.CombinedOutput()
if err != nil { if err != nil {
@@ -375,7 +406,7 @@ func (m *Monitor) tcpDuration(iface, hp string) time.Duration {
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)
+40 -16
View File
@@ -1,5 +1,3 @@
//go:build linux
package monitor_test package monitor_test
import ( import (
@@ -8,26 +6,33 @@ import (
"git.eeqj.de/sneak/rtnetmon/internal/monitor" "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) { func TestNewMonitor(t *testing.T) {
t.Parallel() t.Parallel()
mon := monitor.NewMonitor("test0", "Test Interface A", "test1", mon := monitor.NewMonitor([]monitor.IfaceSpec{
"Test Interface B", "/tmp/test.log") {Name: ifaceTest0, Label: "Test Interface A"},
{Name: ifaceTest1, Label: "Test Interface B"},
}, "/tmp/test.log")
if mon.InterfaceA() == nil { ifaces := mon.Interfaces()
t.Fatal("interfaceA is nil") if len(ifaces) != 2 {
t.Fatalf("interfaces = %d, want 2", len(ifaces))
} }
if mon.InterfaceB() == nil { if ifaces[0].Name != ifaceTest0 {
t.Fatal("interfaceB is nil") t.Errorf("interfaces[0].Name = %q, want %q", ifaces[0].Name, ifaceTest0)
} }
if mon.InterfaceA().Name != "test0" { if ifaces[1].Name != ifaceTest1 {
t.Errorf("interfaceA.Name = %q, want %q", mon.InterfaceA().Name, "test0") t.Errorf("interfaces[1].Name = %q, want %q", ifaces[1].Name, ifaceTest1)
}
if mon.InterfaceB().Name != "test1" {
t.Errorf("interfaceB.Name = %q, want %q", mon.InterfaceB().Name, "test1")
} }
if mon.ICMPTimeout.Milliseconds() != 500 { 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) { func TestAddHosts(t *testing.T) {
t.Parallel() 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") mon.AddReachabilityHost("google.com")
if len(mon.ReachabilityHosts()) != 2 { if len(mon.ReachabilityHosts()) != 2 {
+58
View File
@@ -0,0 +1,58 @@
package monitor_test
import (
"reflect"
"testing"
"git.eeqj.de/sneak/rtnetmon/internal/monitor"
)
func TestPingArgs(t *testing.T) {
t.Parallel()
tests := []struct {
goos string
want []string
}{
{"linux", []string{"-I", ifaceEth0, "-c1", "-W1", host8888}},
{"darwin", []string{"-b", ifaceEth0, "-c1", "-W1000", host8888}},
}
for _, tt := range tests {
t.Run(tt.goos, func(t *testing.T) {
t.Parallel()
got := monitor.PingArgs(tt.goos, ifaceEth0, host8888)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("PingArgs(%q) = %v, want %v", tt.goos, got, tt.want)
}
})
}
}
func TestLossArgs(t *testing.T) {
t.Parallel()
tests := []struct {
goos string
want []string
}{
{"linux", []string{
"-q", "-i", "0.05", "-c", "20", "-W1", "-I", ifaceEth0, host8888,
}},
{"darwin", []string{
"-q", "-i", "0.05", "-c", "20", "-W1000", "-b", ifaceEth0, host8888,
}},
}
for _, tt := range tests {
t.Run(tt.goos, func(t *testing.T) {
t.Parallel()
got := monitor.LossArgs(tt.goos, ifaceEth0, host8888, 20)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("LossArgs(%q) = %v, want %v", tt.goos, got, tt.want)
}
})
}
}
-2
View File
@@ -1,5 +1,3 @@
//go:build linux
package monitor package monitor
import tcell "github.com/gdamore/tcell/v2" import tcell "github.com/gdamore/tcell/v2"
-2
View File
@@ -1,5 +1,3 @@
//go:build linux
package monitor_test package monitor_test
import ( import (
+4 -4
View File
@@ -1,5 +1,3 @@
//go:build linux
package monitor package monitor
import ( import (
@@ -383,9 +381,11 @@ func (m *Monitor) uiLoop(ctx context.Context) {
runtime := time.Since(m.startTime).Round(time.Second).String() runtime := time.Since(m.startTime).Round(time.Second).String()
Put(m.screen, colLeft, rowRuntime, "Runtime: "+runtime, tcell.StyleDefault) Put(m.screen, colLeft, rowRuntime, "Runtime: "+runtime, tcell.StyleDefault)
// Draw one pane per interface; a single interface draws one pane.
y := rowFirstIface y := rowFirstIface
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)
}
m.screen.Show() m.screen.Show()
} }
+12
View File
@@ -0,0 +1,12 @@
package netdetect
// Test-only accessors exposing the unexported route parsers.
// ParseIPRoute exposes parseIPRoute for external tests.
func ParseIPRoute(out string) []Route { return parseIPRoute(out) }
// ParseProcNetRoute exposes parseProcNetRoute for external tests.
func ParseProcNetRoute(out string) []Route { return parseProcNetRoute(out) }
// ParseNetstat exposes parseNetstat for external tests.
func ParseNetstat(out string) []Route { return parseNetstat(out) }
+375
View File
@@ -0,0 +1,375 @@
// 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 (
"encoding/hex"
"errors"
"fmt"
"net"
"sort"
"strings"
)
// Selection failures. Those needing the offending interface names are
// wrapped with %w at the call site.
var (
errUnsupportedOS = errors.New("unsupported operating system")
errOneOfPair = errors.New(
"found only one of the configured interfaces; " +
"rtnetmon needs both, or neither (default route only)")
errNoDefaultRoute = errors.New(
"no default route found; rtnetmon needs one internet interface")
errManyDefaultRoutes = errors.New(
"multiple default-route interfaces found; rtnetmon supports only one")
errNoPhysRoute = errors.New(
"no physical default-route interface found; " +
"rtnetmon needs one internet interface")
errManyPhysRoutes = errors.New(
"multiple physical default-route interfaces found; " +
"rtnetmon supports only one")
)
// 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("%w: %q", errUnsupportedOS, 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("%w (%q, %q)", errOneOfPair, 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 "", errNoDefaultRoute
default:
return "", fmt.Errorf("%w (%s)",
errManyDefaultRoutes, 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 "", errNoPhysRoute
default:
return "", fmt.Errorf("%w (%s)",
errManyPhysRoutes, 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 := range len(fields) - 1 {
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 {
b, err := hex.DecodeString(h)
if err != nil || len(b) != net.IPv4len {
return ""
}
// /proc/net/route stores the address little-endian.
return net.IPv4(b[3], b[2], b[1], b[0]).String()
}
+310
View File
@@ -0,0 +1,310 @@
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)
}
}
+27
View File
@@ -0,0 +1,27 @@
//go:build darwin
package netdetect
import (
"context"
"fmt"
"os/exec"
"time"
)
// routeQueryTimeout bounds the external route-table query.
const routeQueryTimeout = 2 * time.Second
// DefaultRoutes returns the host's IPv4 default routes, read from the macOS
// routing table.
func DefaultRoutes() ([]Route, error) {
ctx, cancel := context.WithTimeout(context.Background(), routeQueryTimeout)
defer cancel()
out, err := exec.CommandContext(ctx, "netstat", "-rn", "-f", "inet").Output()
if err != nil {
return nil, fmt.Errorf("running netstat: %w", err)
}
return parseNetstat(string(out)), nil
}
+36
View File
@@ -0,0 +1,36 @@
//go:build linux
package netdetect
import (
"context"
"fmt"
"os"
"os/exec"
"time"
)
// routeQueryTimeout bounds the external route-table query.
const routeQueryTimeout = 2 * time.Second
// 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) {
ctx, cancel := context.WithTimeout(context.Background(), routeQueryTimeout)
defer cancel()
out, err := exec.CommandContext(ctx,
"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, fmt.Errorf("reading /proc/net/route: %w", err)
}
return parseProcNetRoute(string(data)), nil
}