Compare commits

..
Author SHA1 Message Date
sneak 46da71dd2c TODO.md: drop work logs, keep only open work (closes #20)
check / check (push) Successful in 2m16s
Removes the Status section, which listed work already landed on next,
and the Next Step section, whose two items are done: the milestone PR
merged and the binary has run on a Mac. The Workflow section stays.

Model: opus-5-5
2026-11-11 00:10:58 +00:00
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
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
10 changed files with 426 additions and 116 deletions

No files matched your search

+5 -2
View File
@@ -5,6 +5,8 @@
# below are thin shims that call them. # below are thin shims that call them.
.DEFAULT_GOAL := check .DEFAULT_GOAL := check
VERSION ?= $(shell git describe --tags --always)
bootstrap: bootstrap:
@script/bootstrap @script/bootstrap
@@ -36,13 +38,14 @@ hooks:
@script/install-precommit @script/install-precommit
build: build:
CGO_ENABLED=0 go build -trimpath -o bin/rtnetmon ./cmd/rtnetmon CGO_ENABLED=0 go build -trimpath -ldflags "-X main.Version=$(VERSION)" \
-o bin/rtnetmon ./cmd/rtnetmon
run: build run: build
./bin/rtnetmon ./bin/rtnetmon
dev: dev:
go run ./cmd/rtnetmon go run -ldflags "-X main.Version=$(VERSION)" ./cmd/rtnetmon
deps: deps:
go mod download go mod download
+18 -8
View File
@@ -46,22 +46,32 @@ single pane.
setup, unchanged. When neither exists, the single default-route interface is setup, unchanged. When neither exists, the single default-route interface is
monitored instead. monitored instead.
**macOS.** The physical internet interface is found from the default route. When **macOS.** The physical internet interface is found from the default route. The
a VPN client is running (Mullvad and similar clients create a `utun` tunnel that VPN pane appears only while a VPN is connected, meaning its `utun` tunnel
carries a default route or holds a routable address), that tunnel is monitored carries the IPv4 default route: in `netstat -rn -f inet` that is a `default` row
as the primary pane alongside the physical interface. With no VPN running, only on the tunnel, or both a `0/1` and a `128.0/1` row on it, which some VPN clients
the physical interface is monitored. Interface names are detected on macOS; the 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. 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 `--ifaceA`/`--ifaceB` flags are not used there, but `--labelA`/`--labelB` still
set the pane labels. set the pane labels.
### Supported matrix ### Supported matrix
| OS | Interfaces monitored | | OS | Interfaces monitored |
| ----- | ----------------------------------------------------------- | | ----- | --------------------------------------------------------------------- |
| Linux | `gu0` + `backhaul0` when both exist (two panes) | | Linux | `gu0` + `backhaul0` when both exist (two panes) |
| Linux | the single default-route interface otherwise (one pane) | | Linux | the single default-route interface otherwise (one pane) |
| macOS | VPN tunnel + physical default-route interface (two panes) | | macOS | connected VPN tunnel + physical default-route interface (two panes) |
| macOS | the physical default-route interface with no VPN (one pane) | | 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 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 no/multiple default routes when neither is present; on macOS, no default route
+7 -32
View File
@@ -5,38 +5,13 @@ One issue per unit of work, one branch and one PR per issue:
- ensure a tracked issue exists with a definition of done - ensure a tracked issue exists with a definition of done
- branch from `next` (never from `main`) - branch from `next` (never from `main`)
- do the work; open a PR based on `next` (never on `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 - push; nothing stays local-only
`next` is the branch for the next milestone and must stay green and `next` is the branch for the next milestone and must stay green and mergeable to
mergeable to `main` without notice. Only `sneak` merges `next` into `main` without notice. Only `sneak` merges `next` into `main`. No commits land
`main`, and for now only `sneak` merges into `next`. No commits land directly on `main` or `next`, only merges via PRs. A deploy is a squash commit
directly on `main` or `next` — only merges via PRs. from `main` onto `prod`; `prod` never follows `main` automatically.
Issue branches do NOT touch this file — it is maintained on `next`. Other issue branches do not touch this file; it changes only in its own PR to
Every branch editing `TODO.md` conflicts with every other. `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`.
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.
# 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.
+1 -1
View File
@@ -9,7 +9,7 @@ import (
) )
// Version is the git tag or short commit, set at link time with -X by the // Version is the git tag or short commit, set at link time with -X by the
// Dockerfile. Builds that do not set it report dev. // Makefile and the Dockerfile. Builds that do not set it report dev.
var Version = "dev" //nolint:gochecknoglobals // set at link time with -X var Version = "dev" //nolint:gochecknoglobals // set at link time with -X
func main() { func main() {
+4
View File
@@ -2,6 +2,7 @@ package monitor
import ( import (
"context" "context"
"time"
"git.eeqj.de/sneak/rtnetmon/internal/starlink" "git.eeqj.de/sneak/rtnetmon/internal/starlink"
) )
@@ -59,6 +60,9 @@ func (m *Monitor) TCPHosts() []string { return m.tcpHosts }
// Interfaces returns the monitored interfaces. // Interfaces returns the monitored interfaces.
func (m *Monitor) Interfaces() []*InterfaceStatus { return m.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. // PingArgs exposes pingArgs for external tests.
func PingArgs(goos, iface, host string) []string { return pingArgs(goos, iface, host) } 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. // 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) { func (m *Monitor) uiLoop(ctx context.Context) {
m.logf("UI loop started") m.logf("UI loop started")
@@ -348,13 +357,6 @@ func (m *Monitor) uiLoop(ctx context.Context) {
m.logf("Screen finalized") 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 timestampSpinFrame := 0
lastTimestampSpinUpdate := time.Now() lastTimestampSpinUpdate := time.Now()
@@ -365,8 +367,7 @@ func (m *Monitor) uiLoop(ctx context.Context) {
Put(m.screen, colLeft, rowTopRule, HLine(w), tcell.StyleDefault) Put(m.screen, colLeft, rowTopRule, HLine(w), tcell.StyleDefault)
now := time.Now() now := time.Now()
timeStr := now.Format(time.RFC1123Z) timeStr, localTimeStr := clockStrings(now)
pstTimeStr := now.In(pstLoc).Format(time.RFC1123Z)
if time.Since(lastTimestampSpinUpdate) >= time.Second { if time.Since(lastTimestampSpinUpdate) >= time.Second {
timestampSpinFrame = (timestampSpinFrame + 1) % len(brailleSpins()) 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) Put(m.screen, colSpinner, rowClock, string(brailleChar)+" ", tcell.StyleDefault)
DrawRainbowText(m.screen, colMeter, rowClock, timeStr, colors) DrawRainbowText(m.screen, colMeter, rowClock, timeStr, colors)
DrawRainbowText(m.screen, colMeter+len(timeStr)+clockGap, rowClock, DrawRainbowText(m.screen, colMeter+len(timeStr)+clockGap, rowClock,
pstTimeStr, colors) localTimeStr, colors)
Put(m.screen, colLeft, rowBottomRule, HLine(w), tcell.StyleDefault) Put(m.screen, colLeft, rowBottomRule, HLine(w), tcell.StyleDefault)
runtime := time.Since(m.startTime).Round(time.Second).String() 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)
}
}
+71 -38
View File
@@ -41,11 +41,16 @@ type Interface struct {
IPv4 []string IPv4 []string
} }
// Route is one routing-table entry reduced to what detection needs. // 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 { type Route struct {
Iface string Iface string
Gateway string Gateway string
Default bool Default bool
Scoped bool
} }
// Pane names one interface to display, with its label. // Pane names one interface to display, with its label.
@@ -133,7 +138,7 @@ func selectLinux(ifaces []Interface, routes []Route, f Flags) ([]Pane, error) {
} }
// selectDarwin monitors the physical default-route interface, plus a VPN // 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) { func selectDarwin(ifaces []Interface, routes []Route, f Flags) ([]Pane, error) {
vpn := findVPN(ifaces, routes) vpn := findVPN(ifaces, routes)
@@ -152,15 +157,16 @@ func selectDarwin(ifaces []Interface, routes []Route, f Flags) ([]Pane, error) {
}, nil }, nil
} }
// findVPN returns the name of a VPN tunnel interface, or "" if none is // findVPN returns the name of the connected VPN tunnel, or "" if there is
// running. A tunnel counts as a running VPN when it carries a default route, // none. A tunnel is the VPN only while it carries the default route; one that
// or when it is up with a routable (non-link-local) IPv4 address. Idle system // is merely up, even with a routable address (Tailscale without an exit node,
// tunnels have only a link-local IPv6 address and are skipped. // 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 { func findVPN(ifaces []Interface, routes []Route) string {
routeIfaces := map[string]bool{} routeIfaces := map[string]bool{}
for _, r := range routes { for _, r := range routes {
if r.Default { if r.Default && !r.Scoped {
routeIfaces[r.Iface] = true routeIfaces[r.Iface] = true
} }
} }
@@ -176,10 +182,6 @@ func findVPN(ifaces []Interface, routes []Route) string {
if routeIfaces[ifi.Name] { if routeIfaces[ifi.Name] {
return ifi.Name return ifi.Name
} }
if ifi.Up && hasRoutableIPv4(ifi) {
return ifi.Name
}
} }
return "" return ""
@@ -188,7 +190,9 @@ func findVPN(ifaces []Interface, routes []Route) string {
// onlyDefaultRoute returns the single default-route interface, or an error if // onlyDefaultRoute returns the single default-route interface, or an error if
// there is not exactly one. // there is not exactly one.
func onlyDefaultRoute(routes []Route) (string, error) { func onlyDefaultRoute(routes []Route) (string, error) {
names := defaultRouteIfaces(routes, "") names := defaultRouteIfaces(routes, func(r Route) bool {
return !isTunnel(r.Iface)
})
switch len(names) { switch len(names) {
case 1: case 1:
@@ -202,9 +206,16 @@ func onlyDefaultRoute(routes []Route) (string, error) {
} }
// onlyPhysicalDefaultRoute returns the single non-tunnel default-route // 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) { 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) { switch len(names) {
case 1: case 1:
@@ -218,14 +229,14 @@ func onlyPhysicalDefaultRoute(routes []Route, vpn string) (string, error) {
} }
// defaultRouteIfaces returns the sorted, unique interface names that carry a // defaultRouteIfaces returns the sorted, unique interface names that carry a
// default route, excluding the named VPN interface and any other tunnel. // default route accepted by keep.
func defaultRouteIfaces(routes []Route, vpn string) []string { func defaultRouteIfaces(routes []Route, keep func(Route) bool) []string {
seen := map[string]bool{} seen := map[string]bool{}
var names []string var names []string
for _, r := range routes { 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 continue
} }
@@ -255,21 +266,6 @@ func isTunnel(name string) bool {
return strings.HasPrefix(name, "utun") 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, // singleLabel is the label for a lone pane: the explicit --labelA if given,
// otherwise a plain description. // otherwise a plain description.
func singleLabel(f Flags) string { func singleLabel(f Flags) string {
@@ -340,23 +336,60 @@ func parseProcNetRoute(out string) []Route {
return routes return routes
} }
// parseNetstat reads `netstat -rn -f inet` output (macOS). Rows whose // netstat route flags (see `man netstat`).
// destination is "default" are default routes; the Netif column (field 4) const (
// names the interface. 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.
func parseNetstat(out string) []Route { func parseNetstat(out string) []Route {
const columns = 4 // Destination, Gateway, Flags, Netif; Expire is optional
var routes []Route 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") { for _, line := range strings.Split(out, "\n") {
fields := strings.Fields(line) fields := strings.Fields(line)
if len(fields) < 4 || fields[0] != "default" { if len(fields) < columns {
continue continue
} }
dest, gateway, flags, iface := fields[0], fields[1], fields[2], fields[3]
switch dest {
case "default":
if !strings.Contains(flags, netstatFlagGateway) {
gateway = ""
}
routes = append(routes, Route{ routes = append(routes, Route{
Iface: fields[3], Iface: iface,
Gateway: fields[1], Gateway: gateway,
Default: true, Default: true,
Scoped: strings.Contains(flags, netstatFlagScoped),
}) })
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 return routes
+279 -25
View File
@@ -17,13 +17,125 @@ const (
ifaceEth0 = "eth0" ifaceEth0 = "eth0"
ifaceWlan0 = "wlan0" ifaceWlan0 = "wlan0"
ifaceEn0 = "en0" ifaceEn0 = "en0"
ifaceBridge = "bridge100"
ifaceUtun0 = "utun0"
ifaceUtun3 = "utun3"
ifaceUtun4 = "utun4" ifaceUtun4 = "utun4"
labelGu = "gu LAN - VPN outbound" labelGu = "gu LAN - VPN outbound"
labelCox = "Cox cable direct" labelCox = "Cox cable direct"
labelDefault = "default route" labelDefault = "default route"
labelVPN = "VPN"
addrEn0 = "192.168.1.20" 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"
)
// 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
`
// 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 !
`
) )
// linuxFlags describes the Linux bridge interfaces rtnetmon was built for. // linuxFlags describes the Linux bridge interfaces rtnetmon was built for.
@@ -34,6 +146,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. // selectCase is one Select scenario with fake interfaces and routes.
type selectCase struct { type selectCase struct {
name string name string
@@ -166,30 +289,16 @@ func TestSelectDarwinPanes(t *testing.T) {
goos: osDarwin, goos: osDarwin,
ifaces: []netdetect.Interface{ ifaces: []netdetect.Interface{
{Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}}, {Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}},
{Name: ifaceUtun4, Up: true, IPv4: []string{"10.64.0.2"}}, {Name: ifaceUtun4, Up: true, IPv4: []string{addrVPN}},
{Name: "utun0", Up: true, IPv4: nil}, {Name: ifaceUtun0, Up: true, IPv4: nil},
}, },
routes: []netdetect.Route{ routes: []netdetect.Route{
{Iface: ifaceUtun4, Default: true}, {Iface: ifaceUtun4, Default: true},
{Iface: ifaceEn0, Default: true}, {Iface: ifaceEn0, Gateway: gatewayEn0, Default: true, Scoped: true},
}, },
flags: linuxFlags(), flags: linuxFlags(),
want: []netdetect.Pane{ want: []netdetect.Pane{
{Name: ifaceUtun4, Label: "VPN"}, {Name: ifaceUtun4, Label: labelVPN},
{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: ifaceEn0, Label: labelDefault},
}, },
}, },
@@ -198,11 +307,11 @@ func TestSelectDarwinPanes(t *testing.T) {
goos: osDarwin, goos: osDarwin,
ifaces: []netdetect.Interface{ ifaces: []netdetect.Interface{
{Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}}, {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{ routes: []netdetect.Route{
{Iface: ifaceUtun4, Default: true}, {Iface: ifaceUtun4, Default: true},
{Iface: ifaceEn0, Default: true}, {Iface: ifaceEn0, Gateway: gatewayEn0, Default: true, Scoped: true},
}, },
flags: netdetect.Flags{ flags: netdetect.Flags{
LabelA: "Mullvad", LabelASet: true, LabelA: "Mullvad", LabelASet: true,
@@ -225,10 +334,21 @@ func TestSelectDarwinSingleAndErrors(t *testing.T) {
goos: osDarwin, goos: osDarwin,
ifaces: []netdetect.Interface{ ifaces: []netdetect.Interface{
{Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}}, {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"}}, {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}},
},
{
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, Gateway: gatewayEn0, Default: true}},
flags: linuxFlags(), flags: linuxFlags(),
want: []netdetect.Pane{{Name: ifaceEn0, Label: labelDefault}}, want: []netdetect.Pane{{Name: ifaceEn0, Label: labelDefault}},
}, },
@@ -250,8 +370,8 @@ func TestSelectDarwinSingleAndErrors(t *testing.T) {
{Name: "en1", Up: true, IPv4: []string{"192.168.2.20"}}, {Name: "en1", Up: true, IPv4: []string{"192.168.2.20"}},
}, },
routes: []netdetect.Route{ routes: []netdetect.Route{
{Iface: ifaceEn0, Default: true}, {Iface: ifaceEn0, Gateway: gatewayEn0, Default: true},
{Iface: "en1", Default: true}, {Iface: "en1", Gateway: "192.168.2.1", Default: true},
}, },
flags: linuxFlags(), flags: linuxFlags(),
wantErr: true, wantErr: true,
@@ -264,6 +384,138 @@ 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,
},
})
}
// 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) { func TestParseIPRoute(t *testing.T) {
t.Parallel() t.Parallel()
@@ -297,11 +549,13 @@ func TestParseNetstat(t *testing.T) {
"Destination Gateway Flags Netif Expire\n" + "Destination Gateway Flags Netif Expire\n" +
"default 10.0.0.1 UGScg en0\n" + "default 10.0.0.1 UGScg en0\n" +
"default link#15 UCSg utun4\n" + "default link#15 UCSg utun4\n" +
"default link#45 UCSIg bridge100 !\n" +
"127.0.0.1 127.0.0.1 UH lo0\n" "127.0.0.1 127.0.0.1 UH lo0\n"
got := netdetect.ParseNetstat(out) got := netdetect.ParseNetstat(out)
want := []netdetect.Route{ want := []netdetect.Route{
{Iface: ifaceEn0, Gateway: "10.0.0.1", Default: true}, {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) { if !reflect.DeepEqual(got, want) {
+3
View File
@@ -31,6 +31,9 @@ detect_pkgmgr() {
if [ "$(id -u)" != "0" ]; then if [ "$(id -u)" != "0" ]; then
SUDO="sudo" SUDO="sudo"
fi 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 fi
} }