macOS support: VPN-aware interface detection; single-interface UI when only one interface exists #2

Closed
opened 2026-09-21 08:11:42 +02:00 by clawbot · 3 comments
Collaborator

Owner feature order (sneak, 2026-09-21, chat, verbatim): "when running on linux it should work as it does now (it's designed to be run on a linux system with those specific bridge interfaces on it). when run on macOS it should detect if there is a vpn running (e.g. mullvad) and use that as the gu0 equivalent interface. when it only detects one normal internet interface, it should display only one interface in the UI. it will only run on linux with 1 or 2 interfaces (either default route interface, or the gu/vanilla ones) or on macOS with 1 or 2 interfaes (either default route internet interface, or same plus a mullvad vpn client interface)."

Definition of done:

  • Linux behavior unchanged from today: the existing gu/vanilla bridge-interface setup works exactly as it does now.
  • Linux, no gu/vanilla interfaces present: the default-route interface is monitored alone.
  • macOS: the tool runs; it detects a running VPN client interface (e.g. Mullvad's utun) and uses it as the gu0 equivalent alongside the default-route internet interface.
  • macOS, no VPN running: only the default-route internet interface is monitored.
  • Whenever only one interface is detected (either OS), the UI displays a single interface — no empty second pane.
  • Supported matrix, exhaustively: Linux with 1 (default-route) or 2 (gu/vanilla) interfaces; macOS with 1 (default-route) or 2 (default-route plus VPN client) interfaces. Anything else exits with a clear error.
  • Tests cover the detection logic per platform (interface enumeration/VPN detection behind a mockable boundary; no live-network requirement in unit tests).
  • README updated: platform support, detection behavior, the supported matrix.

Blocked by #1 (standards adoption lands first).

model: claude-fable-5

Owner feature order (sneak, 2026-09-21, chat, verbatim): "when running on linux it should work as it does now (it's designed to be run on a linux system with those specific bridge interfaces on it). when run on macOS it should detect if there is a vpn running (e.g. mullvad) and use that as the gu0 equivalent interface. when it only detects one normal internet interface, it should display only one interface in the UI. it will only run on linux with 1 or 2 interfaces (either default route interface, or the gu/vanilla ones) or on macOS with 1 or 2 interfaes (either default route internet interface, or same plus a mullvad vpn client interface)." Definition of done: - Linux behavior unchanged from today: the existing gu/vanilla bridge-interface setup works exactly as it does now. - Linux, no gu/vanilla interfaces present: the default-route interface is monitored alone. - macOS: the tool runs; it detects a running VPN client interface (e.g. Mullvad's utun) and uses it as the `gu0` equivalent alongside the default-route internet interface. - macOS, no VPN running: only the default-route internet interface is monitored. - Whenever only one interface is detected (either OS), the UI displays a single interface — no empty second pane. - Supported matrix, exhaustively: Linux with 1 (default-route) or 2 (gu/vanilla) interfaces; macOS with 1 (default-route) or 2 (default-route plus VPN client) interfaces. Anything else exits with a clear error. - Tests cover the detection logic per platform (interface enumeration/VPN detection behind a mockable boundary; no live-network requirement in unit tests). - README updated: platform support, detection behavior, the supported matrix. Blocked by https://git.eeqj.de/sneak/rtnetmon/issues/1 (standards adoption lands first). model: claude-fable-5
Owner

@clawbot begin work on this

@clawbot begin work on this
Author
Collaborator

Plan (implementer's brief). One unit, one PR based on next, after #1 lands.

Why it does not run on macOS today. Every Go file carries //go:build linux, ping is invoked with Linux-only flags (-I iface, -W in seconds), and the two interfaces are hard-wired as interfaceA/interfaceB with the UI always drawing both.

Detection, behind one mockable boundary. A small interface in internal/monitor (or a new internal/netdetect package) with two methods, both pure data: list interfaces (name, up flag, IPv4 addresses; from net.Interfaces()), and list default routes (interface name, gateway). The real implementation of the route query is per platform; the selection logic that consumes the data is platform-independent and takes the OS name as a parameter, so every branch of the matrix below is unit-tested on Linux with fake data.

  • Linux default routes: parse ip -4 route show default (fallback /proc/net/route, destination 00000000).
  • macOS default routes: parse netstat -rn -f inet, every row whose destination is default. When a VPN is up macOS keeps the physical default as a second, interface-scoped default row, so both interfaces appear.
  • macOS VPN interface: a utun* interface that carries a default route, or that is up with a non-link-local IPv4 address (Mullvad and other WireGuard/OpenVPN clients use utun; idle system utuns only have fe80:: addresses).

Selection (the supported matrix, exhaustive).

  • Linux: both --ifaceA and --ifaceB (defaults gu0/backhaul0) exist: two panes, exactly today's behavior. Neither exists: the one default-route interface, one pane. Exactly one of the pair exists, or no/multiple default routes: exit with an error naming what was found and what is supported.
  • macOS: VPN interface found: it is pane A (the gu0 equivalent, label "VPN"), the non-VPN default-route interface is pane B. No VPN: one pane on the default-route interface. No default route, or more than one non-VPN default route: exit with an error.
  • The --ifaceA/--ifaceB/--labelA/--labelB flags keep working on both platforms; detection only fills in what was not given explicitly on macOS, and on Linux only when the named pair is absent.

Per-platform command shapes, built by a pure function keyed on OS name (unit-testable for both): Linux ping -I iface -c1 -W1; macOS ping -b iface -c1 -W1000 (-b binds the interface, -W is milliseconds). curl --interface is the same on both. TCP timing binds the source address today; on macOS a Control hook additionally sets IP_BOUND_IF (thin build-tagged file) so traffic leaves the chosen interface rather than the VPN default route. Linux dialing is untouched.

UI. Monitor holds a slice of one or two interfaces; Run starts loops and uiLoop draws panes per entry, so a single interface draws a single pane. NewMonitor takes the list; the README API example follows.

Build tags removed from all files except the two thin per-platform files (darwin / linux). CI builds both (GOOS=darwin go build cross-compiles without a mac; this goes into script/cibuild).

Tests. Table tests over fake interface/route data for every matrix row on both OS names; parser tests for ip route, /proc/net/route and netstat -rn sample output; ping-argument tests. Not executable on this Linux host, to be disclosed in the PR body: real netstat parsing against a live mac, IP_BOUND_IF dialing, and whether Mullvad's leak-protection firewall lets bound traffic out of the physical interface.

README. Platform support, detection behavior, the matrix, updated API example.

Model: fable-5-1

Plan (implementer's brief). One unit, one PR based on `next`, after https://git.eeqj.de/sneak/rtnetmon/issues/1 lands. **Why it does not run on macOS today.** Every Go file carries `//go:build linux`, `ping` is invoked with Linux-only flags (`-I iface`, `-W` in seconds), and the two interfaces are hard-wired as `interfaceA`/`interfaceB` with the UI always drawing both. **Detection, behind one mockable boundary.** A small interface in `internal/monitor` (or a new `internal/netdetect` package) with two methods, both pure data: list interfaces (name, up flag, IPv4 addresses; from `net.Interfaces()`), and list default routes (interface name, gateway). The real implementation of the route query is per platform; the selection logic that consumes the data is platform-independent and takes the OS name as a parameter, so every branch of the matrix below is unit-tested on Linux with fake data. - Linux default routes: parse `ip -4 route show default` (fallback `/proc/net/route`, destination `00000000`). - macOS default routes: parse `netstat -rn -f inet`, every row whose destination is `default`. When a VPN is up macOS keeps the physical default as a second, interface-scoped `default` row, so both interfaces appear. - macOS VPN interface: a `utun*` interface that carries a default route, or that is up with a non-link-local IPv4 address (Mullvad and other WireGuard/OpenVPN clients use `utun`; idle system `utun`s only have `fe80::` addresses). **Selection (the supported matrix, exhaustive).** - Linux: both `--ifaceA` and `--ifaceB` (defaults `gu0`/`backhaul0`) exist: two panes, exactly today's behavior. Neither exists: the one default-route interface, one pane. Exactly one of the pair exists, or no/multiple default routes: exit with an error naming what was found and what is supported. - macOS: VPN interface found: it is pane A (the `gu0` equivalent, label "VPN"), the non-VPN default-route interface is pane B. No VPN: one pane on the default-route interface. No default route, or more than one non-VPN default route: exit with an error. - The `--ifaceA/--ifaceB/--labelA/--labelB` flags keep working on both platforms; detection only fills in what was not given explicitly on macOS, and on Linux only when the named pair is absent. **Per-platform command shapes**, built by a pure function keyed on OS name (unit-testable for both): Linux `ping -I iface -c1 -W1`; macOS `ping -b iface -c1 -W1000` (`-b` binds the interface, `-W` is milliseconds). `curl --interface` is the same on both. TCP timing binds the source address today; on macOS a `Control` hook additionally sets `IP_BOUND_IF` (thin build-tagged file) so traffic leaves the chosen interface rather than the VPN default route. Linux dialing is untouched. **UI.** `Monitor` holds a slice of one or two interfaces; `Run` starts loops and `uiLoop` draws panes per entry, so a single interface draws a single pane. `NewMonitor` takes the list; the README API example follows. **Build tags** removed from all files except the two thin per-platform files (`darwin` / `linux`). CI builds both (`GOOS=darwin go build` cross-compiles without a mac; this goes into `script/cibuild`). **Tests.** Table tests over fake interface/route data for every matrix row on both OS names; parser tests for `ip route`, `/proc/net/route` and `netstat -rn` sample output; ping-argument tests. Not executable on this Linux host, to be disclosed in the PR body: real `netstat` parsing against a live mac, `IP_BOUND_IF` dialing, and whether Mullvad's leak-protection firewall lets bound traffic out of the physical interface. **README.** Platform support, detection behavior, the matrix, updated API example. Model: fable-5-1
Author
Collaborator

Landed on next via #4 (squash commit 486a4739) after a passed independent review.

Model: fable-5-1

Landed on `next` via https://git.eeqj.de/sneak/rtnetmon/pulls/4 (squash commit 486a4739) after a passed independent review. Model: fable-5-1
Sign in to join this conversation.
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/rtnetmon#2