Compare commits

..
Author SHA1 Message Date
clawbot 51d2bd24df 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. 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 06:24:22 +00:00
10 changed files with 123 additions and 433 deletions

No files matched your search

+2 -5
View File
@@ -5,8 +5,6 @@
# below are thin shims that call them.
.DEFAULT_GOAL := check
VERSION ?= $(shell git describe --tags --always)
bootstrap:
@script/bootstrap
@@ -38,14 +36,13 @@ hooks:
@script/install-precommit
build:
CGO_ENABLED=0 go build -trimpath -ldflags "-X main.Version=$(VERSION)" \
-o bin/rtnetmon ./cmd/rtnetmon
CGO_ENABLED=0 go build -trimpath -o bin/rtnetmon ./cmd/rtnetmon
run: build
./bin/rtnetmon
dev:
go run -ldflags "-X main.Version=$(VERSION)" ./cmd/rtnetmon
go run ./cmd/rtnetmon
deps:
go mod download
+11 -21
View File
@@ -46,32 +46,22 @@ 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. 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. 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
**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 | connected VPN tunnel + physical default-route interface (two panes) |
| macOS | the physical default-route interface with no VPN connected (one pane) |
| 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
+32 -7
View File
@@ -5,13 +5,38 @@ 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 `clawbot` squash-merges it into `next`
- pass an independent review, then the change is squash-merged 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`. 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.
`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.
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.
Issue branches do NOT touch this file — it is maintained on `next`.
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
// Makefile and the Dockerfile. Builds that do not set it report dev.
// Dockerfile. Builds that do not set it report dev.
var Version = "dev" //nolint:gochecknoglobals // set at link time with -X
func main() {
-4
View File
@@ -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
View File
@@ -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()
-27
View File
@@ -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 -74
View File
@@ -41,16 +41,11 @@ 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.
type Route struct {
Iface string
Gateway string
Default bool
Scoped bool
}
// Pane names one interface to display, with its label.
@@ -138,7 +133,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 while one is connected.
// tunnel as the primary pane when one is running.
func selectDarwin(ifaces []Interface, routes []Route, f Flags) ([]Pane, error) {
vpn := findVPN(ifaces, routes)
@@ -157,16 +152,15 @@ func selectDarwin(ifaces []Interface, routes []Route, f Flags) ([]Pane, error) {
}, nil
}
// 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.
// 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 && !r.Scoped {
if r.Default {
routeIfaces[r.Iface] = true
}
}
@@ -182,6 +176,10 @@ func findVPN(ifaces []Interface, routes []Route) string {
if routeIfaces[ifi.Name] {
return ifi.Name
}
if ifi.Up && hasRoutableIPv4(ifi) {
return ifi.Name
}
}
return ""
@@ -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,14 @@ 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.
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
}
@@ -266,6 +255,21 @@ 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 {
@@ -336,60 +340,23 @@ 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.
// 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 {
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) < columns {
if len(fields) < 4 || fields[0] != "default" {
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{
Iface: iface,
Gateway: gateway,
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})
}
routes = append(routes, Route{
Iface: fields[3],
Gateway: fields[1],
Default: true,
})
}
return routes
+26 -280
View File
@@ -17,125 +17,13 @@ const (
ifaceEth0 = "eth0"
ifaceWlan0 = "wlan0"
ifaceEn0 = "en0"
ifaceBridge = "bridge100"
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"
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 !
`
addrEn0 = "192.168.1.20"
)
// linuxFlags describes the Linux bridge interfaces rtnetmon was built for.
@@ -146,17 +34,6 @@ 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
@@ -289,16 +166,30 @@ func TestSelectDarwinPanes(t *testing.T) {
goos: osDarwin,
ifaces: []netdetect.Interface{
{Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}},
{Name: ifaceUtun4, Up: true, IPv4: []string{addrVPN}},
{Name: ifaceUtun0, Up: true, IPv4: nil},
{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, Gateway: gatewayEn0, Default: true, Scoped: true},
{Iface: ifaceEn0, Default: true},
},
flags: linuxFlags(),
want: []netdetect.Pane{
{Name: ifaceUtun4, Label: labelVPN},
{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},
},
},
@@ -307,11 +198,11 @@ func TestSelectDarwinPanes(t *testing.T) {
goos: osDarwin,
ifaces: []netdetect.Interface{
{Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}},
{Name: ifaceUtun4, Up: true, IPv4: []string{addrVPN}},
{Name: ifaceUtun4, Up: true, IPv4: []string{"10.64.0.2"}},
},
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,
@@ -334,21 +225,10 @@ func TestSelectDarwinSingleAndErrors(t *testing.T) {
goos: osDarwin,
ifaces: []netdetect.Interface{
{Name: ifaceEn0, Up: true, IPv4: []string{addrEn0}},
{Name: ifaceUtun0, Up: true, IPv4: nil},
{Name: "utun0", Up: true, IPv4: nil},
{Name: "utun1", Up: true, IPv4: []string{"169.254.1.1"}},
},
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}},
routes: []netdetect.Route{{Iface: ifaceEn0, Default: true}},
flags: linuxFlags(),
want: []netdetect.Pane{{Name: ifaceEn0, Label: labelDefault}},
},
@@ -370,8 +250,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,
@@ -384,138 +264,6 @@ 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) {
t.Parallel()
@@ -549,13 +297,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) {
-3
View File
@@ -31,9 +31,6 @@ 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
}