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
11 changed files with 340 additions and 114 deletions
+25 -10
View File
@@ -1,19 +1,28 @@
# .dockerignore does NOT use .gitignore semantics. Docker matches with # .dockerignore does NOT use .gitignore semantics. Docker matches with
# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross # moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross
# `/` and an unprefixed pattern is anchored at the context root. Every # `/` and an unprefixed pattern is anchored at the context root. Every
# depth-independent pattern therefore needs `**/`; only genuinely # depth-independent pattern therefore needs `**/`, or `config/.env` and
# root-anchored entries go unprefixed. Never transplant these into # `certs/server.key` still ship while this file reads as solved. Only
# .gitignore, where `**/` is wrong. # genuinely root-anchored entries go unprefixed. Never transplant these
# into .gitignore, where `**/` is wrong.
# #
# Matching is case-sensitive, so secrets use character ranges rather # Matching is case-sensitive, so secrets use character ranges rather
# than an ALL-CAPS twin, which would still miss `Server.Key`. # than an ALL-CAPS twin, which would still miss `Server.Key`.
#
# Extend with this repo's own host-built artifacts, written anchored:
# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and
# deletes the package directory from the context.
# Excluding .git means `git describe` cannot run in any build stage and # .git is sent without its config. Without a VERSION build argument the
# fails quietly there; rtnetmon embeds no version, so this is safe. # stage that compiles runs `git describe --tags --always` on .git, which
.git # does not need .git/config; that file can hold a credential, such as a
# password in a remote URL or the token the CI checkout step stores there.
.git/config
# Agent scratch: one full checkout of the repo per in-flight agent. # Agent scratch: one full checkout of the repo per in-flight agent.
# Anchored because agents run at the repo root here. # Anchored because it occurs once where agents run at the repo root.
# KNOWN GAP: a repo running agents in subdirectories still ships
# `services/api/.claude/` and must add its own anchored entry.
.claude .claude
# This repo's own host-built artifacts, root-anchored so `bin/` is not # This repo's own host-built artifacts, root-anchored so `bin/` is not
@@ -22,12 +31,15 @@
/rtnetmon /rtnetmon
main.go.old main.go.old
# Environment files. # Environment files. `*.env` covers bare `.env` and the `prod.env`
# convention. Re-include a committed template with a negation if the
# build needs one: `!docs/example.env`.
**/*.[eE][nN][vV] **/*.[eE][nN][vV]
**/.[eE][nN][vV].* **/.[eE][nN][vV].*
**/.[eE][nN][vV][rR][cC] **/.[eE][nN][vV][rR][cC]
# Private keys and the bundles carrying them. # Private keys and the bundles carrying them. Public certificates
# (*.crt, *.cer) are deliberately absent: they are legitimate inputs.
**/*.[pP][eE][mM] **/*.[pP][eE][mM]
**/*.[kK][eE][yY] **/*.[kK][eE][yY]
**/*.[pP]12 **/*.[pP]12
@@ -37,11 +49,14 @@ main.go.old
**/[iI][dD]_[eE][cC][dD][sS][aA] **/[iI][dD]_[eE][cC][dD][sS][aA]
**/[iI][dD]_[eE][dD]25519 **/[iI][dD]_[eE][dD]25519
# Dependencies: restored inside the image, never copied in.
**/node_modules
# OS metadata. # OS metadata.
**/.DS_Store **/.DS_Store
**/Thumbs.db **/Thumbs.db
# Editor state. # Editor state: never a build input, and it churns COPY.
**/*.swp **/*.swp
**/*.swo **/*.swo
**/*~ **/*~
+15 -1
View File
@@ -34,4 +34,18 @@ WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN CGO_ENABLED=0 go build -trimpath -o /rtnetmon ./cmd/rtnetmon/ # The version stamped into the binary: the VERSION build argument when one
# is given, otherwise `git describe --tags --always` of the .git the build
# context carries: the tag on a tagged commit, tag-N-gHASH on a commit after
# one, the short commit when no tag is reachable. git ships in this base
# image. A context that carries .git and still yields no version fails the
# build. With neither, as from a source tarball, the binary reports dev.
ARG VERSION
RUN version="${VERSION:-$(git describe --tags --always)}"; \
if [ -e .git ] && { [ -z "$version" ] || [ "$version" = dev ] || \
[ "$version" = unknown ]; }; then \
echo "no version could be derived although the build context carries .git" >&2; \
exit 1; \
fi; \
CGO_ENABLED=0 go build -trimpath -ldflags="-X main.Version=${version:-dev}" \
-o /rtnetmon ./cmd/rtnetmon/
+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
+16 -11
View File
@@ -46,22 +46,27 @@ 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. 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
+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 - 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 # Status
The repository has been brought up to current repo standards: `script/` On `next`:
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 - Repo standards (https://git.eeqj.de/sneak/rtnetmon/issues/1): `script/`
findings fixed rather than suppressed. The only annotations are entrypoints with the `Makefile` as thin shims, lint and tests run in Docker as
justified `//nolint:gosec` on the `ping`/`curl` subprocess calls (G204) phases that gate the image build, the vendored `.golangci.yml`, and
and on opening the operator-chosen log file (G304): fixed argv with no `REPO_POLICIES.md`.
shell, so these are false positives, annotated as the reference repos do. - 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 # Next Step
Feature work, each on its own branch and PR from `next`: - `sneak` merges the milestone PR, https://git.eeqj.de/sneak/rtnetmon/pulls/6,
carrying `next` into `main`.
- https://git.eeqj.de/sneak/rtnetmon/issues/2 — macOS support: - A first run on a real Mac of what is on `next` now. The fix for
VPN-aware interface detection and a single-interface UI when only one https://git.eeqj.de/sneak/rtnetmon/issues/8 has not run on a Mac, and none of
interface exists. the machines rtnetmon is developed and tested on is one, so that run is
- https://git.eeqj.de/sneak/rtnetmon/issues/3 — show Starlink status `sneak`'s.
lines when Starlink is the non-VPN gateway.
+5 -1
View File
@@ -8,8 +8,12 @@ import (
"git.eeqj.de/sneak/rtnetmon/internal/cli" "git.eeqj.de/sneak/rtnetmon/internal/cli"
) )
// 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.
var Version = "dev" //nolint:gochecknoglobals // set at link time with -X
func main() { func main() {
err := cli.Execute() err := cli.Execute(Version)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
+1 -1
View File
@@ -3,4 +3,4 @@ package cli
import "github.com/spf13/cobra" import "github.com/spf13/cobra"
// NewRootCmd exposes newRootCmd for external tests. // NewRootCmd exposes newRootCmd for external tests.
func NewRootCmd() *cobra.Command { return newRootCmd() } func NewRootCmd() *cobra.Command { return newRootCmd("dev") }
+9 -8
View File
@@ -51,8 +51,9 @@ func defaultTCPHosts() []string {
} }
} }
// newRootCmd builds the cobra root command with its flags bound. // newRootCmd builds the cobra root command with its flags bound. version is
func newRootCmd() *cobra.Command { // logged at startup.
func newRootCmd(version string) *cobra.Command {
cfg := &config{} cfg := &config{}
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "rtnetmon", Use: "rtnetmon",
@@ -60,7 +61,7 @@ func newRootCmd() *cobra.Command {
Long: `rtnetmon is a dual-interface network monitoring dashboard that provides Long: `rtnetmon is a dual-interface network monitoring dashboard that provides
real-time visibility into network health, packet loss, and latency.`, real-time visibility into network health, packet loss, and latency.`,
RunE: func(cmd *cobra.Command, _ []string) error { RunE: func(cmd *cobra.Command, _ []string) error {
return runMonitor(cmd, cfg) return runMonitor(cmd, cfg, version)
}, },
} }
registerFlags(cmd, cfg) registerFlags(cmd, cfg)
@@ -88,8 +89,8 @@ func registerFlags(cmd *cobra.Command, cfg *config) {
// runMonitor detects the interfaces to monitor, then constructs and runs the // runMonitor detects the interfaces to monitor, then constructs and runs the
// monitor from cfg. // monitor from cfg.
func runMonitor(cmd *cobra.Command, cfg *config) error { func runMonitor(cmd *cobra.Command, cfg *config, version string) error {
monitor.Logf(cfg.LogFile, "Starting rtnetmon") monitor.Logf(cfg.LogFile, "Starting rtnetmon %s", version)
specs, err := detectInterfaces(cmd, cfg) specs, err := detectInterfaces(cmd, cfg)
if err != nil { if err != nil {
@@ -169,7 +170,7 @@ func detectInterfaces(
return specs, nil return specs, nil
} }
// Execute builds the root command and runs it. // Execute builds the root command and runs it. version is logged at startup.
func Execute() error { func Execute(version string) error {
return newRootCmd().Execute() return newRootCmd(version).Execute()
} }
+46 -35
View File
@@ -41,11 +41,14 @@ 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. 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 +136,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 +155,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 +180,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 ""
@@ -219,6 +219,8 @@ 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, 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 { func defaultRouteIfaces(routes []Route, vpn string) []string {
seen := map[string]bool{} seen := map[string]bool{}
@@ -255,21 +257,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 +327,47 @@ func parseProcNetRoute(out string) []Route {
return routes return routes
} }
// parseNetstat reads `netstat -rn -f inet` output (macOS). Rows whose // parseNetstat reads `netstat -rn -f inet` output (macOS); the Netif column
// destination is "default" are default routes; the Netif column (field 4) // (field 4) names the interface. A row whose destination is "default" is a
// names the interface. // 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 { 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
} }
routes = append(routes, Route{ dest, gateway, flags, iface := fields[0], fields[1], fields[2], fields[3]
Iface: fields[3],
Gateway: fields[1], switch dest {
Default: true, 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 return routes
+186 -20
View File
@@ -17,13 +17,96 @@ const (
ifaceEth0 = "eth0" ifaceEth0 = "eth0"
ifaceWlan0 = "wlan0" ifaceWlan0 = "wlan0"
ifaceEn0 = "en0" ifaceEn0 = "en0"
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"
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. // 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. // selectCase is one Select scenario with fake interfaces and routes.
type selectCase struct { type selectCase struct {
name string name string
@@ -166,8 +260,8 @@ 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},
@@ -175,21 +269,7 @@ func TestSelectDarwinPanes(t *testing.T) {
}, },
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,7 +278,7 @@ 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},
@@ -225,13 +305,24 @@ 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, Default: true}},
flags: linuxFlags(), flags: linuxFlags(),
want: []netdetect.Pane{{Name: ifaceEn0, Label: labelDefault}}, 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", name: "no default route",
goos: osDarwin, 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) { func TestParseIPRoute(t *testing.T) {
t.Parallel() t.Parallel()
+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
} }