Compare commits

Author SHA1 Message Date
sneak 495889e85b Bring TODO.md up to date with next and the tracker (closes #13)
check / check (push) Successful in 2m17s
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: the repo standards, interface
detection on Linux and macOS with the macOS VPN pane shown only while a
VPN carries the default route, 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-03 13:57:36 +00:00
clawbot 3578d18777 macOS: show the VPN pane only while a VPN is connected (closes #8)
check / check (push) Successful in 3m1s
A utun tunnel counted as the VPN whenever it was up with a routable
IPv4 address, so idle Tailscale or a tunnel left behind by a
disconnected client became the VPN pane and its probes failed. A
tunnel is now the VPN only while it carries the IPv4 default route in
netstat: an unscoped default row, or both 0/1 and 128.0/1 rows on it.
A default row scoped to a tunnel (flag I) does not count; on the
physical interface it still does, since a VPN holding the default
leaves the physical default scoped. Tests feed netstat text in the
macOS layout through the parser into Select. Not run on a real Mac.

Model: opus-5-5
2026-10-03 15:26:39 +02:00
clawbot 66fb8bf149 Refresh apt package lists before bootstrap installs anything (closes #12)
check / check (push) Successful in 2m23s
The CI job image ships with empty apt package lists, so the bootstrap's
`apt-get install -y golang` could not find the package and every CI run
stopped there. On the apt path the bootstrap now runs `apt-get update`
once, before the first install, and not at all when nothing needs
installing.

Model: opus-5-5
2026-10-03 15:09:39 +02:00
clawbot bce8bdbb2e Stamp the git tag or short commit into the binary (closes #10)
check / check (push) Failing after 3s
rtnetmon had no version. main.Version is now set at link time and logged
in the first startup line. `make build` and `make dev` set it from
`git describe --tags --always` unless VERSION is given. The Dockerfile
takes it from the VERSION build argument when one is given, otherwise
from `git describe --tags --always` of the .git the build context now
carries, and fails the build if the context carries .git and no version
comes out. .dockerignore follows the canonical copy: .git is sent,
.git/config, which can hold a credential, is not.

Model: opus-5-5
2026-10-02 10:21:25 +02:00
5 changed files with 280 additions and 91 deletions
+16 -11
View File
@@ -46,22 +46,27 @@ single pane.
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
**macOS.** The physical internet interface is found from the default route. The
VPN pane appears only while a VPN is connected, meaning its `utun` tunnel
carries the IPv4 default route: in `netstat -rn -f inet` that is a `default` row
on the tunnel, or both a `0/1` and a `128.0/1` row on it, which some VPN clients
install instead of replacing the default. That tunnel is then monitored as the
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
`--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) |
| OS | Interfaces monitored |
| ----- | --------------------------------------------------------------------- |
| Linux | `gu0` + `backhaul0` when both exist (two panes) |
| Linux | the single default-route interface otherwise (one pane) |
| macOS | connected VPN tunnel + physical default-route interface (two panes) |
| macOS | the physical default-route interface with no VPN connected (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
+29 -25
View File
@@ -5,38 +5,42 @@ One issue per unit of work, one branch and one PR per issue:
- ensure a tracked issue exists with a definition of done
- branch from `next` (never from `main`)
- do the work; open a PR based on `next` (never on `main`)
- pass an independent review, then the change is squash-merged into `next`
- pass an independent review, then `clawbot` squash-merges it into `next`
- push; nothing stays local-only
`next` is the branch for the next milestone and must stay green and
mergeable to `main` without notice. Only `sneak` merges `next` into
`main`, and for now only `sneak` merges into `next`. No commits land
directly on `main` or `next` — only merges via PRs.
`next` is the branch for the next milestone and must stay green and mergeable to
`main` without notice. Only `sneak` merges `next` into `main`. No commits land
directly on `main` or `next`, only merges via PRs. A deploy is a squash commit
from `main` onto `prod`; `prod` never follows `main` automatically.
Issue branches do NOT touch this file — it is maintained on `next`.
Every branch editing `TODO.md` conflicts with every other.
Other issue branches do not touch this file; it changes only in its own PR to
`next`, because every branch editing `TODO.md` conflicts with every other.
# Status
The repository has been brought up to current repo standards: `script/`
Scripts to Rule Them All entrypoints with the `Makefile` reduced to thin
shims, a `Dockerfile` whose `lint` and `test` phases gate the build, a
`.gitea/workflows/` CI workflow running `script/cibuild`, the vendored
`.golangci.yml`, `REPO_POLICIES.md`, `.editorconfig`, `.dockerignore`, a
`LICENSE` file, and a comprehensive `.gitignore`.
On `next`:
Lint is clean under the standard `default: all` configuration, with the
findings fixed rather than suppressed. The only annotations are
justified `//nolint:gosec` on the `ping`/`curl` subprocess calls (G204)
and on opening the operator-chosen log file (G304): fixed argv with no
shell, so these are false positives, annotated as the reference repos do.
- Repo standards (https://git.eeqj.de/sneak/rtnetmon/issues/1): `script/`
entrypoints with the `Makefile` as thin shims, lint and tests run in Docker as
phases that gate the image build, the vendored `.golangci.yml`, and
`REPO_POLICIES.md`.
- Interface detection on Linux and macOS
(https://git.eeqj.de/sneak/rtnetmon/issues/2): one pane or two, from the
interfaces present. On macOS the VPN pane appears only while a VPN carries the
default route (https://git.eeqj.de/sneak/rtnetmon/issues/8).
- Starlink status lines under the physical interface's pane when a Starlink dish
answers (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
(https://git.eeqj.de/sneak/rtnetmon/issues/10).
- CI that runs `script/cibuild` on every push
(https://git.eeqj.de/sneak/rtnetmon/issues/12).
# Next Step
Feature work, each on its own branch and PR from `next`:
- https://git.eeqj.de/sneak/rtnetmon/issues/2 — macOS support:
VPN-aware interface detection and a single-interface UI when only one
interface exists.
- https://git.eeqj.de/sneak/rtnetmon/issues/3 — show Starlink status
lines when Starlink is the non-VPN gateway.
- `sneak` merges the milestone PR, https://git.eeqj.de/sneak/rtnetmon/pulls/6,
carrying `next` into `main`.
- A first run on a real Mac of what is on `next` now. The fix for
https://git.eeqj.de/sneak/rtnetmon/issues/8 has not run on a Mac, and none of
the machines rtnetmon is developed and tested on is one, so that run is
`sneak`'s.
+46 -35
View File
@@ -41,11 +41,14 @@ type Interface struct {
IPv4 []string
}
// Route is one routing-table entry reduced to what detection needs.
// 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
Default bool
Scoped bool
}
// Pane names one interface to display, with its label.
@@ -133,7 +136,7 @@ func selectLinux(ifaces []Interface, routes []Route, f Flags) ([]Pane, error) {
}
// selectDarwin monitors the physical default-route interface, plus a VPN
// tunnel as the primary pane when one is running.
// tunnel as the primary pane while one is connected.
func selectDarwin(ifaces []Interface, routes []Route, f Flags) ([]Pane, error) {
vpn := findVPN(ifaces, routes)
@@ -152,15 +155,16 @@ func selectDarwin(ifaces []Interface, routes []Route, f Flags) ([]Pane, error) {
}, 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.
// findVPN returns the name of the connected VPN tunnel, or "" if there is
// none. A tunnel is the VPN only while it carries the default route; one that
// is merely up, even with a routable address (Tailscale without an exit node,
// or a tunnel a disconnected client left behind), is not. A default route
// scoped to the tunnel does not count: only traffic bound there uses it.
func findVPN(ifaces []Interface, routes []Route) string {
routeIfaces := map[string]bool{}
for _, r := range routes {
if r.Default {
if r.Default && !r.Scoped {
routeIfaces[r.Iface] = true
}
}
@@ -176,10 +180,6 @@ func findVPN(ifaces []Interface, routes []Route) string {
if routeIfaces[ifi.Name] {
return ifi.Name
}
if ifi.Up && hasRoutableIPv4(ifi) {
return ifi.Name
}
}
return ""
@@ -219,6 +219,8 @@ 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 {
seen := map[string]bool{}
@@ -255,21 +257,6 @@ 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 {
@@ -340,23 +327,47 @@ func parseProcNetRoute(out string) []Route {
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.
// 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.
func parseNetstat(out string) []Route {
const columns = 4 // Destination, Gateway, Flags, Netif; Expire is optional
var routes []Route
var lowHalf []string // interfaces with a 0/1 row
highHalf := map[string]bool{} // interfaces with a 128.0/1 row
for _, line := range strings.Split(out, "\n") {
fields := strings.Fields(line)
if len(fields) < 4 || fields[0] != "default" {
if len(fields) < columns {
continue
}
routes = append(routes, Route{
Iface: fields[3],
Gateway: fields[1],
Default: true,
})
dest, gateway, flags, iface := fields[0], fields[1], fields[2], fields[3]
switch dest {
case "default":
routes = append(routes, Route{
Iface: iface,
Gateway: gateway,
Default: true,
Scoped: strings.Contains(flags, "I"),
})
case "0/1":
lowHalf = append(lowHalf, iface)
case "128.0/1":
highHalf[iface] = true
}
}
for _, iface := range lowHalf {
if highHalf[iface] {
routes = append(routes, Route{Iface: iface, Default: true})
}
}
return routes
+186 -20
View File
@@ -17,13 +17,96 @@ const (
ifaceEth0 = "eth0"
ifaceWlan0 = "wlan0"
ifaceEn0 = "en0"
ifaceUtun0 = "utun0"
ifaceUtun3 = "utun3"
ifaceUtun4 = "utun4"
labelGu = "gu LAN - VPN outbound"
labelCox = "Cox cable direct"
labelDefault = "default route"
labelVPN = "VPN"
addrEn0 = "192.168.1.20"
addrEn0 = "192.168.1.20"
addrVPN = "10.64.0.2"
addrTailscale = "100.101.102.103"
)
// netstatHeader starts `netstat -rn -f inet` output on macOS. Each output
// below adds the rows of one routing state: en0 is the physical interface,
// utun3 is Tailscale's tunnel and utun4 a VPN client's.
const netstatHeader = `Routing tables
Internet:
Destination Gateway Flags Netif Expire`
const (
netstatNoTunnel = netstatHeader + `
default 192.168.1.1 UGScg en0
127 127.0.0.1 UCS lo0
127.0.0.1 127.0.0.1 UH lo0
192.168.1 link#6 UCS en0 !
192.168.1.1 a4:2b:b0:12:34:56 UHLWIir en0 1187
`
// Tailscale on without an exit node: routes for its own range only.
netstatTailscaleIdle = netstatHeader + `
default 192.168.1.1 UGScg en0
100.64/10 link#22 UCS utun3
100.100.100.100/32 link#22 UCS utun3
100.101.102.103/32 link#22 UCS utun3
127.0.0.1 127.0.0.1 UH lo0
`
// A tunnel a disconnected client left behind, still holding its address.
netstatTunnelLeftBehind = netstatHeader + `
default 192.168.1.1 UGScg en0
10.64.0.2 10.64.0.2 UH utun4
127.0.0.1 127.0.0.1 UH lo0
`
// A tunnel whose only default route is scoped to it.
netstatTunnelScopedDefault = netstatHeader + `
default 192.168.1.1 UGScg en0
default link#22 UCSIg utun3
100.64/10 link#22 UCS utun3
127.0.0.1 127.0.0.1 UH lo0
`
// A tunnel with the lower half of the address space but not the upper.
netstatVPNOneHalf = netstatHeader + `
0/1 utun4 USc utun4
default 192.168.1.1 UGScg en0
127.0.0.1 127.0.0.1 UH lo0
`
// A VPN holding the default route; the physical default is now scoped.
netstatVPNDefault = netstatHeader + `
default link#15 UCSg utun4
default 192.168.1.1 UGScIg en0
10.64.0.2 10.64.0.2 UH utun4
127.0.0.1 127.0.0.1 UH lo0
`
// A VPN on both halves, leaving the physical default in place.
netstatVPNHalves = netstatHeader + `
0/1 utun4 USc utun4
default 192.168.1.1 UGScg en0
default 192.168.1.1 UGScIg en0
10.64.0.2 10.64.0.2 UH utun4
127.0.0.1 127.0.0.1 UH lo0
128.0/1 utun4 USc utun4
`
// Idle Tailscale next to a VPN on both halves.
netstatTailscaleAndVPN = netstatHeader + `
0/1 utun4 USc utun4
default 192.168.1.1 UGScg en0
10.64.0.2 10.64.0.2 UH utun4
100.64/10 link#22 UCS utun3
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
`
)
// linuxFlags describes the Linux bridge interfaces rtnetmon was built for.
@@ -34,6 +117,17 @@ func linuxFlags() netdetect.Flags {
}
}
// macIfaces returns the interfaces every Mac in these tests has (en0,
// loopback, and an idle system tunnel with no IPv4 address) plus the given
// tunnels.
func macIfaces(tunnels ...netdetect.Interface) []netdetect.Interface {
return append([]netdetect.Interface{
{Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}},
{Name: "lo0", Up: true, IPv4: []string{"127.0.0.1"}},
{Name: ifaceUtun0, Up: true},
}, tunnels...)
}
// selectCase is one Select scenario with fake interfaces and routes.
type selectCase struct {
name string
@@ -166,8 +260,8 @@ func TestSelectDarwinPanes(t *testing.T) {
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},
{Name: ifaceUtun4, Up: true, IPv4: []string{addrVPN}},
{Name: ifaceUtun0, Up: true, IPv4: nil},
},
routes: []netdetect.Route{
{Iface: ifaceUtun4, Default: true},
@@ -175,21 +269,7 @@ func TestSelectDarwinPanes(t *testing.T) {
},
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: ifaceUtun4, Label: labelVPN},
{Name: ifaceEn0, Label: labelDefault},
},
},
@@ -198,7 +278,7 @@ func TestSelectDarwinPanes(t *testing.T) {
goos: osDarwin,
ifaces: []netdetect.Interface{
{Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}},
{Name: ifaceUtun4, Up: true, IPv4: []string{"10.64.0.2"}},
{Name: ifaceUtun4, Up: true, IPv4: []string{addrVPN}},
},
routes: []netdetect.Route{
{Iface: ifaceUtun4, Default: true},
@@ -225,13 +305,24 @@ func TestSelectDarwinSingleAndErrors(t *testing.T) {
goos: osDarwin,
ifaces: []netdetect.Interface{
{Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}},
{Name: "utun0", Up: true, IPv4: nil},
{Name: ifaceUtun0, 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: "tunnel with a routable address but no default 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: ifaceEn0, Label: labelDefault}},
},
{
name: "no default route",
goos: osDarwin,
@@ -264,6 +355,81 @@ func TestSelectDarwinSingleAndErrors(t *testing.T) {
})
}
// TestSelectDarwinFromNetstat runs macOS routing tables through the netstat
// parser into Select: only a tunnel carrying the default route is the VPN.
func TestSelectDarwinFromNetstat(t *testing.T) {
t.Parallel()
tailscale := netdetect.Interface{
Name: ifaceUtun3, Up: true, IPv4: []string{addrTailscale},
}
vpn := netdetect.Interface{Name: ifaceUtun4, Up: true, IPv4: []string{addrVPN}}
physicalOnly := []netdetect.Pane{{Name: ifaceEn0, Label: labelDefault}}
vpnAndPhysical := []netdetect.Pane{
{Name: ifaceUtun4, Label: labelVPN},
{Name: ifaceEn0, Label: labelDefault},
}
runSelectCases(t, []selectCase{
{
name: "no tunnel",
goos: osDarwin,
ifaces: macIfaces(),
routes: netdetect.ParseNetstat(netstatNoTunnel),
want: physicalOnly,
},
{
name: "tailscale without an exit node",
goos: osDarwin,
ifaces: macIfaces(tailscale),
routes: netdetect.ParseNetstat(netstatTailscaleIdle),
want: physicalOnly,
},
{
name: "tunnel left behind by a disconnected client",
goos: osDarwin,
ifaces: macIfaces(vpn),
routes: netdetect.ParseNetstat(netstatTunnelLeftBehind),
want: physicalOnly,
},
{
name: "tunnel with only a scoped default route",
goos: osDarwin,
ifaces: macIfaces(tailscale),
routes: netdetect.ParseNetstat(netstatTunnelScopedDefault),
want: physicalOnly,
},
{
name: "tunnel with only one half of the address space",
goos: osDarwin,
ifaces: macIfaces(vpn),
routes: netdetect.ParseNetstat(netstatVPNOneHalf),
want: physicalOnly,
},
{
name: "vpn on the default route",
goos: osDarwin,
ifaces: macIfaces(vpn),
routes: netdetect.ParseNetstat(netstatVPNDefault),
want: vpnAndPhysical,
},
{
name: "vpn on both halves",
goos: osDarwin,
ifaces: macIfaces(vpn),
routes: netdetect.ParseNetstat(netstatVPNHalves),
want: vpnAndPhysical,
},
{
name: "idle tailscale next to a connected vpn",
goos: osDarwin,
ifaces: macIfaces(tailscale, vpn),
routes: netdetect.ParseNetstat(netstatTailscaleAndVPN),
want: vpnAndPhysical,
},
})
}
func TestParseIPRoute(t *testing.T) {
t.Parallel()
+3
View File
@@ -31,6 +31,9 @@ detect_pkgmgr() {
if [ "$(id -u)" != "0" ]; then
SUDO="sudo"
fi
# Fresh images, the CI runner's among them, ship with empty
# package lists. This runs once, on the first install.
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get update
fi
}