Compare commits
1
Commits
main
..
b8944cd2a6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8944cd2a6 |
No files matched your search
@@ -3,7 +3,7 @@
|
||||
|
||||
# Target bodies live in script/ (Scripts to Rule Them All); the targets
|
||||
# below are thin shims that call them.
|
||||
.DEFAULT_GOAL := build
|
||||
.DEFAULT_GOAL := check
|
||||
|
||||
VERSION ?= $(shell git describe --tags --always)
|
||||
|
||||
@@ -37,9 +37,7 @@ cibuild:
|
||||
hooks:
|
||||
@script/install-precommit
|
||||
|
||||
build: ./bin/rtnetmon
|
||||
|
||||
./bin/rtnetmon: ./cmd ./internal go.sum go.mod
|
||||
build:
|
||||
CGO_ENABLED=0 go build -trimpath -ldflags "-X main.Version=$(VERSION)" \
|
||||
-o bin/rtnetmon ./cmd/rtnetmon
|
||||
|
||||
|
||||
@@ -55,12 +55,7 @@ primary pane alongside the physical interface. A tunnel that is up without the
|
||||
default route is ignored, whatever its address: Tailscale without an exit node,
|
||||
or a tunnel a disconnected client left behind. A default route scoped to the
|
||||
tunnel alone (flag `I`) does not count either. With no VPN connected, only the
|
||||
physical interface is monitored. The physical interface is the one non-tunnel
|
||||
`default` row with a next-hop gateway (flag `G`); it keeps that gateway even
|
||||
while a VPN holds the default route and its own default is scoped. An on-link
|
||||
`default` row with no gateway, such as the one vmnet or Internet Sharing
|
||||
installs on `bridge100` for a VM's subnet, is ignored. Interface names are
|
||||
detected on macOS; 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.
|
||||
|
||||
|
||||
@@ -24,7 +24,8 @@ On `next`:
|
||||
entrypoints, the vendored `.golangci.yml`, and `REPO_POLICIES.md`.
|
||||
- Interface detection on Linux and macOS
|
||||
(https://git.eeqj.de/sneak/rtnetmon/issues/2).
|
||||
- The fix for macOS VPN detection (https://git.eeqj.de/sneak/rtnetmon/issues/8).
|
||||
- The fix for macOS VPN detection
|
||||
(https://git.eeqj.de/sneak/rtnetmon/issues/8).
|
||||
- Starlink status lines (https://git.eeqj.de/sneak/rtnetmon/issues/3).
|
||||
- A version stamp: built with `make build` or a plain `docker build .`, the
|
||||
binary logs its git tag or short commit at startup
|
||||
|
||||
@@ -2,7 +2,6 @@ package monitor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/rtnetmon/internal/starlink"
|
||||
)
|
||||
@@ -60,9 +59,6 @@ func (m *Monitor) TCPHosts() []string { return m.tcpHosts }
|
||||
// Interfaces returns the monitored interfaces.
|
||||
func (m *Monitor) Interfaces() []*InterfaceStatus { return m.interfaces }
|
||||
|
||||
// ClockStrings exposes clockStrings for external tests.
|
||||
func ClockStrings(now time.Time) (string, string) { return clockStrings(now) }
|
||||
|
||||
// PingArgs exposes pingArgs for external tests.
|
||||
func PingArgs(goos, iface, host string) []string { return pingArgs(goos, iface, host) }
|
||||
|
||||
|
||||
+10
-11
@@ -337,15 +337,6 @@ func drawICMPStats(scr tcell.Screen, y int, st *InterfaceStatus) int {
|
||||
}
|
||||
|
||||
// uiLoop runs the UI event loop.
|
||||
// clockFormat is the layout of both header clocks.
|
||||
const clockFormat = time.RFC1123Z
|
||||
|
||||
// clockStrings renders the header clocks: UTC first, then the host's local
|
||||
// time in whatever location now carries.
|
||||
func clockStrings(now time.Time) (string, string) {
|
||||
return now.UTC().Format(clockFormat), now.Format(clockFormat)
|
||||
}
|
||||
|
||||
func (m *Monitor) uiLoop(ctx context.Context) {
|
||||
m.logf("UI loop started")
|
||||
|
||||
@@ -357,6 +348,13 @@ func (m *Monitor) uiLoop(ctx context.Context) {
|
||||
m.logf("Screen finalized")
|
||||
}()
|
||||
|
||||
pstLoc, err := time.LoadLocation("America/Los_Angeles")
|
||||
if err != nil {
|
||||
m.logf("Error loading PST location: %v", err)
|
||||
|
||||
pstLoc = time.UTC
|
||||
}
|
||||
|
||||
timestampSpinFrame := 0
|
||||
lastTimestampSpinUpdate := time.Now()
|
||||
|
||||
@@ -367,7 +365,8 @@ func (m *Monitor) uiLoop(ctx context.Context) {
|
||||
Put(m.screen, colLeft, rowTopRule, HLine(w), tcell.StyleDefault)
|
||||
|
||||
now := time.Now()
|
||||
timeStr, localTimeStr := clockStrings(now)
|
||||
timeStr := now.Format(time.RFC1123Z)
|
||||
pstTimeStr := now.In(pstLoc).Format(time.RFC1123Z)
|
||||
|
||||
if time.Since(lastTimestampSpinUpdate) >= time.Second {
|
||||
timestampSpinFrame = (timestampSpinFrame + 1) % len(brailleSpins())
|
||||
@@ -381,7 +380,7 @@ func (m *Monitor) uiLoop(ctx context.Context) {
|
||||
Put(m.screen, colSpinner, rowClock, string(brailleChar)+" ", tcell.StyleDefault)
|
||||
DrawRainbowText(m.screen, colMeter, rowClock, timeStr, colors)
|
||||
DrawRainbowText(m.screen, colMeter+len(timeStr)+clockGap, rowClock,
|
||||
localTimeStr, colors)
|
||||
pstTimeStr, colors)
|
||||
Put(m.screen, colLeft, rowBottomRule, HLine(w), tcell.StyleDefault)
|
||||
|
||||
runtime := time.Since(m.startTime).Round(time.Second).String()
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
package monitor_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/rtnetmon/internal/monitor"
|
||||
)
|
||||
|
||||
// TestClockStrings checks that the header shows UTC first and the host's
|
||||
// local time second, so the two differ whenever the host is not on UTC.
|
||||
func TestClockStrings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
berlin := time.FixedZone("CET", 3600)
|
||||
now := time.Date(2026, time.November, 6, 20, 30, 0, 0, berlin)
|
||||
|
||||
utc, local := monitor.ClockStrings(now)
|
||||
|
||||
if want := "Fri, 06 Nov 2026 19:30:00 +0000"; utc != want {
|
||||
t.Errorf("utc = %q, want %q", utc, want)
|
||||
}
|
||||
|
||||
if want := "Fri, 06 Nov 2026 20:30:00 +0100"; local != want {
|
||||
t.Errorf("local = %q, want %q", local, want)
|
||||
}
|
||||
}
|
||||
@@ -41,11 +41,9 @@ type Interface struct {
|
||||
IPv4 []string
|
||||
}
|
||||
|
||||
// Route is one routing-table entry reduced to what detection needs. Gateway
|
||||
// is the next-hop address, empty for an on-link route that has none: a
|
||||
// tunnel, or the pseudo-default that vmnet and Internet Sharing install on a
|
||||
// bridge so the hosted subnet works. Scoped marks a macOS interface-scoped
|
||||
// route (flag I), which only traffic bound to that interface uses.
|
||||
// Route is one routing-table entry reduced to what detection needs. Scoped
|
||||
// marks a macOS interface-scoped route (flag I), which only traffic bound to
|
||||
// that interface uses.
|
||||
type Route struct {
|
||||
Iface string
|
||||
Gateway string
|
||||
@@ -190,9 +188,7 @@ func findVPN(ifaces []Interface, routes []Route) string {
|
||||
// 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, func(r Route) bool {
|
||||
return !isTunnel(r.Iface)
|
||||
})
|
||||
names := defaultRouteIfaces(routes, "")
|
||||
|
||||
switch len(names) {
|
||||
case 1:
|
||||
@@ -206,16 +202,9 @@ func onlyDefaultRoute(routes []Route) (string, error) {
|
||||
}
|
||||
|
||||
// onlyPhysicalDefaultRoute returns the single non-tunnel default-route
|
||||
// interface (ignoring the VPN), or an error if there is not exactly one. Only
|
||||
// a default route with a next-hop gateway counts: scoped routes do, since
|
||||
// while a VPN holds the default route the physical interface keeps only a
|
||||
// default route scoped to itself, but that route still names the gateway. An
|
||||
// on-link default without one (vmnet's on bridge100) reaches nothing beyond
|
||||
// its own subnet and is not an uplink.
|
||||
// interface (ignoring the VPN), or an error if there is not exactly one.
|
||||
func onlyPhysicalDefaultRoute(routes []Route, vpn string) (string, error) {
|
||||
names := defaultRouteIfaces(routes, func(r Route) bool {
|
||||
return r.Iface != vpn && !isTunnel(r.Iface) && r.Gateway != ""
|
||||
})
|
||||
names := defaultRouteIfaces(routes, vpn)
|
||||
|
||||
switch len(names) {
|
||||
case 1:
|
||||
@@ -229,14 +218,16 @@ func onlyPhysicalDefaultRoute(routes []Route, vpn string) (string, error) {
|
||||
}
|
||||
|
||||
// defaultRouteIfaces returns the sorted, unique interface names that carry a
|
||||
// default route accepted by keep.
|
||||
func defaultRouteIfaces(routes []Route, keep func(Route) bool) []string {
|
||||
// default route, excluding the named VPN interface and any other tunnel.
|
||||
// Scoped routes count: while a VPN holds the default route, the physical
|
||||
// interface keeps only a default route scoped to itself.
|
||||
func defaultRouteIfaces(routes []Route, vpn string) []string {
|
||||
seen := map[string]bool{}
|
||||
|
||||
var names []string
|
||||
|
||||
for _, r := range routes {
|
||||
if !r.Default || !keep(r) || seen[r.Iface] {
|
||||
if !r.Default || r.Iface == vpn || isTunnel(r.Iface) || seen[r.Iface] {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -336,20 +327,11 @@ func parseProcNetRoute(out string) []Route {
|
||||
return routes
|
||||
}
|
||||
|
||||
// netstat route flags (see `man netstat`).
|
||||
const (
|
||||
netstatFlagGateway = "G" // destination is reached through a next-hop gateway
|
||||
netstatFlagScoped = "I" // route is scoped to its interface
|
||||
)
|
||||
|
||||
// parseNetstat reads `netstat -rn -f inet` output (macOS); the Netif column
|
||||
// (field 4) names the interface. A row whose destination is "default" is a
|
||||
// default route, scoped when its flags include I. Its Gateway column is the
|
||||
// next hop only when the flags include G; otherwise it names the link itself
|
||||
// (link#N) and the route is on-link, with no gateway. A "0/1" row and a
|
||||
// "128.0/1" row on one interface together also make a default route there:
|
||||
// VPN clients that leave the physical default in place send all traffic
|
||||
// through them.
|
||||
// default route, scoped when its flags include I. A "0/1" row and a "128.0/1"
|
||||
// row on one interface together also make a default route there: VPN clients
|
||||
// that leave the physical default in place send all traffic through them.
|
||||
func parseNetstat(out string) []Route {
|
||||
const columns = 4 // Destination, Gateway, Flags, Netif; Expire is optional
|
||||
|
||||
@@ -369,15 +351,11 @@ func parseNetstat(out string) []Route {
|
||||
|
||||
switch dest {
|
||||
case "default":
|
||||
if !strings.Contains(flags, netstatFlagGateway) {
|
||||
gateway = ""
|
||||
}
|
||||
|
||||
routes = append(routes, Route{
|
||||
Iface: iface,
|
||||
Gateway: gateway,
|
||||
Default: true,
|
||||
Scoped: strings.Contains(flags, netstatFlagScoped),
|
||||
Scoped: strings.Contains(flags, "I"),
|
||||
})
|
||||
case "0/1":
|
||||
lowHalf = append(lowHalf, iface)
|
||||
|
||||
@@ -17,7 +17,6 @@ const (
|
||||
ifaceEth0 = "eth0"
|
||||
ifaceWlan0 = "wlan0"
|
||||
ifaceEn0 = "en0"
|
||||
ifaceBridge = "bridge100"
|
||||
ifaceUtun0 = "utun0"
|
||||
ifaceUtun3 = "utun3"
|
||||
ifaceUtun4 = "utun4"
|
||||
@@ -28,8 +27,6 @@ const (
|
||||
labelVPN = "VPN"
|
||||
|
||||
addrEn0 = "192.168.1.20"
|
||||
gatewayEn0 = "192.168.1.1"
|
||||
addrBridge = "192.168.64.1"
|
||||
addrVPN = "10.64.0.2"
|
||||
addrTailscale = "100.101.102.103"
|
||||
)
|
||||
@@ -109,32 +106,6 @@ default 192.168.1.1 UGScg en0
|
||||
100.101.102.103/32 link#22 UCS utun3
|
||||
127.0.0.1 127.0.0.1 UH lo0
|
||||
128.0/1 utun4 USc utun4
|
||||
`
|
||||
|
||||
// A VM with NAT networking: vmnet installs an on-link default route,
|
||||
// scoped to its bridge and with no gateway, next to the real one.
|
||||
netstatVMBridge = netstatHeader + `
|
||||
default 192.168.1.1 UGScg en0
|
||||
default link#45 UCSIg bridge100 !
|
||||
127.0.0.1 127.0.0.1 UH lo0
|
||||
192.168.64 link#45 UC bridge100 !
|
||||
`
|
||||
|
||||
// The same VM bridge while a VPN holds the default route.
|
||||
netstatVMBridgeAndVPN = netstatHeader + `
|
||||
default link#15 UCSg utun4
|
||||
default 192.168.1.1 UGScIg en0
|
||||
default link#45 UCSIg bridge100 !
|
||||
10.64.0.2 10.64.0.2 UH utun4
|
||||
127.0.0.1 127.0.0.1 UH lo0
|
||||
192.168.64 link#45 UC bridge100 !
|
||||
`
|
||||
|
||||
// Only the VM bridge's on-link default route, no uplink at all.
|
||||
netstatVMBridgeOnly = netstatHeader + `
|
||||
default link#45 UCSIg bridge100 !
|
||||
127.0.0.1 127.0.0.1 UH lo0
|
||||
192.168.64 link#45 UC bridge100 !
|
||||
`
|
||||
)
|
||||
|
||||
@@ -294,7 +265,7 @@ func TestSelectDarwinPanes(t *testing.T) {
|
||||
},
|
||||
routes: []netdetect.Route{
|
||||
{Iface: ifaceUtun4, Default: true},
|
||||
{Iface: ifaceEn0, Gateway: gatewayEn0, Default: true, Scoped: true},
|
||||
{Iface: ifaceEn0, Default: true},
|
||||
},
|
||||
flags: linuxFlags(),
|
||||
want: []netdetect.Pane{
|
||||
@@ -311,7 +282,7 @@ func TestSelectDarwinPanes(t *testing.T) {
|
||||
},
|
||||
routes: []netdetect.Route{
|
||||
{Iface: ifaceUtun4, Default: true},
|
||||
{Iface: ifaceEn0, Gateway: gatewayEn0, Default: true, Scoped: true},
|
||||
{Iface: ifaceEn0, Default: true},
|
||||
},
|
||||
flags: netdetect.Flags{
|
||||
LabelA: "Mullvad", LabelASet: true,
|
||||
@@ -337,7 +308,7 @@ func TestSelectDarwinSingleAndErrors(t *testing.T) {
|
||||
{Name: ifaceUtun0, Up: true, IPv4: nil},
|
||||
{Name: "utun1", Up: true, IPv4: []string{"169.254.1.1"}},
|
||||
},
|
||||
routes: []netdetect.Route{{Iface: ifaceEn0, Gateway: gatewayEn0, Default: true}},
|
||||
routes: []netdetect.Route{{Iface: ifaceEn0, Default: true}},
|
||||
flags: linuxFlags(),
|
||||
want: []netdetect.Pane{{Name: ifaceEn0, Label: labelDefault}},
|
||||
},
|
||||
@@ -348,7 +319,7 @@ func TestSelectDarwinSingleAndErrors(t *testing.T) {
|
||||
{Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}},
|
||||
{Name: "utun6", Up: true, IPv4: []string{"10.2.0.2"}},
|
||||
},
|
||||
routes: []netdetect.Route{{Iface: ifaceEn0, Gateway: gatewayEn0, Default: true}},
|
||||
routes: []netdetect.Route{{Iface: ifaceEn0, Default: true}},
|
||||
flags: linuxFlags(),
|
||||
want: []netdetect.Pane{{Name: ifaceEn0, Label: labelDefault}},
|
||||
},
|
||||
@@ -370,8 +341,8 @@ func TestSelectDarwinSingleAndErrors(t *testing.T) {
|
||||
{Name: "en1", Up: true, IPv4: []string{"192.168.2.20"}},
|
||||
},
|
||||
routes: []netdetect.Route{
|
||||
{Iface: ifaceEn0, Gateway: gatewayEn0, Default: true},
|
||||
{Iface: "en1", Gateway: "192.168.2.1", Default: true},
|
||||
{Iface: ifaceEn0, Default: true},
|
||||
{Iface: "en1", Default: true},
|
||||
},
|
||||
flags: linuxFlags(),
|
||||
wantErr: true,
|
||||
@@ -459,63 +430,6 @@ func TestSelectDarwinFromNetstat(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestSelectDarwinVMBridge covers a VM's NAT bridge: vmnet installs a default
|
||||
// route scoped to the bridge with no gateway, which is not an uplink.
|
||||
func TestSelectDarwinVMBridge(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
vpn := netdetect.Interface{Name: ifaceUtun4, Up: true, IPv4: []string{addrVPN}}
|
||||
bridge := netdetect.Interface{Name: ifaceBridge, Up: true, IPv4: []string{addrBridge}}
|
||||
physicalOnly := []netdetect.Pane{{Name: ifaceEn0, Label: labelDefault}}
|
||||
vpnAndPhysical := []netdetect.Pane{
|
||||
{Name: ifaceUtun4, Label: labelVPN},
|
||||
{Name: ifaceEn0, Label: labelDefault},
|
||||
}
|
||||
|
||||
runSelectCases(t, []selectCase{
|
||||
{
|
||||
name: "on-link default route is not an uplink",
|
||||
goos: osDarwin,
|
||||
ifaces: macIfaces(bridge),
|
||||
routes: []netdetect.Route{
|
||||
{Iface: ifaceEn0, Gateway: gatewayEn0, Default: true},
|
||||
{Iface: ifaceBridge, Default: true, Scoped: true},
|
||||
},
|
||||
flags: linuxFlags(),
|
||||
want: physicalOnly,
|
||||
},
|
||||
{
|
||||
name: "only an on-link default route",
|
||||
goos: osDarwin,
|
||||
ifaces: macIfaces(bridge),
|
||||
routes: []netdetect.Route{{Iface: ifaceBridge, Default: true, Scoped: true}},
|
||||
flags: linuxFlags(),
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "netstat: vm bridge next to the physical default",
|
||||
goos: osDarwin,
|
||||
ifaces: macIfaces(bridge),
|
||||
routes: netdetect.ParseNetstat(netstatVMBridge),
|
||||
want: physicalOnly,
|
||||
},
|
||||
{
|
||||
name: "netstat: vm bridge next to a connected vpn",
|
||||
goos: osDarwin,
|
||||
ifaces: macIfaces(bridge, vpn),
|
||||
routes: netdetect.ParseNetstat(netstatVMBridgeAndVPN),
|
||||
want: vpnAndPhysical,
|
||||
},
|
||||
{
|
||||
name: "netstat: vm bridge with no uplink",
|
||||
goos: osDarwin,
|
||||
ifaces: macIfaces(bridge),
|
||||
routes: netdetect.ParseNetstat(netstatVMBridgeOnly),
|
||||
wantErr: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseIPRoute(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -549,13 +463,11 @@ func TestParseNetstat(t *testing.T) {
|
||||
"Destination Gateway Flags Netif Expire\n" +
|
||||
"default 10.0.0.1 UGScg en0\n" +
|
||||
"default link#15 UCSg utun4\n" +
|
||||
"default link#45 UCSIg bridge100 !\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, Default: true},
|
||||
{Iface: ifaceBridge, Default: true, Scoped: true},
|
||||
{Iface: ifaceUtun4, Gateway: "link#15", Default: true},
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
|
||||
Reference in new issue
Block a user