Compare commits

..
6 Commits
Author SHA1 Message Date
sneak 5b16fd3310 Merge pull request 'Show UTC as the first header clock instead of hardcoded Los Angeles time' (#19) from header-utc-clock into next
check / check (push) Successful in 2m10s
Reviewed-on: #19
2026-11-07 05:23:53 +01:00
sneak 8746591889 Merge branch 'next' into header-utc-clock
check / check (push) Successful in 2m6s
2026-11-07 05:23:40 +01:00
sneak 053c00cd52 Merge pull request 'macOS: ignore on-link default routes when picking the physical uplink' (#18) from macos-onlink-default-route into next
check / check (push) Successful in 2m43s
Reviewed-on: #18
2026-11-07 05:23:30 +01:00
sneak bbceab0b24 Show UTC as the first header clock instead of hardcoded Los Angeles time
check / check (push) Successful in 2m0s
The header drew the host's local time and then America/Los_Angeles
time, so on a Mac in that zone both clocks read the same and neither
was UTC. The first clock is now UTC and the second the host's local
time, which also drops the hardcoded zone and its load-failure path.
2026-11-06 19:12:41 -08:00
sneak 6dc3b9e395 macOS: ignore on-link default routes when picking the physical uplink
check / check (push) Successful in 2m32s
A VM with NAT networking makes vmnet install a default route scoped to
its bridge (bridge100) with no gateway. The detector counted it as a
second physical default route and refused to start:

    multiple physical default-route interfaces found (bridge100, en0)

Scoped routes cannot simply be dropped: while a VPN holds the default
route, the physical interface keeps only a default route scoped to
itself. That route still names its gateway (flag G). The on-link one
does not, so a physical default route now requires a next-hop gateway.

parseNetstat records a gateway only when the G flag is set; a link#N
gateway column is the interface itself, not a next hop.
2026-11-06 19:08:12 -08:00
clawbot 7686440108 Bring TODO.md up to date with next and the tracker (closes #13)
check / check (push) Successful in 3m18s
The workflow lines now say who merges where: clawbot squash-merges
reviewed issue PRs into next, only sneak merges next into main, and a
deploy is a squash commit from main onto prod, never automatic.

Status lists what is on next today, each item linked to its issue: the
repo standards, interface detection on Linux and macOS, the macOS VPN
detection fix, the Starlink status lines, the version stamp, and CI
running script/cibuild. Next Step is the owner's merge of the milestone
PR and a first run on a real Mac.

Model: opus-5-5
2026-10-04 03:43:39 +02:00
6 changed files with 180 additions and 33 deletions

No files matched your search

+6 -1
View File
@@ -55,7 +55,12 @@ 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. Interface names are detected on macOS; 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
`--ifaceA`/`--ifaceB` flags are not used there, but `--labelA`/`--labelB` still
set the pane labels.
+4
View File
@@ -2,6 +2,7 @@ package monitor
import (
"context"
"time"
"git.eeqj.de/sneak/rtnetmon/internal/starlink"
)
@@ -59,6 +60,9 @@ 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) }
+11 -10
View File
@@ -337,6 +337,15 @@ 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")
@@ -348,13 +357,6 @@ 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()
@@ -365,8 +367,7 @@ func (m *Monitor) uiLoop(ctx context.Context) {
Put(m.screen, colLeft, rowTopRule, HLine(w), tcell.StyleDefault)
now := time.Now()
timeStr := now.Format(time.RFC1123Z)
pstTimeStr := now.In(pstLoc).Format(time.RFC1123Z)
timeStr, localTimeStr := clockStrings(now)
if time.Since(lastTimestampSpinUpdate) >= time.Second {
timestampSpinFrame = (timestampSpinFrame + 1) % len(brailleSpins())
@@ -380,7 +381,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,
pstTimeStr, colors)
localTimeStr, colors)
Put(m.screen, colLeft, rowBottomRule, HLine(w), tcell.StyleDefault)
runtime := time.Since(m.startTime).Round(time.Second).String()
+27
View File
@@ -0,0 +1,27 @@
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)
}
}
+37 -15
View File
@@ -41,9 +41,11 @@ type Interface struct {
IPv4 []string
}
// 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.
// 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.
type Route struct {
Iface string
Gateway string
@@ -188,7 +190,9 @@ 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, "")
names := defaultRouteIfaces(routes, func(r Route) bool {
return !isTunnel(r.Iface)
})
switch len(names) {
case 1:
@@ -202,9 +206,16 @@ 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.
// 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.
func onlyPhysicalDefaultRoute(routes []Route, vpn string) (string, error) {
names := defaultRouteIfaces(routes, vpn)
names := defaultRouteIfaces(routes, func(r Route) bool {
return r.Iface != vpn && !isTunnel(r.Iface) && r.Gateway != ""
})
switch len(names) {
case 1:
@@ -218,16 +229,14 @@ func onlyPhysicalDefaultRoute(routes []Route, vpn string) (string, error) {
}
// defaultRouteIfaces returns the sorted, unique interface names that carry a
// 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 {
// default route accepted by keep.
func defaultRouteIfaces(routes []Route, keep func(Route) bool) []string {
seen := map[string]bool{}
var names []string
for _, r := range routes {
if !r.Default || r.Iface == vpn || isTunnel(r.Iface) || seen[r.Iface] {
if !r.Default || !keep(r) || seen[r.Iface] {
continue
}
@@ -327,11 +336,20 @@ 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. 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. 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.
func parseNetstat(out string) []Route {
const columns = 4 // Destination, Gateway, Flags, Netif; Expire is optional
@@ -351,11 +369,15 @@ 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, "I"),
Scoped: strings.Contains(flags, netstatFlagScoped),
})
case "0/1":
lowHalf = append(lowHalf, iface)
+95 -7
View File
@@ -17,6 +17,7 @@ const (
ifaceEth0 = "eth0"
ifaceWlan0 = "wlan0"
ifaceEn0 = "en0"
ifaceBridge = "bridge100"
ifaceUtun0 = "utun0"
ifaceUtun3 = "utun3"
ifaceUtun4 = "utun4"
@@ -27,6 +28,8 @@ 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"
)
@@ -106,6 +109,32 @@ 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 !
`
)
@@ -265,7 +294,7 @@ func TestSelectDarwinPanes(t *testing.T) {
},
routes: []netdetect.Route{
{Iface: ifaceUtun4, Default: true},
{Iface: ifaceEn0, Default: true},
{Iface: ifaceEn0, Gateway: gatewayEn0, Default: true, Scoped: true},
},
flags: linuxFlags(),
want: []netdetect.Pane{
@@ -282,7 +311,7 @@ func TestSelectDarwinPanes(t *testing.T) {
},
routes: []netdetect.Route{
{Iface: ifaceUtun4, Default: true},
{Iface: ifaceEn0, Default: true},
{Iface: ifaceEn0, Gateway: gatewayEn0, Default: true, Scoped: true},
},
flags: netdetect.Flags{
LabelA: "Mullvad", LabelASet: true,
@@ -308,7 +337,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, Default: true}},
routes: []netdetect.Route{{Iface: ifaceEn0, Gateway: gatewayEn0, Default: true}},
flags: linuxFlags(),
want: []netdetect.Pane{{Name: ifaceEn0, Label: labelDefault}},
},
@@ -319,7 +348,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, Default: true}},
routes: []netdetect.Route{{Iface: ifaceEn0, Gateway: gatewayEn0, Default: true}},
flags: linuxFlags(),
want: []netdetect.Pane{{Name: ifaceEn0, Label: labelDefault}},
},
@@ -341,8 +370,8 @@ func TestSelectDarwinSingleAndErrors(t *testing.T) {
{Name: "en1", Up: true, IPv4: []string{"192.168.2.20"}},
},
routes: []netdetect.Route{
{Iface: ifaceEn0, Default: true},
{Iface: "en1", Default: true},
{Iface: ifaceEn0, Gateway: gatewayEn0, Default: true},
{Iface: "en1", Gateway: "192.168.2.1", Default: true},
},
flags: linuxFlags(),
wantErr: true,
@@ -430,6 +459,63 @@ 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()
@@ -463,11 +549,13 @@ 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, Gateway: "link#15", Default: true},
{Iface: ifaceUtun4, Default: true},
{Iface: ifaceBridge, Default: true, Scoped: true},
}
if !reflect.DeepEqual(got, want) {