Skip to content

Linux support hardening + cross-platform audit (0.5.0) - #39

Merged
korjwl1 merged 24 commits into
mainfrom
agent/linux-support-testing
Aug 10, 2026
Merged

Linux support hardening + cross-platform audit (0.5.0)#39
korjwl1 merged 24 commits into
mainfrom
agent/linux-support-testing

Conversation

@korjwl1

@korjwl1 korjwl1 commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Summary

Linux support hardening + cross-platform audit round, verified on all 3 OSes:

Also: ctl start/stop, --json output, 3-OS CI matrix, pipe scoped to spawning user SID (#20), Linux gateway fingerprinting (#22), locale-agnostic ping parsing (#32), dead-code purge (keygen / CIDR calc / speed test / mini mode).

Releasing as 0.5.0 after merge.

🤖 Generated with Claude Code

korjwl1 and others added 24 commits August 4, 2026 01:27
…der perf (Refs #35)

- Restore file-drop overlay: Svelte 5 prunes child-combinator rules with
  runtime-added ancestor classes; use descendant combinator
- Remove dead CSS (import-modal remnants in App.svelte, use-current-network
  remnants in AutomationEditor)
- Remove 4 never-imported components (MiniMode, SplitTunnelUI,
  ScriptWarning, KeyGenerator) and unused tPlain translator
- Prune 100 unused i18n keys per locale (en/ko/ja), all statically verified
- Gate store notifications in stores/tunnels.js: Svelte object stores
  re-notify unconditionally, causing full re-sorts and reactive recompute
  at 1 Hz while idle
- Key LogViewer rows by monotonic id (ring-buffer wrap rewrote all 1000
  rows per record) and TunnelList rows by name
- Settings: carry trusted_ssids from fresh fetch (lost-update with CLI)
- Drop frontend/frontend/bindings stubs committed by accident in bootstrap

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… for unreachable hosts (Fixes #32)

Router-sourced 'Destination host unreachable' replies carry no RTT token
yet ping.exe can exit 0; report unreachable instead of a made-up number.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mer race (Refs #35)

- Linux: don't register tray.OnClick — Wails StatusNotifier fires it on
  the dbusmenu 'opened' event too, raising the window on every right-click
- Linux: serialize showDock; concurrent SetFrameless toggles could map the
  window permanently undecorated
- helper: shutdown AfterFunc re-checks it is still the current timer under
  h.mu (Stop() can't cancel an already-running callback); armShutdownTimer
  also stops a stale pending timer when a tunnel is active
- macOS: showDock retry goroutine bails on quit via package appQuitting —
  Show() on a destroyed window re-runs window creation
- macOS: track SSID warm-up retry goroutine in healthWg
- Linux: reap pkexec to avoid one zombie per spawn attempt

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s cross-check; validate automation rules on save (Refs #35)

Removed (never reachable from any platform, GUI binding, or cgo export):
notify package, diag CIDR/speedtest, config keygen, storage.atomicRename,
tunnel.ClearAllActiveStates, update.BrewUpgradeCommand,
network.OriginalNetworkState, windows VerifyIpForwardRoute (superseded by
batch variant), ipc.MultiStatusResponse, wifi.DefaultRules.

Wired wifi.ValidateRule into SaveAutomationRules — saves silently no-op'd
malformed rules. Derive ipc.ProtocolVersion from ProtocolMajor/Minor so a
constant bump can't leave the wire string behind.

Also fix TunnelDetail autoLatency frozen at first computation: the dep-less
$: statement never re-ran, so the latency-target label/tooltip kept showing
the first-selected tunnel's endpoint forever (pre-existing, found in audit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- darwin: reapply() verified DNS drift before rewriting — every route
  event (roam, DHCP renew, sleep/wake) was spawning 10-20 networksetup
  writes and HUP'ing mDNSResponder, wiping the machine-wide DNS cache
- darwin: loop watchdog reads interface counters via IFMIB sysctl instead
  of exec'ing netstat every 5s per full-tunnel connection (~17k
  spawns/day). NET_RT_IFLIST2 was rejected empirically: its if_msghdr2
  ifi_ibytes wraps at 32 bits; only IFMIB carries true 64-bit counters.
  netstat kept as fallback; live cross-check test added
- helper: latency probes drop to 5min cadence with no GUI subscribed —
  the headless helper (wg-quick semantics) pinged every 30s forever
- wifi: SSID poll relaxes to 60s when an event watcher (NM DBus/wlanapi)
  is attached; Linux burned ~17k nmcli spawns/day alongside a working
  DBus watcher. Watchers now report attachment
- helper: reuse the diff's JSON bytes in the 1Hz status broadcast
  instead of marshalling the same struct twice
- gui: config watcher 1s → 3s (runs for the GUI lifetime, also hidden)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…on services, failed-verify and ForceShutdown paths (Fixes #34)

Four gaps left tunnel DNS state behind after disconnect:
- RestoreDNSFromSnapshot only wrote -setdnsservers; search domains from
  DNS= hostnames persisted on every service forever. The snapshot now
  carries servers AND search domains (DNSSnapshot), and the restore
  iterates the union of snapshot, per-manager captures, and live
  services — so services that appeared mid-session (Ethernet plug,
  iPhone USB) are cleaned with their own captured originals
- SetDNS commits dnsActive BEFORE verification: a verify failure used
  to return with dnsActive=false, so rollback and crash recovery both
  no-op'd and the user was stuck on tunnel DNS unrecoverably
- ForceShutdown (helper-upgrade path) restores DNS best-effort before
  os.Exit — utun dies with the process but networksetup overrides don't
- Crash-recovery journal gains pre_mod_search (additive; old journals
  restore search domains to Empty, which is correct for the common case)

Also: darwin ping -W is milliseconds, not seconds — "-W 3" meant 3ms;
now 3000. Regression test injects run() and asserts both servers and
search domains are restored per service.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…(Refs #35)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e right network (Fixes #22)

- Default-route selection now requires RTF_UP + /0 netmask, picks the
  lowest metric, and skips tunnel/virtual interfaces — previously the
  first 00000000-destination row won, so a wg0 default route or a downed
  secondary uplink could supply the gateway
- ARP lookup is scoped to the route's device and requires ATF_COM —
  previously any row with a matching IP won, so a docker bridge sharing
  192.168.x.1 with the LAN (or a stale incomplete entry) could supply
  the MAC. Gateway-MAC is a trust signal for Automation rules
- Physical-interface classification consults /sys/class/net/<if>/device
  on Linux: docker0, virbr0, tailscale0, veth*, vmnet*, CNI bridges have
  no backing device and no longer satisfy subnet rules for networks the
  machine isn't on. Name denylist kept for the other platforms
- Parsers extracted into an untagged file with fixture tests that run on
  every platform; Linux CI exercises the full path

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ID verification (Fixes #20)

The pipe SDDL granted GRGW to Interactive Users (S-1-5-4) — every
logged-on account on a multi-user machine could drive the SYSTEM helper
(disconnect tunnels, disable kill switch, forge SSIDs into automation,
force shutdown). And verifyPeerUID was a stub Windows always skipped:
--uid was never passed there and os.Getuid() is -1 on Windows anyway.

- GUI captures its token user SID and forwards it via --owner-sid
  (validated against a strict SID pattern before touching the PowerShell
  argv or the security descriptor)
- Pipe SDDL now grants GRGW to that SID only; falls back to the old IU
  grant when no SID was supplied (helper spawned by an older GUI)
- Per-connection verifyPeer: GetNamedPipeClientProcessId → process token
  → EqualSid against the owner (SYSTEM also allowed). Defense in depth
  on top of the ACL; the owner's own elevated processes keep the same
  user SID so admin terminals still work
- Unix path unchanged (SO_PEERCRED/Getpeereid UID check as before)

Verified: darwin+windows builds, vet, full test suite, SID-injection
unit tests. Behavioral multi-user validation still needs a Windows
machine — noted on the issue.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…Linux-only test fixture bug (Refs #25, #35)

- CI now runs on pull_request + workflow_dispatch only — a bare push
  (including to main) triggers nothing; releases remain tag-only
- macOS and Windows join Ubuntu: frontend build + go test + vet each
  (Windows without -race: needs cgo/mingw; Linux -race covers shared code)
- TestMatchAsset_FindsPlatformAsset used a linux-amd64 decoy asset that
  legitimately out-matched the expected one the first time the suite ran
  on Linux — platform-neutral decoy now

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…it sweep (Refs #35)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The macOS LaunchDaemon shipped RunAtLoad=true and installed itself
unconditionally on first launch, so a root helper started at every boot
regardless of the auto_start setting — which only ever governed the GUI
LaunchAgent. The result was a root process with no window and no tray
icon on a machine where "Launch at startup" was switched off, and it was
not merely idle: the helper evaluates the user's Wi-Fi automation rules
itself, so it could bring a tunnel up while the user believed the app
was closed.

A running GUI is the user's statement of intent that WireGuide should be
active. Make that the whole rule:

  - RunAtLoad=false, so launchd registers the job but never starts it.
    installAndLoadDaemon now kickstarts explicitly after bootstrap,
    which bootstrap no longer does on its own.
  - Drop the isDaemon() guard around the shutdown grace window. It
    applies to every launch mode now, so a helper that loses its GUI
    (or never gets one) exits instead of lingering. The active-tunnel
    check in armShutdownTimer still keeps a live tunnel alive.

Users who want WireGuide from login enable auto_start, which installs
the GUI LaunchAgent; the GUI then spawns the helper on the normal path.
The trade is an admin prompt on any launch that finds no live helper
socket, which is the intended cost of leaving no invisible root process
behind. A live socket still short-circuits, so relaunching the GUI while
a tunnel is up does not re-prompt.

Existing installs migrate themselves: the plist content changed, so
PlistNeedsReinstall detects the drift and forces a reinstall.

Tests pin both halves of the rule (RunAtLoad read back via plutil, not
string-matched, and the kickstart that RunAtLoad=false now depends on).
Also removes build/darwin/com.wireguide.helper.plist: no build task
referenced it and its contents had drifted from what the code generates,
so it read as policy while being dead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tying the helper's lifetime to the GUI left the CLI with no way to bring
the stack up: dialHelper deliberately never spawns or elevates a helper,
so with the app closed `ctl connect` failed permanently. It also exposed
a second problem — a CLI command was indistinguishable from a GUI.

Request.Transient (protocol 1.0 -> 1.1, additive) marks short-lived
clients so the server never counts them as control connections. Control
connections drive the shutdown grace window: attaching cancels it,
detaching re-arms it. A `ctl` invocation connects, pings and exits within
milliseconds, so without the flag a single `ctl status` cut a GUI-less
helper's remaining life from 60s to 10s — the CLI killed the helper it
had just queried. Older helpers ignore the field and behave as before.

`ctl start` launches the app and waits for the helper socket. It is the
only command that starts anything; connect/status still fail with "is
the app running?", matching the contract docker has with dockerd.
Starting stays explicit because it costs an admin prompt on macOS and
because a running WireGuide is what the helper treats as consent to
apply automation rules. Launch is per-OS (open -b by bundle ID, falling
back to the enclosing .app, then to re-execing ourselves; Linux and
Windows re-exec directly, the GUI being this same binary).

`ctl stop` needs no per-OS teardown. It calls Helper.RequestQuit, and
the helper broadcasts EventQuit so a connected GUI runs its normal quit
path — which disconnects tunnels and stops the helper on the way out.
Shutting the helper down directly would be wrong: the GUI's health
monitor would respawn it and prompt for a password seconds after the
user asked everything to stop. With no GUI attached the helper shuts
itself down instead. The tray's Quit body moves to trayManager.quitApp
so both routes leave identical state behind.

Both commands are idempotent and report the end state, so they are safe
to script: start on a running app and stop on a stopped one exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
launchd appends the helper's StandardOutPath forever with no rotation,
and this line fires per connected tunnel every 30 seconds. On a machine
running since April it was 33,720 of 35,240 lines — 95.7% of a 7.7 MB
log.

Nothing is lost by demoting it: the same value is already broadcast in
the status event, rendered in the UI and readable via `ctl status`. The
helper's log level is runtime-mutable, so anyone debugging a latency
problem can turn it back on live with `wireguide ctl set loglevel debug`
or the Settings UI.

This is the fix rather than log rotation. Rotation would cap a file that
is 96% one redundant line; removing the line drops four months of logs
to roughly 330 KB, and the helper now only runs alongside the GUI. That
leaves no case for adding newsyslog wiring to the privileged install
script.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found by running `ctl start` for real: it launched
/Applications/WireGuide.app — the installed release — from a dev build's
CLI. Both bundles claim com.korjwl1.wireguide, and `open -b` leaves the
choice to LaunchServices.

The consequence is not cosmetic. The two builds generate different
LaunchDaemon plists, so whichever app starts sees the other's plist as
drift and reinstalls its own, putting an admin-password prompt in front
of the user on every single launch. Observed exactly that: the release
app opened an authorization dialog to roll the plist back to
RunAtLoad=true, undoing the migration the dev build had just done.

Try the .app enclosing our own executable first, and only ask
LaunchServices to resolve the bundle ID when there is no such bundle
(the CLI is a bare binary on $PATH, where no "our own" app exists and
the bundle ID is the right question). Symlinks are resolved before the
walk so a linked CLI still finds the bundle its real binary lives in.

The path walk moves to bundleFromExePath so it can be tested without an
os.Executable that happens to sit in the right place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…oming up

Any launch that finds no live helper shows a macOS admin-password dialog,
and the helper socket only appears once it is answered. osascript gives
that dialog no deadline, so the CLI's own two-minute cap cancelled
nothing — it just made `ctl start` lie. Observed twice while testing:
the command printed "the helper did not come up" and exited nonzero
while the app was up and the prompt was still on screen; answering it
afterwards brought everything up correctly, by which point a script had
already taken the failure branch.

Raise the cap to 10 minutes, which is a human answering a password
prompt rather than a machine timing out, and say something after 15
seconds so a CLI blocked on a prompt hidden behind a full-screen window
doesn't read as hung. The timeout message now points at the still-open
prompt instead of implying the launch failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… connection (Refs #6)

The helper now ties its lifetime to the GUI (self-exits after the 60s
startup grace; ctl calls are Transient and don't touch the timer), so the
headless automation matrix — whose rules disconnect every tunnel for long
stretches — lost its helper mid-run: status calls returned nothing and
the matrix failed. Hold one non-transient control connection (python
stand-in speaking the length-prefixed frame) for the test duration.

Verified on the Pi: automation matrix PASS with the patch; healthcheck
rerun PASS unmodified (its one failure was a transient curl timeout, not
a product regression — helper journal shows no activity in the window).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…op (Fixes #38)

Older Homebrew skips auto_updates casks even when named explicitly, and
the skip exits 0 — RunUpdate reported success while doing nothing,
stranding brew installs on old versions (observed: 0.3.1 pinned for
three months). --greedy forces the upgrade on every brew version.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d no GUI

Transient CLI clients (ctl, wifi-rule evaluation) intentionally never
fire the IPC server's OnConnect/OnDisconnect, so after the GUI quits
with a tunnel up, a `ctl disconnect` of the last tunnel left the
elevated helper with no GUI, no tunnel, and no shutdown timer — alive
until reboot or `ctl stop`. Regression vs 0.4.2, where the CLI's
non-transient disconnect re-armed the grace window.

Arm the grace window from every teardown path that can drop the active
count to zero (Disconnect handler, rule-driven disconnect, connect
rollback) when no control connection is attached. armShutdownTimer's
active-tunnel guard keeps this a no-op while any tunnel survives.

Verified live on Windows: helper self-exits 10s after the sequence
GUI-kill → ctl disconnect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…levate tests

The 3-OS CI matrix runs `go test ./...` on windows-latest, where two
tests added on this branch fail on path-separator rules alone:

- TestBundleFromExePath pins darwin-only .app bundle lookup with Unix
  fixtures that filepath mangles under Windows; skip it there.
- TestValidateArgsSocketSID used /var/run paths that fail the new
  absolute-path check on Windows; use the production pipe address
  (\\.\pipe\wireguide) so the SID rules stay exercised on all three
  OSes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@korjwl1
korjwl1 merged commit bf08b7e into main Aug 10, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants