diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..f80b696
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,142 @@
+name: CI
+
+# PR-only by design: pushing to a branch (including main via a merged PR)
+# must never rebuild or retest on its own — releases are cut exclusively
+# by v* tags (release.yml), and the repo owner was explicit that a bare
+# push should not light up CI. workflow_dispatch covers manual runs.
+on:
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ linux:
+ name: Linux (test + package)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version: '1.25.12'
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '20.19.2'
+ cache: npm
+ cache-dependency-path: frontend/package-lock.json
+
+ - name: Install Linux build dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev
+
+ - name: Install pinned build tools
+ run: |
+ go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-alpha.74
+ go install github.com/go-task/task/v3/cmd/task@v3.45.4
+ echo "$HOME/go/bin" >> "$GITHUB_PATH"
+
+ # main.go embeds frontend/dist, so the frontend must exist before any
+ # command that loads the root Go package (test, vet, or bindings).
+ - name: Build frontend
+ working-directory: frontend
+ run: |
+ npm ci
+ npm run build
+
+ - name: Test and vet
+ run: |
+ go test -race ./...
+ go vet ./...
+
+ - name: Build native Linux binary
+ run: task linux:build ARCH=amd64
+
+ - name: Check runtime linking
+ run: |
+ file bin/wireguide
+ if ldd bin/wireguide | grep -q 'not found'; then
+ ldd bin/wireguide
+ exit 1
+ fi
+
+ - name: Build and inspect DEB
+ env:
+ GOARCH: amd64
+ GIT_COMMITTER_NAME: WireGuide CI
+ GIT_COMMITTER_EMAIL: noreply@example.com
+ run: |
+ wails3 tool package -name wireguide -format deb -config ./build/linux/nfpm/nfpm.yaml -out ./bin
+ dpkg-deb --info bin/wireguide.deb
+ dpkg-deb --contents bin/wireguide.deb
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: linux-amd64-ci
+ path: |
+ bin/wireguide
+ bin/wireguide.deb
+ if-no-files-found: error
+ retention-days: 7
+
+ macos:
+ name: macOS (test)
+ runs-on: macos-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version: '1.25.12'
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '20.19.2'
+ cache: npm
+ cache-dependency-path: frontend/package-lock.json
+
+ - name: Build frontend
+ working-directory: frontend
+ run: |
+ npm ci
+ npm run build
+
+ - name: Test and vet
+ run: |
+ go test -race ./...
+ go vet ./...
+
+ windows:
+ name: Windows (test)
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version: '1.25.12'
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '20.19.2'
+ cache: npm
+ cache-dependency-path: frontend/package-lock.json
+
+ - name: Build frontend
+ working-directory: frontend
+ shell: pwsh
+ run: |
+ npm ci
+ npm run build
+
+ # No -race on Windows: the race detector needs cgo + a C toolchain,
+ # and the runner's mingw setup is not something we want this suite
+ # to depend on. The Linux job's -race pass covers the shared code.
+ - name: Test and vet
+ shell: pwsh
+ run: |
+ go test ./...
+ go vet ./...
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index cf85b2a..8e1cafd 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -34,16 +34,16 @@ jobs:
- uses: actions/setup-go@v5
with:
- go-version: '1.25'
+ go-version: '1.25.12'
- uses: actions/setup-node@v4
with:
- node-version: '20'
+ node-version: '20.19.2'
- name: Install wails3 + task
run: |
- go install github.com/wailsapp/wails/v3/cmd/wails3@latest
- go install github.com/go-task/task/v3/cmd/task@latest
+ go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-alpha.74
+ go install github.com/go-task/task/v3/cmd/task@v3.45.4
echo "$HOME/go/bin" >> "$GITHUB_PATH"
- name: Build .app bundle (ad-hoc signed)
@@ -87,17 +87,17 @@ jobs:
- uses: actions/setup-go@v5
with:
- go-version: '1.25'
+ go-version: '1.25.12'
- uses: actions/setup-node@v4
with:
- node-version: '20'
+ node-version: '20.19.2'
- name: Install wails3 + task
shell: pwsh
run: |
- go install github.com/wailsapp/wails/v3/cmd/wails3@latest
- go install github.com/go-task/task/v3/cmd/task@latest
+ go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-alpha.74
+ go install github.com/go-task/task/v3/cmd/task@v3.45.4
# Append, don't overwrite — PATH already has system entries.
"$env:USERPROFILE\go\bin" | Out-File -FilePath $env:GITHUB_PATH -Append
@@ -253,7 +253,7 @@ jobs:
- uses: actions/setup-go@v5
with:
- go-version: '1.25'
+ go-version: '1.25.12'
- name: Sign SHA256SUMS (Ed25519)
# HARD failure when the key is missing or mismatched — every
diff --git a/.gitignore b/.gitignore
index 5ed7ea3..88294fc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,7 @@ bin
frontend/dist
frontend/node_modules
build/linux/appimage/build
+build/linux/wireguide.desktop
build/windows/nsis/MicrosoftEdgeWebview2Setup.exe
# Go
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 9f02398..b19e662 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -8,14 +8,14 @@ Thanks for your interest in contributing!
- Go 1.25+
- Node.js 20+
-- [Wails v3](https://v3alpha.wails.io/) (`go install github.com/wailsapp/wails/v3/cmd/wails3@latest`)
+- [Wails v3](https://v3alpha.wails.io/) (`go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-alpha.74`)
- macOS with Apple Silicon (for now)
### Build & Run
```bash
# Install frontend dependencies
-cd frontend && npm install && cd ..
+cd frontend && npm ci && cd ..
# Development mode (hot reload)
task dev
diff --git a/README.ko.md b/README.ko.md
index be0abd5..f315d65 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -65,8 +65,8 @@ brew install --cask wireguide
```bash
brew install go node
-go install github.com/go-task/task/v3/cmd/task@latest
-go install github.com/wailsapp/wails/v3/cmd/wails3@latest
+go install github.com/go-task/task/v3/cmd/task@v3.45.4
+go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-alpha.74
task build
./bin/wireguide
diff --git a/README.md b/README.md
index 879c0ec..ff38ebd 100644
--- a/README.md
+++ b/README.md
@@ -136,8 +136,8 @@ installer registers the helper service and shortcut.
```bash
brew install go node
-go install github.com/go-task/task/v3/cmd/task@latest
-go install github.com/wailsapp/wails/v3/cmd/wails3@latest
+go install github.com/go-task/task/v3/cmd/task@v3.45.4
+go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-alpha.74
task build
./bin/wireguide
diff --git a/build/Taskfile.yml b/build/Taskfile.yml
index 1819c53..336bdf0 100644
--- a/build/Taskfile.yml
+++ b/build/Taskfile.yml
@@ -19,7 +19,7 @@ tasks:
- sh: npm version
msg: "Looks like npm isn't installed. Npm is part of the Node installer: https://nodejs.org/en/download/"
cmds:
- - npm install
+ - npm ci
build:frontend:
label: build:frontend (DEV={{.DEV}})
diff --git a/build/darwin/com.wireguide.helper.plist b/build/darwin/com.wireguide.helper.plist
deleted file mode 100644
index 3ebe898..0000000
--- a/build/darwin/com.wireguide.helper.plist
+++ /dev/null
@@ -1,49 +0,0 @@
-
-
-
-
- Label
- com.wireguide.helper
-
- ProgramArguments
-
- /Library/PrivilegedHelperTools/com.wireguide.helper
- --helper
- --socket=/var/run/wireguide/wireguide.sock
- --uid=__UID__
- --data-dir=/Library/Application Support/wireguide
-
-
- RunAtLoad
-
-
- KeepAlive
-
-
-
-
-
- ThrottleInterval
- 5
-
- StandardErrorPath
- /var/log/wireguide-helper.log
- StandardOutPath
- /var/log/wireguide-helper.log
-
-
diff --git a/build/linux/Taskfile.yml b/build/linux/Taskfile.yml
index ac92ad1..90ef4af 100644
--- a/build/linux/Taskfile.yml
+++ b/build/linux/Taskfile.yml
@@ -142,6 +142,8 @@ tasks:
cmds:
- task: generate:dotdesktop
- task: generate:deb
+ vars:
+ ARCH: '{{.ARCH | default ARCH}}'
create:rpm:
summary: Creates a rpm package
@@ -150,6 +152,8 @@ tasks:
cmds:
- task: generate:dotdesktop
- task: generate:rpm
+ vars:
+ ARCH: '{{.ARCH | default ARCH}}'
create:aur:
summary: Creates a arch linux packager package
@@ -158,19 +162,27 @@ tasks:
cmds:
- task: generate:dotdesktop
- task: generate:aur
+ vars:
+ ARCH: '{{.ARCH | default ARCH}}'
generate:deb:
summary: Creates a deb package
+ env:
+ GOARCH: '{{.ARCH | default ARCH}}'
cmds:
- wails3 tool package -name "{{.APP_NAME}}" -format deb -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
generate:rpm:
summary: Creates a rpm package
+ env:
+ GOARCH: '{{.ARCH | default ARCH}}'
cmds:
- wails3 tool package -name "{{.APP_NAME}}" -format rpm -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
generate:aur:
summary: Creates a arch linux packager package
+ env:
+ GOARCH: '{{.ARCH | default ARCH}}'
cmds:
- wails3 tool package -name "{{.APP_NAME}}" -format archlinux -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
diff --git a/build/linux/nfpm/nfpm.yaml b/build/linux/nfpm/nfpm.yaml
index 356e2f2..2832d37 100644
--- a/build/linux/nfpm/nfpm.yaml
+++ b/build/linux/nfpm/nfpm.yaml
@@ -24,20 +24,35 @@ contents:
- src: "./build/linux/wireguide.desktop"
dst: "/usr/share/applications/wireguide.desktop"
-# Default dependencies for Debian 12/Ubuntu 22.04+ with WebKit 4.1
+# Wails v3 alpha.74 uses GTK3 and the WebKitGTK 4.1 API on Linux.
depends:
- libgtk-3-0
- libwebkit2gtk-4.1-0
+ - libayatana-appindicator3-1
+ - iproute2
+ - nftables
+ - pkexec | policykit-1
+
+# NetworkManager supplies SSID detection and immediate Wi-Fi change events,
+# while resolvconf avoids the guarded /etc/resolv.conf fallback on systems
+# that do not run systemd-resolved. Noto supplies Korean UI glyphs and stable
+# cross-platform text metrics. The core client can still operate without these,
+# so keep them as recommendations rather than hard dependencies.
+recommends:
+ - network-manager
+ - resolvconf
+ - fonts-noto-core
+ - fonts-noto-cjk
# Distribution-specific overrides for different package formats and WebKit versions
overrides:
- # RPM packages for RHEL/CentOS/AlmaLinux/Rocky Linux (WebKit 4.0)
+ # RPM package names used by current Fedora/RHEL-family distributions.
rpm:
depends:
- gtk3
- webkit2gtk4.1
- # Arch Linux packages (WebKit 4.1)
+ # Arch Linux package names.
archlinux:
depends:
- gtk3
diff --git a/docs/linux-test-plan.md b/docs/linux-test-plan.md
new file mode 100644
index 0000000..7bae58a
--- /dev/null
+++ b/docs/linux-test-plan.md
@@ -0,0 +1,84 @@
+# Linux verification plan
+
+Target baseline: Debian 13 / Raspberry Pi OS, ARM64, Wayland (`labwc`) and
+WayVNC. CI baseline: Ubuntu latest, AMD64.
+
+## Automated gate
+
+- Clean frontend install and production build (`npm ci`, `npm run build`).
+- Go unit tests and vet after `frontend/dist` exists.
+- Race tests on Linux AMD64 CI. Raspberry Pi's 39-bit ARM64 VMA layout is not
+ supported by Go's race runtime, so `-race` cannot run on this device.
+- Native Wails production build and `ldd` missing-library check.
+- DEB generation, metadata inspection, desktop-file validation, and simulated
+ APT installation.
+- Repeat the native build on ARM64 hardware before a release.
+
+## GUI and desktop integration
+
+- Launch under X11/Xvfb for a crash smoke test.
+- Launch in a real Wayland session and verify initial window, resizing,
+ minimising, close-to-tray, tray menu actions, theme, scaling, and dialogs.
+- On Raspberry Pi's detached/headless labwc output, also test through XWayland
+ (`GDK_BACKEND=x11`). Native Wayland WebKitGTK rendering is corrupted on that
+ virtual output even with the original dependency set, while XWayland renders
+ correctly. This is a test-host compositor limitation, not a frontend result.
+- On the Pi, verified the real Linux tray click path (not only its menu): native
+ close hides the window without terminating the process, and a left click on
+ the tray icon restores a decorated window.
+- Confirm the PolicyKit prompt appears, cancellation is handled, successful
+ authentication starts the helper, and closing the last GUI causes the idle
+ helper to exit.
+- Verify XDG autostart creation/removal and launch after a fresh login.
+- Install the DEB, check application-menu/icon/tray integration, then purge it
+ and confirm no package-owned files remain.
+
+## Linux-only network behaviour
+
+### Raspberry Pi smoke-test result (2026-08-04)
+
+- Imported and connected a disposable WireGuard tunnel through the real helper.
+- Confirmed the `wg` interface was up with MTU 1420 and the configured IPv4
+ route was installed.
+- Disconnected and deleted the tunnel, then confirmed both the interface and
+ route were removed.
+- Confirmed routine RTNETLINK traffic no longer produces false primary-network
+ changes; reconnect decisions now compare the actual default route snapshot.
+- This local peer intentionally had no server, so handshake, payload, DNS and
+ kill-switch verification still require a real test endpoint.
+
+Run these with a local console or a second management path. A full-tunnel or
+kill-switch defect can sever the SSH/VNC session used to test it.
+
+- Helper Unix socket: directory ownership/mode, peer UID rejection, stale
+ socket recovery, GUI reconnect, helper version replacement.
+- TUN: create/configure/remove the interface and recover after forced helper
+ termination.
+- Routing: IPv4/IPv6 split tunnel, dual-stack full tunnel, endpoint bypass,
+ custom `Table`/`FwMark`, repeated connect/disconnect, and no leaked routes or
+ policy rules.
+- DNS: test systemd-resolved, resolvconf, and plain `/etc/resolv.conf` paths;
+ verify restoration after disconnect, helper crash, and reboot.
+- Firewall: kill switch and DNS leak protection using nftables; verify LAN and
+ endpoint exceptions and cleanup after every failure path.
+- Network changes: NetworkManager Wi-Fi SSID events, gateway MAC matching,
+ Ethernet/Wi-Fi handover, DHCP/default-route change, offline/online recovery.
+- Power: logind suspend/resume reconnect and fallback polling on systems
+ without logind.
+
+## Cross-OS comparison points
+
+| Capability | Linux | macOS | Windows |
+|---|---|---|---|
+| Elevation/helper | PolicyKit + Unix socket/peer UID | launchd helper | UAC + named pipe |
+| Tunnel/routes | TUN + `ip` policy routing | utun + route socket | Wintun/IP Helper APIs |
+| DNS | resolved/resolvconf/resolv.conf | networksetup | Windows IP APIs |
+| Kill switch | nftables | pf | Windows firewall/WFP path |
+| Wi-Fi identity | NetworkManager + `/proc` ARP | CoreWLAN | WLAN/IP Helper APIs |
+| Sleep/network events | logind + netlink | IOKit/SystemConfiguration | Windows notifications |
+| Autostart | XDG desktop entry | LaunchAgent | registry Run key |
+| Desktop shell | GTK/WebKitGTK + AppIndicator | AppKit/WebKit | WebView2/tray |
+
+For every platform-sensitive test, compare the observable contract rather
+than the implementation: identical tunnel status, route/DNS cleanup, reconnect
+timing, error text, persisted settings, and tray/window state.
diff --git a/frontend/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.js b/frontend/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.js
deleted file mode 100644
index 1ea1058..0000000
--- a/frontend/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.js
+++ /dev/null
@@ -1,9 +0,0 @@
-//@ts-check
-// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
-// This file is automatically generated. DO NOT EDIT
-
-// eslint-disable-next-line @typescript-eslint/ban-ts-comment
-// @ts-ignore: Unused imports
-import { Create as $Create } from "@wailsio/runtime";
-
-Object.freeze($Create.Events);
diff --git a/frontend/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts b/frontend/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts
deleted file mode 100644
index 3dd1807..0000000
--- a/frontend/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
-// This file is automatically generated. DO NOT EDIT
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 3f6e3a9..7f8d615 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -8,43 +8,28 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
- "@codemirror/autocomplete": "^6.20.1",
- "@codemirror/commands": "^6.10.3",
- "@codemirror/language": "^6.12.3",
- "@codemirror/lint": "^6.9.5",
- "@codemirror/search": "^6.6.0",
- "@codemirror/state": "^6.6.0",
- "@codemirror/theme-one-dark": "^6.1.3",
- "@codemirror/view": "^6.41.0",
- "@lezer/highlight": "^1.2.3",
- "@wailsio/runtime": "latest",
- "codemirror": "^6.0.2"
+ "@codemirror/autocomplete": "6.20.3",
+ "@codemirror/commands": "6.10.4",
+ "@codemirror/language": "6.12.4",
+ "@codemirror/lint": "6.9.7",
+ "@codemirror/search": "6.7.1",
+ "@codemirror/state": "6.7.1",
+ "@codemirror/theme-one-dark": "6.1.3",
+ "@codemirror/view": "6.43.7",
+ "@lezer/highlight": "1.2.3",
+ "@wailsio/runtime": "3.0.0-alpha.74",
+ "codemirror": "6.0.2"
},
"devDependencies": {
- "@sveltejs/vite-plugin-svelte": "^3.0.1",
- "svelte": "^4.2.8",
- "vite": "^5.0.8"
- }
- },
- "node_modules/@ampproject/remapping": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
- "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "@jridgewell/gen-mapping": "^0.3.5",
- "@jridgewell/trace-mapping": "^0.3.24"
- },
- "engines": {
- "node": ">=6.0.0"
+ "@sveltejs/vite-plugin-svelte": "7.2.0",
+ "svelte": "5.56.8",
+ "vite": "8.2.0"
}
},
"node_modules/@codemirror/autocomplete": {
- "version": "6.20.1",
- "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz",
- "integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==",
- "license": "MIT",
+ "version": "6.20.3",
+ "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
+ "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
@@ -53,22 +38,20 @@
}
},
"node_modules/@codemirror/commands": {
- "version": "6.10.3",
- "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz",
- "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
- "license": "MIT",
+ "version": "6.10.4",
+ "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz",
+ "integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==",
"dependencies": {
"@codemirror/language": "^6.0.0",
- "@codemirror/state": "^6.6.0",
+ "@codemirror/state": "^6.7.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/language": {
- "version": "6.12.3",
- "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
- "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
- "license": "MIT",
+ "version": "6.12.4",
+ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
+ "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
@@ -79,21 +62,19 @@
}
},
"node_modules/@codemirror/lint": {
- "version": "6.9.5",
- "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz",
- "integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==",
- "license": "MIT",
+ "version": "6.9.7",
+ "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
+ "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
"dependencies": {
"@codemirror/state": "^6.0.0",
- "@codemirror/view": "^6.35.0",
+ "@codemirror/view": "^6.42.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
- "version": "6.6.0",
- "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz",
- "integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==",
- "license": "MIT",
+ "version": "6.7.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz",
+ "integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.37.0",
@@ -101,10 +82,9 @@
}
},
"node_modules/@codemirror/state": {
- "version": "6.6.0",
- "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
- "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
- "license": "MIT",
+ "version": "6.7.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz",
+ "integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
@@ -122,425 +102,41 @@
}
},
"node_modules/@codemirror/view": {
- "version": "6.41.0",
- "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.41.0.tgz",
- "integrity": "sha512-6H/qadXsVuDY219Yljhohglve8xf4B8xJkVOEWfA5uiYKiTFppjqsvsfR5iPA0RbvRBoOyTZpbLIxe9+0UR8xA==",
- "license": "MIT",
+ "version": "6.43.7",
+ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.7.tgz",
+ "integrity": "sha512-FZsExxkoxnAN+d9TgqXLg5g4A1oQwzX9WlkOT5i2PKkcW7xx3Bmu0vs90g6fo9Mpdsb/l96dnAraQ8932aO4/g==",
"dependencies": {
- "@codemirror/state": "^6.6.0",
+ "@codemirror/state": "^6.7.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
- "node_modules/@esbuild/aix-ppc64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
- "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "aix"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/android-arm": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
- "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/android-arm64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
- "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/android-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
- "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/darwin-arm64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
- "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/darwin-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
- "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/freebsd-arm64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
- "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/freebsd-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
- "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/linux-arm": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
- "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/linux-arm64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
- "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/linux-ia32": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
- "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/linux-loong64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
- "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
- "cpu": [
- "loong64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/linux-mips64el": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
- "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
- "cpu": [
- "mips64el"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/linux-ppc64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
- "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/linux-riscv64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
- "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/linux-s390x": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
- "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
- "cpu": [
- "s390x"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/linux-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
- "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/netbsd-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
- "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/openbsd-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
- "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/sunos-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
- "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "sunos"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/win32-arm64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
- "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/win32-ia32": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
- "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/win32-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
- "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=12"
- }
- },
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
- "license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
- "license": "MIT",
"engines": {
"node": ">=6.0.0"
}
@@ -549,15 +145,13 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
- "dev": true,
- "license": "MIT"
+ "dev": true
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
- "license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
@@ -593,417 +187,295 @@
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
- "node_modules/@rollup/rollup-android-arm-eabi": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
- "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==",
- "cpu": [
- "arm"
- ],
+ "node_modules/@oxc-project/types": {
+ "version": "0.142.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz",
+ "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==",
"dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ]
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
},
- "node_modules/@rollup/rollup-android-arm64": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz",
- "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==",
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.2.tgz",
+ "integrity": "sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA==",
"cpu": [
"arm64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"android"
- ]
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
},
- "node_modules/@rollup/rollup-darwin-arm64": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz",
- "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==",
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.2.tgz",
+ "integrity": "sha512-9u9Xv6c1AJZT0FfwH5vrMG5Jjcwhc1MlyrPu0XfTqkzsmqfks2M6W/o5XwAJgVVN/jHpqqngC1WevHKKTIUtIA==",
"cpu": [
"arm64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"darwin"
- ]
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
},
- "node_modules/@rollup/rollup-darwin-x64": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz",
- "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==",
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.2.tgz",
+ "integrity": "sha512-9W1mbGZAfW3oqd85bhBkmpyHCCzL1TeG/zFFP3vg7b0rlly8cxOcre5nXwz+LHazCwac2MNWgPdPCHndABjpWQ==",
"cpu": [
"x64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"darwin"
- ]
- },
- "node_modules/@rollup/rollup-freebsd-arm64": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz",
- "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==",
- "cpu": [
- "arm64"
],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ]
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
},
- "node_modules/@rollup/rollup-freebsd-x64": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz",
- "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==",
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.2.tgz",
+ "integrity": "sha512-0p1lhiCSCyaerFwtrdZQUx7NqGk6LQnaRKWX7tFQqwQgvX0rjM15cIkm3pax1UpEakK14C4mOxx/jSqCBdBRqQ==",
"cpu": [
"x64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"freebsd"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz",
- "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==",
- "cpu": [
- "arm"
],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
},
- "node_modules/@rollup/rollup-linux-arm-musleabihf": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz",
- "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==",
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.2.tgz",
+ "integrity": "sha512-e+cOJXrJ2L3zx6YzqPg+f6Wbk3V1cKB8bOhbaYdVYN3DdquzNdRAmrbETz1qnt5yp/c7JNlNjmITiA2cVneQ7w==",
"cpu": [
"arm"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm64-gnu": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz",
- "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==",
- "cpu": [
- "arm64"
],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
},
- "node_modules/@rollup/rollup-linux-arm64-musl": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz",
- "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==",
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.2.tgz",
+ "integrity": "sha512-JsSMsj6sNat/MuhG5fnBD7QgbtpHKVe30x5/bAVirDHdhoQRXJkF6xc0Jqk8O4fiCUQAzMOoH9wZi3m60c8wtg==",
"cpu": [
"arm64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-loong64-gnu": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz",
- "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==",
- "cpu": [
- "loong64"
],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
},
- "node_modules/@rollup/rollup-linux-loong64-musl": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz",
- "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==",
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.2.tgz",
+ "integrity": "sha512-B5G/zJdHaoJn9vD50eGHWkiWfmq8Uhi3IiLPJTzmZTrAalk1bztUikSXo0qga18ibE0IXboyeMUnhPjhAJ45wQ==",
"cpu": [
- "loong64"
+ "arm64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-ppc64-gnu": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz",
- "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==",
- "cpu": [
- "ppc64"
],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
},
- "node_modules/@rollup/rollup-linux-ppc64-musl": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz",
- "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==",
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.2.tgz",
+ "integrity": "sha512-6mC/awzKka8W6EoekjegpfGkjz8jXWDX63pqu/HYVpyKtZfu65Jsh4QAH3Kej3CAv/c1oGX7psTmFEbr0mDxLA==",
"cpu": [
"ppc64"
],
"dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-riscv64-gnu": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz",
- "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-riscv64-musl": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz",
- "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==",
- "cpu": [
- "riscv64"
],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
},
- "node_modules/@rollup/rollup-linux-s390x-gnu": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz",
- "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==",
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.2.tgz",
+ "integrity": "sha512-412MX9fJLdA1IK28EZnc8jYv2HRTleOZgfLQumJ5zy7OeJLZlg/CETwFaXjNmGVxG51cFHpKLqb5LKvBC+HsHA==",
"cpu": [
"s390x"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
- ]
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
},
- "node_modules/@rollup/rollup-linux-x64-gnu": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz",
- "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==",
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.2.tgz",
+ "integrity": "sha512-Q/+HI/ToJafZ1iCqGgVQXUEkIjufHCTF0gBQ2a5o3cg7GJ2h0qyq3nvvSmU+bGda2/7ygXpTY4TM6gO9OhQ0ZA==",
"cpu": [
"x64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
- ]
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
},
- "node_modules/@rollup/rollup-linux-x64-musl": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz",
- "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==",
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.2.tgz",
+ "integrity": "sha512-ZKp/w41n6wCvxzxQHtQSbuphfX3Y4cCvbjkKHusrLx4lh+JWLTU7StSltO/DKARISzbj368d+qUaCjI8K2wzXw==",
"cpu": [
"x64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"linux"
- ]
- },
- "node_modules/@rollup/rollup-openbsd-x64": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz",
- "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==",
- "cpu": [
- "x64"
],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ]
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
},
- "node_modules/@rollup/rollup-openharmony-arm64": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz",
- "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==",
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.2.tgz",
+ "integrity": "sha512-pxE6xD4KS3eAROkKK5yrhB9/3+vhlhVGMvlQLbdpzrBGDbKrnzx3RLwPaHvasLo6jgaiBLn7e04Df9C4tYhjmA==",
"cpu": [
"arm64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"openharmony"
- ]
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
},
- "node_modules/@rollup/rollup-win32-arm64-msvc": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz",
- "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==",
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.2.tgz",
+ "integrity": "sha512-4MqEue5re+xIZzAWsB8sj0P1kqZySWqIuN4t6QaIO/YA6SFwySOLruvWQFzfmqk8LBK2P30KCSJwf6mJCZZ5/A==",
"cpu": [
"arm64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"win32"
- ]
- },
- "node_modules/@rollup/rollup-win32-ia32-msvc": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz",
- "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==",
- "cpu": [
- "ia32"
],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
},
- "node_modules/@rollup/rollup-win32-x64-gnu": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz",
- "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==",
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.2.tgz",
+ "integrity": "sha512-NweNxxD0Nf9t8v7kodun45Ijp3EIwYY+uydPP6qBEYvfBqhIjN6dZMzlQja3tqX/aLs3F3Uz+AxDpKgRhpOZQg==",
"cpu": [
"x64"
],
"dev": true,
- "license": "MIT",
"optional": true,
"os": [
"win32"
- ]
- },
- "node_modules/@rollup/rollup-win32-x64-msvc": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz",
- "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==",
- "cpu": [
- "x64"
],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
},
- "node_modules/@sveltejs/vite-plugin-svelte": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-3.1.2.tgz",
- "integrity": "sha512-Txsm1tJvtiYeLUVRNqxZGKR/mI+CzuIQuc2gn+YCs9rMTowpNZ2Nqt53JdL8KF9bLhAf2ruR/dr9eZCwdTriRA==",
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true
+ },
+ "node_modules/@sveltejs/acorn-typescript": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz",
+ "integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==",
"dev": true,
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@sveltejs/vite-plugin-svelte-inspector": "^2.1.0",
- "debug": "^4.3.4",
- "deepmerge": "^4.3.1",
- "kleur": "^4.1.5",
- "magic-string": "^0.30.10",
- "svelte-hmr": "^0.16.0",
- "vitefu": "^0.2.5"
- },
- "engines": {
- "node": "^18.0.0 || >=20"
- },
"peerDependencies": {
- "svelte": "^4.0.0 || ^5.0.0-next.0",
- "vite": "^5.0.0"
+ "acorn": "^8.9.0"
}
},
- "node_modules/@sveltejs/vite-plugin-svelte-inspector": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-2.1.0.tgz",
- "integrity": "sha512-9QX28IymvBlSCqsCll5t0kQVxipsfhFFL+L2t3nTWfXnddYwxBuAEtTtlaVQpRz9c37BhJjltSeY4AJSC03SSg==",
+ "node_modules/@sveltejs/vite-plugin-svelte": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.2.0.tgz",
+ "integrity": "sha512-1SpkuMSRLfugrVX+IrKfE1RUegzo8AQzKQ6qQPfVzbcWi5IhuTPaKb5ZrLpucleFznkc4/RTeSPoRnGWFxX+EQ==",
"dev": true,
- "license": "MIT",
"dependencies": {
- "debug": "^4.3.4"
+ "deepmerge": "^4.3.1",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.0",
+ "vitefu": "^1.1.2"
},
"engines": {
- "node": "^18.0.0 || >=20"
+ "node": "^20.19 || ^22.12 || >=24"
},
"peerDependencies": {
- "@sveltejs/vite-plugin-svelte": "^3.0.0",
- "svelte": "^4.0.0 || ^5.0.0-next.0",
- "vite": "^5.0.0"
+ "svelte": "^5.46.4",
+ "vite": "^8.0.0-beta.7 || ^8.0.0"
}
},
"node_modules/@types/estree": {
- "version": "1.0.8",
- "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
- "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
- "dev": true,
- "license": "MIT"
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true
+ },
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "dev": true
},
"node_modules/@wailsio/runtime": {
- "version": "3.0.0-alpha.79",
- "resolved": "https://registry.npmjs.org/@wailsio/runtime/-/runtime-3.0.0-alpha.79.tgz",
- "integrity": "sha512-NITzxKmJsMEruc39L166lbPJVECxzcbdqpHVqOOF7Cu/7Zqk/e3B/gNpkUjhNyo5rVb3V1wpS8oEgLUmpu1cwA==",
- "license": "MIT"
+ "version": "3.0.0-alpha.74",
+ "resolved": "https://registry.npmjs.org/@wailsio/runtime/-/runtime-3.0.0-alpha.74.tgz",
+ "integrity": "sha512-6N3F6MpLDgLfTRIwgwAzxSrIVtlPICxMYDrs0bz5uUJ58IPCQjcqxWOedoMysFdqVaogi5VmCxXLKRJzI5hW2A=="
},
"node_modules/acorn": {
- "version": "8.16.0",
- "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
- "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
+ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
"dev": true,
- "license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
@@ -1012,11 +484,10 @@
}
},
"node_modules/aria-query": {
- "version": "5.3.2",
- "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
- "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
+ "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
"dev": true,
- "license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
}
@@ -1026,23 +497,17 @@
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"dev": true,
- "license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
}
},
- "node_modules/code-red": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/code-red/-/code-red-1.0.4.tgz",
- "integrity": "sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==",
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/sourcemap-codec": "^1.4.15",
- "@types/estree": "^1.0.1",
- "acorn": "^8.10.0",
- "estree-walker": "^3.0.3",
- "periscopic": "^3.1.0"
+ "engines": {
+ "node": ">=6"
}
},
"node_modules/codemirror": {
@@ -1066,95 +531,69 @@
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
- "node_modules/css-tree": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz",
- "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==",
+ "node_modules/deepmerge": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"dev": true,
"license": "MIT",
- "dependencies": {
- "mdn-data": "2.0.30",
- "source-map-js": "^1.0.1"
- },
"engines": {
- "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ "node": ">=0.10.0"
}
},
- "node_modules/debug": {
- "version": "4.4.3",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
- "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/devalue": {
+ "version": "5.9.0",
+ "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.0.tgz",
+ "integrity": "sha512-RWrqdArjvPbsATEhOPUo6Wndc/iWnkWKlhIrdlF3zMMYo/c3CVtoaVAyLtWxz5h8nSlkHzxnzV2uLydPXmtF+A==",
+ "dev": true
+ },
+ "node_modules/esm-env": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
+ "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
+ "dev": true
+ },
+ "node_modules/esrap": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.0.tgz",
+ "integrity": "sha512-GQ/7RN8uOtEfNpzZzBMTzW9JBcX42oaSVtPzdF+6cEL8pqIL094iUpr9jzYGn4O4P/1S60dJ6izyT8F4LYARng==",
"dev": true,
- "license": "MIT",
"dependencies": {
- "ms": "^2.1.3"
+ "@jridgewell/sourcemap-codec": "^1.4.15"
},
- "engines": {
- "node": ">=6.0"
+ "peerDependencies": {
+ "@typescript-eslint/types": "^8.2.0"
},
"peerDependenciesMeta": {
- "supports-color": {
+ "@typescript-eslint/types": {
"optional": true
}
}
},
- "node_modules/deepmerge": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
- "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
- "license": "MIT",
"engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/esbuild": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
- "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "bin": {
- "esbuild": "bin/esbuild"
+ "node": ">=12.0.0"
},
- "engines": {
- "node": ">=12"
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
},
- "optionalDependencies": {
- "@esbuild/aix-ppc64": "0.21.5",
- "@esbuild/android-arm": "0.21.5",
- "@esbuild/android-arm64": "0.21.5",
- "@esbuild/android-x64": "0.21.5",
- "@esbuild/darwin-arm64": "0.21.5",
- "@esbuild/darwin-x64": "0.21.5",
- "@esbuild/freebsd-arm64": "0.21.5",
- "@esbuild/freebsd-x64": "0.21.5",
- "@esbuild/linux-arm": "0.21.5",
- "@esbuild/linux-arm64": "0.21.5",
- "@esbuild/linux-ia32": "0.21.5",
- "@esbuild/linux-loong64": "0.21.5",
- "@esbuild/linux-mips64el": "0.21.5",
- "@esbuild/linux-ppc64": "0.21.5",
- "@esbuild/linux-riscv64": "0.21.5",
- "@esbuild/linux-s390x": "0.21.5",
- "@esbuild/linux-x64": "0.21.5",
- "@esbuild/netbsd-x64": "0.21.5",
- "@esbuild/openbsd-x64": "0.21.5",
- "@esbuild/sunos-x64": "0.21.5",
- "@esbuild/win32-arm64": "0.21.5",
- "@esbuild/win32-ia32": "0.21.5",
- "@esbuild/win32-x64": "0.21.5"
- }
- },
- "node_modules/estree-walker": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
- "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/estree": "^1.0.0"
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
}
},
"node_modules/fsevents": {
@@ -1163,7 +602,6 @@
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
- "license": "MIT",
"optional": true,
"os": [
"darwin"
@@ -1177,56 +615,278 @@
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"dev": true,
- "license": "MIT",
"dependencies": {
"@types/estree": "^1.0.6"
}
},
- "node_modules/kleur": {
- "version": "4.1.5",
- "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
- "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
+ "node_modules/lightningcss": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
"dev": true,
- "license": "MIT",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
"engines": {
- "node": ">=6"
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.33.0",
+ "lightningcss-darwin-arm64": "1.33.0",
+ "lightningcss-darwin-x64": "1.33.0",
+ "lightningcss-freebsd-x64": "1.33.0",
+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
+ "lightningcss-linux-arm64-gnu": "1.33.0",
+ "lightningcss-linux-arm64-musl": "1.33.0",
+ "lightningcss-linux-x64-gnu": "1.33.0",
+ "lightningcss-linux-x64-musl": "1.33.0",
+ "lightningcss-win32-arm64-msvc": "1.33.0",
+ "lightningcss-win32-x64-msvc": "1.33.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
}
},
"node_modules/locate-character": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
- "dev": true,
- "license": "MIT"
+ "dev": true
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
- "license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
- "node_modules/mdn-data": {
- "version": "2.0.30",
- "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
- "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==",
- "dev": true,
- "license": "CC0-1.0"
- },
- "node_modules/ms": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
- "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/nanoid": {
- "version": "3.3.11",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
- "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "version": "3.3.17",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz",
+ "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==",
"dev": true,
"funding": [
{
@@ -1234,7 +894,6 @@
"url": "https://github.com/sponsors/ai"
}
],
- "license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
@@ -1242,29 +901,41 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
- "node_modules/periscopic": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/periscopic/-/periscopic-3.1.0.tgz",
- "integrity": "sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==",
+ "node_modules/obug": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
+ "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
"dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/estree": "^1.0.0",
- "estree-walker": "^3.0.0",
- "is-reference": "^3.0.0"
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "engines": {
+ "node": ">=12.20.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
- "license": "ISC"
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
},
"node_modules/postcss": {
- "version": "8.5.8",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
- "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
+ "version": "8.5.25",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
+ "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
"dev": true,
"funding": [
{
@@ -1280,9 +951,8 @@
"url": "https://github.com/sponsors/ai"
}
],
- "license": "MIT",
"dependencies": {
- "nanoid": "^3.3.11",
+ "nanoid": "^3.3.16",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -1290,49 +960,36 @@
"node": "^10 || ^12 || >=14"
}
},
- "node_modules/rollup": {
- "version": "4.60.1",
- "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
- "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
+ "node_modules/rolldown": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.2.tgz",
+ "integrity": "sha512-opwpo1tQBAcpSUJDt94B7hhLNGOKjCdE//XXjeLrnx9b83bjnw45tXdg1b09yEw/VLFBJGZpwRULMmOZo7ol+A==",
"dev": true,
- "license": "MIT",
"dependencies": {
- "@types/estree": "1.0.8"
+ "@oxc-project/types": "=0.142.0",
+ "@rolldown/pluginutils": "^1.0.0"
},
"bin": {
- "rollup": "dist/bin/rollup"
+ "rolldown": "bin/cli.mjs"
},
"engines": {
- "node": ">=18.0.0",
- "npm": ">=8.0.0"
+ "node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
- "@rollup/rollup-android-arm-eabi": "4.60.1",
- "@rollup/rollup-android-arm64": "4.60.1",
- "@rollup/rollup-darwin-arm64": "4.60.1",
- "@rollup/rollup-darwin-x64": "4.60.1",
- "@rollup/rollup-freebsd-arm64": "4.60.1",
- "@rollup/rollup-freebsd-x64": "4.60.1",
- "@rollup/rollup-linux-arm-gnueabihf": "4.60.1",
- "@rollup/rollup-linux-arm-musleabihf": "4.60.1",
- "@rollup/rollup-linux-arm64-gnu": "4.60.1",
- "@rollup/rollup-linux-arm64-musl": "4.60.1",
- "@rollup/rollup-linux-loong64-gnu": "4.60.1",
- "@rollup/rollup-linux-loong64-musl": "4.60.1",
- "@rollup/rollup-linux-ppc64-gnu": "4.60.1",
- "@rollup/rollup-linux-ppc64-musl": "4.60.1",
- "@rollup/rollup-linux-riscv64-gnu": "4.60.1",
- "@rollup/rollup-linux-riscv64-musl": "4.60.1",
- "@rollup/rollup-linux-s390x-gnu": "4.60.1",
- "@rollup/rollup-linux-x64-gnu": "4.60.1",
- "@rollup/rollup-linux-x64-musl": "4.60.1",
- "@rollup/rollup-openbsd-x64": "4.60.1",
- "@rollup/rollup-openharmony-arm64": "4.60.1",
- "@rollup/rollup-win32-arm64-msvc": "4.60.1",
- "@rollup/rollup-win32-ia32-msvc": "4.60.1",
- "@rollup/rollup-win32-x64-gnu": "4.60.1",
- "@rollup/rollup-win32-x64-msvc": "4.60.1",
- "fsevents": "~2.3.2"
+ "@rolldown/binding-android-arm64": "1.2.2",
+ "@rolldown/binding-darwin-arm64": "1.2.2",
+ "@rolldown/binding-darwin-x64": "1.2.2",
+ "@rolldown/binding-freebsd-x64": "1.2.2",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.2",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.2",
+ "@rolldown/binding-linux-arm64-musl": "1.2.2",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.2",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.2",
+ "@rolldown/binding-linux-x64-gnu": "1.2.2",
+ "@rolldown/binding-linux-x64-musl": "1.2.2",
+ "@rolldown/binding-openharmony-arm64": "1.2.2",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.2",
+ "@rolldown/binding-win32-x64-msvc": "1.2.2"
}
},
"node_modules/source-map-js": {
@@ -1340,7 +997,6 @@
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
- "license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
@@ -1352,62 +1008,65 @@
"license": "MIT"
},
"node_modules/svelte": {
- "version": "4.2.20",
- "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.20.tgz",
- "integrity": "sha512-eeEgGc2DtiUil5ANdtd8vPwt9AgaMdnuUFnPft9F5oMvU/FHu5IHFic+p1dR/UOB7XU2mX2yHW+NcTch4DCh5Q==",
+ "version": "5.56.8",
+ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.8.tgz",
+ "integrity": "sha512-PY8LOw7xP6c8IOiVqdo0sbbZVYhXRSfklOQLAUyGBKqjTX0wx/z4l/9J+PmBpmlLnxzEb1NqltxQ5/wZme/Cmg==",
"dev": true,
- "license": "MIT",
- "peer": true,
"dependencies": {
- "@ampproject/remapping": "^2.2.1",
- "@jridgewell/sourcemap-codec": "^1.4.15",
- "@jridgewell/trace-mapping": "^0.3.18",
- "@types/estree": "^1.0.1",
- "acorn": "^8.9.0",
- "aria-query": "^5.3.0",
- "axobject-query": "^4.0.0",
- "code-red": "^1.0.3",
- "css-tree": "^2.3.1",
- "estree-walker": "^3.0.3",
- "is-reference": "^3.0.1",
+ "@jridgewell/remapping": "^2.3.4",
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@sveltejs/acorn-typescript": "^1.0.10",
+ "@types/estree": "^1.0.5",
+ "@types/trusted-types": "^2.0.7",
+ "acorn": "^8.12.1",
+ "aria-query": "5.3.1",
+ "axobject-query": "^4.1.0",
+ "clsx": "^2.1.1",
+ "devalue": "^5.8.1",
+ "esm-env": "^1.2.1",
+ "esrap": "^2.2.12",
+ "is-reference": "^3.0.3",
"locate-character": "^3.0.0",
- "magic-string": "^0.30.4",
- "periscopic": "^3.1.0"
+ "magic-string": "^0.30.11",
+ "zimmerframe": "^1.1.2"
},
"engines": {
- "node": ">=16"
+ "node": ">=18"
}
},
- "node_modules/svelte-hmr": {
- "version": "0.16.0",
- "resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.16.0.tgz",
- "integrity": "sha512-Gyc7cOS3VJzLlfj7wKS0ZnzDVdv3Pn2IuVeJPk9m2skfhcu5bq3wtIZyQGggr7/Iim5rH5cncyQft/kRLupcnA==",
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
- "license": "ISC",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
"engines": {
- "node": "^12.20 || ^14.13.1 || >= 16"
+ "node": ">=12.0.0"
},
- "peerDependencies": {
- "svelte": "^3.19.0 || ^4.0.0"
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/vite": {
- "version": "5.4.21",
- "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
- "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+ "version": "8.2.0",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz",
+ "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==",
"dev": true,
- "license": "MIT",
- "peer": true,
"dependencies": {
- "esbuild": "^0.21.3",
- "postcss": "^8.4.43",
- "rollup": "^4.20.0"
+ "lightningcss": "^1.33.0",
+ "picomatch": "^4.0.5",
+ "postcss": "^8.5.23",
+ "rolldown": "~1.2.0",
+ "tinyglobby": "^0.2.17"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
- "node": "^18.0.0 || >=20.0.0"
+ "node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
@@ -1416,23 +1075,33 @@
"fsevents": "~2.3.3"
},
"peerDependencies": {
- "@types/node": "^18.0.0 || >=20.0.0",
- "less": "*",
- "lightningcss": "^1.21.0",
- "sass": "*",
- "sass-embedded": "*",
- "stylus": "*",
- "sugarss": "*",
- "terser": "^5.4.0"
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.4.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
- "less": {
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
"optional": true
},
- "lightningcss": {
+ "less": {
"optional": true
},
"sass": {
@@ -1449,17 +1118,22 @@
},
"terser": {
"optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
}
}
},
"node_modules/vitefu": {
- "version": "0.2.5",
- "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-0.2.5.tgz",
- "integrity": "sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==",
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz",
+ "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==",
"dev": true,
- "license": "MIT",
"peerDependencies": {
- "vite": "^3.0.0 || ^4.0.0 || ^5.0.0"
+ "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"vite": {
@@ -1472,6 +1146,12 @@
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
+ },
+ "node_modules/zimmerframe": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
+ "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
+ "dev": true
}
}
}
diff --git a/frontend/package.json b/frontend/package.json
index 1762eb3..249de53 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -10,21 +10,21 @@
"preview": "vite preview"
},
"dependencies": {
- "@codemirror/autocomplete": "^6.20.1",
- "@codemirror/commands": "^6.10.3",
- "@codemirror/language": "^6.12.3",
- "@codemirror/lint": "^6.9.5",
- "@codemirror/search": "^6.6.0",
- "@codemirror/state": "^6.6.0",
- "@codemirror/theme-one-dark": "^6.1.3",
- "@codemirror/view": "^6.41.0",
- "@lezer/highlight": "^1.2.3",
- "@wailsio/runtime": "latest",
- "codemirror": "^6.0.2"
+ "@codemirror/autocomplete": "6.20.3",
+ "@codemirror/commands": "6.10.4",
+ "@codemirror/language": "6.12.4",
+ "@codemirror/lint": "6.9.7",
+ "@codemirror/search": "6.7.1",
+ "@codemirror/state": "6.7.1",
+ "@codemirror/theme-one-dark": "6.1.3",
+ "@codemirror/view": "6.43.7",
+ "@lezer/highlight": "1.2.3",
+ "@wailsio/runtime": "3.0.0-alpha.74",
+ "codemirror": "6.0.2"
},
"devDependencies": {
- "@sveltejs/vite-plugin-svelte": "^3.0.1",
- "svelte": "^4.2.8",
- "vite": "^5.0.8"
+ "@sveltejs/vite-plugin-svelte": "7.2.0",
+ "svelte": "5.56.8",
+ "vite": "8.2.0"
}
}
diff --git a/frontend/public/style.css b/frontend/public/style.css
index 57a3014..b73acdd 100644
--- a/frontend/public/style.css
+++ b/frontend/public/style.css
@@ -114,9 +114,14 @@
/* ============ Non-color tokens (shared across themes) ============ */
- /* Typography: macOS system font stack — SF Pro Text/Display auto-selected */
+ /* Native UI font stack. Keep each platform's normal system face while
+ * giving Linux an explicit metric-compatible choice and Korean glyphs.
+ * A bare sans-serif fallback resolved to DejaVu Sans on Raspberry Pi OS,
+ * whose button baselines sit visibly high compared with SF/Segoe UI. */
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display",
- "Helvetica Neue", Helvetica, Arial, sans-serif;
+ "Segoe UI", "Apple SD Gothic Neo", "Malgun Gothic",
+ "Cantarell", "Noto Sans", "Noto Sans CJK KR", "Helvetica Neue", Helvetica,
+ Arial, "Liberation Sans", sans-serif;
--font-mono: ui-monospace, "SF Mono", Menlo, Monaco, "Cascadia Mono",
Consolas, monospace;
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index 11d32a0..a27b3a8 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -920,7 +920,10 @@
@media (prefers-reduced-motion: no-preference) {
.drop-overlay { transition: opacity var(--dur-base) var(--ease-out); }
}
- :global(.file-drop-target-active) > .drop-overlay {
+ /* Descendant (not child >) combinator: Svelte 5 prunes child-combinator
+ * rules whose parent class it can't statically prove, and Wails adds
+ * .file-drop-target-active at runtime — a `>` here ships commented-out. */
+ :global(.file-drop-target-active) .drop-overlay {
opacity: 1;
visibility: visible;
backdrop-filter: blur(10px) saturate(180%);
@@ -1451,107 +1454,4 @@
color: var(--text-primary);
font: var(--text-title-2);
}
- .modal label {
- display: block;
- margin: var(--space-3) 0 var(--space-1);
- font: var(--text-subheadline);
- color: var(--text-secondary);
- }
- .modal input[type="text"] {
- width: 100%;
- height: 24px;
- padding: 0 var(--space-2);
- background: var(--bg-input);
- border: 0.5px solid var(--border);
- border-radius: var(--radius-sm);
- color: var(--text-primary);
- font: var(--text-body);
- box-sizing: border-box;
- outline: none;
- }
- .modal input[type="text"]:focus {
- border-color: var(--accent);
- box-shadow: 0 0 0 3px var(--blue-tint);
- }
- .hint {
- font: var(--text-footnote);
- color: var(--text-secondary);
- margin: 0 0 var(--space-3);
- }
- .btn-file-select {
- width: 100%;
- padding: var(--space-3);
- background: var(--bg-card);
- border: 1px dashed var(--border);
- border-radius: var(--radius-sm);
- color: var(--text-primary);
- font: var(--text-body);
- cursor: pointer;
- margin-bottom: var(--space-2);
- }
- @media (prefers-reduced-motion: no-preference) {
- .btn-file-select {
- transition: background-color var(--dur-fast) var(--ease-out),
- border-color var(--dur-fast) var(--ease-out);
- }
- }
- .btn-file-select:hover {
- background: var(--bg-hover);
- border-color: var(--accent);
- }
- .preview {
- margin: var(--space-3) 0;
- padding: var(--space-3);
- background: var(--editor-bg);
- border: 0.5px solid var(--editor-border);
- border-radius: var(--radius-sm);
- font: 10px/14px var(--font-mono);
- color: var(--text-secondary);
- max-height: 200px;
- overflow-y: auto;
- white-space: pre-wrap;
- }
- .errors {
- margin: var(--space-2) 0;
- padding: var(--space-2) var(--space-3);
- background: var(--error-bg);
- border: 0.5px solid var(--red);
- border-radius: var(--radius-sm);
- }
- .errors p {
- margin: var(--space-1) 0;
- color: var(--error-text);
- font: var(--text-body);
- }
- .modal-footer {
- display: flex;
- gap: var(--space-2);
- justify-content: flex-end;
- margin-top: var(--space-4);
- }
- .btn {
- height: 28px;
- padding: 0 var(--space-3);
- border: 0;
- border-radius: var(--radius-sm);
- font: var(--text-headline);
- cursor: pointer;
- color: var(--text-primary);
- display: inline-flex;
- align-items: center;
- justify-content: center;
- }
- .btn:disabled { opacity: 0.45; cursor: not-allowed; }
- .btn-connect {
- background: var(--accent);
- color: var(--text-inverse);
- }
- @media (prefers-reduced-motion: no-preference) {
- .btn, .btn-connect {
- transition: background-color var(--dur-fast) var(--ease-out),
- filter var(--dur-fast) var(--ease-out);
- }
- }
- .btn-connect:hover:not(:disabled) { filter: brightness(1.08); }
- .btn-connect:active:not(:disabled) { filter: brightness(0.94); }
diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json
index 8de23e7..2ce05ef 100644
--- a/frontend/src/i18n/en.json
+++ b/frontend/src/i18n/en.json
@@ -1,11 +1,9 @@
{
"app": {
- "name": "WireGuide",
"disconnected": "Disconnected",
"connecting": "Connecting...",
"connected": "Connected",
- "no_handshake": "No Handshake",
- "error": "Error"
+ "no_handshake": "No Handshake"
},
"nav": {
"tunnels": "Tunnels",
@@ -63,21 +61,6 @@
"rx": "Download",
"tx": "Upload",
"latency": "Latency",
- "wifi_auto_connect": "Auto-Connect",
- "wifi_auto_connect_hint": "This tunnel activates when you join one of these networks.",
- "wifi_empty": "No networks added yet.",
- "wifi_added": "Auto-connect networks",
- "wifi_known": "Saved networks",
- "wifi_current": "Current",
- "wifi_current_tooltip": "You are connected to this network now",
- "wifi_manual": "Add a network not listed above",
- "wifi_manual_hint": "Type an SSID exactly as it appears on the network you want to match.",
- "wifi_no_known": "No saved Wi-Fi networks were found. Use the manual input below.",
- "wifi_pick_label": "Networks that should auto-connect this tunnel",
- "wifi_combo_placeholder": "Type or pick a network…",
- "kill_switch": "Kill Switch",
- "dns_protection": "DNS Protection",
- "details": "Details",
"rename_hint": "Double-click to rename",
"notes": "Notes",
"notes_placeholder": "Freeform notes — endpoint location, credentials hint, etc.",
@@ -86,21 +69,8 @@
"sort_added_old": "Oldest first"
},
"import": {
- "title": "Import Tunnel",
- "drop_file": "Drop .conf file here",
- "or": "or",
- "select_file": "Select File",
- "from_clipboard": "Paste from Clipboard",
- "preview_title": "Preview",
- "tunnel_name": "Tunnel Name",
- "confirm": "Import",
- "cancel": "Cancel",
- "success": "Tunnel imported successfully",
- "exists": "Tunnel \"{name}\" already exists. Overwrite?",
- "overwrite": "Overwrite",
"zip_result_title": "Import Results",
"zip_ok": "Done",
- "zip_none": "No .conf files found in zip",
"zip_summary": "{ok} imported, {fail} failed",
"zip_summary_ok": "{ok} imported"
},
@@ -108,21 +78,14 @@
"title": "Edit Tunnel: {name}",
"save": "Save",
"cancel": "Cancel",
- "validation_error": "Validation Error",
"name_placeholder": "Tunnel name",
"name_required": "Tunnel name is required"
},
- "scripts": {
- "warning_title": "Script Warning",
- "warning_message": "This configuration contains system commands that will be executed:",
- "allow": "Allow",
- "deny": "Deny",
- "denied_note": "Scripts will not be executed for this tunnel."
- },
"settings": {
"title": "Settings",
"general": "General",
"auto_start": "Launch at startup",
+ "auto_start_hint": "Off by default. WireGuide's background helper runs only while the app is open — automation rules apply during that time. Turn this on to have WireGuide start with your session.",
"theme": "Theme",
"theme_dark": "Dark",
"theme_light": "Light",
@@ -133,7 +96,6 @@
"lang_auto": "Auto",
"close": "Close",
"advanced": "Advanced",
- "wifi_rules": "Wi-Fi Rules",
"log_level": "Log Level",
"log_level_debug": "Debug",
"log_level_info": "Info",
@@ -150,13 +112,10 @@
"about": "About",
"about_tagline": "Friendly WireGuard for every desktop.",
"about_desc": "An open-source VPN client built around WireGuard — drop a config and you're online. A kill switch that doesn't leak, DNS protection that doesn't dodge, and Wi-Fi rules that pick the right tunnel on networks you trust. No command line. No telemetry. No upsell.",
- "about_source": "Source on GitHub",
"about_releases": "Release notes",
"about_issues": "Report an issue",
"about_license": "License (MIT)",
"about_credits": "Built with WireGuard®, Wails, and Svelte.",
- "about_copyright": "© 2026 WireGuide. Released under the MIT License.",
- "about_check_update": "Check for updates",
"about_made_by": "Made by",
"about_footer_copyright": "© 2026 WireGuide",
"up_to_date": "You're on the latest version.",
@@ -174,41 +133,15 @@
"delete_message": "Are you sure you want to delete \"{name}\"?",
"yes": "Delete",
"no": "Cancel",
- "close": "Close",
"disconnect_first": "Disconnect the tunnel first"
},
- "error": {
- "connection_failed": "Connection failed",
- "network_error": "Network error — check your internet connection",
- "key_mismatch": "Key mismatch — verify your configuration",
- "permission_denied": "Administrator privileges required",
- "invalid_config": "Invalid configuration",
- "file_read_error": "Cannot read file — check file permissions",
- "empty_file": "Empty file"
- },
- "time": {
- "seconds_ago": "{n}s ago",
- "just_now": "just now"
- },
"log": {
- "title": "Logs",
"clear": "Clear",
"copy": "Copy",
"auto_scroll": "Auto-scroll",
"no_entries": "No log entries",
"filter_all": "All"
},
- "notify": {
- "disconnected": "VPN connection lost",
- "reconnecting": "Reconnecting to {name}...",
- "reconnected": "Reconnected to {name}",
- "kill_switch_active": "Kill switch active — internet blocked",
- "error": "VPN error: {message}"
- },
- "daemon": {
- "not_running": "WireGuide daemon is not running",
- "install_hint": "Start the daemon with: sudo wireguided"
- },
"tools": {
"tab_dns_leak": "DNS Leak Test",
"tab_routes": "Routes",
@@ -229,44 +162,9 @@
"route_legend_vpn": "VPN route",
"route_legend_direct": "Direct route"
},
- "new_tunnel": {
- "title": "New Tunnel",
- "mode_form": "Form",
- "mode_text": "Text",
- "section_interface": "Interface",
- "name_label": "Name",
- "name_placeholder": "my-tunnel",
- "private_key_label": "Private Key",
- "generate": "Generate",
- "address_label": "Address",
- "dns_label": "DNS",
- "mtu_label": "MTU",
- "section_peer": "Peer",
- "peer_public_key_label": "Public Key",
- "peer_psk_label": "Preshared Key (optional)",
- "peer_endpoint_label": "Endpoint",
- "peer_allowed_ips_label": "Allowed IPs",
- "peer_keepalive_label": "Persistent Keepalive",
- "create": "Create",
- "cancel": "Cancel"
- },
- "keygen": {
- "private_key": "Private Key",
- "public_key": "Public Key",
- "generate": "Generate New Keypair",
- "copy": "Copy"
- },
- "split_tunnel": {
- "title": "Split Tunnel",
- "no_subnets": "No subnets added",
- "add": "Add",
- "subnet_placeholder": "10.0.0.0/24"
- },
"stats": {
"speed_graph": "Speed Graph",
- "waiting": "Waiting for data...",
- "rx": "Download",
- "tx": "Upload"
+ "waiting": "Waiting for data..."
},
"conflict": {
"title": "Routing Conflict Detected",
@@ -287,11 +185,8 @@
"vpn_warning": "The VPN connection will be briefly interrupted during the update. The app will restart automatically.",
"proceed": "Update Now",
"cancel": "Later",
- "copy_command": "Copy",
- "copied": "Copied",
"check_now": "Check now",
"checking": "Checking…",
- "up_to_date": "You're on the latest version ({version})",
"last_checked": "Last checked {time}",
"never_checked": "Never checked",
"first_check_scheduled": "First check scheduled…",
@@ -308,13 +203,6 @@
"desc": "Wi-Fi auto-connect rules need access to your current network name. Grant permission in System Settings → Privacy & Security → Location Services.",
"open_settings": "Open Settings"
},
- "wifi_rules": {
- "trusted_ssids": "Trusted Networks",
- "trusted_hint": "On these networks, all auto-managed tunnels disconnect automatically.",
- "no_trusted": "No trusted networks added.",
- "add": "Add",
- "ssid_placeholder": "Network name (SSID)"
- },
"automation": {
"title": "Automation",
"hint": "Rules run top to bottom; the first matching condition wins. Pick a condition and type its value, or use the suggestion for the network you're on. \"This network\" matches a specific router by its gateway MAC (precise; tells apart networks that share a subnet); \"subnet\" matches an IP range. Add separate rules to connect and to disconnect.",
@@ -330,15 +218,10 @@
"cond_none": "otherwise",
"cond_none_desc": "no other rule matched",
"ssid_placeholder": "Wi-Fi name",
- "subnet_current": "current network",
"cond_network": "on this network",
- "use_current": "Use current",
- "net_captured": "this network",
- "net_not_set": "click \"Use current\" while on the network",
- "net_placeholder": "pick a network or click \"Use current\"",
"mac_placeholder": "gateway MAC (e.g. b0:38:6c:54:8b:ab)",
"drag_hint": "drag to reorder — the topmost matching rule wins",
"mac_invalid": "not a valid MAC address (e.g. b0:38:6c:54:8b:ab)",
"subnet_invalid": "not a valid CIDR (e.g. 192.168.0.0/24)"
}
-}
\ No newline at end of file
+}
diff --git a/frontend/src/i18n/index.js b/frontend/src/i18n/index.js
index b886e96..d27ecb9 100644
--- a/frontend/src/i18n/index.js
+++ b/frontend/src/i18n/index.js
@@ -48,13 +48,5 @@ function translate(lang, key, params = {}) {
// `svelte-i18n`), conceptually equivalent to Swift's `L.tr("key")`.
export const t = derived(locale, ($locale) => (key, params) => translate($locale, key, params));
-// Non-reactive translator for plain-JS call sites (notifications, log
-// formatting, etc.) that aren't tied to the Svelte render tree. Reads
-// the current locale once per call. Do NOT use this inside `.svelte`
-// templates — use `$t(...)` there so re-renders happen on language change.
-export function tPlain(key, params = {}) {
- return translate(get(locale), key, params);
-}
-
// Initialize with detected language
locale.set(detectLanguage());
diff --git a/frontend/src/i18n/ja.json b/frontend/src/i18n/ja.json
index e232cc4..b508dbc 100644
--- a/frontend/src/i18n/ja.json
+++ b/frontend/src/i18n/ja.json
@@ -1,11 +1,9 @@
{
"app": {
- "name": "WireGuide",
"disconnected": "未接続",
"connecting": "接続中…",
"connected": "接続済み",
- "no_handshake": "ハンドシェイクなし",
- "error": "エラー"
+ "no_handshake": "ハンドシェイクなし"
},
"nav": {
"tunnels": "トンネル",
@@ -63,21 +61,6 @@
"rx": "ダウンロード",
"tx": "アップロード",
"latency": "遅延",
- "wifi_auto_connect": "自動接続",
- "wifi_auto_connect_hint": "これらのネットワークに接続するとこのトンネルが自動で起動します。",
- "wifi_empty": "登録されたネットワークがありません。",
- "wifi_added": "自動接続ネットワーク",
- "wifi_known": "保存済みネットワーク",
- "wifi_current": "現在",
- "wifi_current_tooltip": "現在このネットワークに接続中です",
- "wifi_manual": "リストにないネットワークを追加",
- "wifi_manual_hint": "対象ネットワークの SSID を正確に入力してください。",
- "wifi_no_known": "保存済み Wi-Fi ネットワークが見つかりませんでした。下から手動で入力してください。",
- "wifi_pick_label": "このトンネルを自動で起動するネットワーク",
- "wifi_combo_placeholder": "ネットワークを入力または選択…",
- "kill_switch": "キルスイッチ",
- "dns_protection": "DNS 保護",
- "details": "詳細",
"rename_hint": "ダブルクリックで名前変更",
"notes": "メモ",
"notes_placeholder": "エンドポイントの場所、認証情報のヒントなど自由メモ",
@@ -86,21 +69,8 @@
"sort_added_old": "古い順"
},
"import": {
- "title": "トンネルのインポート",
- "drop_file": ".conf ファイルをここにドロップ",
- "or": "または",
- "select_file": "ファイルを選択",
- "from_clipboard": "クリップボードから貼り付け",
- "preview_title": "プレビュー",
- "tunnel_name": "トンネル名",
- "confirm": "インポート",
- "cancel": "キャンセル",
- "success": "トンネルをインポートしました",
- "exists": "トンネル \"{name}\" はすでに存在します。上書きしますか?",
- "overwrite": "上書き",
"zip_result_title": "インポート結果",
"zip_ok": "完了",
- "zip_none": "zip 内に .conf ファイルが見つかりませんでした",
"zip_summary": "{ok}件インポート、{fail}件失敗",
"zip_summary_ok": "{ok}件インポート"
},
@@ -108,21 +78,14 @@
"title": "トンネル編集: {name}",
"save": "保存",
"cancel": "キャンセル",
- "validation_error": "検証エラー",
"name_placeholder": "トンネル名",
"name_required": "トンネル名を入力してください"
},
- "scripts": {
- "warning_title": "スクリプト警告",
- "warning_message": "この設定には実行されるシステムコマンドが含まれています:",
- "allow": "許可",
- "deny": "拒否",
- "denied_note": "このトンネルではスクリプトは実行されません。"
- },
"settings": {
"title": "設定",
"general": "一般",
"auto_start": "起動時に自動実行",
+ "auto_start_hint": "既定はオフです。WireGuide のバックグラウンドヘルパーはアプリが開いている間だけ動作し、自動化ルールもその間だけ適用されます。ログイン時に WireGuide を起動するにはオンにしてください。",
"theme": "テーマ",
"theme_dark": "ダーク",
"theme_light": "ライト",
@@ -133,7 +96,6 @@
"lang_auto": "自動",
"close": "閉じる",
"advanced": "詳細",
- "wifi_rules": "Wi-Fi ルール",
"log_level": "ログレベル",
"log_level_debug": "デバッグ",
"log_level_info": "情報",
@@ -150,13 +112,10 @@
"about": "情報",
"about_tagline": "デスクトップのための、親しみやすい WireGuard。",
"about_desc": "WireGuard を中心に作られたオープンソースの VPN クライアント。設定ファイルをドロップすればすぐにつながります。漏えいしないキルスイッチ、抜け道のない DNS 保護、信頼するネットワークで自動的にトンネルを開く Wi-Fi ルール。コマンドラインも、テレメトリも、勧誘もありません。",
- "about_source": "GitHub ソースコード",
"about_releases": "リリースノート",
"about_issues": "問題を報告",
"about_license": "ライセンス (MIT)",
"about_credits": "WireGuide®、Wails、Svelte で構築。",
- "about_copyright": "© 2026 WireGuide. MIT ライセンスで公開。",
- "about_check_update": "アップデートを確認",
"about_made_by": "作者",
"about_footer_copyright": "© 2026 WireGuide",
"section_appearance": "外観",
@@ -174,41 +133,15 @@
"delete_message": "\"{name}\" を削除しますか?",
"yes": "削除",
"no": "キャンセル",
- "close": "閉じる",
"disconnect_first": "先にトンネルを切断してください"
},
- "error": {
- "connection_failed": "接続に失敗しました",
- "network_error": "ネットワークエラー — インターネット接続を確認してください",
- "key_mismatch": "鍵の不一致 — 設定を確認してください",
- "permission_denied": "管理者権限が必要です",
- "invalid_config": "無効な設定",
- "file_read_error": "ファイルを読み込めません — ファイル権限を確認してください",
- "empty_file": "空のファイルです"
- },
- "time": {
- "seconds_ago": "{n}秒前",
- "just_now": "今"
- },
"log": {
- "title": "ログ",
"clear": "クリア",
"auto_scroll": "自動スクロール",
"no_entries": "ログがありません",
"filter_all": "すべて",
"copy": "コピー"
},
- "notify": {
- "disconnected": "VPN 接続が切断されました",
- "reconnecting": "{name} に再接続中…",
- "reconnected": "{name} に再接続しました",
- "kill_switch_active": "キルスイッチ有効 — インターネットをブロック中",
- "error": "VPN エラー: {message}"
- },
- "daemon": {
- "not_running": "WireGuide デーモンが実行されていません",
- "install_hint": "デーモン起動: sudo wireguided"
- },
"tools": {
"tab_dns_leak": "DNS 漏洩テスト",
"tab_routes": "ルート",
@@ -229,44 +162,9 @@
"route_legend_vpn": "VPN ルート",
"route_legend_direct": "直接ルート"
},
- "new_tunnel": {
- "title": "新しいトンネル",
- "mode_form": "フォーム",
- "mode_text": "テキスト",
- "section_interface": "インターフェース",
- "name_label": "名前",
- "name_placeholder": "my-tunnel",
- "private_key_label": "秘密鍵",
- "generate": "生成",
- "address_label": "アドレス",
- "dns_label": "DNS",
- "mtu_label": "MTU",
- "section_peer": "ピア",
- "peer_public_key_label": "公開鍵",
- "peer_psk_label": "事前共有鍵 (任意)",
- "peer_endpoint_label": "エンドポイント",
- "peer_allowed_ips_label": "許可 IP",
- "peer_keepalive_label": "Persistent Keepalive",
- "create": "作成",
- "cancel": "キャンセル"
- },
- "keygen": {
- "private_key": "秘密鍵",
- "public_key": "公開鍵",
- "generate": "新しい鍵ペアを生成",
- "copy": "コピー"
- },
- "split_tunnel": {
- "title": "スプリットトンネル",
- "no_subnets": "サブネットが追加されていません",
- "add": "追加",
- "subnet_placeholder": "10.0.0.0/24"
- },
"stats": {
"speed_graph": "速度グラフ",
- "waiting": "データ待機中…",
- "rx": "ダウンロード",
- "tx": "アップロード"
+ "waiting": "データ待機中…"
},
"conflict": {
"title": "ルーティング競合を検出",
@@ -287,11 +185,8 @@
"vpn_warning": "更新中に VPN 接続が一時的に切断される場合があります。アプリは自動的に再起動します。",
"proceed": "更新する",
"cancel": "あとで",
- "copy_command": "コピー",
- "copied": "コピーしました",
"check_now": "今すぐ確認",
"checking": "確認中…",
- "up_to_date": "最新バージョンです ({version})",
"last_checked": "前回 {time}",
"never_checked": "未確認",
"first_check_scheduled": "まもなく初回確認…",
@@ -308,13 +203,6 @@
"desc": "Wi-Fi 自動接続ルールが動作するには、現在のネットワーク名へのアクセスが必要です。System Settings → プライバシーとセキュリティ → 位置情報サービスで許可してください。",
"open_settings": "設定を開く"
},
- "wifi_rules": {
- "trusted_ssids": "信頼するネットワーク",
- "trusted_hint": "これらのネットワークに接続すると、自動接続中のトンネルがすべて自動的に切断されます。",
- "no_trusted": "信頼するネットワークが追加されていません。",
- "add": "追加",
- "ssid_placeholder": "ネットワーク名 (SSID)"
- },
"automation": {
"title": "オートメーション",
"hint": "ルールは上から順に評価され、最初に一致した条件が適用されます。条件を選んで値を入力するか、現在のネットワークの候補を使用してください。「このネットワーク」はゲートウェイMACで特定のルーターを正確に識別し(サブネットが重複しても区別)、「サブネット」はIP範囲で一致します。接続/切断のルールを個別に追加できます。",
@@ -330,15 +218,10 @@
"cond_none": "それ以外",
"cond_none_desc": "他のルールに一致しないとき",
"ssid_placeholder": "Wi-Fi名",
- "subnet_current": "現在のネットワーク",
"cond_network": "このネットワークで",
- "use_current": "現在を使用",
- "net_captured": "このネットワーク",
- "net_not_set": "対象ネットワークで「現在を使用」をクリック",
- "net_placeholder": "ネットワークを選択、または「現在を使用」",
"mac_placeholder": "ゲートウェイMAC (例: b0:38:6c:54:8b:ab)",
"drag_hint": "ドラッグで並べ替え — 最上位の一致ルールが優先",
"mac_invalid": "有効なMACアドレスではありません (例: b0:38:6c:54:8b:ab)",
"subnet_invalid": "有効なCIDRではありません (例: 192.168.0.0/24)"
}
-}
\ No newline at end of file
+}
diff --git a/frontend/src/i18n/ko.json b/frontend/src/i18n/ko.json
index 33156a1..988e1b9 100644
--- a/frontend/src/i18n/ko.json
+++ b/frontend/src/i18n/ko.json
@@ -1,11 +1,9 @@
{
"app": {
- "name": "WireGuide",
"disconnected": "연결 안 됨",
"connecting": "연결 중…",
"connected": "연결됨",
- "no_handshake": "핸드셰이크 없음",
- "error": "오류"
+ "no_handshake": "핸드셰이크 없음"
},
"nav": {
"tunnels": "터널",
@@ -63,21 +61,6 @@
"rx": "다운로드",
"tx": "업로드",
"latency": "지연 시간",
- "wifi_auto_connect": "자동 연결",
- "wifi_auto_connect_hint": "이 네트워크에 접속하면 이 터널이 자동으로 켜집니다.",
- "wifi_empty": "추가된 네트워크가 없습니다.",
- "wifi_added": "자동 연결 네트워크",
- "wifi_known": "저장된 네트워크",
- "wifi_current": "현재",
- "wifi_current_tooltip": "지금 이 네트워크에 연결되어 있습니다",
- "wifi_manual": "목록에 없는 네트워크 추가",
- "wifi_manual_hint": "원하는 네트워크의 SSID를 정확히 입력하세요.",
- "wifi_no_known": "저장된 Wi-Fi 네트워크를 찾지 못했습니다. 아래에서 직접 입력하세요.",
- "wifi_pick_label": "이 터널을 자동으로 켤 네트워크",
- "wifi_combo_placeholder": "네트워크 입력 또는 선택…",
- "kill_switch": "킬 스위치",
- "dns_protection": "DNS 보호",
- "details": "상세 정보",
"rename_hint": "두 번 클릭해서 이름 변경",
"notes": "메모",
"notes_placeholder": "엔드포인트 위치, 자격증명 힌트 등 자유 메모",
@@ -86,21 +69,8 @@
"sort_added_old": "오래된 추가순"
},
"import": {
- "title": "터널 가져오기",
- "drop_file": ".conf 파일을 여기에 놓으세요",
- "or": "또는",
- "select_file": "파일 선택",
- "from_clipboard": "클립보드에서 붙여넣기",
- "preview_title": "미리보기",
- "tunnel_name": "터널 이름",
- "confirm": "가져오기",
- "cancel": "취소",
- "success": "터널을 가져왔습니다",
- "exists": "터널 \"{name}\"이(가) 이미 있습니다. 덮어쓰시겠습니까?",
- "overwrite": "덮어쓰기",
"zip_result_title": "가져오기 결과",
"zip_ok": "완료",
- "zip_none": "zip에서 .conf 파일을 찾을 수 없습니다",
"zip_summary": "{ok}개 가져옴, {fail}개 실패",
"zip_summary_ok": "{ok}개 가져옴"
},
@@ -108,21 +78,14 @@
"title": "터널 편집: {name}",
"save": "저장",
"cancel": "취소",
- "validation_error": "유효성 검사 오류",
"name_placeholder": "터널 이름",
"name_required": "터널 이름을 입력하세요"
},
- "scripts": {
- "warning_title": "스크립트 경고",
- "warning_message": "이 설정에는 실행될 시스템 명령이 포함되어 있습니다:",
- "allow": "허용",
- "deny": "거부",
- "denied_note": "이 터널의 스크립트는 실행되지 않습니다."
- },
"settings": {
"title": "설정",
"general": "일반",
"auto_start": "시작 시 자동 실행",
+ "auto_start_hint": "기본값은 꺼짐입니다. WireGuide의 백그라운드 헬퍼는 앱이 열려 있는 동안에만 실행되며, 자동화 규칙도 그동안 적용됩니다. 로그인과 함께 WireGuide를 시작하려면 켜세요.",
"theme": "테마",
"theme_dark": "어두움",
"theme_light": "밝음",
@@ -133,7 +96,6 @@
"lang_auto": "자동",
"close": "닫기",
"advanced": "고급",
- "wifi_rules": "Wi-Fi 규칙",
"log_level": "로그 수준",
"log_level_debug": "디버그",
"log_level_info": "정보",
@@ -150,13 +112,10 @@
"about": "정보",
"about_tagline": "데스크톱을 위한 친근한 WireGuard.",
"about_desc": "WireGuard를 중심으로 만든 오픈소스 VPN 클라이언트. 설정 파일을 끌어다 놓으면 바로 연결됩니다. 누수 없는 킬 스위치, 빈틈 없는 DNS 보호, 신뢰하는 네트워크에서 자동으로 터널을 켜는 Wi-Fi 규칙까지. 명령줄도, 텔레메트리도, 영업도 없습니다.",
- "about_source": "GitHub 소스 코드",
"about_releases": "릴리스 노트",
"about_issues": "문제 신고",
"about_license": "라이선스 (MIT)",
"about_credits": "WireGuide®, Wails, Svelte 기반.",
- "about_copyright": "© 2026 WireGuide. MIT 라이선스로 공개.",
- "about_check_update": "업데이트 확인",
"about_made_by": "만든 사람",
"about_footer_copyright": "© 2026 WireGuide",
"section_appearance": "외관",
@@ -174,41 +133,15 @@
"delete_message": "\"{name}\"을(를) 삭제하시겠습니까?",
"yes": "삭제",
"no": "취소",
- "close": "닫기",
"disconnect_first": "먼저 연결을 해제하세요"
},
- "error": {
- "connection_failed": "연결 실패",
- "network_error": "네트워크 오류 — 인터넷 연결을 확인하세요",
- "key_mismatch": "키 불일치 — 설정을 확인하세요",
- "permission_denied": "관리자 권한이 필요합니다",
- "invalid_config": "유효하지 않은 설정",
- "file_read_error": "파일을 읽을 수 없습니다 — 파일 권한을 확인하세요",
- "empty_file": "빈 파일입니다"
- },
- "time": {
- "seconds_ago": "{n}초 전",
- "just_now": "방금"
- },
"log": {
- "title": "로그",
"clear": "지우기",
"auto_scroll": "자동 스크롤",
"no_entries": "로그 항목 없음",
"filter_all": "전체",
"copy": "복사"
},
- "notify": {
- "disconnected": "VPN 연결이 끊겼습니다",
- "reconnecting": "{name}에 재연결 중…",
- "reconnected": "{name}에 재연결되었습니다",
- "kill_switch_active": "킬 스위치 활성화 — 인터넷 차단됨",
- "error": "VPN 오류: {message}"
- },
- "daemon": {
- "not_running": "WireGuide 데몬이 실행되지 않고 있습니다",
- "install_hint": "데몬 시작: sudo wireguided"
- },
"tools": {
"tab_dns_leak": "DNS 유출 검사",
"tab_routes": "라우트",
@@ -229,44 +162,9 @@
"route_legend_vpn": "VPN 경로",
"route_legend_direct": "직접 경로"
},
- "new_tunnel": {
- "title": "새 터널",
- "mode_form": "폼",
- "mode_text": "텍스트",
- "section_interface": "인터페이스",
- "name_label": "이름",
- "name_placeholder": "my-tunnel",
- "private_key_label": "개인 키",
- "generate": "생성",
- "address_label": "주소",
- "dns_label": "DNS",
- "mtu_label": "MTU",
- "section_peer": "피어",
- "peer_public_key_label": "공개 키",
- "peer_psk_label": "사전 공유 키 (선택)",
- "peer_endpoint_label": "엔드포인트",
- "peer_allowed_ips_label": "허용 IP",
- "peer_keepalive_label": "Persistent Keepalive",
- "create": "만들기",
- "cancel": "취소"
- },
- "keygen": {
- "private_key": "개인 키",
- "public_key": "공개 키",
- "generate": "키 쌍 새로 생성",
- "copy": "복사"
- },
- "split_tunnel": {
- "title": "스플릿 터널",
- "no_subnets": "추가된 서브넷이 없습니다",
- "add": "추가",
- "subnet_placeholder": "10.0.0.0/24"
- },
"stats": {
"speed_graph": "속도 그래프",
- "waiting": "데이터 대기 중…",
- "rx": "다운로드",
- "tx": "업로드"
+ "waiting": "데이터 대기 중…"
},
"conflict": {
"title": "라우팅 충돌 감지",
@@ -287,11 +185,8 @@
"vpn_warning": "업데이트 중 VPN 연결이 잠시 끊길 수 있습니다. 앱이 자동으로 재시작됩니다.",
"proceed": "업데이트",
"cancel": "나중에",
- "copy_command": "복사",
- "copied": "복사됨",
"check_now": "지금 확인",
"checking": "확인 중…",
- "up_to_date": "최신 버전입니다 ({version})",
"last_checked": "마지막 확인 {time}",
"never_checked": "확인 기록 없음",
"first_check_scheduled": "곧 첫 확인 예정…",
@@ -308,13 +203,6 @@
"desc": "Wi-Fi 자동 연결 규칙이 동작하려면 현재 Wi-Fi 이름에 접근할 수 있어야 합니다. System Settings → 개인 정보 보호 및 보안 → 위치 서비스에서 허용해주세요.",
"open_settings": "설정 열기"
},
- "wifi_rules": {
- "trusted_ssids": "신뢰하는 네트워크",
- "trusted_hint": "이 네트워크에 접속하면 자동 연결된 모든 터널이 자동으로 해제됩니다.",
- "no_trusted": "추가된 신뢰 네트워크가 없습니다.",
- "add": "추가",
- "ssid_placeholder": "네트워크 이름 (SSID)"
- },
"automation": {
"title": "자동화",
"hint": "규칙은 위에서 아래로 평가되며 먼저 맞는 조건이 적용됩니다. 조건을 고르고 값을 직접 입력하거나, 현재 네트워크의 자동완성을 쓰세요. \"이 네트워크\"는 게이트웨이 MAC으로 특정 공유기를 정확히 식별하고(서브넷이 겹쳐도 구분), \"서브넷\"은 IP 대역으로 매칭합니다. 연결/해제 규칙을 각각 추가하세요.",
@@ -330,15 +218,10 @@
"cond_none": "그 외",
"cond_none_desc": "다른 규칙이 안 맞을 때",
"ssid_placeholder": "Wi-Fi 이름",
- "subnet_current": "현재 네트워크",
"cond_network": "이 네트워크에서",
- "use_current": "현재 사용",
- "net_captured": "이 네트워크",
- "net_not_set": "해당 네트워크에서 \"현재 사용\" 클릭",
- "net_placeholder": "네트워크 선택 또는 \"현재 사용\" 클릭",
"mac_placeholder": "게이트웨이 MAC (예: b0:38:6c:54:8b:ab)",
"drag_hint": "드래그해서 순서 변경 — 맨 위의 일치 규칙이 우선",
"mac_invalid": "올바른 MAC 주소가 아닙니다 (예: b0:38:6c:54:8b:ab)",
"subnet_invalid": "올바른 CIDR이 아닙니다 (예: 192.168.0.0/24)"
}
-}
\ No newline at end of file
+}
diff --git a/frontend/src/lib/AutomationEditor.svelte b/frontend/src/lib/AutomationEditor.svelte
index d6680f4..18a2d9b 100644
--- a/frontend/src/lib/AutomationEditor.svelte
+++ b/frontend/src/lib/AutomationEditor.svelte
@@ -537,15 +537,6 @@
border-color: var(--error-text, #ff453a);
background: color-mix(in srgb, var(--error-text, #ff453a) 8%, var(--bg-primary));
}
- .am-val-network { flex: 1; min-width: 120px; display: inline-flex; align-items: baseline; gap: 6px; font: 400 12px var(--font-sans); color: var(--text-primary); }
- .am-mac { font: 400 10px var(--font-mono); color: var(--text-muted); }
- .am-usecurrent {
- font: 500 11px var(--font-sans); color: var(--accent);
- background: color-mix(in srgb, var(--accent) 10%, transparent);
- border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
- border-radius: 7px; padding: 4px 9px; cursor: pointer; flex-shrink: 0; white-space: nowrap;
- }
- .am-usecurrent:hover { background: color-mix(in srgb, var(--accent) 18%, transparent); }
.am-remove { background: transparent; border: 0; color: var(--text-muted); cursor: pointer; padding: 4px; border-radius: 6px; flex-shrink: 0; }
.am-remove:hover { background: color-mix(in srgb, var(--red, #ff3b30) 18%, transparent); color: var(--red, #ff3b30); }
.am-add {
diff --git a/frontend/src/lib/KeyGenerator.svelte b/frontend/src/lib/KeyGenerator.svelte
deleted file mode 100644
index 1c61dc8..0000000
--- a/frontend/src/lib/KeyGenerator.svelte
+++ /dev/null
@@ -1,102 +0,0 @@
-
-
-
-
- {$t('keygen.generate')}
-
-
- {#if generated}
-
-
- {$t('keygen.private_key')}
- {privateKey.substring(0, 20)}…
-
-
- {$t('keygen.public_key')}
-
- {publicKey}
-
- {copied ? '✓' : $t('keygen.copy')}
-
-
- {/if}
-
-
-
diff --git a/frontend/src/lib/LogViewer.svelte b/frontend/src/lib/LogViewer.svelte
index 4d6ac14..b932d88 100644
--- a/frontend/src/lib/LogViewer.svelte
+++ b/frontend/src/lib/LogViewer.svelte
@@ -120,7 +120,7 @@
- {#each filtered as entry, i (i)}
+ {#each filtered as entry (entry.id)}
{formatTime(entry.time)}
{entry.source}
diff --git a/frontend/src/lib/MiniMode.svelte b/frontend/src/lib/MiniMode.svelte
deleted file mode 100644
index 2165f21..0000000
--- a/frontend/src/lib/MiniMode.svelte
+++ /dev/null
@@ -1,115 +0,0 @@
-
-
-
-
-
- {#if isConnected}
-
- ↓ {formatBytes(status.rx_bytes)}
- ↑ {formatBytes(status.tx_bytes)}
-
- {/if}
-
-
- {isConnected ? $t('tunnel.disconnect') : $t('tunnel.connect')}
-
-
-
-
diff --git a/frontend/src/lib/ScriptWarning.svelte b/frontend/src/lib/ScriptWarning.svelte
deleted file mode 100644
index c69d3e2..0000000
--- a/frontend/src/lib/ScriptWarning.svelte
+++ /dev/null
@@ -1,123 +0,0 @@
-
-
-
dispatch('deny')}>
-
-
{$t('scripts.warning_title')}
-
{$t('scripts.warning_message')}
-
-
- {#each scripts as script}
-
- {script.Hook}
- {script.Command}
-
- {/each}
-
-
-
-
{$t('scripts.denied_note')}
-
-
-
-
diff --git a/frontend/src/lib/Settings.svelte b/frontend/src/lib/Settings.svelte
index a948e35..2963bc2 100644
--- a/frontend/src/lib/Settings.svelte
+++ b/frontend/src/lib/Settings.svelte
@@ -136,10 +136,6 @@
tray_icon_style: 'color',
auto_update_check: true,
compact_list: false,
- wifi_rules: {
- trusted_ssids: [],
- per_tunnel: {},
- },
};
let loaded = false;
let appVersion = '';
@@ -162,12 +158,6 @@
// the Go side becomes undefined here; default to true to match
// Settings.AutoUpdateCheckEnabled() semantics.
settings.auto_update_check = (s.auto_update_check === false) ? false : true;
- if (s.wifi_rules) {
- settings.wifi_rules = {
- trusted_ssids: s.wifi_rules.trusted_ssids || [],
- per_tunnel: s.wifi_rules.per_tunnel || {},
- };
- }
}
} catch (e) {
console.error('load settings:', e);
@@ -180,8 +170,8 @@
// Re-fetch the freshest settings.json before writing so per-tunnel
// wifi rule edits made in TunnelDetail (which calls SaveSettings
// independently with its own modified per_tunnel map) aren't
- // silently overwritten. We own only `trusted_ssids` in wifi_rules;
- // `per_tunnel` belongs to TunnelDetail. If the fresh fetch fails
+ // silently overwritten. This screen owns NO part of wifi_rules —
+ // both halves are carried from the fresh fetch. If the fresh fetch fails
// (helper restarting, IPC flake) we abort rather than write our
// potentially-stale per_tunnel snapshot — a deferred save is far
// better than clobbering the user's per-tunnel edits.
@@ -216,8 +206,13 @@
// them from the fresh fetch so saving a Settings toggle never
// wipes them.
automation: fresh?.automation,
+ // Legacy Wi-Fi trust rules have no UI on this screen — carry both
+ // halves from the fresh fetch (not a load-time snapshot) so a
+ // Settings toggle can't clobber CLI edits made while it is open.
+ // The Go side still migrates trusted_ssids/per_tunnel into the
+ // Automation model, so the round-trip itself must stay.
wifi_rules: {
- trusted_ssids: settings.wifi_rules?.trusted_ssids || [],
+ trusted_ssids: fresh?.wifi_rules?.trusted_ssids || [],
per_tunnel: perTunnel,
},
});
@@ -435,8 +430,11 @@
{$t('settings.section_startup')}
-
-
{$t('settings.auto_start')}
+
+
+
{$t('settings.auto_start')}
+
{$t('settings.auto_start_hint')}
+
diff --git a/frontend/src/lib/SplitTunnelUI.svelte b/frontend/src/lib/SplitTunnelUI.svelte
deleted file mode 100644
index 7177952..0000000
--- a/frontend/src/lib/SplitTunnelUI.svelte
+++ /dev/null
@@ -1,149 +0,0 @@
-
-
-
-
- setMode('all')}>
- 0.0.0.0/0, ::/0
-
- setMode('custom')}>
- {$t('split_tunnel.title')}
-
-
-
- {#if mode === 'custom'}
-
- {#each customIPs as ip}
-
- {ip}
- removeSubnet(ip)}>✕
-
- {/each}
- {#if customIPs.length === 0}
-
{$t('split_tunnel.no_subnets')}
- {/if}
-
-
-
- {$t('split_tunnel.add')}
-
- {/if}
-
-
-
diff --git a/frontend/src/lib/TunnelDetail.svelte b/frontend/src/lib/TunnelDetail.svelte
index 8d0d2d4..90f8b89 100644
--- a/frontend/src/lib/TunnelDetail.svelte
+++ b/frontend/src/lib/TunnelDetail.svelte
@@ -145,22 +145,26 @@
latencyTargetSaveTimer = setTimeout(saveLatencyTarget, 600);
}
- function autoLatencyTarget() {
- if (!detail) return { label: $selectedTunnel?.endpoint || '—', fallback: true };
- for (const peer of detail.Peers || []) {
+ // Takes its inputs as parameters (not via closure) so the `$:` below
+ // actually re-runs: the compiler only tracks dependencies referenced in
+ // the reactive statement itself, and a dep-less statement runs exactly
+ // once — which froze this on the first tunnel's endpoint forever.
+ function autoLatencyTarget(det, sel) {
+ if (!det) return { label: sel?.endpoint || '—', fallback: true };
+ for (const peer of det.Peers || []) {
for (const allowed of peer.AllowedIPs || []) {
if (allowed.endsWith('/32')) return { label: allowed.slice(0, -3), fallback: false };
if (allowed.endsWith('/128')) return { label: allowed.slice(0, -4), fallback: false };
}
}
- const fullTunnel = (detail.Peers || []).some(peer =>
+ const fullTunnel = (det.Peers || []).some(peer =>
(peer.AllowedIPs || []).some(ip => ip === '0.0.0.0/0' || ip === '::/0')
);
if (fullTunnel) return { label: '8.8.8.8', fallback: false };
- return { label: $selectedTunnel?.endpoint || '—', fallback: true };
+ return { label: sel?.endpoint || '—', fallback: true };
}
- $: autoLatency = autoLatencyTarget();
+ $: autoLatency = autoLatencyTarget(detail, $selectedTunnel);
$: latencyTargetDisplay = latencyTargetSaved
? latencyTargetSaved
: `${$t('tunnel.latency_target_placeholder')}: ${autoLatency.fallback ? $t('tunnel.endpoint') : autoLatency.label}`;
diff --git a/frontend/src/lib/TunnelList.svelte b/frontend/src/lib/TunnelList.svelte
index 251196a..1c8a651 100644
--- a/frontend/src/lib/TunnelList.svelte
+++ b/frontend/src/lib/TunnelList.svelte
@@ -115,7 +115,7 @@
{$t('tunnel.drop_hint')}
{:else}
- {#each sorted as tun}
+ {#each sorted as tun (tun.name)}
{
s.entries[s.head] = {
+ // Monotonic id — LogViewer keys its {#each} on this. Index keys
+ // break down once the ring wraps: every push shifts all rows by
+ // one, forcing Svelte to rewrite the whole list per log record.
+ id: s.version,
time: e.time,
level: (e.level || 'info').toLowerCase(),
source: e.source || 'gui',
diff --git a/frontend/src/stores/tunnels.js b/frontend/src/stores/tunnels.js
index ab07e8d..a83f6ae 100644
--- a/frontend/src/stores/tunnels.js
+++ b/frontend/src/stores/tunnels.js
@@ -7,38 +7,51 @@ export const connectionStatus = writable({ state: 'disconnected' });
let statusUnsub = null;
+// Last-broadcast fingerprint for the notification gate below.
+let lastStatusJSON = '';
+
// Subscribe to backend status events. The tunnel list is not event-driven
// on the backend side — it's refreshed manually via `refreshTunnels()` after
// each mutating operation (connect/disconnect/create/delete/rename).
+//
+// Every set()/update() here is gated behind a value comparison. Svelte
+// stores treat all objects as unequal (safe_not_equal), so returning the
+// same reference from update() still notifies every subscriber — at the
+// helper's 1 Hz broadcast rate that meant TunnelList re-sorting and every
+// dependent `$:` recomputing each second even while idle.
export function subscribeToEvents() {
unsubscribe();
statusUnsub = Events.On('status', (event) => {
const status = event.data;
- connectionStatus.set(status);
+ const statusJSON = JSON.stringify(status);
+ if (statusJSON !== lastStatusJSON) {
+ lastStatusJSON = statusJSON;
+ connectionStatus.set(status);
+ }
// Sync is_connected flag on tunnel objects. The backend now sends
// active_tunnels (array of connected tunnel names) to support
// multiple simultaneous tunnels.
const activeSet = new Set(status?.active_tunnels || []);
- tunnels.update((list) => {
- let changed = false;
- const next = list.map((t) => {
- const conn = activeSet.has(t.name);
- if (t.is_connected === conn) return t;
- changed = true;
- return { ...t, is_connected: conn };
- });
- return changed ? next : list;
+ const list = get(tunnels);
+ let changed = false;
+ const next = list.map((t) => {
+ const conn = activeSet.has(t.name);
+ if (t.is_connected === conn) return t;
+ changed = true;
+ return { ...t, is_connected: conn };
});
+ if (changed) tunnels.set(next);
- selectedTunnel.update((sel) => {
- if (!sel) return sel;
+ const sel = get(selectedTunnel);
+ if (sel) {
const nowConnected = activeSet.has(sel.name);
- if (sel.is_connected === nowConnected) return sel;
- return { ...sel, is_connected: nowConnected };
- });
+ if (sel.is_connected !== nowConnected) {
+ selectedTunnel.set({ ...sel, is_connected: nowConnected });
+ }
+ }
});
}
@@ -78,7 +91,10 @@ export async function refreshTunnels(TunnelService) {
export async function refreshStatus(TunnelService) {
try {
const status = await TunnelService.GetStatus();
- if (status) connectionStatus.set(status);
+ if (status) {
+ lastStatusJSON = JSON.stringify(status);
+ connectionStatus.set(status);
+ }
} catch (e) {
console.error('status error:', e);
}
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index ca0dde4..b13e9ed 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -3,54 +3,16 @@ import { svelte } from "@sveltejs/vite-plugin-svelte";
import wails from "@wailsio/runtime/plugins/vite";
// https://vitejs.dev/config/
-//
-// Bundle split policy: the previous single 531 KB chunk delays cold start
-// because the whole app (Settings, LogViewer, TunnelDetail, KeyGenerator)
-// has to parse before the first paint. We extract each modal/heavy
-// component into its own chunk so the initial render only needs the
-// tunnel-list path; modals load on demand when the user opens them.
export default defineConfig({
plugins: [svelte(), wails("./bindings")],
build: {
- // Bump chunk size warning ceiling because the runtime+Svelte combined
- // chunk is still ~300 KB even after splitting — that's expected for a
- // Svelte+Wails app and not actionable.
+ // WebKitGTK versions shipped by supported Linux distributions lag the
+ // evergreen browsers Vite targets by default. Transpile modern syntax so
+ // the application mounts on those embedded WebKit runtimes as well.
+ target: "safari13",
+ // Let Rolldown derive safe chunk boundaries. The previous forced chunk
+ // graph created circular startup imports under Vite 8 and left WebKitGTK
+ // with a blank window before the Svelte application could mount.
chunkSizeWarningLimit: 600,
- rollupOptions: {
- output: {
- manualChunks: {
- // Wails bindings + runtime — heavy and rarely changes; cache
- // separately so app code updates don't bust this chunk.
- "wails-runtime": ["@wailsio/runtime"],
- // CodeMirror — ~250 KB just for the editor framework + every
- // language pack we import. ConfigEditor is the only consumer
- // today but isolating CodeMirror into its own chunk means the
- // editor view shows the chrome immediately and the heavy
- // syntax-highlight code streams in next, instead of blocking
- // the first paint.
- "codemirror": [
- "@codemirror/autocomplete",
- "@codemirror/commands",
- "@codemirror/language",
- "@codemirror/lint",
- "@codemirror/search",
- "@codemirror/state",
- "@codemirror/view",
- ],
- // Heavy modals — loaded only when the user opens them. Keeping
- // them out of the main chunk shaves ~150 KB off the initial
- // load.
- "modal-logs": ["./src/lib/LogViewer.svelte"],
- "modal-settings": ["./src/lib/Settings.svelte"],
- "modal-tunnel-detail": ["./src/lib/TunnelDetail.svelte"],
- "modal-config-editor": ["./src/lib/ConfigEditor.svelte"],
- "modal-keygen": ["./src/lib/KeyGenerator.svelte"],
- "modal-history": ["./src/lib/History.svelte"],
- "modal-route-viz": ["./src/lib/RouteVisualization.svelte"],
- "modal-stats": ["./src/lib/StatsDashboard.svelte"],
- "modal-dnsleak": ["./src/lib/DNSLeakTest.svelte"],
- },
- },
- },
},
});
diff --git a/go.mod b/go.mod
index 7d4927c..2f787b8 100644
--- a/go.mod
+++ b/go.mod
@@ -1,16 +1,15 @@
module github.com/korjwl1/wireguide
-go 1.25.0
+go 1.25.12
require (
github.com/Microsoft/go-winio v0.6.2
github.com/godbus/dbus/v5 v5.2.2
github.com/makiuchi-d/gozxing v0.1.1
github.com/wailsapp/wails/v3 v3.0.0-alpha.74
- golang.org/x/crypto v0.49.0
- golang.org/x/image v0.35.0
- golang.org/x/sys v0.42.0
- golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb
+ golang.org/x/image v0.44.0
+ golang.org/x/sys v0.47.0
+ golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
)
@@ -25,8 +24,8 @@ require (
github.com/ebitengine/purego v0.9.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
- github.com/go-git/go-billy/v5 v5.7.0 // indirect
- github.com/go-git/go-git/v5 v5.16.4 // indirect
+ github.com/go-git/go-billy/v5 v5.9.0 // indirect
+ github.com/go-git/go-git/v5 v5.19.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/go-cmp v0.7.0 // indirect
@@ -44,7 +43,7 @@ require (
github.com/mdlayher/genetlink v1.3.2 // indirect
github.com/mdlayher/netlink v1.7.2 // indirect
github.com/mdlayher/socket v0.5.1 // indirect
- github.com/pjbgf/sha1cd v0.5.0 // indirect
+ github.com/pjbgf/sha1cd v0.6.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.52.0 // indirect
@@ -52,9 +51,10 @@ require (
github.com/skeema/knownhosts v1.3.2 // indirect
github.com/wailsapp/go-webview2 v1.0.23 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
- golang.org/x/net v0.51.0 // indirect
- golang.org/x/sync v0.20.0 // indirect
- golang.org/x/text v0.35.0 // indirect
+ golang.org/x/crypto v0.54.0 // indirect
+ golang.org/x/net v0.57.0 // indirect
+ golang.org/x/sync v0.22.0 // indirect
+ golang.org/x/text v0.40.0 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
diff --git a/go.sum b/go.sum
index fe66fb5..8ebfa0a 100644
--- a/go.sum
+++ b/go.sum
@@ -32,12 +32,12 @@ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
-github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM=
-github.com/go-git/go-billy/v5 v5.7.0/go.mod h1:/1IUejTKH8xipsAcdfcSAlUlo2J7lkYV8GTKxAT/L3E=
+github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA=
+github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
-github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y=
-github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
+github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00=
+github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
@@ -94,8 +94,8 @@ github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE9
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
-github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
-github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
+github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
+github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -126,17 +126,17 @@ github.com/wailsapp/wails/v3 v3.0.0-alpha.74/go.mod h1:4saK4A4K9970X+X7RkMwP2lyG
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
-golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
-golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
-golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
-golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
-golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
-golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
+golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
+golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
+golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
+golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
+golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
+golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
-golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
-golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
-golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
+golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
+golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
+golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -146,14 +146,14 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
-golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
-golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
-golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
+golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
+golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
-golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
+golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
+golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -161,8 +161,8 @@ golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1N
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
-golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
-golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
+golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 h1:cqHQ3AycTHvM2R7ikgyX57D+XvtcSnGylsLkOVhta/w=
+golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/internal/app/settings_ops.go b/internal/app/settings_ops.go
index 1c0350a..da37a2e 100644
--- a/internal/app/settings_ops.go
+++ b/internal/app/settings_ops.go
@@ -182,6 +182,14 @@ func (s *TunnelService) SaveAutomationRules(tunnel string, rules []wifi.Rule) er
if tunnel == "" {
return fmt.Errorf("automation: empty tunnel name")
}
+ // Reject malformed rules up front — the helper's evaluator silently
+ // no-ops on rules it can't interpret, so a bad save would otherwise
+ // look accepted while doing nothing.
+ for i, r := range rules {
+ if err := wifi.ValidateRule(r); err != nil {
+ return fmt.Errorf("automation: rule %d: %w", i+1, err)
+ }
+ }
return s.settingsStore.Update(func(st *storage.Settings) error {
st.EnsureAutomation()
if len(rules) == 0 {
@@ -379,8 +387,13 @@ func (s *TunnelService) RunUpdate(info *update.UpdateInfo) error {
// value never gets surfaced anywhere in practice.
upCtx, upCancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer upCancel()
- slog.Info("update: running brew upgrade --cask wireguide")
- cmd := exec.CommandContext(upCtx, brewBin, "upgrade", "--cask", "wireguide")
+ // --greedy: older Homebrew skips auto_updates casks even when named
+ // explicitly, and the skip exits 0 — so this call reported success
+ // while doing nothing, stranding installs on old versions (observed
+ // live: 0.3.1 pinned for three months of "Update Now" clicks). The
+ // flag forces the upgrade regardless of brew version or cask flags.
+ slog.Info("update: running brew upgrade --cask --greedy wireguide")
+ cmd := exec.CommandContext(upCtx, brewBin, "upgrade", "--cask", "--greedy", "wireguide")
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("brew upgrade failed: %w (%s)", err, string(out))
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
index bdabf0f..9243771 100644
--- a/internal/cli/cli.go
+++ b/internal/cli/cli.go
@@ -8,12 +8,14 @@ package cli
import (
"context"
+ "encoding/json"
"fmt"
"io"
"log/slog"
"net"
"os"
"path/filepath"
+ "slices"
"strconv"
"strings"
"time"
@@ -43,6 +45,10 @@ func Run(args []string) int {
case "help", "-h", "--help":
usage(os.Stdout)
return 0
+ case "start":
+ return cmdStart(rest)
+ case "stop":
+ return cmdStop(rest)
case "status":
return cmdStatus(rest)
case "list", "ls":
@@ -77,9 +83,13 @@ func Run(args []string) int {
func usage(w io.Writer) {
fmt.Fprint(w, `wireguide ctl — control the WireGuide helper from the command line
+App:
+ wireguide ctl start launch WireGuide (app + helper) and wait
+ wireguide ctl stop quit WireGuide (app + helper)
+
Tunnels:
- wireguide ctl status show connection status
- wireguide ctl list list tunnels (● = connected)
+ wireguide ctl status [--json] show connection status
+ wireguide ctl list [--json] list tunnels (● = connected)
wireguide ctl connect connect a tunnel
wireguide ctl disconnect [name] disconnect one tunnel (or all)
wireguide ctl import [name] import a .conf (name defaults to filename)
@@ -113,17 +123,24 @@ Examples:
wireguide ctl automation add work disconnect mac:b0:38:6c:54:8b:ab
wireguide ctl automation add work connect else
-The WireGuide app (or its helper) must be running for connect/disconnect/status;
+WireGuide must be running for connect/disconnect/status — start it with
+'wireguide ctl start' (or by opening the app). Nothing else starts it for you.
list, import, rename, delete and automation edits work against local files.
`)
}
// dialHelper connects to the running helper's IPC socket. The CLI does not
// spawn/elevate a helper itself — it attaches to the one the app started, so
-// a plain `ctl` invocation never triggers an admin prompt.
+// a plain `ctl` invocation never triggers an admin prompt. Use `ctl start`
+// to bring the app up.
+//
+// The client is TRANSIENT: the helper must not mistake a CLI command for a
+// GUI attaching and detaching. Without that, every `ctl` invocation would
+// re-arm the helper's 10s "GUI disconnected" shutdown window — a status
+// query would cut the helper's life short. See ipc.Request.Transient.
func dialHelper() (*ipc.Client, error) {
addr := ipc.DefaultSocketPath()
- c, err := ipc.NewClient(addr)
+ c, err := ipc.NewTransientClient(addr)
if err != nil {
return nil, fmt.Errorf("cannot reach the WireGuide helper (is the app running?): %w", err)
}
@@ -153,7 +170,9 @@ func tunnelStore() (*storage.TunnelStore, error) {
return storage.NewTunnelStore(paths.TunnelsDir), nil
}
-func cmdStatus(_ []string) int {
+func cmdStatus(args []string) int {
+ jsonOut := hasFlag(args, "--json")
+
c, err := dialHelper()
if err != nil {
fmt.Fprintln(os.Stderr, err)
@@ -167,6 +186,9 @@ func cmdStatus(_ []string) int {
return 1
}
if len(active.Names) == 0 {
+ if jsonOut {
+ return printJSON([]domain.ConnectionStatus{})
+ }
fmt.Println("disconnected")
return 0
}
@@ -180,6 +202,9 @@ func cmdStatus(_ []string) int {
if len(rows) == 0 {
rows = []domain.ConnectionStatus{st}
}
+ if jsonOut {
+ return printJSON(rows)
+ }
for _, r := range rows {
hs := r.LastHandshake
if hs == "" {
@@ -191,7 +216,16 @@ func cmdStatus(_ []string) int {
return 0
}
-func cmdList(_ []string) int {
+// tunnelListEntry is the --json shape for `ctl list`; domain.ConnectionStatus
+// doesn't apply here since a listed tunnel may never have been connected.
+type tunnelListEntry struct {
+ Name string `json:"name"`
+ Active bool `json:"active"`
+}
+
+func cmdList(args []string) int {
+ jsonOut := hasFlag(args, "--json")
+
store, err := tunnelStore()
if err != nil {
fmt.Fprintln(os.Stderr, "list:", err)
@@ -214,6 +248,13 @@ func cmdList(_ []string) int {
}
c.Close()
}
+ if jsonOut {
+ entries := make([]tunnelListEntry, len(names))
+ for i, n := range names {
+ entries[i] = tunnelListEntry{Name: n, Active: activeSet[n]}
+ }
+ return printJSON(entries)
+ }
if len(names) == 0 {
fmt.Println("(no tunnels)")
return 0
@@ -228,6 +269,23 @@ func cmdList(_ []string) int {
return 0
}
+// hasFlag reports whether flag is present anywhere in args.
+func hasFlag(args []string, flag string) bool {
+ return slices.Contains(args, flag)
+}
+
+// printJSON marshals v as indented JSON to stdout. Always returns 0 unless
+// marshalling itself fails.
+func printJSON(v any) int {
+ data, err := json.MarshalIndent(v, "", " ")
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "json:", err)
+ return 1
+ }
+ fmt.Println(string(data))
+ return 0
+}
+
func cmdConnect(args []string) int {
if len(args) < 1 {
fmt.Fprintln(os.Stderr, "usage: wireguide ctl connect ")
@@ -325,6 +383,10 @@ func cmdImport(args []string) int {
fmt.Fprintln(os.Stderr, "import:", err)
return 1
}
+ if store.Exists(name) {
+ fmt.Fprintf(os.Stderr, "import: tunnel %q already exists (rename or delete it before importing)\n", name)
+ return 1
+ }
if _, err := store.ImportFromContent(name, string(data)); err != nil {
fmt.Fprintln(os.Stderr, "import:", err)
return 1
diff --git a/internal/cli/lifecycle.go b/internal/cli/lifecycle.go
new file mode 100644
index 0000000..fee939b
--- /dev/null
+++ b/internal/cli/lifecycle.go
@@ -0,0 +1,230 @@
+package cli
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "time"
+
+ "github.com/korjwl1/wireguide/internal/ipc"
+ "github.com/korjwl1/wireguide/internal/sysexec"
+)
+
+// macBundleID must match CFBundleIdentifier in build/darwin/Info.plist.
+// `open -b` uses it to find an installed WireGuide.app wherever it lives,
+// which beats guessing at /Applications.
+const macBundleID = "com.korjwl1.wireguide"
+
+// startTimeout bounds how long `ctl start` waits for the helper socket.
+//
+// Deliberately long. Any launch that finds no live helper shows a macOS
+// admin-password dialog, and the socket appears only once the user has
+// typed it — osascript gives that dialog no deadline of its own, so a
+// short timeout here does not cancel anything, it just makes the CLI lie.
+// At two minutes this reported failure and exited nonzero while the app
+// was still up and waiting; the user then typed the password, everything
+// worked, and a script had already taken the failure branch.
+const startTimeout = 10 * time.Minute
+
+// authHintAfter is how long to wait before mentioning that the password
+// prompt may be hidden. Auth dialogs open behind full-screen windows often
+// enough that a silent CLI looks hung rather than blocked on the user.
+const authHintAfter = 15 * time.Second
+
+// cmdStart launches the WireGuide app and waits until the helper is
+// reachable.
+//
+// Deliberately the ONLY command that starts anything. `connect`, `status`
+// and friends fail with "is the app running?" instead of silently starting
+// a VPN stack behind the user's back — the same contract the docker CLI has
+// with dockerd. Starting is an explicit act because on macOS it costs an
+// admin-password prompt, and because a running WireGuide is exactly what
+// the helper treats as consent to apply automation rules.
+func cmdStart(_ []string) int {
+ // Already up? Then this is a no-op, not an error — `ctl start` should
+ // be safe to put at the top of a script.
+ if c, err := dialHelper(); err == nil {
+ c.Close()
+ fmt.Println("WireGuide is already running")
+ return 0
+ }
+
+ if err := launchApp(); err != nil {
+ fmt.Fprintln(os.Stderr, "start:", err)
+ return 1
+ }
+
+ fmt.Println("starting WireGuide…")
+ if runtime.GOOS == "darwin" {
+ fmt.Println("(macOS may ask for your administrator password to start the VPN helper)")
+ }
+
+ start := time.Now()
+ deadline := start.Add(startTimeout)
+ hinted := false
+ for time.Now().Before(deadline) {
+ time.Sleep(500 * time.Millisecond)
+ if c, err := dialHelper(); err == nil {
+ c.Close()
+ fmt.Println("WireGuide is running")
+ return 0
+ }
+ if !hinted && time.Since(start) > authHintAfter {
+ hinted = true
+ fmt.Println("still waiting — if you don't see the password prompt, check behind other windows.")
+ }
+ }
+ fmt.Fprintf(os.Stderr,
+ "start: gave up after %s waiting for the helper to come up.\n", startTimeout)
+ fmt.Fprintln(os.Stderr,
+ "if the administrator password prompt is still open, answering it will finish the start; re-run 'wireguide ctl status' to check.")
+ return 1
+}
+
+// cmdStop asks the running app to quit — GUI and helper together.
+//
+// The request goes to the helper rather than to the GUI directly, because
+// the helper is the process the CLI can already reach on every platform.
+// It broadcasts EventQuit to the GUI (which then runs its own quit path and
+// stops the helper on the way out), or shuts itself down when no GUI is
+// attached. That keeps `stop` free of per-OS "terminate that application"
+// machinery.
+func cmdStop(_ []string) int {
+ c, err := dialHelper()
+ if err != nil {
+ // Nothing to stop is success: `ctl stop` states a desired end
+ // state, and we're already in it.
+ fmt.Println("WireGuide is not running")
+ return 0
+ }
+ defer c.Close()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ var resp ipc.RequestQuitResponse
+ if err := c.CallWithContext(ctx, ipc.MethodRequestQuit, nil, &resp); err != nil {
+ fmt.Fprintln(os.Stderr, "stop:", err)
+ if strings.Contains(err.Error(), "method not found") {
+ fmt.Fprintln(os.Stderr,
+ "this helper predates 'ctl stop' — quit WireGuide from its tray icon, or restart the app once to upgrade the helper.")
+ }
+ return 1
+ }
+ if resp.NotifiedGUI {
+ fmt.Println("stopping WireGuide…")
+ } else {
+ fmt.Println("no app was running; stopped the leftover helper")
+ }
+
+ // Confirm it actually went away rather than reporting success on a
+ // request that was merely accepted.
+ deadline := time.Now().Add(20 * time.Second)
+ for time.Now().Before(deadline) {
+ time.Sleep(500 * time.Millisecond)
+ probe, perr := dialHelper()
+ if perr != nil {
+ fmt.Println("WireGuide stopped")
+ return 0
+ }
+ probe.Close()
+ }
+ fmt.Fprintln(os.Stderr, "stop: WireGuide did not shut down within 20s")
+ return 1
+}
+
+// launchApp starts the GUI, detached from this process so the CLI can exit
+// without taking the app with it.
+func launchApp() error {
+ switch runtime.GOOS {
+ case "darwin":
+ // `open` returns as soon as the app is launched and does not tie
+ // the app's lifetime to ours.
+ //
+ // Our OWN bundle comes first, deliberately. `open -b` asks
+ // LaunchServices to resolve the bundle ID, and more than one
+ // WireGuide.app can claim it — a build tree alongside an
+ // installed copy in /Applications. LaunchServices then picks one
+ // we did not choose, and the app that starts can be a different
+ // version from the CLI that started it. That is not cosmetic:
+ // the two builds generate different LaunchDaemon plists, so each
+ // detects the other's plist as drift and reinstalls its own,
+ // prompting for an admin password every single launch.
+ if app := enclosingAppBundle(); app != "" {
+ if err := exec.Command("open", app).Run(); err == nil {
+ return nil
+ }
+ }
+ // Not inside a bundle — the CLI is a bare binary (e.g. installed
+ // to /usr/local/bin). Now the bundle ID is the right question to
+ // ask, since there is no "our own" app to prefer.
+ if err := exec.Command("open", "-b", macBundleID).Run(); err == nil {
+ return nil
+ }
+ return spawnSelfDetached()
+ default:
+ // Linux and Windows: the GUI is this same binary invoked with no
+ // arguments (see main.go — only `ctl` routes into the CLI), so
+ // re-exec ourselves rather than hunting for a launcher.
+ return spawnSelfDetached()
+ }
+}
+
+// enclosingAppBundle returns the path of the .app bundle containing this
+// executable, or "" when we're not inside one (bare binary on $PATH).
+//
+// Symlinks are resolved first: a CLI reached through a symlink (Homebrew
+// linking into /usr/local/bin, a hand-made shortcut) reports the link's
+// path from os.Executable on some platforms, which would hide the bundle
+// the real binary lives in.
+func enclosingAppBundle() string {
+ exe, err := os.Executable()
+ if err != nil {
+ return ""
+ }
+ if resolved, err := filepath.EvalSymlinks(exe); err == nil {
+ exe = resolved
+ }
+ return bundleFromExePath(exe)
+}
+
+// bundleFromExePath walks up from an executable path to the .app bundle
+// containing it, returning "" when there isn't one. Split out from
+// enclosingAppBundle so the path logic is testable without an os.Executable
+// that happens to sit in the right place.
+//
+// Bounded to three levels: a bundle's binary lives at exactly
+// Foo.app/Contents/MacOS/bin, and walking further would happily match an
+// unrelated ancestor (e.g. a checkout under ~/Projects/Thing.app/…).
+func bundleFromExePath(exe string) string {
+ dir := filepath.Dir(exe)
+ for i := 0; i < 3 && dir != "/" && dir != "." && dir != string(filepath.Separator); i++ {
+ if strings.HasSuffix(dir, ".app") {
+ return dir
+ }
+ dir = filepath.Dir(dir)
+ }
+ return ""
+}
+
+// spawnSelfDetached re-executes this binary with no arguments (which starts
+// the GUI) and detaches it, so the app outlives the CLI process.
+func spawnSelfDetached() error {
+ exe, err := os.Executable()
+ if err != nil {
+ return fmt.Errorf("cannot locate the WireGuide executable: %w", err)
+ }
+ cmd := exec.Command(exe)
+ // Detach stdio: inheriting the terminal would keep the app tied to the
+ // shell and leak its logs into the user's session.
+ cmd.Stdin, cmd.Stdout, cmd.Stderr = nil, nil, nil
+ sysexec.Detach(cmd)
+ if err := cmd.Start(); err != nil {
+ return fmt.Errorf("cannot launch the WireGuide app: %w", err)
+ }
+ // Release the child so it isn't reaped when the CLI exits.
+ return cmd.Process.Release()
+}
diff --git a/internal/cli/lifecycle_test.go b/internal/cli/lifecycle_test.go
new file mode 100644
index 0000000..d1adaa8
--- /dev/null
+++ b/internal/cli/lifecycle_test.go
@@ -0,0 +1,73 @@
+package cli
+
+import (
+ "runtime"
+ "testing"
+)
+
+// TestBundleFromExePath pins the lookup that decides WHICH WireGuide.app
+// `ctl start` launches.
+//
+// This exists because getting it wrong is not a cosmetic failure. When the
+// CLI cannot identify its own bundle it falls back to resolving the bundle
+// ID through LaunchServices, and a build tree next to an installed copy in
+// /Applications both claim com.korjwl1.wireguide. LaunchServices then starts
+// whichever it likes — observed live: `ctl start` from a dev build launched
+// /Applications/WireGuide.app instead, whose LaunchDaemon plist differs, so
+// each build kept reinstalling its own plist and prompting for an admin
+// password on every launch.
+func TestBundleFromExePath(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ // bundleFromExePath only runs on the darwin launch path, and these
+ // fixtures are Unix paths that filepath mangles under Windows
+ // separator rules.
+ t.Skip("darwin-only bundle lookup; fixtures use Unix paths")
+ }
+ tests := []struct {
+ name string
+ exe string
+ want string
+ }{
+ {
+ name: "standard bundle layout",
+ exe: "/Applications/WireGuide.app/Contents/MacOS/wireguide",
+ want: "/Applications/WireGuide.app",
+ },
+ {
+ name: "bundle in a build tree",
+ exe: "/Users/me/src/wireguide/bin/WireGuide.app/Contents/MacOS/wireguide",
+ want: "/Users/me/src/wireguide/bin/WireGuide.app",
+ },
+ {
+ name: "bare binary on PATH is not in a bundle",
+ exe: "/usr/local/bin/wireguide",
+ want: "",
+ },
+ {
+ name: "dev build next to its bundle is not in a bundle",
+ exe: "/Users/me/src/wireguide/bin/wireguide",
+ want: "",
+ },
+ {
+ // Only the three levels a real bundle uses are searched, so a
+ // directory that merely ends in .app far up the tree does not
+ // get mistaken for the enclosing bundle.
+ name: "unrelated .app ancestor is out of range",
+ exe: "/Users/me/Weird.app/a/b/c/d/wireguide",
+ want: "",
+ },
+ {
+ name: "relative path inside a bundle",
+ exe: "bin/WireGuide.app/Contents/MacOS/wireguide",
+ want: "bin/WireGuide.app",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := bundleFromExePath(tt.exe); got != tt.want {
+ t.Errorf("bundleFromExePath(%q) = %q, want %q", tt.exe, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/config/keygen.go b/internal/config/keygen.go
deleted file mode 100644
index f128dda..0000000
--- a/internal/config/keygen.go
+++ /dev/null
@@ -1,39 +0,0 @@
-package config
-
-import (
- "crypto/rand"
- "encoding/base64"
-
- "golang.org/x/crypto/curve25519"
-)
-
-// KeyPair holds a WireGuard private/public key pair.
-type KeyPair struct {
- PrivateKey string `json:"private_key"`
- PublicKey string `json:"public_key"`
-}
-
-// GenerateKeyPair creates a new WireGuard key pair (Curve25519).
-func GenerateKeyPair() (*KeyPair, error) {
- // Generate random 32 bytes for private key
- var privKey [32]byte
- if _, err := rand.Read(privKey[:]); err != nil {
- return nil, err
- }
-
- // Clamp the private key per WireGuard spec
- privKey[0] &= 248
- privKey[31] &= 127
- privKey[31] |= 64
-
- // Derive public key
- pubKey, err := curve25519.X25519(privKey[:], curve25519.Basepoint)
- if err != nil {
- return nil, err
- }
-
- return &KeyPair{
- PrivateKey: base64.StdEncoding.EncodeToString(privKey[:]),
- PublicKey: base64.StdEncoding.EncodeToString(pubKey),
- }, nil
-}
diff --git a/internal/config/keygen_test.go b/internal/config/keygen_test.go
deleted file mode 100644
index 1c90920..0000000
--- a/internal/config/keygen_test.go
+++ /dev/null
@@ -1,42 +0,0 @@
-package config
-
-import (
- "encoding/base64"
- "testing"
-)
-
-func TestGenerateKeyPair(t *testing.T) {
- kp, err := GenerateKeyPair()
- if err != nil {
- t.Fatalf("GenerateKeyPair failed: %v", err)
- }
- if kp.PrivateKey == "" || kp.PublicKey == "" {
- t.Fatal("keys should not be empty")
- }
-
- // Verify Base64 + 32 bytes
- privBytes, err := base64.StdEncoding.DecodeString(kp.PrivateKey)
- if err != nil || len(privBytes) != 32 {
- t.Errorf("private key invalid: len=%d err=%v", len(privBytes), err)
- }
- pubBytes, err := base64.StdEncoding.DecodeString(kp.PublicKey)
- if err != nil || len(pubBytes) != 32 {
- t.Errorf("public key invalid: len=%d err=%v", len(pubBytes), err)
- }
-
- // Verify keys validate with our validator
- if !isValidWireGuardKey(kp.PrivateKey) {
- t.Error("generated private key fails validation")
- }
- if !isValidWireGuardKey(kp.PublicKey) {
- t.Error("generated public key fails validation")
- }
-}
-
-func TestGenerateKeyPairUniqueness(t *testing.T) {
- kp1, _ := GenerateKeyPair()
- kp2, _ := GenerateKeyPair()
- if kp1.PrivateKey == kp2.PrivateKey {
- t.Error("two generated keys should not be identical")
- }
-}
diff --git a/internal/diag/cidr.go b/internal/diag/cidr.go
deleted file mode 100644
index 58cd9c7..0000000
--- a/internal/diag/cidr.go
+++ /dev/null
@@ -1,98 +0,0 @@
-// Package diag provides network diagnostic tools.
-package diag
-
-import (
- "encoding/binary"
- "fmt"
- "math"
- "math/big"
- "net"
-)
-
-// CIDRInfo describes a CIDR block.
-type CIDRInfo struct {
- CIDR string `json:"cidr"`
- Network string `json:"network"`
- Broadcast string `json:"broadcast"`
- FirstHost string `json:"first_host"`
- LastHost string `json:"last_host"`
- TotalHosts int64 `json:"total_hosts"`
- Netmask string `json:"netmask"`
- PrefixLen int `json:"prefix_len"`
-}
-
-// CalculateCIDR computes network details for a CIDR string.
-func CalculateCIDR(cidr string) (*CIDRInfo, error) {
- ip, ipNet, err := net.ParseCIDR(cidr)
- if err != nil {
- return nil, fmt.Errorf("invalid CIDR: %w", err)
- }
-
- ones, bits := ipNet.Mask.Size()
- isIPv4 := bits == 32
-
- info := &CIDRInfo{
- CIDR: cidr,
- Network: ipNet.IP.String(),
- Netmask: net.IP(ipNet.Mask).String(),
- PrefixLen: ones,
- }
-
- if isIPv4 {
- networkIP := ipToUint32(ipNet.IP.To4())
- hostBits := 32 - ones
-
- if hostBits >= 32 {
- // /0 — the shift would overflow uint32. Use the exact
- // IPv4 host count (2^32 − 2 = 4294967294) instead of
- // math.MaxInt64, which was misleadingly large.
- info.TotalHosts = (int64(1) << 32) - 2
- info.FirstHost = "0.0.0.1"
- info.LastHost = "255.255.255.254"
- info.Broadcast = "255.255.255.255"
- } else {
- totalHosts := int64(1) << hostBits
-
- if hostBits > 1 {
- info.TotalHosts = totalHosts - 2 // exclude network + broadcast
- info.FirstHost = uint32ToIP(networkIP + 1).String()
- info.LastHost = uint32ToIP(networkIP + uint32(totalHosts) - 2).String()
- info.Broadcast = uint32ToIP(networkIP + uint32(totalHosts) - 1).String()
- } else if hostBits == 1 {
- info.TotalHosts = 2
- info.FirstHost = ipNet.IP.String()
- info.LastHost = uint32ToIP(networkIP + 1).String()
- info.Broadcast = info.LastHost
- } else {
- info.TotalHosts = 1
- info.FirstHost = ip.String()
- info.LastHost = ip.String()
- info.Broadcast = ip.String()
- }
- }
- } else {
- // IPv6 simplified
- hostBits := 128 - ones
- total := new(big.Int).Lsh(big.NewInt(1), uint(hostBits))
- if total.IsInt64() {
- info.TotalHosts = total.Int64()
- } else {
- info.TotalHosts = math.MaxInt64 // cap to avoid garbage values for large IPv6 subnets
- }
- info.FirstHost = ipNet.IP.String()
- info.LastHost = "..."
- info.Broadcast = "N/A (IPv6)"
- }
-
- return info, nil
-}
-
-func ipToUint32(ip net.IP) uint32 {
- return binary.BigEndian.Uint32(ip)
-}
-
-func uint32ToIP(n uint32) net.IP {
- ip := make(net.IP, 4)
- binary.BigEndian.PutUint32(ip, n)
- return ip
-}
diff --git a/internal/diag/cidr_test.go b/internal/diag/cidr_test.go
deleted file mode 100644
index 2ccf039..0000000
--- a/internal/diag/cidr_test.go
+++ /dev/null
@@ -1,52 +0,0 @@
-package diag
-
-import "testing"
-
-func TestCalculateCIDR24(t *testing.T) {
- info, err := CalculateCIDR("192.168.1.0/24")
- if err != nil {
- t.Fatalf("error: %v", err)
- }
- if info.Network != "192.168.1.0" {
- t.Errorf("network: %s", info.Network)
- }
- if info.Broadcast != "192.168.1.255" {
- t.Errorf("broadcast: %s", info.Broadcast)
- }
- if info.FirstHost != "192.168.1.1" {
- t.Errorf("first host: %s", info.FirstHost)
- }
- if info.LastHost != "192.168.1.254" {
- t.Errorf("last host: %s", info.LastHost)
- }
- if info.TotalHosts != 254 {
- t.Errorf("total hosts: %d", info.TotalHosts)
- }
-}
-
-func TestCalculateCIDR32(t *testing.T) {
- info, err := CalculateCIDR("10.0.0.1/32")
- if err != nil {
- t.Fatalf("error: %v", err)
- }
- if info.TotalHosts != 1 {
- t.Errorf("expected 1 host, got %d", info.TotalHosts)
- }
-}
-
-func TestCalculateCIDR16(t *testing.T) {
- info, err := CalculateCIDR("172.16.0.0/16")
- if err != nil {
- t.Fatalf("error: %v", err)
- }
- if info.TotalHosts != 65534 {
- t.Errorf("expected 65534 hosts, got %d", info.TotalHosts)
- }
-}
-
-func TestCalculateCIDRInvalid(t *testing.T) {
- _, err := CalculateCIDR("not-a-cidr")
- if err == nil {
- t.Error("expected error for invalid CIDR")
- }
-}
diff --git a/internal/diag/ping.go b/internal/diag/ping.go
index d387d1e..33278d6 100644
--- a/internal/diag/ping.go
+++ b/internal/diag/ping.go
@@ -64,8 +64,14 @@ func PingEndpointContext(ctx context.Context, endpoint string) *PingResult {
switch runtime.GOOS {
case "windows":
cmd = exec.CommandContext(ctx, "ping", "-n", "3", "-w", "3000", ip)
+ case "darwin":
+ // -W is per-reply wait in MILLISECONDS on macOS (BSD ping),
+ // unlike Linux where it's seconds. "-W 3" silently meant 3ms.
+ cmd = exec.CommandContext(ctx, "ping", "-c", "3", "-W", "3000", ip)
default:
cmd = exec.CommandContext(ctx, "ping", "-c", "3", "-W", "3", ip)
+ }
+ if runtime.GOOS != "windows" {
// Force canonical output so the parsers below aren't at the mercy
// of the user's locale. No Windows equivalent: ping.exe follows
// the system MUI language regardless of environment (see the
@@ -74,32 +80,33 @@ func PingEndpointContext(ctx context.Context, endpoint string) *PingResult {
}
sysexec.Hide(cmd)
- start := time.Now()
out, err := cmd.CombinedOutput()
- elapsed := time.Since(start)
if err != nil {
result.Error = "Host unreachable"
return result
}
- result.Reachable = true
-
// Parse average latency from ping output
latency := parsePingLatency(string(out))
if latency > 0 {
+ result.Reachable = true
result.LatencyMs = latency
- } else {
- // Fallback: parse individual round-trip times from ping lines
- // (e.g. "time=12.3 ms") and average them, which is more accurate
- // than dividing the total wall-clock elapsed time.
- if avg := parseIndividualPingTimes(string(out)); avg > 0 {
- result.LatencyMs = avg
- } else {
- result.LatencyMs = float64(elapsed.Milliseconds()) / 3
- }
+ return result
}
-
+ // Fallback: parse individual round-trip times from ping lines
+ // (e.g. "time=12.3 ms") and average them.
+ if avg := parseIndividualPingTimes(string(out)); avg > 0 {
+ result.Reachable = true
+ result.LatencyMs = avg
+ return result
+ }
+ // No RTT token anywhere in the output. This happens for router-sourced
+ // "Destination host unreachable" replies, which ping.exe can exit 0
+ // for — the host was never actually reached. The previous wall-clock/3
+ // estimate fabricated ~667ms+RTT latencies here (issue #32); report
+ // unreachable instead and let the UI render "—".
+ result.Error = "No reply times in ping output"
return result
}
diff --git a/internal/diag/ping_test.go b/internal/diag/ping_test.go
index ea5a496..eb20578 100644
--- a/internal/diag/ping_test.go
+++ b/internal/diag/ping_test.go
@@ -80,3 +80,21 @@ func TestParseIndividualPingTimes_Empty(t *testing.T) {
t.Errorf("got %.3f, want 0", got)
}
}
+
+// Router-sourced "Destination host unreachable" replies carry no RTT token,
+// yet ping.exe can exit 0 for them. Both parsers must return 0 so Ping
+// reports unreachable instead of fabricating a latency (issue #32).
+func TestParsers_DestinationHostUnreachable(t *testing.T) {
+ out := "Pinging 10.0.0.7 with 32 bytes of data:\r\n" +
+ "Reply from 192.168.1.1: Destination host unreachable.\r\n" +
+ "Reply from 192.168.1.1: Destination host unreachable.\r\n" +
+ "Reply from 192.168.1.1: Destination host unreachable.\r\n" +
+ "Ping statistics for 10.0.0.7:\r\n" +
+ " Packets: Sent = 3, Received = 3, Lost = 0 (0% loss),\r\n"
+ if got := parsePingLatency(out); got != 0 {
+ t.Errorf("parsePingLatency: got %.3f, want 0", got)
+ }
+ if got := parseIndividualPingTimes(out); got != 0 {
+ t.Errorf("parseIndividualPingTimes: got %.3f, want 0", got)
+ }
+}
diff --git a/internal/diag/speed.go b/internal/diag/speed.go
deleted file mode 100644
index d77cf4f..0000000
--- a/internal/diag/speed.go
+++ /dev/null
@@ -1,103 +0,0 @@
-package diag
-
-import (
- "context"
- "fmt"
- "io"
- "net/http"
- "time"
-)
-
-// SpeedTestResult holds download/upload speed test results.
-type SpeedTestResult struct {
- DownloadMbps float64 `json:"download_mbps"`
- UploadMbps float64 `json:"upload_mbps"`
- LatencyMs float64 `json:"latency_ms"`
- Error string `json:"error,omitempty"`
- // Truncated is set when the download hit the time cap before the
- // full sample arrived. DownloadMbps is still populated with the
- // partial-stream throughput (useful as a lower bound), but the GUI
- // should label it as "≥X Mbps (incomplete)" rather than a precise
- // measurement.
- Truncated bool `json:"truncated,omitempty"`
-}
-
-// RunSpeedTest performs a simple download speed test.
-// Uses a public HTTP endpoint to measure throughput. The caller
-// passes a context so the GUI can cancel mid-test (e.g. user closes
-// the diagnostics tab) without leaving a 10MB download draining in
-// the background.
-func RunSpeedTest(ctx context.Context) *SpeedTestResult {
- result := &SpeedTestResult{}
-
- // Measure latency first. Bound the HEAD with its own sub-timeout:
- // http.DefaultClient has no client-level timeout, so if the caller
- // passed a context without a deadline (e.g. context.Background) a
- // black-holed network would hang this HEAD indefinitely, before the
- // download's own 60s cap is ever reached.
- start := time.Now()
- headCtx, headCancel := context.WithTimeout(ctx, 10*time.Second)
- defer headCancel()
- headReq, err := http.NewRequestWithContext(headCtx, http.MethodHead, "https://www.google.com", nil)
- if err != nil {
- result.Error = fmt.Sprintf("connectivity check setup: %v", err)
- return result
- }
- resp, err := http.DefaultClient.Do(headReq)
- if err != nil {
- result.Error = fmt.Sprintf("connectivity check failed: %v", err)
- return result
- }
- resp.Body.Close()
- result.LatencyMs = float64(time.Since(start).Milliseconds())
-
- // Download test. We pick a 10 MB target on the assumption of
- // "broadband" links (≥10 Mbps → ≤8 s). On an LTE-grade 1 Mbps link
- // that would take 80 s, far past any reasonable diagnostic budget.
- // Strategy: ask for 10 MB but cap the read at 60 seconds AND
- // 10 MB whichever comes first. If we hit the time cap we report
- // the partial throughput instead of timing-out with no result.
- const targetBytes = 10_000_000
- const maxDuration = 60 * time.Second
- dlCtx, cancel := context.WithTimeout(ctx, maxDuration)
- defer cancel()
- testURL := "https://speed.cloudflare.com/__down?bytes=10000000"
- dlReq, err := http.NewRequestWithContext(dlCtx, http.MethodGet, testURL, nil)
- if err != nil {
- result.Error = fmt.Sprintf("download test setup: %v", err)
- return result
- }
-
- start = time.Now()
- dlResp, err := http.DefaultClient.Do(dlReq)
- if err != nil {
- result.Error = fmt.Sprintf("download test failed: %v", err)
- return result
- }
- defer dlResp.Body.Close()
-
- // LimitReader prevents pathological misconfigured servers from
- // streaming forever; ctx already provides the time cap.
- bytes, copyErr := io.Copy(io.Discard, io.LimitReader(dlResp.Body, targetBytes))
- elapsed := time.Since(start).Seconds()
-
- // Distinguish "completed download" from "timed out mid-stream".
- // Truncated = true when ctx deadline expired AND we got partial
- // data — the GUI uses this to label the result as a lower bound
- // rather than a precise measurement.
- if dlCtx.Err() == context.DeadlineExceeded && bytes < targetBytes {
- result.Truncated = true
- } else if copyErr != nil && copyErr != io.EOF {
- // Real I/O error (connection reset, etc.) — surface it.
- result.Error = fmt.Sprintf("download stream error: %v", copyErr)
- }
-
- if elapsed > 0 && bytes > 0 {
- result.DownloadMbps = float64(bytes) * 8 / elapsed / 1_000_000
- }
-
- // Upload test skipped for simplicity (would need a server to accept data)
- result.UploadMbps = 0
-
- return result
-}
diff --git a/internal/elevate/sid_other.go b/internal/elevate/sid_other.go
new file mode 100644
index 0000000..55fb933
--- /dev/null
+++ b/internal/elevate/sid_other.go
@@ -0,0 +1,7 @@
+//go:build !windows
+
+package elevate
+
+// CurrentUserSID is Windows-only; Unix platforms identify the socket
+// owner by UID instead.
+func CurrentUserSID() string { return "" }
diff --git a/internal/elevate/sid_windows.go b/internal/elevate/sid_windows.go
new file mode 100644
index 0000000..4806427
--- /dev/null
+++ b/internal/elevate/sid_windows.go
@@ -0,0 +1,18 @@
+//go:build windows
+
+package elevate
+
+import "golang.org/x/sys/windows"
+
+// CurrentUserSID returns the current process token's user SID in string
+// form (S-1-5-21-…). "" on failure. Note os.Getuid() is -1 on Windows —
+// the SID is the only usable owner identity, which is why the helper's
+// pipe ACL and peer checks are keyed on it rather than a UID.
+func CurrentUserSID() string {
+ tok := windows.GetCurrentProcessToken()
+ u, err := tok.GetTokenUser()
+ if err != nil || u == nil || u.User.Sid == nil {
+ return ""
+ }
+ return u.User.Sid.String()
+}
diff --git a/internal/elevate/spawn.go b/internal/elevate/spawn.go
index 0196c94..9ad7619 100644
--- a/internal/elevate/spawn.go
+++ b/internal/elevate/spawn.go
@@ -6,6 +6,7 @@ import (
"log/slog"
"os"
"path/filepath"
+ "regexp"
"strings"
)
@@ -15,6 +16,11 @@ type Args struct {
SocketPath string
// SocketUID is the UID to chown the socket to (Unix only). On Windows, 0.
SocketUID int
+ // SocketSID is the spawning user's Windows SID (S-1-5-21-…). The
+ // helper scopes the pipe ACL and per-connection peer checks to it,
+ // replacing the every-interactive-user IU grant (issue #20). Empty
+ // on Unix and on Windows builds that fail to read the token.
+ SocketSID string
// DataDir for crash recovery state
DataDir string
// ForceReinstall skips the "already running" socket check and
@@ -42,9 +48,20 @@ func ValidateArgs(a Args) error {
if err := validateSpawnPath("DataDir", a.DataDir); err != nil {
return err
}
+ if a.SocketSID != "" && !sidPattern.MatchString(a.SocketSID) {
+ // The SID travels through a PowerShell argument list and is later
+ // interpolated into a pipe security descriptor — refuse anything
+ // that isn't a plain S-1-… SID string.
+ return fmt.Errorf("SocketSID is not a valid SID string: %q", a.SocketSID)
+ }
return nil
}
+// sidPattern matches SDDL SID strings like S-1-5-21-…-1001. Windows also
+// accepts two-letter aliases (BA, SY) in SDDL, but we only ever pass full
+// numeric SIDs read from the process token.
+var sidPattern = regexp.MustCompile(`^S-1-\d+(-\d+)+$`)
+
// validateSpawnPath rejects paths that:
// - are not absolute (relative paths could resolve unpredictably under
// the privileged helper's CWD),
diff --git a/internal/elevate/spawn_darwin.go b/internal/elevate/spawn_darwin.go
index 5db122b..2b18667 100644
--- a/internal/elevate/spawn_darwin.go
+++ b/internal/elevate/spawn_darwin.go
@@ -22,9 +22,15 @@ const (
// SpawnHelper starts the privileged helper process.
//
-// On first launch: installs the LaunchDaemon (one-time admin password prompt
-// via macOS native dialog). After that, the helper starts at boot via launchd
-// and the app never asks for a password again.
+// Installs (or restarts) the LaunchDaemon via a macOS native admin dialog.
+// The plist sets RunAtLoad=false, so launchd never starts the helper on its
+// own — the helper's lifetime is tied to the GUI's. That means the admin
+// prompt appears on first launch and again on any launch that finds no live
+// helper socket (i.e. after the helper self-exited when the GUI closed).
+// This is the intended trade: no invisible root process outliving the app.
+//
+// A live socket short-circuits the whole path (step 1), so relaunching the
+// GUI while a tunnel is still up does NOT re-prompt.
//
// ctx governs ONLY the post-install socket-readiness polling. The osascript
// admin dialog is intentionally detached from ctx — a user typing their
@@ -79,8 +85,19 @@ func generatePlistContent(exe string, args Args) string {
--uid=%d
--data-dir=%s
+
RunAtLoad
-
+
KeepAlive
SuccessfulExit
@@ -163,6 +180,12 @@ func installAndLoadDaemon(ctx context.Context, args Args) error {
// 4. Set ownership/permissions
// 5. Bootout old daemon (ignore errors — may not exist)
// 6. Bootstrap new daemon
+ // 7. Kickstart it — REQUIRED, because the plist sets RunAtLoad=false.
+ // bootstrap alone only registers the job with launchd; without the
+ // kickstart the process never starts and the socket-readiness poll
+ // below would time out with "daemon installed but socket not live".
+ // -k replaces a survivor from a torn-down previous instance rather
+ // than leaving it running.
// xattr -d strips com.apple.quarantine from the freshly copied helper
// binary. macOS adds this attr to anything downloaded (e.g. inside a
// dmg/zip release) and Gatekeeper blocks quarantined binaries from
@@ -189,7 +212,8 @@ func installAndLoadDaemon(ctx context.Context, args Args) error {
`chmod 644 %s && `+
`launchctl bootout system/%s 2>/dev/null; `+
`i=0; while [ $i -lt 20 ] && launchctl print system/%s >/dev/null 2>&1; do sleep 0.1; i=$((i+1)); done; `+
- `launchctl bootstrap system %s`,
+ `launchctl bootstrap system %s && `+
+ `launchctl kickstart -k system/%s`,
shellQuote(exe), shellQuote(daemonBinary),
shellQuote(daemonBinary),
shellQuote(daemonBinary),
@@ -200,6 +224,7 @@ func installAndLoadDaemon(ctx context.Context, args Args) error {
daemonLabel,
daemonLabel,
shellQuote(daemonPlist),
+ daemonLabel,
)
escaped := strings.ReplaceAll(shellScript, `\`, `\\`)
diff --git a/internal/elevate/spawn_darwin_test.go b/internal/elevate/spawn_darwin_test.go
new file mode 100644
index 0000000..c4f67e1
--- /dev/null
+++ b/internal/elevate/spawn_darwin_test.go
@@ -0,0 +1,85 @@
+//go:build darwin
+
+package elevate
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// testArgs returns a representative Args for plist generation.
+func testArgs() Args {
+ return Args{
+ SocketPath: "/var/run/wireguide/wireguide.sock",
+ SocketUID: 501,
+ DataDir: "/Library/Application Support/wireguide",
+ }
+}
+
+// TestGeneratedPlistLints guards the XML comments embedded in the plist
+// template. plutil is what installAndLoadDaemon runs before attempting the
+// install, so a malformed template would surface as a failed admin-prompt
+// install rather than a build error.
+func TestGeneratedPlistLints(t *testing.T) {
+ plist := generatePlistContent("/Library/PrivilegedHelperTools/com.wireguide.helper", testArgs())
+
+ path := filepath.Join(t.TempDir(), "test.plist")
+ if err := os.WriteFile(path, []byte(plist), 0644); err != nil {
+ t.Fatalf("write plist: %v", err)
+ }
+ if out, err := exec.Command("plutil", "-lint", path).CombinedOutput(); err != nil {
+ t.Fatalf("plutil -lint rejected the generated plist: %v\n%s", err, out)
+ }
+}
+
+// TestPlistDoesNotRunAtLoad pins the helper's boot behaviour. RunAtLoad=false
+// is the whole reason a closed WireGuide leaves no root process behind: with
+// it true, launchd starts the helper at every boot with no GUI, no window and
+// no tray icon, and the helper's Wi-Fi automation rules could bring a tunnel
+// up while the user believes the app is closed.
+//
+// The runtime half of the same rule lives in helper.Run, which arms the
+// startup grace window unconditionally. Both must hold.
+func TestPlistDoesNotRunAtLoad(t *testing.T) {
+ plist := generatePlistContent("/Library/PrivilegedHelperTools/com.wireguide.helper", testArgs())
+
+ path := filepath.Join(t.TempDir(), "test.plist")
+ if err := os.WriteFile(path, []byte(plist), 0644); err != nil {
+ t.Fatalf("write plist: %v", err)
+ }
+
+ // Read the key back through plutil rather than string-matching, so an
+ // XML comment mentioning RunAtLoad can't make this pass spuriously.
+ out, err := exec.Command("plutil", "-extract", "RunAtLoad", "raw", "-o", "-", path).CombinedOutput()
+ if err != nil {
+ t.Fatalf("plutil -extract RunAtLoad: %v\n%s", err, out)
+ }
+ if got := strings.TrimSpace(string(out)); got != "false" {
+ t.Errorf("RunAtLoad = %q, want \"false\" — the helper must not start at boot; "+
+ "users who want WireGuide from login enable auto_start, which installs the GUI LaunchAgent", got)
+ }
+}
+
+// TestInstallScriptKickstarts pairs with RunAtLoad=false: `launchctl
+// bootstrap` only registers the job, so without an explicit kickstart the
+// helper never starts and installAndLoadDaemon's readiness poll times out
+// with "daemon installed but socket not live after 6s".
+func TestInstallScriptKickstarts(t *testing.T) {
+ // Mirror the command construction in installAndLoadDaemon closely
+ // enough to catch a bootstrap that lost its kickstart.
+ src, err := os.ReadFile("spawn_darwin.go")
+ if err != nil {
+ t.Fatalf("read source: %v", err)
+ }
+ s := string(src)
+ if !strings.Contains(s, "launchctl bootstrap system %s") {
+ t.Fatal("install script no longer bootstraps the daemon")
+ }
+ if !strings.Contains(s, "launchctl kickstart -k system/%s") {
+ t.Error("install script bootstraps but never kickstarts; with RunAtLoad=false " +
+ "the helper process would never start")
+ }
+}
diff --git a/internal/elevate/spawn_linux.go b/internal/elevate/spawn_linux.go
index 31a3de3..95bb348 100644
--- a/internal/elevate/spawn_linux.go
+++ b/internal/elevate/spawn_linux.go
@@ -37,7 +37,17 @@ func SpawnHelper(ctx context.Context, args Args) error {
// Put the helper in its own process group so it survives Ctrl+C on the
// parent terminal (macOS version uses `& disown` for the same purpose).
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
- return cmd.Start() // background
+ if err := cmd.Start(); err != nil {
+ return err
+ }
+ // Reap pkexec when it exits — without a Wait every spawn attempt (three
+ // per failed startup, plus one per health-monitor recovery) leaves a
+ // zombie parented to the GUI for the life of the process. Note Start()
+ // succeeding says nothing about authorization: pkexec exits non-zero
+ // AFTER the user dismisses the polkit dialog, so callers must treat
+ // readiness-poll timeout, not Start() error, as the failure signal.
+ go func() { _ = cmd.Wait() }()
+ return nil
}
// PlistNeedsReinstall is a no-op on Linux — there is no LaunchDaemon plist.
diff --git a/internal/elevate/spawn_test.go b/internal/elevate/spawn_test.go
new file mode 100644
index 0000000..7327d0f
--- /dev/null
+++ b/internal/elevate/spawn_test.go
@@ -0,0 +1,42 @@
+package elevate
+
+import (
+ "runtime"
+ "testing"
+)
+
+func TestValidateArgsSocketSID(t *testing.T) {
+ // The SID rules under test are path-independent, but the base Args must
+ // pass validateSpawnPath's absolute-path check on every OS — Unix
+ // fixtures are not absolute under Windows filepath rules. The Windows
+ // SocketPath is the production pipe address, which IsAbs accepts.
+ base := Args{SocketPath: "/var/run/wireguide/wireguide.sock", DataDir: "/var/lib/wireguide"}
+ if runtime.GOOS == "windows" {
+ base = Args{SocketPath: `\\.\pipe\wireguide`, DataDir: `C:\ProgramData\wireguide`}
+ }
+
+ valid := base
+ valid.SocketSID = "S-1-5-21-3623811015-3361044348-30300820-1013"
+ if err := ValidateArgs(valid); err != nil {
+ t.Errorf("valid SID rejected: %v", err)
+ }
+
+ empty := base // empty SID is fine (Unix / fallback)
+ if err := ValidateArgs(empty); err != nil {
+ t.Errorf("empty SID rejected: %v", err)
+ }
+
+ for _, bad := range []string{
+ "IU", // SDDL alias — we only pass numeric SIDs
+ "S-1-5-21-abc", // non-numeric component
+ "S-1", // too short
+ "S-1-5-21-1013;X", // injection attempt into the SDDL/argv
+ "S-1-5-21-1013')", // PowerShell breakout attempt
+ } {
+ inv := base
+ inv.SocketSID = bad
+ if err := ValidateArgs(inv); err == nil {
+ t.Errorf("invalid SID %q accepted", bad)
+ }
+ }
+}
diff --git a/internal/elevate/spawn_windows.go b/internal/elevate/spawn_windows.go
index 34f565e..24fd3e5 100644
--- a/internal/elevate/spawn_windows.go
+++ b/internal/elevate/spawn_windows.go
@@ -28,6 +28,12 @@ func SpawnHelper(ctx context.Context, args Args) error {
`'--helper','--socket=%s','--data-dir=%s'`,
psEscape(args.SocketPath), psEscape(args.DataDir),
)
+ if args.SocketSID != "" {
+ // ValidateArgs vetted the SID format; the helper scopes the pipe
+ // ACL and peer checks to this SID instead of all interactive
+ // users (issue #20).
+ argList += fmt.Sprintf(`,'--owner-sid=%s'`, psEscape(args.SocketSID))
+ }
ps := fmt.Sprintf(
`Start-Process '%s' -ArgumentList %s -Verb RunAs -WindowStyle Hidden`,
psEscape(exe), argList,
diff --git a/internal/firewall/darwin.go b/internal/firewall/darwin.go
index 5581703..e042f8a 100644
--- a/internal/firewall/darwin.go
+++ b/internal/firewall/darwin.go
@@ -11,6 +11,7 @@ import (
"os/exec"
"path/filepath"
"regexp"
+ "sort"
"strings"
"sync"
"time"
@@ -74,16 +75,14 @@ type DarwinFirewall struct {
// DNS rules and DNS leaks despite dnsProtectionEnabled==true.
savedDNSInterface string
savedDNSServers []string
- // savedTunnelIface / savedTunnelEndpoints cache the most recent
- // kill-switch tunnel parameters so AddKillSwitchTunnel /
- // RemoveKillSwitchTunnel can rebuild the pf anchor without losing
- // the active tunnel's permits when only one of (iface, dns) changes.
- savedTunnelIface string
- savedTunnelEndpoints []string
+ // killSwitchTunnels is the complete interface -> endpoint permit model.
+ // PF anchor loads replace the prior ruleset, so every add/remove must
+ // render all survivors rather than only the most recently added utun.
+ killSwitchTunnels map[string][]string
}
func NewPlatformFirewall() FirewallManager {
- return &DarwinFirewall{}
+ return &DarwinFirewall{killSwitchTunnels: make(map[string][]string)}
}
// buildKillSwitchRules renders the pf rule text loaded into the
@@ -93,33 +92,56 @@ func NewPlatformFirewall() FirewallManager {
// DNS sub-anchor directive + catch-all block. That's the layout used
// when the user toggles the kill switch on without an active tunnel.
func buildKillSwitchRules(interfaceName string, endpoints []string) (string, error) {
+ tunnels := make(map[string][]string)
+ if interfaceName != "" {
+ tunnels[interfaceName] = endpoints
+ }
+ return buildKillSwitchRulesForTunnels(tunnels)
+}
+
+func buildKillSwitchRulesForTunnels(tunnels map[string][]string) (string, error) {
var rules strings.Builder
rules.WriteString("# WireGuide kill switch rules\n")
rules.WriteString("# Allow loopback\n")
rules.WriteString("pass quick on lo0 all\n")
- for _, ep := range endpoints {
- ip, port, _ := net.SplitHostPort(ep)
- if ip == "" {
- ip = ep
- }
- if ip == "" {
- continue
- }
- if net.ParseIP(ip) == nil {
- return "", fmt.Errorf("invalid endpoint IP %q", ip)
+ interfaces := make([]string, 0, len(tunnels))
+ for interfaceName := range tunnels {
+ if !validIfaceName.MatchString(interfaceName) {
+ return "", fmt.Errorf("invalid interface name %q", interfaceName)
}
- if port != "" {
- fmt.Fprintf(&rules, "pass out quick proto udp to %s port %s\n", ip, port)
- } else {
- fmt.Fprintf(&rules, "pass out quick proto udp to %s\n", ip)
+ interfaces = append(interfaces, interfaceName)
+ }
+ sort.Strings(interfaces)
+ seenEndpoints := make(map[string]struct{})
+ for _, interfaceName := range interfaces {
+ for _, ep := range tunnels[interfaceName] {
+ if _, seen := seenEndpoints[ep]; seen {
+ continue
+ }
+ seenEndpoints[ep] = struct{}{}
+ ip, port, _ := net.SplitHostPort(ep)
+ if ip == "" {
+ ip = ep
+ }
+ if ip == "" {
+ continue
+ }
+ if net.ParseIP(ip) == nil {
+ return "", fmt.Errorf("invalid endpoint IP %q", ip)
+ }
+ if port != "" {
+ fmt.Fprintf(&rules, "pass out quick proto udp to %s port %s\n", ip, port)
+ } else {
+ fmt.Fprintf(&rules, "pass out quick proto udp to %s\n", ip)
+ }
}
}
rules.WriteString("pass out quick proto udp from any port 68 to any port 67\n")
rules.WriteString("pass out quick proto udp from any port 546 to any port 547\n")
- if interfaceName != "" {
+ for _, interfaceName := range interfaces {
fmt.Fprintf(&rules, "pass quick on %s all\n", interfaceName)
}
@@ -150,7 +172,11 @@ func (f *DarwinFirewall) EnableKillSwitch(interfaceName string, _ []string, endp
slog.Warn("loading /etc/pf.conf failed; anchor may not be evaluated", "error", err)
}
- rules, err := buildKillSwitchRules(interfaceName, endpoints)
+ tunnels := make(map[string][]string)
+ if interfaceName != "" {
+ tunnels[interfaceName] = append([]string(nil), endpoints...)
+ }
+ rules, err := buildKillSwitchRulesForTunnels(tunnels)
if err != nil {
return err
}
@@ -175,8 +201,7 @@ func (f *DarwinFirewall) EnableKillSwitch(interfaceName string, _ []string, endp
f.mu.Lock()
f.pfWasEnabled = pfWas
f.killSwitchEnabled = true
- f.savedTunnelIface = interfaceName
- f.savedTunnelEndpoints = append([]string(nil), endpoints...)
+ f.killSwitchTunnels = tunnels
f.mu.Unlock()
slog.Info("kill switch enabled", "interface", interfaceName, "endpoints", len(endpoints))
return nil
@@ -220,14 +245,12 @@ func loadDNSSubAnchor(interfaceName string, dnsServers []string) error {
return loadAnchorRules(dnsAnchorName, dnsRules.String())
}
-// AddKillSwitchTunnel folds a newly-connected tunnel's per-iface permit
-// + endpoint permits into the kill-switch anchor. On darwin we only
-// track one tunnel at a time in the anchor — the most-recently-added
-// one wins. Multi-tunnel kill-switch on darwin is not supported.
+// AddKillSwitchTunnel folds a newly-connected tunnel's per-iface permit and
+// endpoint permits into the complete kill-switch anchor.
//
// No-op when the kill switch isn't enabled (handleConnect should gate
// on IsKillSwitchEnabled before calling, but be defensive).
-func (f *DarwinFirewall) AddKillSwitchTunnel(interfaceName string, endpoints []string) error {
+func (f *DarwinFirewall) AddKillSwitchTunnel(interfaceName string, _ []string, endpoints []string) error {
if interfaceName == "" {
return fmt.Errorf("AddKillSwitchTunnel: empty interface name")
}
@@ -240,9 +263,11 @@ func (f *DarwinFirewall) AddKillSwitchTunnel(interfaceName string, endpoints []s
f.mu.Unlock()
return nil
}
+ tunnels := cloneTunnelEndpoints(f.killSwitchTunnels)
+ tunnels[interfaceName] = append([]string(nil), endpoints...)
f.mu.Unlock()
- rules, err := buildKillSwitchRules(interfaceName, endpoints)
+ rules, err := buildKillSwitchRulesForTunnels(tunnels)
if err != nil {
return err
}
@@ -252,33 +277,25 @@ func (f *DarwinFirewall) AddKillSwitchTunnel(interfaceName string, endpoints []s
f.reapplyDNSSubAnchorIfActive()
f.mu.Lock()
- f.savedTunnelIface = interfaceName
- f.savedTunnelEndpoints = append([]string(nil), endpoints...)
+ f.killSwitchTunnels = tunnels
f.mu.Unlock()
slog.Info("kill switch tunnel added", "interface", interfaceName, "endpoints", len(endpoints))
return nil
}
// RemoveKillSwitchTunnel rebuilds the anchor without the disconnected
-// tunnel's permits. Since darwin only stores one tunnel at a time, the
-// rebuild drops to base-only (loopback + DHCP + DNS sub-anchor +
-// catch-all block).
+// tunnel's permits while preserving every other active utun.
func (f *DarwinFirewall) RemoveKillSwitchTunnel(interfaceName string) error {
f.mu.Lock()
if !f.killSwitchEnabled {
f.mu.Unlock()
return nil
}
- saved := f.savedTunnelIface
+ tunnels := cloneTunnelEndpoints(f.killSwitchTunnels)
+ delete(tunnels, interfaceName)
f.mu.Unlock()
- // If the disconnected tunnel isn't the one we have permits for,
- // leave the anchor alone — another tunnel is still active.
- if saved != "" && saved != interfaceName {
- return nil
- }
-
- rules, err := buildKillSwitchRules("", nil)
+ rules, err := buildKillSwitchRulesForTunnels(tunnels)
if err != nil {
return err
}
@@ -288,13 +305,20 @@ func (f *DarwinFirewall) RemoveKillSwitchTunnel(interfaceName string) error {
f.reapplyDNSSubAnchorIfActive()
f.mu.Lock()
- f.savedTunnelIface = ""
- f.savedTunnelEndpoints = nil
+ f.killSwitchTunnels = tunnels
f.mu.Unlock()
slog.Info("kill switch tunnel removed", "interface", interfaceName)
return nil
}
+func cloneTunnelEndpoints(src map[string][]string) map[string][]string {
+ dst := make(map[string][]string, len(src))
+ for interfaceName, endpoints := range src {
+ dst[interfaceName] = append([]string(nil), endpoints...)
+ }
+ return dst
+}
+
// EnableEndpointProtection is a no-op on macOS — but NOT because the
// loop class is impossible here. wireguard-go's Darwin bind does NOT
// set IP_BOUND_IF on its UDP socket (the previous comment was wrong);
@@ -375,8 +399,7 @@ func (f *DarwinFirewall) DisableKillSwitch() error {
f.mu.Lock()
f.killSwitchEnabled = false
- f.savedTunnelIface = ""
- f.savedTunnelEndpoints = nil
+ f.killSwitchTunnels = make(map[string][]string)
f.mu.Unlock()
slog.Info("kill switch disabled", "dns_reapplied", dnsReapplied)
return nil
@@ -531,8 +554,7 @@ func (f *DarwinFirewall) Cleanup() error {
}
f.dnsProtectionEnabled = false
f.killSwitchEnabled = false
- f.savedTunnelIface = ""
- f.savedTunnelEndpoints = nil
+ f.killSwitchTunnels = make(map[string][]string)
f.pfWasEnabled = false
f.mu.Unlock()
diff --git a/internal/firewall/interface.go b/internal/firewall/interface.go
index d150b41..8d1d767 100644
--- a/internal/firewall/interface.go
+++ b/internal/firewall/interface.go
@@ -23,8 +23,10 @@ type FirewallManager interface {
// AddKillSwitchTunnel installs the per-tunnel permit filters (Permit
// tunnel LUID + Permit each peer endpoint outbound). Called when a tunnel
// connects WHILE the kill switch is already enabled. No-op if the kill
- // switch is off. Idempotent for the same tunnel name.
- AddKillSwitchTunnel(interfaceName string, endpoints []string) error
+ // switch is off. ifaceAddresses has the same meaning as EnableKillSwitch
+ // (and is ignored on platforms whose firewall keys only on interface).
+ // Idempotent for the same tunnel name.
+ AddKillSwitchTunnel(interfaceName string, ifaceAddresses []string, endpoints []string) error
// RemoveKillSwitchTunnel removes the per-tunnel permits that
// AddKillSwitchTunnel installed. Called when a tunnel disconnects. The
diff --git a/internal/firewall/linux.go b/internal/firewall/linux.go
index 7c0a3c3..4b39424 100644
--- a/internal/firewall/linux.go
+++ b/internal/firewall/linux.go
@@ -30,10 +30,16 @@ type LinuxFirewall struct {
killSwitchEnabled bool
dnsProtectionEnabled bool
fwmark int
+ killSwitchTunnels map[string][]string
+ killSwitchAddresses map[string][]string
}
func NewPlatformFirewall() FirewallManager {
- return &LinuxFirewall{fwmark: 51820}
+ return &LinuxFirewall{
+ fwmark: 51820,
+ killSwitchTunnels: make(map[string][]string),
+ killSwitchAddresses: make(map[string][]string),
+ }
}
// SetFwMark configures the fwmark used by kill switch nftables rules.
@@ -51,69 +57,67 @@ func (f *LinuxFirewall) EnableKillSwitch(interfaceName string, ifaceAddresses []
defer f.mu.Unlock()
// Validate interface name before interpolating into nft rules.
- if !validIfaceName.MatchString(interfaceName) {
+ if interfaceName != "" && !validIfaceName.MatchString(interfaceName) {
return fmt.Errorf("invalid interface name %q", interfaceName)
}
+ f.killSwitchTunnels = make(map[string][]string)
+ f.killSwitchAddresses = make(map[string][]string)
+ if interfaceName != "" {
+ f.killSwitchTunnels[interfaceName] = append([]string(nil), endpoints...)
+ f.killSwitchAddresses[interfaceName] = append([]string(nil), ifaceAddresses...)
+ }
+ if err := f.applyKillSwitchLocked(false); err != nil {
+ return err
+ }
+ f.killSwitchEnabled = true
+ return nil
+}
- // Build endpoint allow rules with port restrictions (H11)
- var endpointRules strings.Builder
- for _, ep := range endpoints {
- ip, port, _ := net.SplitHostPort(ep)
- if ip == "" {
- ip = ep // fallback: bare IP without port
- }
- if ip == "" {
- continue
- }
- // Validate that the endpoint is a real IP before interpolating into nft rules.
- if net.ParseIP(ip) == nil {
- slog.Warn("skipping invalid endpoint IP in nft rules", "endpoint", ep)
- continue
- }
- addrKw := "ip"
- if strings.Contains(ip, ":") {
- addrKw = "ip6"
- }
- // Validate the port before interpolating: net.SplitHostPort does
- // NOT check that the port is numeric, so a crafted endpoint could
- // otherwise inject nft syntax (or break the ruleset load, which
- // fails the kill switch open).
- if port != "" {
- p, err := strconv.Atoi(port)
- if err != nil || p < 1 || p > 65535 {
- slog.Warn("skipping endpoint with invalid port in nft rules", "endpoint", ep)
+func (f *LinuxFirewall) applyKillSwitchLocked(replace bool) error {
+ var endpointRules, tunnelOutputRules, tunnelInputRules, forwardRules, prerawRules strings.Builder
+ for interfaceName, endpoints := range f.killSwitchTunnels {
+ for _, ep := range endpoints {
+ ip, port, _ := net.SplitHostPort(ep)
+ if ip == "" {
+ ip = ep
+ }
+ if net.ParseIP(ip) == nil {
+ slog.Warn("skipping invalid endpoint IP in nft rules", "endpoint", ep)
continue
}
- fmt.Fprintf(&endpointRules, " %s daddr %s udp dport %d accept\n", addrKw, ip, p)
- } else {
- fmt.Fprintf(&endpointRules, " %s daddr %s accept\n", addrKw, ip)
- }
- }
-
- // Extract plain IP addresses from CIDR interface addresses for the
- // preraw anti-spoof chain (C3).
- var ifaceIPs []string
- for _, addr := range ifaceAddresses {
- ip, _, err := net.ParseCIDR(addr)
- if err != nil {
- // Try as plain IP
- ip = net.ParseIP(addr)
- }
- if ip != nil {
- ifaceIPs = append(ifaceIPs, ip.String())
+ addrKw := "ip"
+ if strings.Contains(ip, ":") {
+ addrKw = "ip6"
+ }
+ if port != "" {
+ p, err := strconv.Atoi(port)
+ if err != nil || p < 1 || p > 65535 {
+ slog.Warn("skipping endpoint with invalid port in nft rules", "endpoint", ep)
+ continue
+ }
+ fmt.Fprintf(&endpointRules, " %s daddr %s udp dport %d accept\n", addrKw, ip, p)
+ } else {
+ fmt.Fprintf(&endpointRules, " %s daddr %s accept\n", addrKw, ip)
+ }
}
- }
-
- // Build the preraw chain: drops spoofed packets arriving on non-WG
- // interfaces destined for the WG address (C3).
- var prerawRules strings.Builder
- for _, ip := range ifaceIPs {
- addrKw := "ip"
- if strings.Contains(ip, ":") {
- addrKw = "ip6"
+ fmt.Fprintf(&tunnelOutputRules, " oifname \"%s\" accept\n", interfaceName)
+ fmt.Fprintf(&tunnelInputRules, " iifname \"%s\" accept\n", interfaceName)
+ fmt.Fprintf(&forwardRules, " oifname \"%s\" accept\n iifname \"%s\" accept\n", interfaceName, interfaceName)
+
+ for _, addr := range f.killSwitchAddresses[interfaceName] {
+ ip, _, err := net.ParseCIDR(addr)
+ if err != nil {
+ ip = net.ParseIP(addr)
+ }
+ if ip == nil {
+ continue
+ }
+ addrKw := "ip"
+ if ip.To4() == nil {
+ addrKw = "ip6"
+ }
+ fmt.Fprintf(&prerawRules, " iifname != \"%s\" %s daddr %s fib saddr type != local drop\n", interfaceName, addrKw, ip.String())
}
- fmt.Fprintf(&prerawRules, " iifname != \"%s\" %s daddr %s fib saddr type != local drop\n",
- interfaceName, addrKw, ip)
}
fwmarkHex := fmt.Sprintf("0x%08x", f.fwmark)
@@ -130,15 +134,15 @@ table inet %s {
# Allow DHCPv6 (H11)
udp sport 546 udp dport 547 accept
# Allow WireGuard endpoints
-%s # Allow WireGuard tunnel
- oifname %s accept
+%s # Allow WireGuard tunnels
+%s
# Allow established connections
ct state established,related accept
}
chain input {
type filter hook input priority 0; policy drop;
iif lo accept
- iifname %s accept
+%s
# Allow DHCP responses
udp sport 67 udp dport 68 accept
# Allow DHCPv6 responses (H11)
@@ -147,8 +151,7 @@ table inet %s {
}
chain forward {
type filter hook forward priority 0; policy drop;
- oifname %s accept
- iifname %s accept
+%s
}
chain preraw {
type filter hook prerouting priority raw; policy accept;
@@ -162,25 +165,48 @@ table inet %s {
meta l4proto udp meta mark %s ct mark set meta mark
}
}
-`, nftTable, endpointRules.String(), interfaceName, interfaceName,
- interfaceName, interfaceName,
- prerawRules.String(), fwmarkHex, fwmarkHex)
+`, nftTable, endpointRules.String(), tunnelOutputRules.String(), tunnelInputRules.String(),
+ forwardRules.String(), prerawRules.String(), fwmarkHex, fwmarkHex)
+ if replace {
+ if err := nftFlush(); err != nil && !isNftNotFound(err) {
+ return err
+ }
+ }
if err := nftApply(rules); err != nil {
return err
}
- f.killSwitchEnabled = true
return nil
}
-// AddKillSwitchTunnel is a no-op on linux. nftables rules built by
-// EnableKillSwitch already key on the WG interface name; multi-tunnel
-// support would need a real implementation but single-tunnel matches
-// today's helper behaviour.
-func (f *LinuxFirewall) AddKillSwitchTunnel(string, []string) error { return nil }
+// AddKillSwitchTunnel rebuilds the nftables ruleset with the new tunnel while
+// preserving every existing interface and endpoint permit.
+func (f *LinuxFirewall) AddKillSwitchTunnel(interfaceName string, ifaceAddresses []string, endpoints []string) error {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ if !f.killSwitchEnabled {
+ return nil
+ }
+ if !validIfaceName.MatchString(interfaceName) {
+ return fmt.Errorf("invalid interface name %q", interfaceName)
+ }
+ f.killSwitchTunnels[interfaceName] = append([]string(nil), endpoints...)
+ f.killSwitchAddresses[interfaceName] = append([]string(nil), ifaceAddresses...)
+ return f.applyKillSwitchLocked(true)
+}
-// RemoveKillSwitchTunnel is a no-op on linux for the same reason.
-func (f *LinuxFirewall) RemoveKillSwitchTunnel(string) error { return nil }
+// RemoveKillSwitchTunnel drops only one tunnel's permits and leaves the base
+// blockade plus every surviving tunnel intact.
+func (f *LinuxFirewall) RemoveKillSwitchTunnel(interfaceName string) error {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ if !f.killSwitchEnabled {
+ return nil
+ }
+ delete(f.killSwitchTunnels, interfaceName)
+ delete(f.killSwitchAddresses, interfaceName)
+ return f.applyKillSwitchLocked(true)
+}
// EnableEndpointProtection is a no-op on Linux. The loop class this guards
// against on Windows (userspace wireguard-go re-encrypting its own UDP
@@ -201,6 +227,8 @@ func (f *LinuxFirewall) DisableKillSwitch() error {
// (the previous behaviour) made IsKillSwitchEnabled() report a rule
// that no longer exists.
f.killSwitchEnabled = false
+ f.killSwitchTunnels = make(map[string][]string)
+ f.killSwitchAddresses = make(map[string][]string)
if err := nftFlush(); err != nil {
if isNftNotFound(err) {
return nil
diff --git a/internal/firewall/windows.go b/internal/firewall/windows.go
index 00ce423..fa7933f 100644
--- a/internal/firewall/windows.go
+++ b/internal/firewall/windows.go
@@ -505,6 +505,9 @@ func (f *WindowsFirewall) installTunnelFiltersLocked(interfaceName string, endpo
// transaction for callers (AddKillSwitchTunnel) that arrive after the
// kill switch is already enabled. Caller MUST hold f.mu.
func (f *WindowsFirewall) addTunnelFiltersLocked(interfaceName string, endpoints []string) error {
+ if len(f.tunnelFilterIDs[interfaceName]) > 0 {
+ return nil
+ }
if status := fwpmTransactionBegin0(f.sessionHandle); status != 0 {
return fmt.Errorf("FwpmTransactionBegin0(add-tunnel): 0x%x", status)
}
@@ -512,6 +515,7 @@ func (f *WindowsFirewall) addTunnelFiltersLocked(interfaceName string, endpoints
defer func() {
if !committed {
fwpmTransactionAbort0(f.sessionHandle)
+ delete(f.tunnelFilterIDs, interfaceName)
}
}()
if err := f.installTunnelFiltersLocked(interfaceName, endpoints); err != nil {
@@ -573,9 +577,8 @@ func (f *WindowsFirewall) addEndpointFilterLocked(ip net.IP) (uint64, error) {
// AddKillSwitchTunnel installs Permit-tunnel + Permit-endpoint filters
// for one tunnel into the active kill-switch filter set. No-op if the
// kill switch isn't enabled. Safe to call multiple times for the same
-// name (it will add additional filters; RemoveKillSwitchTunnel removes
-// all of them).
-func (f *WindowsFirewall) AddKillSwitchTunnel(interfaceName string, endpoints []string) error {
+// name (an existing tracked set is left unchanged).
+func (f *WindowsFirewall) AddKillSwitchTunnel(interfaceName string, _ []string, endpoints []string) error {
f.mu.Lock()
defer f.mu.Unlock()
if !f.killSwitchEnabled {
diff --git a/internal/gui/config_watcher.go b/internal/gui/config_watcher.go
index 0a4f607..9f6b941 100644
--- a/internal/gui/config_watcher.go
+++ b/internal/gui/config_watcher.go
@@ -18,14 +18,16 @@ import (
// - config.json → "config_changed" (settings incl. automation rules)
// - the tunnels dir listing → "tunnels_changed" (import / delete / rename)
//
-// A 1 s mtime/listing poll is used (fsnotify isn't a dependency); the
-// latency is imperceptible for these and the cost is a couple of stat
-// calls per second. Reacting to the GUI's own writes is harmless — the
-// frontend re-reads and re-applies the same values idempotently.
+// A 3 s mtime/listing poll is used (fsnotify isn't a dependency); the
+// latency is imperceptible for catching CLI-side edits, and it runs for
+// the whole GUI lifetime including while hidden in the tray — 1 s bought
+// nothing but 3× the stat+readdir churn. Reacting to the GUI's own
+// writes is harmless — the frontend re-reads and re-applies the same
+// values idempotently.
func startConfigWatcher(app *application.App, configPath, tunnelsDir string, done <-chan struct{}, wg *sync.WaitGroup) {
go func() {
defer wg.Done()
- ticker := time.NewTicker(1 * time.Second)
+ ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
fileMtime := func(p string) time.Time {
diff --git a/internal/gui/dock_darwin.go b/internal/gui/dock_darwin.go
index 8a23d54..f4bd12d 100644
--- a/internal/gui/dock_darwin.go
+++ b/internal/gui/dock_darwin.go
@@ -34,7 +34,12 @@ func showDock() {
go func() {
for i := 0; i < 10; i++ {
time.Sleep(200 * time.Millisecond)
- if dockWindow == nil {
+ // dockWindow is assigned once and never cleared, so the nil
+ // check alone can't stop this loop during teardown. Show() on
+ // a destroyed Wails window RE-RUNS window creation, so a retry
+ // landing mid-quit would resurrect the window or deadlock in
+ // InvokeSync — bail out once quit has been initiated.
+ if dockWindow == nil || appQuitting.Load() {
return
}
dockWindow.Show()
diff --git a/internal/gui/dock_other.go b/internal/gui/dock_other.go
index 4fd6fb1..c058d27 100644
--- a/internal/gui/dock_other.go
+++ b/internal/gui/dock_other.go
@@ -2,10 +2,22 @@
package gui
-import "github.com/wailsapp/wails/v3/pkg/application"
+import (
+ "runtime"
+ "sync"
+
+ "github.com/wailsapp/wails/v3/pkg/application"
+)
var dockWindow *application.WebviewWindow
+// showDock is reachable concurrently on Linux — StatusNotifier Activate and
+// dbusmenu events each arrive on their own godbus goroutine, and Wails runs
+// menu callbacks on fresh goroutines. The frameless toggle below must not
+// interleave between two callers or the window can map undecorated with no
+// recovery (GTK caches the decorated hint), so the whole show is serialized.
+var showDockMu sync.Mutex
+
// showDock brings back the main window after close-to-tray. Unlike macOS
// there is no dock icon / activation policy to juggle (and no async retry
// dance) — un-minimise + Show + Focus on the window is the whole job.
@@ -13,12 +25,23 @@ var dockWindow *application.WebviewWindow
// window hidden while minimised would otherwise reappear only in the
// taskbar, still collapsed.
func showDock() {
+ showDockMu.Lock()
+ defer showDockMu.Unlock()
if dockWindow == nil {
return
}
if dockWindow.IsMinimised() {
dockWindow.Restore()
}
+ if runtime.GOOS == "linux" {
+ // labwc/XWayland may forget server-side decorations when a hidden GTK
+ // window is mapped again. Merely setting decorated=true is ineffective
+ // because GTK already caches that value and sends no new WM hint. Toggle
+ // it while the window is hidden so the next map is unambiguously
+ // decorated, without exposing an intermediate frameless frame.
+ dockWindow.SetFrameless(true)
+ dockWindow.SetFrameless(false)
+ }
dockWindow.Show()
dockWindow.Focus()
}
diff --git a/internal/gui/event_bridge.go b/internal/gui/event_bridge.go
index 3f2729e..3b0dfef 100644
--- a/internal/gui/event_bridge.go
+++ b/internal/gui/event_bridge.go
@@ -26,6 +26,9 @@ type eventBridge struct {
// wake, wifi rules, health-check recovery). nil when the bridge runs
// without a history store.
onReconcileHistory func(activeNames []string, rx, tx map[string]int64, reason string)
+ // onQuitRequested terminates the app. Fired for ipc.EventQuit, which
+ // the helper broadcasts when someone runs `wireguide ctl stop`.
+ onQuitRequested func()
mu sync.Mutex
subscribedTo *ipc.Client // tracks which client we're currently subscribed on
@@ -36,12 +39,14 @@ func newEventBridge(
clients *ipc.ClientHolder,
onStatusChange func(activeNames []string, handshakeMap map[string]bool),
onReconcileHistory func(activeNames []string, rx, tx map[string]int64, reason string),
+ onQuitRequested func(),
) *eventBridge {
return &eventBridge{
app: app,
clients: clients,
onStatusChange: onStatusChange,
onReconcileHistory: onReconcileHistory,
+ onQuitRequested: onQuitRequested,
}
}
@@ -163,6 +168,18 @@ func (b *eventBridge) handleEvent(method string, params json.RawMessage) {
} else {
b.app.Event.Emit("auto_connected", payload)
}
+ case ipc.EventQuit:
+ // `wireguide ctl stop` — the user asked for the whole app to go
+ // away. Run the same teardown the tray's Quit item does.
+ //
+ // In a goroutine: we're on the event-stream read loop, and
+ // quitApp's doShutdown makes blocking RPCs back to the helper.
+ // Quitting inline would deadlock — the helper can't answer while
+ // this goroutine is the one that has to read its reply.
+ slog.Info("event bridge: quit requested by CLI")
+ if b.onQuitRequested != nil {
+ go b.onQuitRequested()
+ }
case ipc.EventSettingsChanged:
// A setting was applied through another client (the CLI). Forward
// the changed value so the Settings UI updates its toggle live.
diff --git a/internal/gui/gui.go b/internal/gui/gui.go
index 992e4a5..004ddc1 100644
--- a/internal/gui/gui.go
+++ b/internal/gui/gui.go
@@ -191,7 +191,16 @@ func Run(assetsHandler http.Handler, dataDir string) error {
// listener races with Wails' listener — the window gets destroyed despite
// Cancel. Hooks run sequentially BEFORE listeners, so Cancel() here
// reliably prevents Wails' default close/destroy behavior.
- win.RegisterHook(events.Common.WindowClosing, func(event *application.WindowEvent) {
+ // Linux must intercept the native delete event itself. Wails maps
+ // Linux.WindowDeleteEvent to Common.WindowClosing asynchronously; hooking
+ // only the mapped event allows the default Common.WindowClosing listener to
+ // destroy the window before close-to-tray can cancel it. Windows and macOS
+ // expose a cancellable Common.WindowClosing event directly.
+ closingEvent := events.Common.WindowClosing
+ if runtime.GOOS == "linux" {
+ closingEvent = events.Linux.WindowDeleteEvent
+ }
+ win.RegisterHook(closingEvent, func(event *application.WindowEvent) {
event.Cancel()
win.Hide()
hideDock()
@@ -219,18 +228,20 @@ func Run(assetsHandler http.Handler, dataDir string) error {
tray.SetIcon(trayOffIconDark)
} else {
tray.SetLabel("WireGuide")
- // Windows also needs an explicit SetIcon at init or Wails falls
- // back to the embedded white-W template — setIconState only
- // runs on connect/disconnect transitions, so a fresh launch
- // with no tunnel previously never showed our rounded icon.
- if runtime.GOOS == "windows" && len(trayOffIconWindows) > 0 {
+ // Windows and Linux need an explicit icon at startup. Without it,
+ // Linux StatusNotifier hosts display their own fallback icon and
+ // Windows falls back to Wails' embedded template. setIconState only
+ // runs on connection transitions, so it cannot initialise the icon.
+ if (runtime.GOOS == "windows" || runtime.GOOS == "linux") && len(trayOffIconWindows) > 0 {
tray.SetIcon(trayOffIconWindows)
}
- // Windows convention: left-click on a tray icon is the primary
- // action — show the main window (WireGuard-for-Windows, Discord,
- // Slack all behave this way); the menu stays on right-click.
+ // Windows convention: left-click is the primary action and shows
+ // the main window; the context menu remains on right-click.
// Registered only here: on macOS any click opens the NSStatusItem
- // menu natively, and an OnClick handler would fight it.
+ // menu natively, and an OnClick handler would fight it. On Linux,
+ // Wails' StatusNotifier fires the click handler for the dbusmenu
+ // "opened" event too, so registering it would raise the window on
+ // every right-click; Linux users get the "Show Window" menu item.
if runtime.GOOS == "windows" {
tray.OnClick(showDock)
}
@@ -296,7 +307,7 @@ func Run(assetsHandler http.Handler, dataDir string) error {
// process restarts. The health monitor swaps the client in the holder.
// Pass the tray's cheap icon-update hook — NOT the full menu rebuild —
// so the 1 Hz status stream doesn't trigger IPC round-trips on every event.
- bridge := newEventBridge(app, clients, trayMgr.setIconState, tunnelService.ReconcileHistoryFromStatus)
+ bridge := newEventBridge(app, clients, trayMgr.setIconState, tunnelService.ReconcileHistoryFromStatus, trayMgr.quitApp)
bridge.start()
// Push the persisted log level to the helper now that the event
diff --git a/internal/gui/helper_lifecycle.go b/internal/gui/helper_lifecycle.go
index 782e325..9630a9b 100644
--- a/internal/gui/helper_lifecycle.go
+++ b/internal/gui/helper_lifecycle.go
@@ -24,8 +24,10 @@ func ensureHelper(ctx context.Context, dataDir string) (*ipc.Client, error) {
forceReinstall := false
args := elevate.Args{
SocketPath: addr,
- SocketUID: os.Getuid(),
- DataDir: dataDir,
+ // -1 on Windows — the SID below is the owner identity there.
+ SocketUID: os.Getuid(),
+ SocketSID: elevate.CurrentUserSID(),
+ DataDir: dataDir,
}
// Try an existing helper first (survives GUI restarts).
diff --git a/internal/gui/ssid_reporter.go b/internal/gui/ssid_reporter.go
index 7edf272..9779df1 100644
--- a/internal/gui/ssid_reporter.go
+++ b/internal/gui/ssid_reporter.go
@@ -40,7 +40,9 @@ func startSSIDReporter(clients *ipc.ClientHolder, done <-chan struct{}, wg *sync
// otherwise leave the helper SSID-less until the next roam —
// observed live as `ssid=(none)` right after a dev upgrade.
// Retry briefly until a real value appears.
+ wg.Add(1)
go func() {
+ defer wg.Done()
for _, d := range []time.Duration{2 * time.Second, 5 * time.Second, 15 * time.Second} {
select {
case <-done:
diff --git a/internal/gui/tray.go b/internal/gui/tray.go
index e907261..dc6ac2e 100644
--- a/internal/gui/tray.go
+++ b/internal/gui/tray.go
@@ -32,6 +32,12 @@ import (
// when the observer verifiably reports a light menu bar. We never call
// SetTemplateIcon, so the Wails v3 sticky-isTemplateIcon bug (once set,
// later SetIcon calls render monochrome) is never triggered.
+// appQuitting latches once the user picks Quit from the tray. Package-level
+// (unlike trayManager.quitting, which is per-instance) so the window-show
+// retry goroutines in the dock_* files can observe teardown without holding
+// a trayManager reference.
+var appQuitting atomic.Bool
+
var (
trayOnIconDark []byte // white W + green dot (dark menu bar)
trayOffIconDark []byte // white W, no badge
@@ -419,6 +425,32 @@ func (t *trayManager) startAppearanceWatch() {
})
}
+// quitApp is the single, platform-independent app teardown: stop the tray
+// rebuild machinery, run doShutdown (which disconnects tunnels and stops the
+// helper), then terminate. It backs both the tray's Quit item and
+// `wireguide ctl stop`, which reaches it via the helper's EventQuit
+// broadcast — so both routes leave exactly the same state behind.
+//
+// Safe to call more than once: doShutdown is guarded by a sync.Once and the
+// quit flags are idempotent.
+func (t *trayManager) quitApp() {
+ // Latch the quit flag BEFORE Destroy so any in-flight debounce
+ // timer that fires between here and the AfterFunc cancel will
+ // see it and bail. Then stop the timer explicitly to prevent
+ // the goroutine from running at all in the common case.
+ t.quitting.Store(true)
+ appQuitting.Store(true)
+ t.mu.Lock()
+ if t.rebuildTimer != nil {
+ t.rebuildTimer.Stop()
+ t.rebuildTimer = nil
+ }
+ t.mu.Unlock()
+ t.doShutdown()
+ t.tray.Destroy()
+ t.app.Quit()
+}
+
func newTrayManager(app *application.App, win *application.WebviewWindow, tray *application.SystemTray, svc *wgapp.TunnelService, doShutdown func()) *trayManager {
t := &trayManager{
app: app,
@@ -485,10 +517,9 @@ func (t *trayManager) setIconState(activeNames []string, handshakeMap map[string
if activeChanged {
onIcon, offIcon := t.macIcons()
- if runtime.GOOS == "windows" && len(trayOnIconWindows) > 0 {
- // Use the rounded-red app-icon variants on Windows so the
- // tray icon actually stands out against a light system-tray
- // background and isn't framed by a white square.
+ if (runtime.GOOS == "windows" || runtime.GOOS == "linux") && len(trayOnIconWindows) > 0 {
+ // Use the rounded-red app-icon variants on Windows and Linux so
+ // the tray icon stands out against light system-tray backgrounds.
onIcon, offIcon = trayOnIconWindows, trayOffIconWindows
}
if anyConnected {
@@ -496,7 +527,7 @@ func (t *trayManager) setIconState(activeNames []string, handshakeMap map[string
tooltip := "WireGuide — " + strings.Join(activeNames, ", ")
t.tray.SetTooltip(tooltip)
} else {
- if runtime.GOOS == "darwin" || (runtime.GOOS == "windows" && len(offIcon) > 0) {
+ if runtime.GOOS == "darwin" || ((runtime.GOOS == "windows" || runtime.GOOS == "linux") && len(offIcon) > 0) {
t.tray.SetIcon(offIcon)
}
t.tray.SetTooltip("WireGuide")
@@ -627,20 +658,7 @@ func (t *trayManager) rebuildMenu() {
})
m.AddSeparator()
m.Add("Quit").OnClick(func(ctx *application.Context) {
- // Latch the quit flag BEFORE Destroy so any in-flight debounce
- // timer that fires between here and the AfterFunc cancel will
- // see it and bail. Then stop the timer explicitly to prevent
- // the goroutine from running at all in the common case.
- t.quitting.Store(true)
- t.mu.Lock()
- if t.rebuildTimer != nil {
- t.rebuildTimer.Stop()
- t.rebuildTimer = nil
- }
- t.mu.Unlock()
- t.doShutdown()
- t.tray.Destroy()
- t.app.Quit()
+ t.quitApp()
})
if created || runtime.GOOS == "windows" {
// Windows must go through SetMenu on EVERY rebuild: the tray popup
diff --git a/internal/helper/events.go b/internal/helper/events.go
index 1736193..903663f 100644
--- a/internal/helper/events.go
+++ b/internal/helper/events.go
@@ -115,8 +115,10 @@ func (h *Helper) statusDTO() ipc.ConnectionStatus {
result.LatencyMs = lat
}
- // Include lightweight per-tunnel info (name + state + handshake
- // presence + latency) so the frontend can show correct badges.
+ // Include complete per-tunnel status. The same DTO backs both the
+ // frontend's selected-tunnel statistics and `ctl status --json`; copying
+ // only name/state/handshake silently zeroed interface, duration, traffic,
+ // and endpoint whenever more than one tunnel was active.
// Pre-allocate to avoid the latent-bug of `append` aliasing a
// slice on the manager-returned struct.
if len(allStats) > 1 {
@@ -125,11 +127,9 @@ func (h *Helper) statusDTO() ipc.ConnectionStatus {
if ts == nil {
continue
}
- sub := domain.ConnectionStatus{
- State: ts.State,
- TunnelName: ts.TunnelName,
- LastHandshake: ts.LastHandshake,
- }
+ sub := *ts
+ sub.ActiveTunnels = nil
+ sub.Tunnels = nil
if lat, ok := latencies[ts.TunnelName]; ok {
sub.LatencyMs = lat
}
@@ -151,6 +151,13 @@ func (h *Helper) statusDTO() ipc.ConnectionStatus {
// and risks chewing through the 30s tick.
func (h *Helper) latencyLoop() {
const tickInterval = 30 * time.Second
+ // With no GUI subscribed, nobody consumes 30s-fresh values — only an
+ // occasional `ctl status` reads the cache. The helper deliberately
+ // outlives the GUI while a tunnel is up (wg-quick semantics), so
+ // without this the headless state would spawn ping subprocesses every
+ // 30s forever. Probes continue at a slow cadence rather than stopping
+ // so ctl status latency stays approximately fresh.
+ const idleTickInterval = 5 * time.Minute
// Sleep briefly on startup so we don't ping immediately during
// helper boot, when the tunnel state is still settling.
select {
@@ -162,10 +169,14 @@ func (h *Helper) latencyLoop() {
for {
h.measureLatencies()
+ interval := tickInterval
+ if !h.server.HasSubscribers() {
+ interval = idleTickInterval
+ }
select {
case <-h.done:
return
- case <-time.After(tickInterval):
+ case <-time.After(interval):
}
}
}
@@ -276,7 +287,15 @@ func (h *Helper) runOneLatencyProbe(t latencyTask) {
h.latencyMu.Lock()
h.latencyByTunnel[t.tunnelName] = latency
h.latencyMu.Unlock()
- slog.Info("endpoint latency measured",
+ // Debug, not Info: this fires per connected tunnel every 30s, and
+ // launchd appends StandardOutPath forever with no rotation — at Info
+ // it was 95.7% of a 7.7 MB helper log (33,720 of 35,240 lines over
+ // four months). 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 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).
+ slog.Debug("endpoint latency measured",
"tunnel", t.tunnelName, "target", measuredTarget,
"configured_target", t.latencyProbeTarget,
"via_tunnel", viaTunnel,
@@ -313,7 +332,10 @@ func (h *Helper) eventLoop() {
}
if !bytes.Equal(lastJSON, currentJSON) {
lastJSON = currentJSON
- h.server.Broadcast(ipc.EventStatus, status)
+ // Pass the bytes the diff already produced — RawMessage
+ // embeds as-is, avoiding a second marshal of the same
+ // struct inside Broadcast at 1 Hz.
+ h.server.Broadcast(ipc.EventStatus, json.RawMessage(currentJSON))
}
}
}
diff --git a/internal/helper/handlers.go b/internal/helper/handlers.go
index 356ec94..f666a1e 100644
--- a/internal/helper/handlers.go
+++ b/internal/helper/handlers.go
@@ -24,6 +24,7 @@ func (h *Helper) registerHandlers() {
h.server.Handle(ipc.MethodPing, h.handlePing)
h.server.Handle(ipc.MethodShutdown, h.handleShutdown)
h.server.Handle(ipc.MethodForceShutdown, h.handleForceShutdown)
+ h.server.Handle(ipc.MethodRequestQuit, h.handleRequestQuit)
h.server.Handle(ipc.MethodSetLogLevel, h.handleSetLogLevel)
h.server.Handle(ipc.MethodConnect, h.handleConnect)
h.server.Handle(ipc.MethodDisconnect, h.handleDisconnect)
@@ -64,6 +65,36 @@ func (h *Helper) handleShutdown(params json.RawMessage) (interface{}, error) {
return ipc.Empty{}, nil
}
+// handleRequestQuit implements `wireguide ctl stop` — bring the whole app
+// down, GUI included.
+//
+// Two cases, because "stop" has to mean the same thing either way:
+//
+// - A GUI is attached: broadcast EventQuit and let the GUI terminate
+// itself. Its normal quit path disconnects tunnels and then stops us.
+// We must NOT shut down directly here — the GUI's health monitor would
+// see the helper vanish and respawn it, which on macOS means an admin
+// password prompt seconds after the user asked everything to stop.
+//
+// - No GUI attached (helper running solo): nothing will relay the quit,
+// so shut ourselves down on the same grace-free path as MethodShutdown.
+//
+// Reports which branch ran so `ctl stop` can tell the user whether it
+// stopped an app or just a stray helper.
+func (h *Helper) handleRequestQuit(params json.RawMessage) (interface{}, error) {
+ if h.server.HasControlConn() {
+ slog.Info("quit requested via CLI — asking the GUI to terminate")
+ h.server.Broadcast(ipc.EventQuit, ipc.Empty{})
+ return ipc.RequestQuitResponse{NotifiedGUI: true}, nil
+ }
+ slog.Info("quit requested via CLI — no GUI attached, shutting the helper down")
+ go func() {
+ time.Sleep(100 * time.Millisecond) // let the response go out first
+ h.shutdown()
+ }()
+ return ipc.RequestQuitResponse{NotifiedGUI: false}, nil
+}
+
// handleForceShutdown bypasses graceful teardown and exits as fast as
// possible. Used by the GUI's upgrade path when MethodShutdown failed
// (wedged handler, stale state).
@@ -93,11 +124,18 @@ func (h *Helper) handleForceShutdown(params json.RawMessage) (interface{}, error
if err := h.firewall.Cleanup(); err != nil {
slog.Warn("ForceShutdown: firewall.Cleanup failed", "error", err)
}
+ // The utun devices die with this process, but networksetup DNS
+ // overrides persist in SystemConfiguration — restore them or a
+ // helper upgrade while connected leaves tunnel DNS behind
+ // (issue #34). Best-effort under the same deadline.
+ if h.manager != nil {
+ h.manager.RestoreDNSBestEffort()
+ }
}()
select {
case <-done:
- case <-time.After(1 * time.Second):
- slog.Warn("ForceShutdown: firewall.Cleanup timed out; exiting anyway")
+ case <-time.After(3 * time.Second):
+ slog.Warn("ForceShutdown: cleanup timed out; exiting anyway")
}
os.Exit(0)
}()
@@ -178,6 +216,19 @@ func (h *Helper) handleRename(params json.RawMessage) (interface{}, error) {
// monitor sees the config during Connect), then rolls back on failure.
// Caller MUST hold h.connectMu.
func (h *Helper) doConnectHeld(cfg *domain.WireGuardConfig) error {
+ // A firewall cannot permit a not-yet-created tunnel interface, and a
+ // pre-enabled base kill switch deliberately blocks DNS and WireGuard UDP.
+ // This applies equally to nftables, PF, and WFP (and is fatal before Engine
+ // creation when a peer endpoint is a hostname). Suspend it for the bounded
+ // Connect transaction, then rebuild the complete permit set from every
+ // active tunnel. A failed Connect restores the previous blockade too.
+ restoreKillSwitch := h.firewall.IsKillSwitchEnabled()
+ if restoreKillSwitch {
+ if err := h.firewall.DisableKillSwitch(); err != nil {
+ return fmt.Errorf("temporarily disable kill switch for connect: %w", err)
+ }
+ }
+
h.mu.Lock()
prevCfgs := h.copyActiveCfgs()
h.activeCfgs[cfg.Name] = cfg
@@ -190,8 +241,80 @@ func (h *Helper) doConnectHeld(cfg *domain.WireGuardConfig) error {
h.activeCfgs[cfg.Name] = prev
}
h.mu.Unlock()
+ if restoreKillSwitch {
+ if restoreErr := h.enableKillSwitchForActiveTunnels(); restoreErr != nil {
+ return fmt.Errorf("connect: %v; restore kill switch: %w", err, restoreErr)
+ }
+ }
return err
}
+
+ if restoreKillSwitch {
+ if err := h.enableKillSwitchForActiveTunnels(); err != nil {
+ // Do not report a successful connection while the user's requested
+ // blockade is absent. Roll the new tunnel back and make one last
+ // attempt to restore the pre-connect kill-switch state.
+ disconnectErr := h.manager.DisconnectTunnel(cfg.Name)
+ h.mu.Lock()
+ delete(h.activeCfgs, cfg.Name)
+ if prev, ok := prevCfgs[cfg.Name]; ok {
+ h.activeCfgs[cfg.Name] = prev
+ }
+ h.mu.Unlock()
+ restoreErr := h.enableKillSwitchForActiveTunnels()
+ h.maybeArmShutdownAfterTeardown("connect rolled back, no GUI attached")
+ return fmt.Errorf("restore kill switch after connect: %v (disconnect rollback: %v; blockade restore: %v)",
+ err, disconnectErr, restoreErr)
+ }
+ }
+ return nil
+}
+
+// enableKillSwitchForActiveTunnels atomically rebuilds the kill-switch model
+// from manager state. EnableKillSwitch installs the base set and the first
+// tunnel, then AddKillSwitchTunnel folds in every remaining interface. With no
+// active tunnel it intentionally installs the base blockade only.
+//
+// Caller MUST hold h.connectMu so Connect/Disconnect cannot invalidate the
+// status snapshot while the firewall rules are being rebuilt.
+func (h *Helper) enableKillSwitchForActiveTunnels() error {
+ type activeTunnel struct {
+ name string
+ interfaceName string
+ addresses []string
+ }
+ h.mu.Lock()
+ activeCfgs := h.copyActiveCfgs()
+ h.mu.Unlock()
+ var active []activeTunnel
+ for _, st := range h.manager.AllStatuses() {
+ if st == nil || st.InterfaceName == "" {
+ continue
+ }
+ var addresses []string
+ if cfg := activeCfgs[st.TunnelName]; cfg != nil {
+ addresses = append([]string(nil), cfg.Interface.Address...)
+ }
+ active = append(active, activeTunnel{name: st.TunnelName, interfaceName: st.InterfaceName, addresses: addresses})
+ }
+
+ if len(active) == 0 {
+ return h.firewall.EnableKillSwitch("", nil, nil)
+ }
+
+ // Endpoints are already resolved by Engine before routes are installed.
+ // Supplying the union to each tunnel is conservative and prevents a peer
+ // belonging to another simultaneously active tunnel from being fenced out.
+ endpoints := h.manager.ResolvedEndpoints()
+ if err := h.firewall.EnableKillSwitch(active[0].interfaceName, active[0].addresses, endpoints); err != nil {
+ return err
+ }
+ for _, tunnel := range active[1:] {
+ if err := h.firewall.AddKillSwitchTunnel(tunnel.interfaceName, tunnel.addresses, endpoints); err != nil {
+ _ = h.firewall.DisableKillSwitch()
+ return fmt.Errorf("add tunnel %q to kill switch: %w", tunnel.name, err)
+ }
+ }
return nil
}
@@ -281,14 +404,14 @@ func (h *Helper) applyPostConnectFirewall(cfg *domain.WireGuardConfig) {
// because the only "permit tunnel" filter still references whatever
// LUID was current at Enable time.
if h.firewall.IsKillSwitchEnabled() {
- status := h.manager.Status()
+ status := h.manager.StatusFor(cfg.Name)
ifaceName := ""
if status != nil {
ifaceName = status.InterfaceName
}
if ifaceName != "" {
eps := h.manager.ResolvedEndpoints()
- if err := h.firewall.AddKillSwitchTunnel(ifaceName, eps); err != nil {
+ if err := h.firewall.AddKillSwitchTunnel(ifaceName, cfg.Interface.Address, eps); err != nil {
slog.Warn("AddKillSwitchTunnel after connect failed", "error", err)
}
}
@@ -393,6 +516,7 @@ func (h *Helper) handleDisconnect(params json.RawMessage) (interface{}, error) {
"interface", iface, "error", err)
}
}
+ h.maybeArmShutdownAfterTeardown("tunnel disconnected, no GUI attached")
return ipc.Empty{}, nil
}
@@ -413,6 +537,9 @@ func (h *Helper) handleActiveTunnels(params json.RawMessage) (interface{}, error
}
func (h *Helper) handleSetKillSwitch(params json.RawMessage) (interface{}, error) {
+ h.connectMu.Lock()
+ defer h.connectMu.Unlock()
+
var req ipc.KillSwitchRequest
if err := json.Unmarshal(params, &req); err != nil {
return nil, err
@@ -426,27 +553,7 @@ func (h *Helper) handleSetKillSwitch(params json.RawMessage) (interface{}, error
// "the kill switch is on the moment I flip the toggle" instead
// of the old "kill switch can only be enabled while connected"
// gate that surprised users into a half-state.
- var (
- ifaceName string
- endpoints []string
- ifaceAddresses []string
- )
- if status := h.manager.Status(); status != nil {
- ifaceName = status.InterfaceName
- }
- if ifaceName != "" {
- // Tunnel is up — bundle its permits into the initial install.
- // Pre-resolved endpoints come from NewEngine; doing DNS now
- // would either fail (kill switch is about to block) or loop
- // back through the tunnel we're about to fence in.
- endpoints = h.manager.ResolvedEndpoints()
- h.mu.Lock()
- for _, cfg := range h.activeCfgs {
- ifaceAddresses = append(ifaceAddresses, cfg.Interface.Address...)
- }
- h.mu.Unlock()
- }
- if err := h.firewall.EnableKillSwitch(ifaceName, ifaceAddresses, endpoints); err != nil {
+ if err := h.enableKillSwitchForActiveTunnels(); err != nil {
return nil, err
}
} else {
diff --git a/internal/helper/helper.go b/internal/helper/helper.go
index 45fbbc2..8b5b8e6 100644
--- a/internal/helper/helper.go
+++ b/internal/helper/helper.go
@@ -198,9 +198,23 @@ type Helper struct {
// Run starts the helper listening on addr. Blocks until shutdown.
// ownerUID: UID to chown socket to (Unix only, use -1 on Windows).
+// ownerSID: spawning user's SID (Windows only, "" on Unix) — scopes the
+// pipe ACL and per-connection peer checks to that user (issue #20).
// dataDir: persistent data dir for crash recovery state.
-func Run(addr string, ownerUID int, dataDir string) error {
- listener, err := ipc.Listen(addr, ownerUID)
+func Run(addr string, ownerUID int, ownerSID, dataDir string) error {
+ // wireguard-go allocates sizeable per-Device transient buffer pools. With
+ // the runtime default GOGC=100, repeated connect/disconnect on a long-lived
+ // helper retained hundreds of MiB of reclaimable heap before GC caught up
+ // (30 cycles on linux/arm64: ~118 MiB -> ~283 MiB). GOGC=50 kept the same
+ // workload near ~100 MiB with a modest CPU increase (~2.14s -> ~2.34s),
+ // while GOGC=20 bought little more memory at a much higher CPU cost.
+ // Respect an explicit administrator-provided GOGC override.
+ if _, explicitlyConfigured := os.LookupEnv("GOGC"); !explicitlyConfigured {
+ previousGCPercent := debug.SetGCPercent(50)
+ defer debug.SetGCPercent(previousGCPercent)
+ }
+
+ listener, err := ipc.Listen(addr, ownerUID, ownerSID)
if err != nil {
return fmt.Errorf("listen %s: %w", addr, err)
}
@@ -215,7 +229,7 @@ func Run(addr string, ownerUID int, dataDir string) error {
manager.SetEndpointProtector(fw)
h := &Helper{
- server: ipc.NewServer(listener, ownerUID),
+ server: ipc.NewServer(listener, ownerUID).WithOwnerSID(ownerSID),
manager: manager,
firewall: fw,
activeCfgs: make(map[string]*domain.WireGuardConfig),
@@ -271,23 +285,24 @@ func Run(addr string, ownerUID int, dataDir string) error {
// Register RPC handlers
h.registerHandlers()
- // Grace-window shutdown on GUI disconnect — only when NOT running as a
- // LaunchDaemon. When the daemon plist has KeepAlive=true, launchd
- // handles restarts; the helper should stay alive even when no GUI is
- // connected (so the next GUI launch connects instantly without a
- // password prompt). In osascript/dev mode, the helper still shuts down
- // after the grace window to avoid orphan processes.
- if !isDaemon() {
- h.server.OnConnect(h.cancelShutdownTimer)
- h.server.OnDisconnect(h.startShutdownTimer)
- // Arm the startup grace window now: a helper that never receives
- // a GUI connection must not run forever (see startupGrace). The
- // first OnConnect cancels it; the fire-time active-tunnel check
- // keeps a crash-recovered tunnel alive even with no GUI.
- h.armShutdownTimer(startupGrace, "startup, no GUI connected yet")
- } else {
- slog.Info("running as LaunchDaemon — shutdown grace disabled")
- }
+ // Grace-window shutdown on GUI disconnect. This applies to EVERY launch
+ // mode, LaunchDaemon included: a running GUI is the user's statement of
+ // intent that WireGuide should be active, so a helper with no GUI (and
+ // no active tunnel) has no reason to exist. The LaunchDaemon plist sets
+ // RunAtLoad=false precisely so the boot path never produces an
+ // invisible root helper; this guard is the runtime half of the same
+ // rule, covering the case where the GUI dies without a clean Shutdown.
+ //
+ // Users who want WireGuide up from boot enable auto_start, which
+ // installs the GUI LaunchAgent — the GUI then spawns the helper on the
+ // normal path.
+ h.server.OnConnect(h.cancelShutdownTimer)
+ h.server.OnDisconnect(h.startShutdownTimer)
+ // Arm the startup grace window now: a helper that never receives
+ // a GUI connection must not run forever (see startupGrace). The
+ // first OnConnect cancels it; the fire-time active-tunnel check
+ // keeps a crash-recovered tunnel alive even with no GUI.
+ h.armShutdownTimer(startupGrace, "startup, no GUI connected yet")
// Start event emitter (diff loop)
h.goSafe("eventLoop", h.eventLoop)
@@ -517,30 +532,67 @@ func (h *Helper) armShutdownTimer(grace time.Duration, reason string) {
active = h.manager.ActiveTunnel()
}
+ h.mu.Lock()
+ defer h.mu.Unlock()
+
if active != "" {
slog.Info("tunnel is active — helper stays alive (wg-quick semantics)",
"reason", reason, "active_tunnel", active)
+ // A previously armed window (e.g. the startup grace) is obsolete
+ // now that a tunnel is up — stop it rather than leaving it to fire
+ // into a transient not-connected instant later.
+ if h.shutdownTimer != nil {
+ h.shutdownTimer.Stop()
+ h.shutdownTimer = nil
+ }
return
}
slog.Info("no active tunnel — starting shutdown grace window",
"reason", reason, "grace", grace)
- h.mu.Lock()
- defer h.mu.Unlock()
if h.shutdownTimer != nil {
h.shutdownTimer.Stop()
}
- h.shutdownTimer = time.AfterFunc(grace, func() {
+ var t *time.Timer
+ t = time.AfterFunc(grace, func() {
+ // Timer.Stop() cannot cancel a callback that has already started,
+ // so re-check under the lock that we are still the current timer —
+ // otherwise a cancel racing with the fire still shuts the helper
+ // down right after a GUI attached.
+ h.mu.Lock()
+ current := h.shutdownTimer
+ h.mu.Unlock()
+ if current != t {
+ return
+ }
// Double-check at fire time: a tunnel may have been activated between
// timer start and fire (e.g., reconnect monitor brought it back up).
- if t := h.manager.ActiveTunnel(); t != "" {
- slog.Info("shutdown timer fired but tunnel is now active — aborting shutdown",
- "active_tunnel", t)
- return
+ if h.manager != nil {
+ if tn := h.manager.ActiveTunnel(); tn != "" {
+ slog.Info("shutdown timer fired but tunnel is now active — aborting shutdown",
+ "active_tunnel", tn)
+ return
+ }
}
slog.Info("no reconnect within grace window, shutting down")
h.shutdown()
})
+ h.shutdownTimer = t
+}
+
+// maybeArmShutdownAfterTeardown re-arms the grace window after a tunnel
+// teardown that may have dropped the active count to zero. Transient CLI
+// clients (`ctl disconnect`, wifi-rule evaluation) never fire the server's
+// OnDisconnect, so without this a helper whose GUI already quit — kept alive
+// only by its active tunnel — would lose that tunnel and then live forever:
+// no GUI, no tunnel, no timer. armShutdownTimer's own active-tunnel guard
+// makes this a no-op while any tunnel is still up, and a GUI that IS attached
+// keeps its normal lifecycle (its later disconnect arms the window).
+func (h *Helper) maybeArmShutdownAfterTeardown(reason string) {
+ if h.server.HasControlConn() {
+ return
+ }
+ h.armShutdownTimer(shutdownGrace, reason)
}
// cancelShutdownTimer aborts a pending grace-window shutdown. Called when the
@@ -570,12 +622,6 @@ func (h *Helper) shutdown() {
h.server.Shutdown()
}
-// isDaemon returns true when the helper was started by launchd (LaunchDaemon).
-// launchd always sets the process's parent PID to 1 (init/launchd).
-func isDaemon() bool {
- return os.Getppid() == 1
-}
-
// suspendFirewall saves the current firewall state and disables all firewall
// rules. Called by the reconnect monitor before Disconnect so that old pf rules
// referencing the previous utun interface name don't block the new connection.
diff --git a/internal/helper/wifi_rules.go b/internal/helper/wifi_rules.go
index e745eb7..de8e29a 100644
--- a/internal/helper/wifi_rules.go
+++ b/internal/helper/wifi_rules.go
@@ -255,4 +255,5 @@ func (h *Helper) disconnectAutoManaged(name string) {
h.latencyMu.Lock()
delete(h.latencyByTunnel, name)
h.latencyMu.Unlock()
+ h.maybeArmShutdownAfterTeardown("rule-driven disconnect, no GUI attached")
}
diff --git a/internal/ipc/client.go b/internal/ipc/client.go
index 958832b..32ec10a 100644
--- a/internal/ipc/client.go
+++ b/internal/ipc/client.go
@@ -46,6 +46,11 @@ type Client struct {
pendingMu sync.Mutex
pending map[uint64]chan *Response
+ // transient marks every request this client sends with
+ // Request.Transient, keeping the helper from treating it as a control
+ // connection. Set by NewTransientClient; see Request.Transient.
+ transient bool
+
// Lifecycle
closeOnce sync.Once
closed chan struct{}
@@ -53,7 +58,23 @@ type Client struct {
// NewClient creates a client connected to addr. It performs an initial Ping
// to verify the helper is responsive and that the protocol version matches.
+//
+// The resulting client counts as a control connection: while it is attached
+// the helper's shutdown grace window stays cancelled. Use it for the GUI —
+// the process whose presence means "the user wants WireGuide running".
func NewClient(addr string) (*Client, error) {
+ return newClient(addr, false)
+}
+
+// NewTransientClient is NewClient for short-lived callers — the `wireguide
+// ctl` CLI. Its requests carry Request.Transient, so the helper does not
+// treat the connection as a control connection and the client's exit does
+// not re-arm the shutdown grace window. See Request.Transient.
+func NewTransientClient(addr string) (*Client, error) {
+ return newClient(addr, true)
+}
+
+func newClient(addr string, transient bool) (*Client, error) {
conn, err := Dial(addr)
if err != nil {
return nil, fmt.Errorf("dial: %w", err)
@@ -64,6 +85,10 @@ func NewClient(addr string) (*Client, error) {
controlConn: conn,
pending: make(map[uint64]chan *Response),
closed: make(chan struct{}),
+ // Must be set before the initial Ping below: that Ping is the very
+ // request the server inspects to decide whether this connection is
+ // a control connection.
+ transient: transient,
}
go c.readLoop()
@@ -136,6 +161,7 @@ func (c *Client) CallWithContext(ctx context.Context, method string, params inte
if err != nil {
return err
}
+ req.Transient = c.transient
respCh := make(chan *Response, 1)
c.pendingMu.Lock()
diff --git a/internal/ipc/ipc_test.go b/internal/ipc/ipc_test.go
index 7fd42ff..209f6a2 100644
--- a/internal/ipc/ipc_test.go
+++ b/internal/ipc/ipc_test.go
@@ -13,7 +13,10 @@ func testSocketPath(t *testing.T) string {
if runtime.GOOS == "windows" {
return `\\.\pipe\wireguide-test-` + t.Name()
}
- return filepath.Join(os.TempDir(), "wireguide-test-"+t.Name()+".sock")
+ // Listen deliberately rejects sockets placed directly in a directory
+ // owned by another UID. /tmp is normally root-owned on Linux, so give
+ // each test a private, test-user-owned parent directory instead.
+ return filepath.Join(t.TempDir(), "wireguide.sock")
}
// registerTestPing registers a Helper.Ping handler that returns the current
@@ -26,7 +29,7 @@ func registerTestPing(s *Server) {
func TestClientServerRPC(t *testing.T) {
addr := testSocketPath(t)
- listener, err := Listen(addr, -1)
+ listener, err := Listen(addr, -1, "")
if err != nil {
t.Fatalf("Listen: %v", err)
}
@@ -62,7 +65,7 @@ func TestClientServerRPC(t *testing.T) {
func TestEventBroadcast(t *testing.T) {
addr := testSocketPath(t)
- listener, err := Listen(addr, -1)
+ listener, err := Listen(addr, -1, "")
if err != nil {
t.Fatalf("Listen: %v", err)
}
@@ -109,7 +112,7 @@ func TestEventBroadcast(t *testing.T) {
func TestMethodNotFound(t *testing.T) {
addr := testSocketPath(t)
- listener, err := Listen(addr, -1)
+ listener, err := Listen(addr, -1, "")
if err != nil {
t.Fatalf("Listen: %v", err)
}
diff --git a/internal/ipc/peercred_unix.go b/internal/ipc/peercred_unix.go
index 397c7e0..4c09875 100644
--- a/internal/ipc/peercred_unix.go
+++ b/internal/ipc/peercred_unix.go
@@ -43,11 +43,13 @@ func getPeerCredFromFD(fd uintptr) (uid uint32, pid int32, err error) {
return getPeerCredPlatform(fd)
}
-// verifyPeerUID checks that the peer's UID matches the expected owner.
-// Returns nil if the check passes or if peer credential retrieval is not
-// supported (fail-open would be worse, but on Unix we always have one of
+// verifyPeer checks that the peer's UID matches the expected owner.
+// expectedSID is Windows-only and ignored on Unix. Returns nil if the
+// check passes or if peer credential retrieval is not supported
+// (fail-open would be worse, but on Unix we always have one of
// SO_PEERCRED or Getpeereid, so this path should not be hit).
-func verifyPeerUID(conn net.Conn, expectedUID int) error {
+func verifyPeer(conn net.Conn, expectedUID int, expectedSID string) error {
+ _ = expectedSID
if expectedUID < 0 {
// No owner restriction requested (e.g. test mode).
return nil
diff --git a/internal/ipc/peercred_windows.go b/internal/ipc/peercred_windows.go
index 14c38a0..b99e5c0 100644
--- a/internal/ipc/peercred_windows.go
+++ b/internal/ipc/peercred_windows.go
@@ -4,28 +4,75 @@ package ipc
import (
"errors"
+ "fmt"
"net"
+
+ "golang.org/x/sys/windows"
)
-// getPeerCredential is a no-op on Windows. Access control is enforced by the
-// SDDL on the named pipe (see transport_windows.go), so peer credential
-// checking is not needed.
+// getPeerCredential is a no-op on Windows — peer identity is checked by
+// SID in verifyPeer below, not by UID.
func getPeerCredential(conn net.Conn) (uid uint32, pid int32, err error) {
return 0, 0, nil
}
-// verifyPeerUID intentionally fails closed when an explicit UID match is
-// requested. The Windows transport relies on the SDDL applied at pipe
-// creation time (see transport_windows.go) to gate access; a per-
-// connection UID check is not implemented here. Returning success
-// unconditionally would silently grant access on any future caller that
-// passes expectedUID >= 0 expecting enforcement, so we surface the gap
-// instead of failing open.
+// verifyPeer checks that the connecting process's token user matches the
+// expected owner SID (issue #20). The pipe's SDDL is the first gate; this
+// is the per-connection second gate, protecting against ACL regressions
+// and any path that loosens the descriptor.
//
-// expectedUID < 0 means "any peer is fine" — we honour that as a no-op.
-func verifyPeerUID(conn net.Conn, expectedUID int) error {
- if expectedUID < 0 {
+// expectedSID == "" means the helper was spawned without --owner-sid
+// (older GUI or manual start): fall back to the historical behaviour —
+// SDDL-only gating when no UID restriction was requested, fail closed if
+// a caller expected UID enforcement (expectedUID >= 0), because Windows
+// has no UID to enforce.
+func verifyPeer(conn net.Conn, expectedUID int, expectedSID string) error {
+ if expectedSID == "" {
+ if expectedUID < 0 {
+ return nil
+ }
+ return errors.New("verifyPeer: no owner SID configured; per-connection UID check not possible on Windows")
+ }
+
+ want, err := windows.StringToSid(expectedSID)
+ if err != nil {
+ return fmt.Errorf("verifyPeer: invalid expected SID %q: %w", expectedSID, err)
+ }
+
+ fdc, ok := conn.(interface{ Fd() uintptr })
+ if !ok {
+ return errors.New("verifyPeer: pipe connection does not expose a handle")
+ }
+ var pid uint32
+ if err := windows.GetNamedPipeClientProcessId(windows.Handle(fdc.Fd()), &pid); err != nil {
+ return fmt.Errorf("verifyPeer: GetNamedPipeClientProcessId: %w", err)
+ }
+ proc, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
+ if err != nil {
+ return fmt.Errorf("verifyPeer: OpenProcess(%d): %w", pid, err)
+ }
+ defer windows.CloseHandle(proc)
+ var tok windows.Token
+ if err := windows.OpenProcessToken(proc, windows.TOKEN_QUERY, &tok); err != nil {
+ return fmt.Errorf("verifyPeer: OpenProcessToken(%d): %w", pid, err)
+ }
+ defer tok.Close()
+ u, err := tok.GetTokenUser()
+ if err != nil {
+ return fmt.Errorf("verifyPeer: GetTokenUser(%d): %w", pid, err)
+ }
+
+ // The owner's own elevated processes keep the same user SID
+ // (elevation changes group membership, not the user), so an admin
+ // terminal run by the owner still passes. SYSTEM is allowed for
+ // service-context tooling (e.g. the helper health-checking itself).
+ system, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid)
+ if err == nil && u.User.Sid.Equals(system) {
return nil
}
- return errors.New("verifyPeerUID: per-connection UID check not implemented on Windows; rely on pipe SDDL")
+ if !u.User.Sid.Equals(want) {
+ return fmt.Errorf("verifyPeer: pid %d user %s does not match pipe owner %s",
+ pid, u.User.Sid.String(), expectedSID)
+ }
+ return nil
}
diff --git a/internal/ipc/protocol.go b/internal/ipc/protocol.go
index 1cccbe5..ba4d984 100644
--- a/internal/ipc/protocol.go
+++ b/internal/ipc/protocol.go
@@ -1,7 +1,10 @@
// Package ipc provides JSON-RPC 2.0 IPC between GUI and helper processes.
package ipc
-import "encoding/json"
+import (
+ "encoding/json"
+ "fmt"
+)
// Protocol version uses simple major.minor semver:
// - MAJOR bumps when fields are RENAMED or REMOVED, or a method's
@@ -14,14 +17,21 @@ import "encoding/json"
//
// The on-the-wire format is "major.minor" so this stays trivial to
// compare in protocol_test.go.
+// Minor 1 added Request.Transient and Helper.RequestQuit / event.quit.
+// All three are additive: an older helper ignores the Transient field
+// (treating a CLI client as a control connection, i.e. the pre-1.1
+// behaviour) and answers RequestQuit with method-not-found, which the
+// CLI reports as "this helper is too old to stop from the CLI".
const (
ProtocolMajor = 1
- ProtocolMinor = 0
+ ProtocolMinor = 1
)
// ProtocolVersion is the canonical "major.minor" string used in
// PingResponse.Version. Mismatched majors abort the handshake.
-var ProtocolVersion = "1.0"
+// Derived from the constants above so bumping them cannot silently
+// leave the wire string behind.
+var ProtocolVersion = fmt.Sprintf("%d.%d", ProtocolMajor, ProtocolMinor)
// MajorVersionMatches reports whether two "major.minor" version strings
// share the same MAJOR. A missing dot is treated as MAJOR-only
@@ -45,6 +55,19 @@ type Request struct {
ID uint64 `json:"id,omitempty"` // 0 for notifications
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
+
+ // Transient marks a short-lived client (the `wireguide ctl` CLI) that
+ // must NOT be counted as a control connection. Control connections
+ // drive the helper's shutdown grace window: attaching cancels it and
+ // detaching re-arms it. A CLI command connects and exits within
+ // milliseconds, so without this flag every `ctl` invocation would
+ // re-arm the 10s "GUI disconnected" window and cut a GUI-less
+ // helper's remaining life down to 10 seconds — the CLI would be
+ // killing the very helper it just talked to.
+ //
+ // The GUI leaves this false: it is the connection whose presence
+ // legitimately means "the user wants WireGuide running".
+ Transient bool `json:"transient,omitempty"`
}
// Response is a JSON-RPC 2.0 response.
@@ -110,6 +133,15 @@ const (
// current Automation rules against the current network context and
// returns each tunnel's decision WITHOUT connecting/disconnecting.
MethodAutomationPreview = "Automation.Preview"
+ // MethodRequestQuit asks the helper to bring the WHOLE app down —
+ // this is `wireguide ctl stop`. It is deliberately NOT the same as
+ // MethodShutdown: shutting the helper down while the GUI is still
+ // running just makes the GUI's health monitor respawn it (and prompt
+ // for an admin password on macOS). Instead the helper broadcasts
+ // EventQuit so a connected GUI terminates itself, and the GUI's own
+ // shutdown path then stops the helper. With no GUI attached the
+ // helper simply shuts itself down.
+ MethodRequestQuit = "Helper.RequestQuit"
)
// Event names (server → client notifications)
@@ -119,6 +151,11 @@ const (
EventLog = "event.log"
EventWifiSSID = "event.wifi_ssid"
EventAutoConnect = "event.auto_connect"
+ // EventQuit tells a connected GUI to terminate — the cross-platform
+ // half of `wireguide ctl stop`. The GUI runs its normal quit path
+ // (which disconnects tunnels and stops the helper), so no per-OS
+ // "quit that application" mechanism is needed.
+ EventQuit = "event.quit"
// EventCriticalError signals that a background goroutine inside the
// helper has died permanently (e.g. exceeded goSafe restart budget).
// The GUI is expected to surface this via a banner/toast so the user
diff --git a/internal/ipc/server.go b/internal/ipc/server.go
index c99d7e2..a2e4432 100644
--- a/internal/ipc/server.go
+++ b/internal/ipc/server.go
@@ -32,6 +32,9 @@ type Server struct {
listener net.Listener
handlers map[string]Handler
ownerUID int // expected peer UID on Unix (-1 to skip check)
+ // ownerSID is the expected peer user SID on Windows ("" to fall back
+ // to SDDL-only gating). Set via WithOwnerSID from helper.Run.
+ ownerSID string
mu sync.Mutex
eventSubs map[*subscriber]struct{} // active event subscribers
@@ -73,6 +76,14 @@ func NewServer(listener net.Listener, ownerUID ...int) *Server {
}
}
+// WithOwnerSID sets the expected peer user SID (Windows). Chainable so
+// helper.Run can construct the server in one expression. Call before
+// Serve — the field is read per-connection without a lock.
+func (s *Server) WithOwnerSID(sid string) *Server {
+ s.ownerSID = sid
+ return s
+}
+
// Handle registers an RPC handler for the given method.
func (s *Server) Handle(method string, h Handler) {
s.mu.Lock()
@@ -206,6 +217,17 @@ func (s *Server) HasSubscribers() bool {
return n > 0
}
+// HasControlConn reports whether at least one control connection (i.e. a
+// GUI) is attached. Transient CLI clients are excluded by construction —
+// they never enter controlConns. Used by the RequestQuit handler to choose
+// between "ask the GUI to quit" and "just shut myself down".
+func (s *Server) HasControlConn() bool {
+ s.mu.Lock()
+ n := len(s.controlConns)
+ s.mu.Unlock()
+ return n > 0
+}
+
// Broadcast sends an event notification to all subscribers.
func (s *Server) Broadcast(method string, params interface{}) {
// Cheap pre-check: if nobody is subscribed, skip the JSON marshal
@@ -249,7 +271,7 @@ func (s *Server) handleConn(conn net.Conn) {
defer conn.Close()
// Verify the connecting process belongs to the expected owner.
- if err := verifyPeerUID(conn, s.ownerUID); err != nil {
+ if err := verifyPeer(conn, s.ownerUID, s.ownerSID); err != nil {
slog.Warn("ipc: rejecting connection: peer credential check failed", "error", err)
return
}
@@ -303,7 +325,11 @@ func (s *Server) handleConn(conn net.Conn) {
return // handleSubscribe takes over the connection
}
- if !isControl {
+ // Transient clients (the `ctl` CLI) never become control
+ // connections: they connect, issue one command and exit, which
+ // would otherwise look like a GUI attaching and immediately
+ // detaching and would re-arm the shutdown grace window.
+ if !isControl && !req.Transient {
isControl = true
s.mu.Lock()
s.controlConns[conn] = struct{}{}
diff --git a/internal/ipc/transient_test.go b/internal/ipc/transient_test.go
new file mode 100644
index 0000000..44c0611
--- /dev/null
+++ b/internal/ipc/transient_test.go
@@ -0,0 +1,145 @@
+package ipc
+
+import (
+ "os"
+ "path/filepath"
+ "runtime"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+// shortSocketPath is testSocketPath with a deliberately tiny directory
+// name. t.TempDir() embeds the (long) test name in the path, and a Unix
+// domain socket path is capped at ~104 bytes on macOS — the tests in this
+// file have names long enough to blow that budget.
+func shortSocketPath(t *testing.T) string {
+ t.Helper()
+ if runtime.GOOS == "windows" {
+ return `\\.\pipe\wireguide-test-` + t.Name()
+ }
+ dir, err := os.MkdirTemp("", "wgt")
+ if err != nil {
+ t.Fatalf("MkdirTemp: %v", err)
+ }
+ t.Cleanup(func() { os.RemoveAll(dir) })
+ return filepath.Join(dir, "s.sock")
+}
+
+// startTransientTestServer spins up a server that counts OnConnect /
+// OnDisconnect firings, the two callbacks that drive the helper's shutdown
+// grace window.
+func startTransientTestServer(t *testing.T) (addr string, connects, disconnects *atomic.Int32, srv *Server) {
+ t.Helper()
+ addr = shortSocketPath(t)
+ listener, err := Listen(addr, -1, "")
+ if err != nil {
+ t.Fatalf("Listen: %v", err)
+ }
+ t.Cleanup(func() { listener.Close() })
+
+ connects = &atomic.Int32{}
+ disconnects = &atomic.Int32{}
+
+ srv = NewServer(listener)
+ registerTestPing(srv)
+ srv.OnConnect(func() { connects.Add(1) })
+ srv.OnDisconnect(func() { disconnects.Add(1) })
+ go srv.Serve()
+ t.Cleanup(srv.Shutdown)
+
+ time.Sleep(100 * time.Millisecond) // listener ready
+ return addr, connects, disconnects, srv
+}
+
+// TestTransientClientIsNotAControlConn is the regression guard for the bug
+// where a single `wireguide ctl status` cut a GUI-less helper's remaining
+// life from 60s to 10s.
+//
+// The CLI connects, pings and exits within milliseconds. If that counts as a
+// control connection, the helper sees "GUI attached" immediately followed by
+// "GUI disconnected" and re-arms its 10s shutdown window — so merely asking
+// the helper a question would kill it.
+func TestTransientClientIsNotAControlConn(t *testing.T) {
+ addr, connects, disconnects, srv := startTransientTestServer(t)
+
+ client, err := NewTransientClient(addr)
+ if err != nil {
+ t.Fatalf("NewTransientClient: %v", err)
+ }
+ // NewTransientClient already sent a Ping — the request the server
+ // inspects when deciding whether to upgrade the connection.
+ if got := connects.Load(); got != 0 {
+ t.Errorf("OnConnect fired %d time(s) for a transient client, want 0", got)
+ }
+ if srv.HasControlConn() {
+ t.Error("HasControlConn() = true for a transient client, want false")
+ }
+
+ client.Close()
+ time.Sleep(200 * time.Millisecond)
+ if got := disconnects.Load(); got != 0 {
+ t.Errorf("OnDisconnect fired %d time(s) when a transient client left, want 0 "+
+ "— this is what shortened the helper's shutdown grace window", got)
+ }
+}
+
+// TestControlClientCountsAsControlConn pins the other half: the GUI's client
+// MUST drive the grace window, otherwise a helper would never notice its GUI
+// going away and would outlive it.
+func TestControlClientCountsAsControlConn(t *testing.T) {
+ addr, connects, disconnects, srv := startTransientTestServer(t)
+
+ client, err := NewClient(addr)
+ if err != nil {
+ t.Fatalf("NewClient: %v", err)
+ }
+ if got := connects.Load(); got != 1 {
+ t.Fatalf("OnConnect fired %d time(s) for a normal client, want 1", got)
+ }
+ if !srv.HasControlConn() {
+ t.Error("HasControlConn() = false while a normal client is attached, want true")
+ }
+
+ client.Close()
+ deadline := time.Now().Add(2 * time.Second)
+ for time.Now().Before(deadline) && disconnects.Load() == 0 {
+ time.Sleep(50 * time.Millisecond)
+ }
+ if got := disconnects.Load(); got != 1 {
+ t.Errorf("OnDisconnect fired %d time(s) when the normal client left, want 1", got)
+ }
+ if srv.HasControlConn() {
+ t.Error("HasControlConn() = true after the only control client left, want false")
+ }
+}
+
+// TestTransientClientDoesNotMaskAGUI checks the mixed case: a CLI command
+// running while the GUI is attached must not disturb the GUI's control
+// connection, and must not fire OnDisconnect when it exits.
+func TestTransientClientDoesNotMaskAGUI(t *testing.T) {
+ addr, connects, disconnects, srv := startTransientTestServer(t)
+
+ gui, err := NewClient(addr)
+ if err != nil {
+ t.Fatalf("NewClient (gui): %v", err)
+ }
+ defer gui.Close()
+
+ cli, err := NewTransientClient(addr)
+ if err != nil {
+ t.Fatalf("NewTransientClient: %v", err)
+ }
+ cli.Close()
+ time.Sleep(200 * time.Millisecond)
+
+ if got := connects.Load(); got != 1 {
+ t.Errorf("OnConnect fired %d time(s), want 1 (the GUI only)", got)
+ }
+ if got := disconnects.Load(); got != 0 {
+ t.Errorf("OnDisconnect fired %d time(s) while the GUI is still attached, want 0", got)
+ }
+ if !srv.HasControlConn() {
+ t.Error("HasControlConn() = false while the GUI is still attached, want true")
+ }
+}
diff --git a/internal/ipc/transport_unix.go b/internal/ipc/transport_unix.go
index 6c50596..476af75 100644
--- a/internal/ipc/transport_unix.go
+++ b/internal/ipc/transport_unix.go
@@ -14,7 +14,9 @@ import (
// Listen creates a Unix socket listener at addr.
// If ownerUID >= 0, chowns the socket to that UID and sets mode 0600.
-func Listen(addr string, ownerUID int) (net.Listener, error) {
+// ownerSID is Windows-only and ignored here.
+func Listen(addr string, ownerUID int, ownerSID string) (net.Listener, error) {
+ _ = ownerSID
// Ensure parent directory exists. On macOS the socket lives in
// /var/run/wireguide/ — the helper (root) creates it, and the GUI
// (unprivileged user) needs to traverse it to reach the socket.
diff --git a/internal/ipc/transport_windows.go b/internal/ipc/transport_windows.go
index bbcfb00..58bc082 100644
--- a/internal/ipc/transport_windows.go
+++ b/internal/ipc/transport_windows.go
@@ -12,18 +12,12 @@ import (
"golang.org/x/sys/windows"
)
-// Listen creates a named pipe listener.
-// ownerSID (parameter int is ignored on Windows; use SDDL string) controls ACL.
-func Listen(addr string, ownerUID int) (net.Listener, error) {
- // H13: SYSTEM and Administrators get full control (GA). Interactive Users
- // (IU / S-1-5-4) get read+write only (GRGW) so the unprivileged GUI
- // process can connect to the helper pipe without requiring elevation.
- // IU covers any user who has logged on interactively — this is the
- // minimal group that enables the privilege-separation design.
- sddl := "D:(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGW;;;IU)"
-
+// Listen creates a named pipe listener. ownerUID is ignored on Windows;
+// ownerSID (the spawning user's SID) scopes the pipe ACL to that user.
+func Listen(addr string, ownerUID int, ownerSID string) (net.Listener, error) {
+ _ = ownerUID
config := &winio.PipeConfig{
- SecurityDescriptor: sddl,
+ SecurityDescriptor: buildPipeSDDL(ownerSID),
MessageMode: false,
InputBufferSize: 65536,
OutputBufferSize: 65536,
@@ -36,6 +30,28 @@ func Listen(addr string, ownerUID int) (net.Listener, error) {
return l, nil
}
+// buildPipeSDDL returns the pipe's security descriptor. SYSTEM and
+// Administrators get full control (GA). The read+write (GRGW) grant that
+// lets the unprivileged GUI connect is scoped to the spawning user's SID
+// — the previous grant to Interactive Users (IU / S-1-5-4) let EVERY
+// logged-on account on a multi-user machine drive a SYSTEM helper:
+// disconnect tunnels, disable the kill switch, forge SSIDs into the
+// automation engine, force shutdown (issue #20).
+//
+// An empty or malformed SID falls back to the historical IU grant so a
+// helper started by an older GUI (no --owner-sid) keeps working; the
+// fallback is logged as a warning at the call site of Run. Validation
+// uses windows.StringToSid — never interpolate an unvalidated string
+// into a security descriptor.
+func buildPipeSDDL(ownerSID string) string {
+ if ownerSID != "" {
+ if _, err := windows.StringToSid(ownerSID); err == nil {
+ return fmt.Sprintf("D:(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGW;;;%s)", ownerSID)
+ }
+ }
+ return "D:(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGW;;;IU)"
+}
+
// Dial connects to a named pipe and verifies the server is owned by a trusted
// principal (Local System or Built-in Administrators).
func Dial(addr string) (net.Conn, error) {
diff --git a/internal/ipc/types.go b/internal/ipc/types.go
index 8ee6e74..a6e56a3 100644
--- a/internal/ipc/types.go
+++ b/internal/ipc/types.go
@@ -88,14 +88,6 @@ type ActiveTunnelsResponse struct {
Names []string `json:"names"`
}
-// MultiStatusResponse carries status for every active tunnel plus an
-// aggregate state. The frontend can iterate Tunnels for per-tunnel detail
-// or use the top-level State for a single-tunnel-compatible view.
-type MultiStatusResponse struct {
- State domain.State `json:"state"`
- Tunnels []ConnectionStatus `json:"tunnels"`
-}
-
// BoolResponse wraps a single bool.
type BoolResponse struct {
Value bool `json:"value"`
@@ -106,6 +98,13 @@ type StringResponse struct {
Value string `json:"value"`
}
+// RequestQuitResponse is returned from Helper.RequestQuit. NotifiedGUI
+// distinguishes "the app is shutting down" from "there was no app, just a
+// stray helper, and it's now stopping" so `ctl stop` can say which.
+type RequestQuitResponse struct {
+ NotifiedGUI bool `json:"notified_gui"`
+}
+
// WifiSSIDPayload is broadcast by the helper whenever the system's
// active Wi-Fi SSID changes. The GUI evaluates Settings.WifiRules and
// triggers Connect / Disconnect accordingly.
diff --git a/internal/network/darwin.go b/internal/network/darwin.go
index bd034a7..087f387 100644
--- a/internal/network/darwin.go
+++ b/internal/network/darwin.go
@@ -429,11 +429,12 @@ func (m *DarwinManager) reapply() {
needBypassUpdate := (hasV4 || hasV6) && (gatewayChanged || endpointsChanged)
if !needBypassUpdate {
- // No bypass work needed, but still re-apply DNS — macOS can
- // reassign DNS when switching network services.
+ // No bypass work needed, but DNS may still need attention — macOS
+ // can reassign DNS when switching network services. Verify before
+ // rewriting: this branch runs on every route-table event.
if len(dns) > 0 {
- if err := m.applyDNS(dns); err != nil {
- slog.Warn("reapply: applyDNS failed", "error", err)
+ if err := m.applyDNSIfDrifted(dns); err != nil {
+ slog.Warn("reapply: applyDNSIfDrifted failed", "error", err)
}
}
return
@@ -900,6 +901,20 @@ func (m *DarwinManager) SetDNS(ifaceName string, entries []string) error {
// Push the new DNS to every service in parallel.
m.applyDNSToServices(entries, services)
+ // Commit state BEFORE verification: once applyDNSToServices ran, the
+ // system carries our overrides, and every restore path (Cleanup →
+ // RestoreDNS, crash-recovery SavedDNSSnapshot) keys off dnsActive.
+ // Returning a verification error with dnsActive still false left the
+ // user stuck on tunnel DNS that nothing would ever remove (issue #34
+ // gap: connect rollback no-op'd on !dnsActive).
+ m.mu.Lock()
+ // Defensive copy — callers today build a fresh slice but a future
+ // caller passing cfg.Interface.DNS directly would let reapply() (via
+ // append-and-grow on the slice header) silently read live config.
+ m.lastDNS = append([]string(nil), entries...)
+ m.dnsActive = true
+ m.mu.Unlock()
+
// Verify DNS actually took effect on at least one service. macOS can
// silently fail to apply DNS settings (e.g. permission issues, MDM
// profiles overriding). Without this check the user thinks VPN DNS
@@ -916,14 +931,6 @@ func (m *DarwinManager) SetDNS(ifaceName string, entries []string) error {
// Without this users can keep hitting stale resolutions for several
// minutes — wg-quick does this at the end of its set_dns.
flushDNSCache()
-
- m.mu.Lock()
- // Defensive copy — callers today build a fresh slice but a future
- // caller passing cfg.Interface.DNS directly would let reapply() (via
- // append-and-grow on the slice header) silently read live config.
- m.lastDNS = append([]string(nil), entries...)
- m.dnsActive = true
- m.mu.Unlock()
return nil
}
@@ -932,10 +939,66 @@ func (m *DarwinManager) SetDNS(ifaceName string, entries []string) error {
// present when SetDNS was first called, so they can be properly restored.
func (m *DarwinManager) applyDNS(entries []string) error {
services := getAllNetworkServices()
+ m.captureNewServices(services)
+ m.applyDNSToServices(entries, services)
+ flushDNSCache()
+ return nil
+}
+
+// applyDNSIfDrifted re-applies DNS only to services whose current values
+// differ from the desired set. Route-table events fire on Wi-Fi roams,
+// DHCP renewals, sleep/wake and other VPNs starting; unconditionally
+// rewriting identical DNS on every one of them spawned 10-20 networksetup
+// processes and HUP'd mDNSResponder — wiping the machine-wide resolver
+// cache — per event. Reads are parallel networksetup queries; the resolver
+// flush runs only when at least one service actually needed a rewrite.
+func (m *DarwinManager) applyDNSIfDrifted(entries []string) error {
+ services := getAllNetworkServices()
+ m.captureNewServices(services)
- // M5: Check for new services that weren't in the original savedDNS map.
- // Collect the list of services needing DNS capture under the lock, then
- // release the lock once, do all network calls, and re-lock once to store.
+ servers, search := splitDNSEntries(entries)
+ drifted := make([]bool, len(services))
+ var wg sync.WaitGroup
+ for i, svc := range services {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ cur, err := getCurrentDNS(svc)
+ if err != nil || !stringSetEqual(cur, servers) {
+ // Read failure counts as drift: rewriting is the safe side.
+ drifted[i] = true
+ return
+ }
+ if len(search) > 0 {
+ curSearch, serr := getCurrentSearchDomains(svc)
+ if serr != nil || !stringSetEqual(curSearch, search) {
+ drifted[i] = true
+ }
+ }
+ }()
+ }
+ wg.Wait()
+
+ var need []string
+ for i, svc := range services {
+ if drifted[i] {
+ need = append(need, svc)
+ }
+ }
+ if len(need) == 0 {
+ slog.Debug("reapply: DNS already correct on all services — skipping rewrite")
+ return nil
+ }
+ m.applyDNSToServices(entries, need)
+ flushDNSCache()
+ return nil
+}
+
+// captureNewServices saves the original DNS of network services that
+// appeared after SetDNS was first called, so they can be restored too.
+// Collect the list needing capture under the lock, release it for the
+// network calls, and re-lock once to store.
+func (m *DarwinManager) captureNewServices(services []string) {
var newServices []string
m.mu.Lock()
if m.dnsActive {
@@ -947,41 +1010,36 @@ func (m *DarwinManager) applyDNS(entries []string) error {
}
m.mu.Unlock()
- // Fetch DNS for newly discovered services without holding the lock.
- if len(newServices) > 0 {
- type savedEntry struct {
- svc string
- dns []string
- search []string
- }
- fetched := make([]savedEntry, len(newServices))
- var wg sync.WaitGroup
- for i, svc := range newServices {
- i, svc := i, svc
- wg.Add(1)
- go func() {
- defer wg.Done()
- dns, _ := getCurrentDNS(svc)
- search, _ := getCurrentSearchDomains(svc)
- fetched[i] = savedEntry{svc: svc, dns: dns, search: search}
- }()
- }
- wg.Wait()
+ if len(newServices) == 0 {
+ return
+ }
+ type savedEntry struct {
+ svc string
+ dns []string
+ search []string
+ }
+ fetched := make([]savedEntry, len(newServices))
+ var wg sync.WaitGroup
+ for i, svc := range newServices {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ dns, _ := getCurrentDNS(svc)
+ search, _ := getCurrentSearchDomains(svc)
+ fetched[i] = savedEntry{svc: svc, dns: dns, search: search}
+ }()
+ }
+ wg.Wait()
- m.mu.Lock()
- for _, e := range fetched {
- if _, exists := m.savedDNS[e.svc]; !exists {
- m.savedDNS[e.svc] = e.dns
- m.savedSearch[e.svc] = e.search
- slog.Info("discovered new network service, saving DNS", "service", e.svc)
- }
+ m.mu.Lock()
+ for _, e := range fetched {
+ if _, exists := m.savedDNS[e.svc]; !exists {
+ m.savedDNS[e.svc] = e.dns
+ m.savedSearch[e.svc] = e.search
+ slog.Info("discovered new network service, saving DNS", "service", e.svc)
}
- m.mu.Unlock()
}
-
- m.applyDNSToServices(entries, services)
- flushDNSCache()
- return nil
+ m.mu.Unlock()
}
// applyDNSToServices is the shared hot path used by both SetDNS and applyDNS.
@@ -1224,21 +1282,26 @@ func (m *DarwinManager) RestoreDNS(ifaceName string) error {
return nil
}
-// SavedDNSSnapshot returns the current per-service DNS snapshot for
-// persistence to the crash recovery journal. Thread-safe.
-func (m *DarwinManager) SavedDNSSnapshot() map[string][]string {
+// SavedDNSSnapshot returns the current per-service DNS snapshot (servers
+// AND search domains) for persistence to the crash recovery journal.
+// Thread-safe.
+func (m *DarwinManager) SavedDNSSnapshot() DNSSnapshot {
m.mu.Lock()
defer m.mu.Unlock()
if !m.dnsActive || len(m.savedDNS) == 0 {
- return nil
+ return DNSSnapshot{}
+ }
+ snap := DNSSnapshot{
+ Servers: make(map[string][]string, len(m.savedDNS)),
+ Search: make(map[string][]string, len(m.savedSearch)),
}
- snapshot := make(map[string][]string, len(m.savedDNS))
for svc, dns := range m.savedDNS {
- cp := make([]string, len(dns))
- copy(cp, dns)
- snapshot[svc] = cp
+ snap.Servers[svc] = append([]string(nil), dns...)
+ }
+ for svc, search := range m.savedSearch {
+ snap.Search[svc] = append([]string(nil), search...)
}
- return snapshot
+ return snap
}
// RestoreDNSFromSnapshot restores DNS from a persisted pre-modification
@@ -1250,28 +1313,72 @@ func (m *DarwinManager) SavedDNSSnapshot() map[string][]string {
//
// Side effect: clears in-memory dnsActive/savedDNS so a subsequent
// Cleanup() doesn't re-fire RestoreDNS over our manual restore.
-func (m *DarwinManager) RestoreDNSFromSnapshot(preModDNS map[string][]string) error {
+//
+// The restore is COMPLETE (issue #34): it writes both servers and search
+// domains, and it iterates the union of the snapshot's services, this
+// manager's own saved maps, and every service live right now — so a
+// service that appeared mid-session (Ethernet plugged in, iPhone USB) and
+// received tunnel DNS from a reapply is cleaned too. Per-service fallback
+// order: global pre-VPN snapshot → this manager's captured original
+// (covers mid-session services the global snapshot predates) → "Empty".
+func (m *DarwinManager) RestoreDNSFromSnapshot(snap DNSSnapshot) error {
m.mu.Lock()
+ savedDNS := m.savedDNS
+ savedSearch := m.savedSearch
m.dnsActive = false
m.savedDNS = make(map[string][]string)
m.savedSearch = make(map[string][]string)
m.lastDNS = nil
m.mu.Unlock()
+ svcSet := make(map[string]struct{})
+ for svc := range snap.Servers {
+ svcSet[svc] = struct{}{}
+ }
+ for svc := range snap.Search {
+ svcSet[svc] = struct{}{}
+ }
+ for svc := range savedDNS {
+ svcSet[svc] = struct{}{}
+ }
+ for svc := range savedSearch {
+ svcSet[svc] = struct{}{}
+ }
+ for _, svc := range getAllNetworkServices() {
+ svcSet[svc] = struct{}{}
+ }
+
var wg sync.WaitGroup
- for svc, orig := range preModDNS {
- svc, orig := svc, orig
+ for svc := range svcSet {
wg.Add(1)
go func() {
defer wg.Done()
- if len(orig) == 0 {
+ servers, ok := snap.Servers[svc]
+ if !ok {
+ servers = savedDNS[svc]
+ }
+ if len(servers) > 0 {
+ args := append([]string{"-setdnsservers", svc}, servers...)
+ if err := run("networksetup", args...); err != nil {
+ slog.Warn("RestoreDNSFromSnapshot: setting DNS failed", "service", svc, "error", err)
+ }
+ } else {
if err := run("networksetup", "-setdnsservers", svc, "Empty"); err != nil {
slog.Warn("RestoreDNSFromSnapshot: clearing DNS failed", "service", svc, "error", err)
}
- } else {
- args := append([]string{"-setdnsservers", svc}, orig...)
+ }
+ search, ok := snap.Search[svc]
+ if !ok {
+ search = savedSearch[svc]
+ }
+ if len(search) > 0 {
+ args := append([]string{"-setsearchdomains", svc}, search...)
if err := run("networksetup", args...); err != nil {
- slog.Warn("RestoreDNSFromSnapshot: setting DNS failed", "service", svc, "error", err)
+ slog.Warn("RestoreDNSFromSnapshot: setting search domains failed", "service", svc, "error", err)
+ }
+ } else {
+ if err := run("networksetup", "-setsearchdomains", svc, "Empty"); err != nil {
+ slog.Warn("RestoreDNSFromSnapshot: clearing search domains failed", "service", svc, "error", err)
}
}
}()
@@ -1318,7 +1425,9 @@ func (m *DarwinManager) Cleanup(ifaceName string) error {
// --- helpers ---
-func run(name string, args ...string) error {
+// run is a var so DNS-restore tests can intercept networksetup writes
+// instead of mutating the host's real network services.
+var run = func(name string, args ...string) error {
ctx, cancel := context.WithTimeout(context.Background(), cmdTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
diff --git a/internal/network/dns_restore_darwin_test.go b/internal/network/dns_restore_darwin_test.go
new file mode 100644
index 0000000..9fbf358
--- /dev/null
+++ b/internal/network/dns_restore_darwin_test.go
@@ -0,0 +1,78 @@
+//go:build darwin
+
+package network
+
+import (
+ "strings"
+ "sync"
+ "testing"
+)
+
+// Issue #34 regression: a last-tunnel disconnect must restore search
+// domains, not just DNS servers — and must cover services the pre-VPN
+// snapshot doesn't know about (they appeared mid-session and got tunnel
+// DNS from a reapply).
+func TestRestoreDNSFromSnapshotRestoresSearchDomains(t *testing.T) {
+ var mu sync.Mutex
+ var calls [][]string
+ origRun := run
+ run = func(name string, args ...string) error {
+ mu.Lock()
+ defer mu.Unlock()
+ calls = append(calls, append([]string{name}, args...))
+ return nil
+ }
+ defer func() { run = origRun }()
+
+ m := NewPlatformManager().(*DarwinManager)
+ m.mu.Lock()
+ m.dnsActive = true
+ // Mid-session service the global snapshot below predates: its original
+ // values were captured into the per-manager maps on discovery.
+ m.savedDNS["USB Ethernet"] = []string{"192.168.10.1"}
+ m.savedSearch["USB Ethernet"] = []string{"lan.local"}
+ m.mu.Unlock()
+
+ snap := DNSSnapshot{
+ Servers: map[string][]string{
+ "Wi-Fi": {"1.1.1.1"},
+ "Ethernet": nil, // was DHCP → must be reset to Empty
+ },
+ Search: map[string][]string{
+ "Wi-Fi": {"corp.example.com"},
+ },
+ }
+ if err := m.RestoreDNSFromSnapshot(snap); err != nil {
+ t.Fatalf("RestoreDNSFromSnapshot: %v", err)
+ }
+
+ find := func(sub string) bool {
+ mu.Lock()
+ defer mu.Unlock()
+ for _, c := range calls {
+ if strings.Contains(strings.Join(c, " "), sub) {
+ return true
+ }
+ }
+ return false
+ }
+
+ for _, want := range []string{
+ "-setdnsservers Wi-Fi 1.1.1.1",
+ "-setsearchdomains Wi-Fi corp.example.com",
+ "-setdnsservers Ethernet Empty",
+ "-setsearchdomains Ethernet Empty",
+ "-setdnsservers USB Ethernet 192.168.10.1",
+ "-setsearchdomains USB Ethernet lan.local",
+ } {
+ if !find(want) {
+ t.Errorf("missing expected restore call containing %q\ncalls: %v", want, calls)
+ }
+ }
+
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if m.dnsActive {
+ t.Error("dnsActive should be false after restore")
+ }
+}
diff --git a/internal/network/interface.go b/internal/network/interface.go
index bb310bf..5814c46 100644
--- a/internal/network/interface.go
+++ b/internal/network/interface.go
@@ -45,19 +45,33 @@ type NetworkManager interface {
Cleanup(ifaceName string) error
}
+// DNSSnapshot carries the pre-VPN per-service DNS state needed for a
+// COMPLETE restore. Search domains must ride along with servers: the
+// previous servers-only snapshot meant the restore path left
+// `networksetup -setsearchdomains` overrides behind forever (issue #34).
+type DNSSnapshot struct {
+ Servers map[string][]string `json:"servers,omitempty"`
+ Search map[string][]string `json:"search,omitempty"`
+}
+
+// Empty reports whether the snapshot holds no state at all.
+func (s DNSSnapshot) Empty() bool {
+ return len(s.Servers) == 0 && len(s.Search) == 0
+}
+
// DNSStateRestorer is an optional interface that allows restoring DNS
// settings from a persisted pre-modification snapshot during crash recovery.
// Unlike RestoreDNS (which needs in-memory state from the same process),
// this uses the snapshot saved to disk, preserving custom user preferences.
type DNSStateRestorer interface {
- RestoreDNSFromSnapshot(preModDNS map[string][]string) error
+ RestoreDNSFromSnapshot(snap DNSSnapshot) error
}
// SavedDNSSnapshot returns the current in-memory DNS snapshot for
// persistence to the crash recovery journal. Platform managers that
// capture per-service DNS state should implement this.
type DNSSnapshotProvider interface {
- SavedDNSSnapshot() map[string][]string
+ SavedDNSSnapshot() DNSSnapshot
}
// RoutingStateRestorer is an optional interface that platform managers may
@@ -68,6 +82,27 @@ type RoutingStateRestorer interface {
RestoreRoutingState(table, fwmark string)
}
+// EndpointRouteStateProvider exposes only endpoint bypass/throw routes that
+// this manager actually installed. Persisting the exact owned set lets crash
+// recovery remove it without flushing unrelated routes from a user-selected
+// custom table.
+type EndpointRouteStateProvider interface {
+ InstalledEndpointRoutes() []string
+}
+
+// EndpointRouteStateRestorer restores the owned endpoint-route set from the
+// crash journal before RemoveRoutes runs in a fresh helper process.
+type EndpointRouteStateRestorer interface {
+ RestoreEndpointRoutes(routes []string)
+}
+
+// PersistentStateDirSetter lets platform managers persist recovery data next
+// to the tunnel journal. Linux uses it for the original resolv.conf snapshot;
+// other platforms do not need to implement it.
+type PersistentStateDirSetter interface {
+ SetPersistentStateDir(dataDir string)
+}
+
// PreCloseCleaner is an optional interface for platform managers that need
// to put the tunnel interface into a "harmless" state BEFORE the TUN
// device is destroyed. On Windows the wintun adapter persists for several
@@ -78,10 +113,3 @@ type RoutingStateRestorer interface {
type PreCloseCleaner interface {
PreCloseAdapterCleanup(ifaceName string)
}
-
-// OriginalNetworkState captures the pre-tunnel network state for restoration.
-type OriginalNetworkState struct {
- DNSServers []string `json:"dns_servers"`
- DefaultGW string `json:"default_gateway"`
- DefaultIf string `json:"default_interface"`
-}
diff --git a/internal/network/linux.go b/internal/network/linux.go
index 396aee3..326a0ab 100644
--- a/internal/network/linux.go
+++ b/internal/network/linux.go
@@ -46,12 +46,20 @@ type LinuxManager struct {
// from "table was never set". Without this, removeFullTunnelRoutes
// cannot tell whether 0 means main table or uninitialised.
tableSet bool
+ // endpointThrowRoutes contains only throw routes successfully installed
+ // by this manager. They must be removed explicitly; deleting the default
+ // route from the policy table does not flush sibling throw routes.
+ endpointThrowRoutes []string
}
func NewPlatformManager() NetworkManager {
return &LinuxManager{}
}
+func (m *LinuxManager) SetPersistentStateDir(dataDir string) {
+ m.dataDir = dataDir
+}
+
func (m *LinuxManager) AssignAddress(ifaceName string, addresses []string) error {
for _, addr := range addresses {
if err := runCmd("ip", "addr", "add", addr, "dev", ifaceName); err != nil {
@@ -122,6 +130,8 @@ func (m *LinuxManager) AddRoutes(ifaceName string, allowedIPs []string, fullTunn
// Table = off → skip routing entirely
if table == -1 {
+ m.table = -1
+ m.tableSet = true
slog.Info("Table=off, skipping route installation")
return nil
}
@@ -391,6 +401,8 @@ func (m *LinuxManager) addFullTunnelRoutesWithConfig(ifaceName string, endpoints
// idiom for "no route to here in this table, try the next rule".
if err := runCmd("ip", proto, "route", "add", "throw", hostCIDR, "table", tableStr); err != nil {
slog.Debug("endpoint throw route add failed (may already exist)", "ep", ep, "error", err)
+ } else {
+ m.endpointThrowRoutes = append(m.endpointThrowRoutes, hostCIDR)
}
}
@@ -398,6 +410,9 @@ func (m *LinuxManager) addFullTunnelRoutesWithConfig(ifaceName string, endpoints
}
func (m *LinuxManager) RemoveRoutes(ifaceName string, allowedIPs []string, fullTunnel bool) error {
+ if m.tableSet && m.table == -1 {
+ return nil
+ }
if fullTunnel {
return m.removeFullTunnelRoutes(ifaceName)
}
@@ -408,12 +423,8 @@ func (m *LinuxManager) RemoveRoutes(ifaceName string, allowedIPs []string, fullT
tableStr = strconv.Itoa(m.table)
}
for _, cidr := range allowedIPs {
- var err error
- if tableStr != "" {
- err = runCmd("ip", "route", "delete", cidr, "dev", ifaceName, "table", tableStr)
- } else {
- err = runCmd("ip", "route", "delete", cidr, "dev", ifaceName)
- }
+ args := splitRouteDeleteArgs(ifaceName, cidr, tableStr)
+ err := runCmd("ip", args...)
if err != nil {
slog.Warn("failed to remove route", "cidr", cidr, "iface", ifaceName, "table", tableStr, "error", err)
}
@@ -421,6 +432,20 @@ func (m *LinuxManager) RemoveRoutes(ifaceName string, allowedIPs []string, fullT
return nil
}
+// splitRouteDeleteArgs mirrors AddRoutes' address-family selection. `ip route`
+// defaults to IPv4, so omitting -6 here leaves IPv6 split routes installed
+// after disconnect or a failed connect rollback.
+func splitRouteDeleteArgs(ifaceName, cidr, tableStr string) []string {
+ args := []string{"route", "delete", cidr, "dev", ifaceName}
+ if strings.Contains(cidr, ":") {
+ args = append([]string{"-6"}, args...)
+ }
+ if tableStr != "" {
+ args = append(args, "table", tableStr)
+ }
+ return args
+}
+
// findOwnedTable scans candidate wg-quick table numbers (51820..51919) for
// one that holds a default route via the given interface. Returns the
// canonical default "51820" if nothing matches — safe because the subsequent
@@ -444,7 +469,10 @@ func (m *LinuxManager) findOwnedTable(ifaceName string) string {
// persisted values (e.g. crash recovery state). This allows removeFullTunnelRoutes
// to use the correct values even on a fresh process.
func (m *LinuxManager) RestoreRoutingState(table, fwmark string) {
- if table != "" {
+ if strings.EqualFold(strings.TrimSpace(table), "off") {
+ m.table = -1
+ m.tableSet = true
+ } else if table != "" {
if parsed, err := strconv.Atoi(table); err == nil {
m.table = parsed
m.tableSet = true
@@ -457,6 +485,14 @@ func (m *LinuxManager) RestoreRoutingState(table, fwmark string) {
}
}
+func (m *LinuxManager) InstalledEndpointRoutes() []string {
+ return append([]string(nil), m.endpointThrowRoutes...)
+}
+
+func (m *LinuxManager) RestoreEndpointRoutes(routes []string) {
+ m.endpointThrowRoutes = append([]string(nil), routes...)
+}
+
func (m *LinuxManager) removeFullTunnelRoutes(ifaceName string) error {
tableStr := strconv.Itoa(m.table)
if !m.tableSet {
@@ -474,6 +510,13 @@ func (m *LinuxManager) removeFullTunnelRoutes(ifaceName string) error {
if err := runCmd("ip", "-6", "route", "delete", "default", "dev", ifaceName, "table", tableStr); err != nil {
slog.Warn("failed to remove IPv6 default route", "table", tableStr, "error", err)
}
+ for _, hostCIDR := range m.endpointThrowRoutes {
+ args := endpointThrowRouteDeleteArgs(hostCIDR, tableStr)
+ if err := runCmd("ip", args...); err != nil {
+ slog.Warn("failed to remove endpoint throw route", "route", hostCIDR, "table", tableStr, "error", err)
+ }
+ }
+ m.endpointThrowRoutes = nil
// Policy rules — delete by priority. Targeting our priority is precise
// even when a previous helper crash left duplicates (each duplicate
@@ -501,6 +544,14 @@ func (m *LinuxManager) removeFullTunnelRoutes(ifaceName string) error {
return nil
}
+func endpointThrowRouteDeleteArgs(hostCIDR, tableStr string) []string {
+ args := []string{"route", "delete", "throw", hostCIDR, "table", tableStr}
+ if strings.Contains(hostCIDR, ":") {
+ args = append([]string{"-6"}, args...)
+ }
+ return args
+}
+
func (m *LinuxManager) SetDNS(ifaceName string, servers []string) error {
if len(servers) == 0 {
return nil
@@ -809,25 +860,17 @@ func (m *LinuxManager) Cleanup(ifaceName string) error {
slog.Warn("Cleanup: RestoreDNS failed", "iface", ifaceName, "error", err)
}
- // H10: Remove routes and policy rules (crash recovery path)
- // Try removing full-tunnel routes with both stored and default values.
- if err := m.removeFullTunnelRoutes(ifaceName); err != nil {
- slog.Warn("Cleanup: removeFullTunnelRoutes failed", "iface", ifaceName, "error", err)
- }
-
- // H10: Clean up nftables rules that may have been left by the firewall.
- // This is best-effort -- the firewall's own Cleanup should handle this,
- // but in crash recovery the firewall object may not have state.
- if out, err := runOut("nft", "delete", "table", "inet", "wireguide"); err != nil {
- slog.Warn("cleanup: nft delete wireguide table", "error", err, "output", strings.TrimSpace(string(out)))
- }
- if out, err := runOut("nft", "delete", "table", "inet", "wireguide_dns"); err != nil {
- slog.Warn("cleanup: nft delete wireguide_dns table", "error", err, "output", strings.TrimSpace(string(out)))
- }
-
- // Delete the interface
+ // Routes are removed by RemoveRoutes before Cleanup, with the tunnel's
+ // actual full/split classification. Do not remove full-tunnel policy rules
+ // here: a split tunnel may coexist with a full tunnel, and deleting the
+ // process-wide rules while cleaning the split tunnel breaks the survivor.
+ // Firewall tables are likewise owned by firewall.Manager and may protect
+ // other active tunnels; crash recovery calls that manager separately.
+ //
+ // Engine.Close normally deletes the TUN first. Keep this final delete as a
+ // best-effort fallback for platform/engine implementations where it remains.
if err := runCmd("ip", "link", "delete", "dev", ifaceName); err != nil {
- slog.Warn("cleanup: failed to delete interface", "iface", ifaceName, "error", err)
+ slog.Debug("cleanup: interface already absent or delete failed", "iface", ifaceName, "error", err)
}
return nil
}
diff --git a/internal/network/linux_test.go b/internal/network/linux_test.go
new file mode 100644
index 0000000..4754da4
--- /dev/null
+++ b/internal/network/linux_test.go
@@ -0,0 +1,69 @@
+//go:build linux
+
+package network
+
+import (
+ "reflect"
+ "testing"
+)
+
+func TestSplitRouteDeleteArgsSelectsAddressFamilyAndTable(t *testing.T) {
+ tests := []struct {
+ name, cidr, table string
+ want []string
+ }{
+ {"ipv4 main", "10.0.0.0/24", "", []string{"route", "delete", "10.0.0.0/24", "dev", "wg0"}},
+ {"ipv6 main", "fd00::/64", "", []string{"-6", "route", "delete", "fd00::/64", "dev", "wg0"}},
+ {"ipv6 custom table", "2001:db8::/32", "51821", []string{"-6", "route", "delete", "2001:db8::/32", "dev", "wg0", "table", "51821"}},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := splitRouteDeleteArgs("wg0", tt.cidr, tt.table); !reflect.DeepEqual(got, tt.want) {
+ t.Fatalf("splitRouteDeleteArgs() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestTableOffPersistsAcrossRemovalAndCrashRecovery(t *testing.T) {
+ mgr := &LinuxManager{}
+ if err := mgr.AddRoutes("not-a-real-interface", []string{"10.255.254.99/32"}, false, nil, "off", ""); err != nil {
+ t.Fatalf("AddRoutes(Table=off): %v", err)
+ }
+ if !mgr.tableSet || mgr.table != -1 {
+ t.Fatalf("Table=off state = tableSet:%v table:%d", mgr.tableSet, mgr.table)
+ }
+ // This would invoke `ip route delete` and merely log the resulting error
+ // if the disabled state were forgotten.
+ if err := mgr.RemoveRoutes("not-a-real-interface", []string{"10.255.254.99/32"}, false); err != nil {
+ t.Fatalf("RemoveRoutes(Table=off): %v", err)
+ }
+
+ recovered := &LinuxManager{}
+ recovered.RestoreRoutingState("off", "")
+ if !recovered.tableSet || recovered.table != -1 {
+ t.Fatalf("restored Table=off state = tableSet:%v table:%d", recovered.tableSet, recovered.table)
+ }
+}
+
+func TestEndpointThrowRouteDeleteArgs(t *testing.T) {
+ tests := []struct {
+ host, table string
+ want []string
+ }{
+ {"203.0.113.7/32", "51888", []string{"route", "delete", "throw", "203.0.113.7/32", "table", "51888"}},
+ {"2001:db8::7/128", "51889", []string{"-6", "route", "delete", "throw", "2001:db8::7/128", "table", "51889"}},
+ }
+ for _, tt := range tests {
+ if got := endpointThrowRouteDeleteArgs(tt.host, tt.table); !reflect.DeepEqual(got, tt.want) {
+ t.Fatalf("endpointThrowRouteDeleteArgs(%q) = %v, want %v", tt.host, got, tt.want)
+ }
+ }
+
+ mgr := &LinuxManager{endpointThrowRoutes: []string{"203.0.113.7/32"}}
+ got := mgr.InstalledEndpointRoutes()
+ got[0] = "mutated"
+ if mgr.endpointThrowRoutes[0] != "203.0.113.7/32" {
+ t.Fatal("InstalledEndpointRoutes returned aliased storage")
+ }
+}
diff --git a/internal/network/route_iphlpapi_windows.go b/internal/network/route_iphlpapi_windows.go
index f4e2d32..c1caff1 100644
--- a/internal/network/route_iphlpapi_windows.go
+++ b/internal/network/route_iphlpapi_windows.go
@@ -196,27 +196,6 @@ var ErrRouteAlreadyExists = fmt.Errorf("route already exists")
// isn't in the table. Almost always benign during best-effort cleanup.
var ErrRouteNotFound = fmt.Errorf("route not found")
-// VerifyIpForwardRoute reports whether a route matching the given
-// (dest, prefix, ifaceLuid) tuple is currently in the kernel route
-// table. Used by addFullTunnelRoutes as a post-install sanity check
-// when the install API returned success — defends against the rare
-// kernel-accepted-but-invalid race where the row exists in nsi but
-// the dataplane doesn't honor it yet (typical after a wintun adapter
-// has just been created and the BFE hasn't picked up the LUID).
-//
-// `verifyTimeout` and polling are the caller's responsibility; this
-// is a single point-in-time check.
-//
-// For verifying many routes in one go, prefer VerifyIpForwardRoutes:
-// it takes a single GetIpForwardTable2 snapshot instead of one per
-// route, which matters on machines with hundreds of routes (heavily
-// containerised hosts, multiple-VPN setups).
-func VerifyIpForwardRoute(ifaceLuid uint64, dest net.IP, prefixLen uint8) bool {
- want := []routeKey{{ifaceLuid: ifaceLuid, dest: canonicalIP(dest), prefixLen: prefixLen}}
- missing := VerifyIpForwardRoutes(want)
- return len(missing) == 0
-}
-
// routeKey is the tuple VerifyIpForwardRoutes matches on.
type routeKey struct {
ifaceLuid uint64
diff --git a/internal/notify/notify.go b/internal/notify/notify.go
deleted file mode 100644
index d8bb901..0000000
--- a/internal/notify/notify.go
+++ /dev/null
@@ -1,108 +0,0 @@
-package notify
-
-import (
- "log/slog"
- "os/exec"
- "runtime"
- "strings"
-
- "github.com/korjwl1/wireguide/internal/sysexec"
-)
-
-// SendNotification sends an OS-level notification. Best-effort: failures are
-// logged at debug level but never propagated.
-func SendNotification(title, message string) {
- // Strip control characters that would otherwise break the
- // notification subsystem's display:
- // - NUL terminates C strings; osascript/notify-send silently
- // truncate at the first NUL.
- // - \n / \r split notify-send into multiple notifications on
- // some implementations.
- // - Bell/escape sequences can mis-render in toast renderers.
- title = sanitizeNotificationText(title)
- message = sanitizeNotificationText(message)
-
- // If sanitization left both empty (input was pure control chars
- // or whitespace), skip the notification entirely. osascript on
- // macOS happily displays a blank notification card that the user
- // can't dismiss; notify-send and PowerShell handle it but the
- // result is just visual noise.
- if title == "" && message == "" {
- return
- }
-
- var err error
- switch runtime.GOOS {
- case "darwin":
- err = notifyMac(title, message)
- case "linux":
- err = notifyLinux(title, message)
- case "windows":
- err = notifyWindows(title, message)
- }
- if err != nil {
- slog.Debug("notification failed", "error", err)
- }
-}
-
-// sanitizeNotificationText replaces control characters (NUL, BEL, ESC,
-// CR, LF, TAB and other C0/C1 chars) with single spaces, collapsing
-// runs of whitespace. The exec.Command interface itself is shell-safe
-// (no shell invocation), so this is purely a display-correctness fix.
-func sanitizeNotificationText(s string) string {
- if s == "" {
- return ""
- }
- var b strings.Builder
- b.Grow(len(s))
- prevSpace := false
- for _, r := range s {
- // Treat C0 (U+0000..U+001F) and DEL (U+007F) as whitespace.
- // Allow tab/space through as a single space.
- if r < 0x20 || r == 0x7F {
- if !prevSpace {
- b.WriteByte(' ')
- prevSpace = true
- }
- continue
- }
- b.WriteRune(r)
- prevSpace = false
- }
- return strings.TrimSpace(b.String())
-}
-
-func notifyMac(title, message string) error {
- script := `on run argv
-set theMessage to item 1 of argv
-set theTitle to item 2 of argv
-display notification theMessage with title theTitle
-end run`
- return exec.Command("osascript", "-e", script, message, title).Run()
-}
-
-func notifyLinux(title, message string) error {
- return exec.Command("notify-send", title, message, "-a", "WireGuide").Run()
-}
-
-func notifyWindows(title, message string) error {
- // PowerShell toast notification — use single-quoted strings with doubled
- // single quotes to prevent PowerShell injection.
- // Sanitize newlines to prevent multi-line injection into the PS script.
- safeTitle := strings.ReplaceAll(title, "'", "''")
- safeTitle = strings.ReplaceAll(safeTitle, "\n", " ")
- safeTitle = strings.ReplaceAll(safeTitle, "\r", " ")
- safeMsg := strings.ReplaceAll(message, "'", "''")
- safeMsg = strings.ReplaceAll(safeMsg, "\n", " ")
- safeMsg = strings.ReplaceAll(safeMsg, "\r", " ")
- ps := `[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
-$template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
-$textNodes = $template.GetElementsByTagName("text")
-$textNodes.Item(0).AppendChild($template.CreateTextNode('` + safeTitle + `')) | Out-Null
-$textNodes.Item(1).AppendChild($template.CreateTextNode('` + safeMsg + `')) | Out-Null
-$toast = [Windows.UI.Notifications.ToastNotification]::new($template)
-[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("WireGuide").Show($toast)`
- cmd := exec.Command("powershell", "-Command", ps)
- sysexec.Hide(cmd)
- return cmd.Run()
-}
diff --git a/internal/reconnect/network_linux.go b/internal/reconnect/network_linux.go
index 9d0e0be..5bf624b 100644
--- a/internal/reconnect/network_linux.go
+++ b/internal/reconnect/network_linux.go
@@ -3,29 +3,31 @@
package reconnect
import (
+ "bufio"
+ "fmt"
+ "io"
"log/slog"
+ "os"
+ "strconv"
+ "strings"
"sync"
"syscall"
"golang.org/x/sys/unix"
)
-// linuxNetworkChangeDetector subscribes to the kernel's RTNETLINK multicast
-// groups for route, address, and link transitions on the underlying
-// interfaces. When a relevant event fires we coalesce a burst into a single
-// notification on ChangeChan (500ms debounce). The reconnect monitor then
-// triggers a per-tunnel reconnect, instead of waiting up to 40s for the
-// generic sleep/wake heuristic to notice.
-//
-// We DO NOT use NetworkManager DBus here — that would tie us to NM, which
-// not every distro ships (Alpine/Arch headless). Raw netlink works on any
-// kernel with CONFIG_RTNETLINK (i.e. every modern Linux).
+// linuxNetworkChangeDetector uses RTNETLINK only as a wake-up source. A
+// notification is emitted only when the actual non-tunnel default route
+// changes. Address and link chatter is common on Linux (NetworkManager,
+// mDNS, IPv6 temporary addresses, and our own TUN setup all generate it), so
+// treating every netlink message as an upstream change causes reconnect loops.
type linuxNetworkChangeDetector struct {
- mu sync.Mutex
- fd int
- stopCh chan struct{}
- changeCh chan struct{}
- running bool
+ mu sync.Mutex
+ fd int
+ stopCh chan struct{}
+ changeCh chan struct{}
+ running bool
+ lastSnapshot string
}
func NewNetworkChangeDetector() NetworkChangeDetector {
@@ -40,6 +42,7 @@ func (d *linuxNetworkChangeDetector) Start() {
}
d.stopCh = make(chan struct{})
d.changeCh = make(chan struct{}, 1)
+ d.lastSnapshot = defaultRouteSnapshot()
d.running = true
d.mu.Unlock()
@@ -69,7 +72,7 @@ func (d *linuxNetworkChangeDetector) Start() {
d.fd = fd
d.mu.Unlock()
go d.readLoop()
- slog.Info("netlink network-change detector started")
+ slog.Info("netlink network-change detector started", "default_route", d.lastSnapshot)
}
func (d *linuxNetworkChangeDetector) Stop() {
@@ -89,33 +92,36 @@ func (d *linuxNetworkChangeDetector) Stop() {
close(stop)
}
if fd != 0 {
- // Shutdown unblocks the recvfrom in readLoop.
_ = unix.Shutdown(fd, unix.SHUT_RDWR)
_ = unix.Close(fd)
}
}
-func (d *linuxNetworkChangeDetector) ChangeChan() <-chan struct{} {
- return d.changeCh
+func (d *linuxNetworkChangeDetector) ChangeChan() <-chan struct{} { return d.changeCh }
+
+func (d *linuxNetworkChangeDetector) checkDefaultRoute() {
+ now := defaultRouteSnapshot()
+ d.mu.Lock()
+ previous := d.lastSnapshot
+ if now == previous {
+ d.mu.Unlock()
+ return
+ }
+ d.lastSnapshot = now
+ d.mu.Unlock()
+
+ slog.Info("network primary upstream changed", "previous", previous, "now", now)
+ select {
+ case d.changeCh <- struct{}{}:
+ default:
+ }
}
-// readLoop drains netlink messages. Every message we receive that isn't a
-// trivial NLMSG_DONE/NLMSG_ERROR is treated as "topology changed" — we don't
-// try to filter by message type because any of our subscribed groups
-// firing implies a route/address/link change worth re-checking.
-//
-// Error handling per man netlink(7):
-// - EINTR / EAGAIN / EWOULDBLOCK: transient; keep going.
-// - ENOBUFS: kernel ran out of receive buffer and dropped messages —
-// we may have missed an RTM event. Fire a single "force" signal so
-// the reconnect monitor re-evaluates, then continue reading.
-// - Anything else (EBADF on shutdown, etc.): exit cleanly.
func (d *linuxNetworkChangeDetector) readLoop() {
buf := make([]byte, 8192)
for {
d.mu.Lock()
- running := d.running
- fd := d.fd
+ running, fd := d.running, d.fd
d.mu.Unlock()
if !running {
return
@@ -123,18 +129,13 @@ func (d *linuxNetworkChangeDetector) readLoop() {
n, _, err := unix.Recvfrom(fd, buf, 0)
if err != nil {
switch err {
- // EAGAIN == EWOULDBLOCK on Linux; listing one is enough.
case syscall.EINTR, syscall.EAGAIN:
continue
case syscall.ENOBUFS:
- slog.Warn("netlink ENOBUFS — kernel dropped messages, forcing reconnect check")
- select {
- case d.changeCh <- struct{}{}:
- default:
- }
+ slog.Warn("netlink ENOBUFS; rechecking the default route")
+ d.checkDefaultRoute()
continue
}
- // EBADF / ENOTCONN are expected during shutdown.
d.mu.Lock()
stillRunning := d.running
d.mu.Unlock()
@@ -143,22 +144,93 @@ func (d *linuxNetworkChangeDetector) readLoop() {
}
return
}
- if n <= 0 {
+ if n > 0 {
+ d.checkDefaultRoute()
+ }
+ }
+}
+
+func defaultRouteSnapshot() string {
+ v4, err4 := os.Open("/proc/net/route")
+ if err4 == nil {
+ defer v4.Close()
+ }
+ v6, err6 := os.Open("/proc/net/ipv6_route")
+ if err6 == nil {
+ defer v6.Close()
+ }
+ return routeSnapshot(v4, v6, func(iface string) bool {
+ if iface == "lo" {
+ return true
+ }
+ _, err := os.Stat("/sys/class/net/" + iface + "/tun_flags")
+ return err == nil
+ })
+}
+
+type defaultRoute struct {
+ iface string
+ gateway string
+ metric uint64
+}
+
+func routeSnapshot(v4, v6 io.Reader, isTunnel func(string) bool) string {
+ parts := make([]string, 0, 2)
+ if route, ok := bestIPv4Default(v4, isTunnel); ok {
+ parts = append(parts, fmt.Sprintf("v4:%s:%s:%d", route.iface, route.gateway, route.metric))
+ }
+ if route, ok := bestIPv6Default(v6, isTunnel); ok {
+ parts = append(parts, fmt.Sprintf("v6:%s:%s:%d", route.iface, route.gateway, route.metric))
+ }
+ return strings.Join(parts, "|")
+}
+
+func bestIPv4Default(r io.Reader, isTunnel func(string) bool) (defaultRoute, bool) {
+ var best defaultRoute
+ found := false
+ if r == nil {
+ return best, false
+ }
+ s := bufio.NewScanner(r)
+ for s.Scan() {
+ f := strings.Fields(s.Text())
+ if len(f) < 8 || f[1] != "00000000" || f[7] != "00000000" || isTunnel(f[0]) {
+ continue
+ }
+ flags, err1 := strconv.ParseUint(f[3], 16, 64)
+ metric, err2 := strconv.ParseUint(f[6], 10, 64)
+ if err1 != nil || err2 != nil || flags&unix.RTF_UP == 0 {
continue
}
- // Signal the debouncer non-blockingly.
- select {
- case d.changeCh <- struct{}{}:
- default:
- // Coalesce: a notification is already pending.
+ candidate := defaultRoute{iface: f[0], gateway: f[2], metric: metric}
+ if !found || candidate.metric < best.metric {
+ best, found = candidate, true
}
}
+ return best, found
}
-// Note on coalescing: the readLoop sends directly to changeCh (cap 1)
-// non-blockingly. If multiple RTM messages arrive in quick succession
-// only the first one is delivered until a consumer drains the channel —
-// the cap-1 buffer already implements "edge-triggered, single pending
-// notification" coalescing. The reconnect monitor downstream runs each
-// reconnect under its own backoff, so a separate debouncer goroutine
-// inside this detector adds no value.
+func bestIPv6Default(r io.Reader, isTunnel func(string) bool) (defaultRoute, bool) {
+ var best defaultRoute
+ found := false
+ if r == nil {
+ return best, false
+ }
+ s := bufio.NewScanner(r)
+ for s.Scan() {
+ f := strings.Fields(s.Text())
+ if len(f) < 10 || f[0] != strings.Repeat("0", 32) || f[1] != "00" || f[2] != strings.Repeat("0", 32) || f[3] != "00" || isTunnel(f[9]) {
+ continue
+ }
+ metric, err := strconv.ParseUint(f[5], 16, 64)
+ flags, flagsErr := strconv.ParseUint(f[8], 16, 64)
+ if err != nil || flagsErr != nil || flags&unix.RTF_UP == 0 {
+ continue
+ }
+ candidate := defaultRoute{iface: f[9], gateway: f[4], metric: metric}
+ if !found || candidate.metric < best.metric {
+ best, found = candidate, true
+ }
+ }
+ return best, found
+}
diff --git a/internal/reconnect/network_linux_test.go b/internal/reconnect/network_linux_test.go
new file mode 100644
index 0000000..faf565b
--- /dev/null
+++ b/internal/reconnect/network_linux_test.go
@@ -0,0 +1,43 @@
+//go:build linux
+
+package reconnect
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestRouteSnapshotIgnoresNoiseAndTunnelDefaults(t *testing.T) {
+ v4 := `Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT
+wlan0 00000000 0101A8C0 0003 0 0 600 00000000 0 0 0
+wg-demo 00000000 00000000 0001 0 0 0 00000000 0 0 0
+wlan0 0001A8C0 00000000 0001 0 0 600 00FFFFFF 0 0 0
+`
+ v6 := `00000000000000000000000000000000 00 00000000000000000000000000000000 00 FE800000000000000001000000000001 00000400 00000000 00000000 00000003 wlan0
+00000000000000000000000000000000 00 00000000000000000000000000000000 00 00000000000000000000000000000000 00000000 00000000 00000000 00000001 wg-demo
+`
+ isTunnel := func(name string) bool { return name == "wg-demo" }
+ got := routeSnapshot(strings.NewReader(v4), strings.NewReader(v6), isTunnel)
+ want := "v4:wlan0:0101A8C0:600|v6:wlan0:FE800000000000000001000000000001:1024"
+ if got != want {
+ t.Fatalf("routeSnapshot() = %q, want %q", got, want)
+ }
+}
+
+func TestRouteSnapshotChangesOnlyForDefaultRouteState(t *testing.T) {
+ base := `Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT
+wlan0 00000000 0101A8C0 0003 0 0 600 00000000 0 0 0
+`
+ withAddressRouteNoise := base + "wlan0 0001A8C0 00000000 0001 0 0 600 00FFFFFF 0 0 0\n"
+ changedGateway := strings.Replace(base, "0101A8C0", "FE01A8C0", 1)
+ none := func(string) bool { return false }
+ a := routeSnapshot(strings.NewReader(base), nil, none)
+ b := routeSnapshot(strings.NewReader(withAddressRouteNoise), nil, none)
+ c := routeSnapshot(strings.NewReader(changedGateway), nil, none)
+ if a != b {
+ t.Fatalf("non-default route noise changed snapshot: %q != %q", a, b)
+ }
+ if a == c {
+ t.Fatalf("gateway change did not change snapshot: %q", a)
+ }
+}
diff --git a/internal/storage/rename.go b/internal/storage/rename.go
index f98d6e1..882ba62 100644
--- a/internal/storage/rename.go
+++ b/internal/storage/rename.go
@@ -6,13 +6,6 @@ import (
"runtime"
)
-// atomicRename moves src to dst. On modern Go (1.21+), os.Rename uses
-// MoveFileEx with MOVEFILE_REPLACE_EXISTING on Windows, so it handles
-// overwriting the destination atomically on all platforms.
-func atomicRename(src, dst string) error {
- return os.Rename(src, dst)
-}
-
// atomicRenameDurable renames src→dst and then fsyncs the containing
// directory so the rename's directory entry survives a power loss. The
// per-file fsync in the writers makes the CONTENT durable, but the
diff --git a/internal/sysexec/detach_other.go b/internal/sysexec/detach_other.go
new file mode 100644
index 0000000..9495048
--- /dev/null
+++ b/internal/sysexec/detach_other.go
@@ -0,0 +1,24 @@
+//go:build !windows
+
+package sysexec
+
+import (
+ "os/exec"
+ "syscall"
+)
+
+// Detach configures cmd so the child survives this process exiting and does
+// not share its controlling terminal. Setsid puts the child in a new session
+// and process group, so a Ctrl-C in the launching shell (or the shell itself
+// going away) doesn't take it down with it.
+//
+// Safe to call before any field on cmd has been set. Idempotent.
+func Detach(cmd *exec.Cmd) {
+ if cmd == nil {
+ return
+ }
+ if cmd.SysProcAttr == nil {
+ cmd.SysProcAttr = &syscall.SysProcAttr{}
+ }
+ cmd.SysProcAttr.Setsid = true
+}
diff --git a/internal/sysexec/detach_windows.go b/internal/sysexec/detach_windows.go
new file mode 100644
index 0000000..b25c47f
--- /dev/null
+++ b/internal/sysexec/detach_windows.go
@@ -0,0 +1,31 @@
+//go:build windows
+
+package sysexec
+
+import (
+ "os/exec"
+ "syscall"
+)
+
+// Win32 process creation flags:
+// - detachedProcess gives the child no inherited console, so it is not
+// killed when the launching console window closes.
+// - createNewProcessGroup keeps a Ctrl-C in the launching console from
+// being delivered to the child.
+const (
+ detachedProcess uint32 = 0x00000008
+ createNewProcessGroup uint32 = 0x00000200
+)
+
+// Detach configures cmd so the child survives this process exiting and is
+// not tied to the launching console. Safe to call before any field on cmd
+// has been set. Idempotent.
+func Detach(cmd *exec.Cmd) {
+ if cmd == nil {
+ return
+ }
+ if cmd.SysProcAttr == nil {
+ cmd.SysProcAttr = &syscall.SysProcAttr{}
+ }
+ cmd.SysProcAttr.CreationFlags |= detachedProcess | createNewProcessGroup
+}
diff --git a/internal/tunnel/connect_phases.go b/internal/tunnel/connect_phases.go
index 11313b5..e436cd2 100644
--- a/internal/tunnel/connect_phases.go
+++ b/internal/tunnel/connect_phases.go
@@ -29,6 +29,10 @@ func (m *Manager) connectPhases(ctx context.Context, cfg *domain.WireGuardConfig
// Compute fullTunnel early — needed by the rollback closure and later
// by AddRoutes. It only depends on cfg which is a parameter.
fullTunnel := cfg.IsFullTunnel()
+ var allAllowedIPs []string
+ for _, peer := range cfg.Peers {
+ allAllowedIPs = append(allAllowedIPs, peer.AllowedIPs...)
+ }
// 2. Engine
factory := m.engineFactory
@@ -46,7 +50,7 @@ func (m *Manager) connectPhases(ctx context.Context, cfg *domain.WireGuardConfig
// errors because we already have a primary failure to report.
rollback := func(primary error) error {
// Undo routes that may have been installed before the failure.
- if err := netMgr.RemoveRoutes(ifaceName, nil, fullTunnel); err != nil {
+ if err := netMgr.RemoveRoutes(ifaceName, allAllowedIPs, fullTunnel); err != nil {
slog.Warn("rollback: RemoveRoutes failed", "error", err)
}
// Strip any endpoint loop protection filters we installed before
@@ -81,7 +85,6 @@ func (m *Manager) connectPhases(ctx context.Context, cfg *domain.WireGuardConfig
if err := checkCtx(); err != nil {
return nil, err
}
-
// 3. MTU — pass the user-configured value straight through. If it's 0
// (unset), the platform adapter does wg-quick's upstream-MTU-minus-80
// auto-detection. Do NOT default to 1420 here: that would shadow the
@@ -175,10 +178,6 @@ func (m *Manager) connectPhases(ctx context.Context, cfg *domain.WireGuardConfig
// itself, looping back to an endpoint that has no bypass yet. This is
// the chicken-and-egg that wg-quick sidesteps by resolving endpoints
// via the `wg` tool BEFORE touching the route table.
- var allAllowedIPs []string
- for _, peer := range cfg.Peers {
- allAllowedIPs = append(allAllowedIPs, peer.AllowedIPs...)
- }
endpointIPs := engine.ResolvedEndpointIPs()
if err := netMgr.AddRoutes(ifaceName, allAllowedIPs, fullTunnel, endpointIPs, cfg.Interface.Table, cfg.Interface.FwMark); err != nil {
return nil, rollback(newTunnelError(ErrNetwork, "adding routes", err))
@@ -186,6 +185,10 @@ func (m *Manager) connectPhases(ctx context.Context, cfg *domain.WireGuardConfig
if err := checkCtx(); err != nil {
return nil, err
}
+ var endpointRoutes []string
+ if provider, ok := netMgr.(network.EndpointRouteStateProvider); ok {
+ endpointRoutes = provider.InstalledEndpointRoutes()
+ }
// 7. DNS — fatal when DNS servers are explicitly configured (matching
// wg-quick's behaviour). A silent DNS failure leaves the user on their
@@ -226,12 +229,13 @@ func (m *Manager) connectPhases(ctx context.Context, cfg *domain.WireGuardConfig
// the per-service overrides to DHCP defaults). Empty PreModDNS
// triggers the fallback path in RecoverFromCrash.
if err := SaveActiveState(m.dataDir, &ActiveTunnelState{
- TunnelName: cfg.Name,
- InterfaceName: ifaceName,
- DNSServers: cfg.Interface.DNS,
- FullTunnel: fullTunnel,
- Table: cfg.Interface.Table,
- FwMark: cfg.Interface.FwMark,
+ TunnelName: cfg.Name,
+ InterfaceName: ifaceName,
+ DNSServers: cfg.Interface.DNS,
+ FullTunnel: fullTunnel,
+ Table: cfg.Interface.Table,
+ FwMark: cfg.Interface.FwMark,
+ EndpointRoutes: endpointRoutes,
}); err != nil {
slog.Warn("failed to persist pre-DNS crash recovery state", "error", err)
}
@@ -253,20 +257,22 @@ func (m *Manager) connectPhases(ctx context.Context, cfg *domain.WireGuardConfig
// would already include this tunnel's overrides, so we
// deliberately ignore them via CapturePreModDNS's first-write-
// wins guard.
- var preModDNS map[string][]string
+ var preMod network.DNSSnapshot
if provider, ok := netMgr.(network.DNSSnapshotProvider); ok {
- preModDNS = provider.SavedDNSSnapshot()
- m.CapturePreModDNS(preModDNS)
+ preMod = provider.SavedDNSSnapshot()
+ m.CapturePreModDNS(preMod)
}
if err := SaveActiveState(m.dataDir, &ActiveTunnelState{
- TunnelName: cfg.Name,
- InterfaceName: ifaceName,
- DNSServers: cfg.Interface.DNS,
- FullTunnel: fullTunnel,
- Table: cfg.Interface.Table,
- FwMark: cfg.Interface.FwMark,
- PreModDNS: preModDNS,
+ TunnelName: cfg.Name,
+ InterfaceName: ifaceName,
+ DNSServers: cfg.Interface.DNS,
+ FullTunnel: fullTunnel,
+ Table: cfg.Interface.Table,
+ FwMark: cfg.Interface.FwMark,
+ EndpointRoutes: endpointRoutes,
+ PreModDNS: preMod.Servers,
+ PreModSearch: preMod.Search,
}); err != nil {
slog.Warn("failed to persist crash recovery state", "error", err)
}
@@ -354,7 +360,7 @@ func (m *Manager) disconnectPhases(cfg *domain.WireGuardConfig, engine *Engine,
// would still hold the previous tunnel's DNS overrides.
// Cleanup's internal RestoreDNS becomes a no-op because
// RestoreDNSFromSnapshot clears dnsActive.
- if pre := m.PreModDNSSnapshot(); pre != nil {
+ if pre, captured := m.PreModDNSSnapshot(); captured {
if r, ok := netMgr.(network.DNSStateRestorer); ok {
if err := r.RestoreDNSFromSnapshot(pre); err != nil {
slog.Warn("RestoreDNSFromSnapshot failed; falling back to per-netMgr restore", "error", err)
diff --git a/internal/tunnel/engine.go b/internal/tunnel/engine.go
index addffd2..a2ed279 100644
--- a/internal/tunnel/engine.go
+++ b/internal/tunnel/engine.go
@@ -2,6 +2,7 @@ package tunnel
import (
"context"
+ "crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
@@ -123,14 +124,11 @@ func NewEngine(cfg *config.WireGuardConfig) (*Engine, error) {
// Platform-specific TUN device name:
// - macOS: "utun" — wireguard-go allocates utun0, utun1, etc.
- // - Linux: "wg" — wireguard-go creates wg0, wg1, etc. ("utun" is invalid on Linux)
- // - Windows: "WireGuide" — Windows expects a proper adapter name, not "utun"
- tunName := "utun"
- switch runtime.GOOS {
- case "linux":
- tunName = "wg"
- case "windows":
- tunName = "WireGuide"
+ // - Linux/Windows: a stable name derived from the tunnel name. CreateTUN
+ // treats its argument as an exact name on these platforms; a single
+ // constant made every second simultaneous tunnel fail with EBUSY.
+ tunName := platformTUNName(runtime.GOOS, cfg.Name)
+ if runtime.GOOS == "windows" {
// Best-effort: close any leftover adapter from a previous helper
// crash before CreateTUN attempts to allocate the same name.
// No-op on non-Windows.
@@ -242,6 +240,25 @@ func NewEngine(cfg *config.WireGuardConfig) (*Engine, error) {
return engine, nil
}
+// platformTUNName returns a deterministic, collision-resistant adapter name.
+// Linux IFNAMSIZ allows 15 visible bytes, hence the 3-byte prefix plus twelve
+// hex digits. Hashing also keeps spaces and non-ASCII tunnel names away from
+// platform naming restrictions without leaking the user-visible name.
+func platformTUNName(goos, tunnelName string) string {
+ if goos == "darwin" {
+ return "utun"
+ }
+ sum := sha256.Sum256([]byte(tunnelName))
+ suffix := fmt.Sprintf("%x", sum[:6])
+ if goos == "linux" {
+ return "wg-" + suffix
+ }
+ if goos == "windows" {
+ return "WireGuide-" + suffix
+ }
+ return "wg-" + suffix
+}
+
// Start transitions the WireGuard device into the running state by
// calling wgDev.Up(). Split from NewEngine so the connect_phases caller
// can install platform firewall rules BEFORE the first handshake fires
@@ -405,7 +422,6 @@ func keyToHex(b64Key string) (string, error) {
return hex.EncodeToString(raw), nil
}
-
// newWireguardSlogLogger builds a wireguard-go logger that routes Errorf to
// our structured log stream at Warn level, and Verbosef at Debug level.
// Verbosef is called by wireguard-go on per-packet events (key rotations,
diff --git a/internal/tunnel/loop_watchdog_darwin.go b/internal/tunnel/loop_watchdog_darwin.go
index b7a0d7d..e27f5d9 100644
--- a/internal/tunnel/loop_watchdog_darwin.go
+++ b/internal/tunnel/loop_watchdog_darwin.go
@@ -35,11 +35,42 @@ package tunnel
// - 3 × 5 s = 15 s sustained: defeats bursty downloads and brief
// speed-tests, trips on the steady-state loop signature.
+/*
+#include
+#include
+#include
+#include
+#include
+#include
+
+// iface_octets fetches the 64-bit interface byte counters via the IFMIB
+// sysctl — the same source netstat prints. Two approaches that look right
+// are wrong (both verified empirically against netstat on live traffic):
+// - NET_RT_IFLIST2's if_msghdr2.ifm_data.ifi_ibytes WRAPS AT 32 BITS on
+// modern macOS even though the field is declared u_int64_t; only the
+// IFMIB ifmd_data carries true 64-bit counters.
+// - Reading pack(4) structs through cgo's generated Go types misreads
+// fields — cgo lays them out with natural alignment. So the struct
+// access stays here in C, compiled by clang against the real headers.
+static int iface_octets(int ifindex, unsigned long long *ibytes, unsigned long long *obytes) {
+ struct ifmibdata md;
+ size_t len = sizeof(md);
+ int mib[6] = {CTL_NET, PF_LINK, NETLINK_GENERIC, IFMIB_IFDATA, ifindex, IFDATA_GENERAL};
+ if (sysctl(mib, 6, &md, &len, NULL, 0) != 0) {
+ return 0;
+ }
+ *ibytes = md.ifmd_data.ifi_ibytes;
+ *obytes = md.ifmd_data.ifi_obytes;
+ return 1;
+}
+*/
+import "C"
import (
"bufio"
"bytes"
"context"
"log/slog"
+ "net"
"os/exec"
"strconv"
"strings"
@@ -156,12 +187,42 @@ func maxU64(a, b uint64) uint64 {
}
// readInterfaceOctets reads the kernel's input/output byte counters for
-// the named interface via `netstat -ibnI `. The first data row is
-// the AF_LINK (aggregate) entry whose Ibytes/Obytes are the totals
-// across all address families on the interface — the per-address-family
-// rows that follow carry the same totals (they're aliases of the link
-// entry, not partitions), so taking the first data row is correct and
-// avoids double-counting on multihomed interfaces.
+// the named interface. Primary path is a NET_RT_IFLIST2 sysctl — a plain
+// syscall, matching the Windows watchdog's GetIfEntry2 approach — because
+// the previous `netstat -ibnI` implementation fork/exec'd a subprocess
+// every 5 s for the life of every full-tunnel connection (~17k spawns/day,
+// keeping laptops out of deep idle). netstat is kept as a fallback should
+// the sysctl ever fail.
+func readInterfaceOctets(ifaceName string) (uint64, uint64, bool) {
+ if in, out, ok := readInterfaceOctetsSysctl(ifaceName); ok {
+ return in, out, ok
+ }
+ return readInterfaceOctetsNetstat(ifaceName)
+}
+
+// readInterfaceOctetsSysctl queries NET_RT_IFLIST2 filtered by interface
+// index and reads if_data64.ifi_ibytes/ifi_obytes from the RTM_IFINFO2
+// message. The buffer walk and struct access live in the cgo preamble's
+// iface_octets — see the comment there for why the C side must do it.
+func readInterfaceOctetsSysctl(ifaceName string) (uint64, uint64, bool) {
+ ifi, err := net.InterfaceByName(ifaceName)
+ if err != nil {
+ return 0, 0, false
+ }
+ var ibytes, obytes C.ulonglong
+ if C.iface_octets(C.int(ifi.Index), &ibytes, &obytes) == 0 {
+ return 0, 0, false
+ }
+ return uint64(ibytes), uint64(obytes), true
+}
+
+// readInterfaceOctetsNetstat is the subprocess fallback: `netstat -ibnI
+// `. The first data row is the AF_LINK (aggregate) entry whose
+// Ibytes/Obytes are the totals across all address families on the
+// interface — the per-address-family rows that follow carry the same
+// totals (they're aliases of the link entry, not partitions), so taking
+// the first data row is correct and avoids double-counting on multihomed
+// interfaces.
//
// Column positions are resolved from the header row dynamically rather
// than hardcoded. Apple has shipped layout-different netstat versions
@@ -172,7 +233,7 @@ func maxU64(a, b uint64) uint64 {
// LC_ALL=C forces English headers ("Ibytes"/"Obytes") on non-English
// macOS installs, mirroring what the rest of the darwin network code
// does for its netstat parsers.
-func readInterfaceOctets(ifaceName string) (uint64, uint64, bool) {
+func readInterfaceOctetsNetstat(ifaceName string) (uint64, uint64, bool) {
ctx, cancel := context.WithTimeout(context.Background(), netstatCmdTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "netstat", "-ibnI", ifaceName)
diff --git a/internal/tunnel/loop_watchdog_darwin_test.go b/internal/tunnel/loop_watchdog_darwin_test.go
index 5d6e29a..d3938ff 100644
--- a/internal/tunnel/loop_watchdog_darwin_test.go
+++ b/internal/tunnel/loop_watchdog_darwin_test.go
@@ -2,7 +2,10 @@
package tunnel
-import "testing"
+import (
+ "net"
+ "testing"
+)
func TestParseNetstatIB(t *testing.T) {
tests := []struct {
@@ -95,3 +98,35 @@ utun4 1380 - - -
})
}
}
+
+// TestReadInterfaceOctetsSysctlMatchesNetstat cross-checks the primary
+// sysctl reader against the netstat fallback on a live interface. The
+// counters advance between the two reads, so equality is asserted within
+// a tolerance rather than exactly.
+func TestReadInterfaceOctetsSysctlMatchesNetstat(t *testing.T) {
+ const iface = "en0"
+ if _, err := net.InterfaceByName(iface); err != nil {
+ t.Skipf("no %s on this machine: %v", iface, err)
+ }
+ sIn, sOut, sOK := readInterfaceOctetsSysctl(iface)
+ nIn, nOut, nOK := readInterfaceOctetsNetstat(iface)
+ if !sOK {
+ t.Fatal("sysctl reader failed on a live interface")
+ }
+ if !nOK {
+ t.Skip("netstat fallback unavailable; cannot cross-check")
+ }
+ const tolerance = 16 << 20 // 16 MiB of traffic between the two reads
+ diff := func(a, b uint64) uint64 {
+ if a > b {
+ return a - b
+ }
+ return b - a
+ }
+ if diff(sIn, nIn) > tolerance {
+ t.Errorf("ibytes: sysctl=%d netstat=%d differ by more than %d", sIn, nIn, tolerance)
+ }
+ if diff(sOut, nOut) > tolerance {
+ t.Errorf("obytes: sysctl=%d netstat=%d differ by more than %d", sOut, nOut, tolerance)
+ }
+}
diff --git a/internal/tunnel/manager.go b/internal/tunnel/manager.go
index 6c0064d..6545c2b 100644
--- a/internal/tunnel/manager.go
+++ b/internal/tunnel/manager.go
@@ -84,7 +84,7 @@ type Manager struct {
// the original DHCP defaults rather than the per-netMgr savedDNS,
// which for a non-first tunnel would have been the previous tunnel's
// already-applied DNS. Guarded by m.mu.
- globalPreModDNS map[string][]string
+ globalPreModDNS *network.DNSSnapshot
// endpointProtector is the optional always-on loop protection hook
// (Windows full-tunnel only). Set by the helper after construction
@@ -181,14 +181,16 @@ func (m *Manager) ConnectWithContext(ctx context.Context, cfg *domain.WireGuardC
// --- Phase 1: claim the connecting slot under the lock ---
m.mu.Lock()
- entry := m.getOrCreateEntry(name)
- switch entry.state {
- case domain.StateConnected:
- m.mu.Unlock()
- return newTunnelError(ErrAlreadyConnected, fmt.Sprintf("tunnel %q is already connected", name), nil)
- case domain.StateConnecting, stateDisconnecting:
- m.mu.Unlock()
- return newTunnelError(ErrTransitionInProgress, fmt.Sprintf("tunnel %q: another transition is in progress", name), nil)
+ entry, entryExists := m.tunnels[name]
+ if entryExists {
+ switch entry.state {
+ case domain.StateConnected:
+ m.mu.Unlock()
+ return newTunnelError(ErrAlreadyConnected, fmt.Sprintf("tunnel %q is already connected", name), nil)
+ case domain.StateConnecting, stateDisconnecting:
+ m.mu.Unlock()
+ return newTunnelError(ErrTransitionInProgress, fmt.Sprintf("tunnel %q: another transition is in progress", name), nil)
+ }
}
// Reject if the new config is full-tunnel and any existing connected tunnel
// is also full-tunnel — two 0.0.0.0/0 routes conflict on the route table.
@@ -201,6 +203,12 @@ func (m *Manager) ConnectWithContext(ctx context.Context, cfg *domain.WireGuardC
}
}
}
+ // Do not create the slot until every preflight check has passed. Creating
+ // it before the full-tunnel conflict check left a disconnected ghost in
+ // AllStatuses()/`ctl status` whenever a second full tunnel was rejected.
+ if !entryExists {
+ entry = m.getOrCreateEntry(name)
+ }
// Stash the tunnel config early so Status() can show "connecting "
// while the phases are running.
@@ -210,6 +218,9 @@ func (m *Manager) ConnectWithContext(ctx context.Context, cfg *domain.WireGuardC
// Create a per-tunnel NetworkManager so this tunnel's routes, DNS
// snapshot, and route monitor are independent of other tunnels.
netMgr := m.netMgrFactory()
+ if setter, ok := netMgr.(network.PersistentStateDirSetter); ok {
+ setter.SetPersistentStateDir(m.dataDir)
+ }
if m.pinInterface {
if dm, ok := netMgr.(interface{ SetPinInterface(bool) }); ok {
dm.SetPinInterface(true)
diff --git a/internal/tunnel/manager_dns.go b/internal/tunnel/manager_dns.go
index 8d20789..6d8cb6e 100644
--- a/internal/tunnel/manager_dns.go
+++ b/internal/tunnel/manager_dns.go
@@ -1,6 +1,11 @@
package tunnel
-import "github.com/korjwl1/wireguide/internal/domain"
+import (
+ "log/slog"
+
+ "github.com/korjwl1/wireguide/internal/domain"
+ "github.com/korjwl1/wireguide/internal/network"
+)
// AllDNSServers returns the union of DNS servers from all connected tunnels'
// configs. Used to re-apply the combined DNS when a tunnel connects or
@@ -21,36 +26,30 @@ func (m *Manager) AllDNSServers() []string {
// after A, B's savedDNS is A's DNS — so when B disconnects last via
// netMgr_B.Cleanup the user's system would get restored to A's DNS
// instead of the original DHCP defaults.
-func (m *Manager) CapturePreModDNS(snapshot map[string][]string) {
+func (m *Manager) CapturePreModDNS(snapshot network.DNSSnapshot) {
m.mu.Lock()
defer m.mu.Unlock()
- if m.globalPreModDNS != nil || len(snapshot) == 0 {
+ if m.globalPreModDNS != nil || snapshot.Empty() {
return
}
- cp := make(map[string][]string, len(snapshot))
- for k, v := range snapshot {
- c := make([]string, len(v))
- copy(c, v)
- cp[k] = c
+ m.globalPreModDNS = &network.DNSSnapshot{
+ Servers: copyServiceMap(snapshot.Servers),
+ Search: copyServiceMap(snapshot.Search),
}
- m.globalPreModDNS = cp
}
-// PreModDNSSnapshot returns a copy of the captured pre-VPN DNS, or nil
-// if nothing has been captured yet.
-func (m *Manager) PreModDNSSnapshot() map[string][]string {
+// PreModDNSSnapshot returns a copy of the captured pre-VPN DNS state and
+// whether anything has been captured yet.
+func (m *Manager) PreModDNSSnapshot() (network.DNSSnapshot, bool) {
m.mu.Lock()
defer m.mu.Unlock()
if m.globalPreModDNS == nil {
- return nil
- }
- cp := make(map[string][]string, len(m.globalPreModDNS))
- for k, v := range m.globalPreModDNS {
- c := make([]string, len(v))
- copy(c, v)
- cp[k] = c
+ return network.DNSSnapshot{}, false
}
- return cp
+ return network.DNSSnapshot{
+ Servers: copyServiceMap(m.globalPreModDNS.Servers),
+ Search: copyServiceMap(m.globalPreModDNS.Search),
+ }, true
}
// ClearPreModDNS drops the captured snapshot once the last tunnel has
@@ -61,6 +60,46 @@ func (m *Manager) ClearPreModDNS() {
m.mu.Unlock()
}
+// RestoreDNSBestEffort restores the pre-VPN DNS state using the global
+// snapshot and any live tunnel's network manager. Used by ForceShutdown,
+// which exits without tunnel teardown: the utun devices die with the
+// process but networksetup overrides persist in SystemConfiguration
+// (issue #34 gap 4) — without this a helper upgrade while connected left
+// tunnel DNS behind until crash recovery ran.
+func (m *Manager) RestoreDNSBestEffort() {
+ pre, captured := m.PreModDNSSnapshot()
+ if !captured {
+ return
+ }
+ m.mu.Lock()
+ var restorer network.DNSStateRestorer
+ for _, e := range m.tunnels {
+ if r, ok := e.netMgr.(network.DNSStateRestorer); ok {
+ restorer = r
+ break
+ }
+ }
+ m.mu.Unlock()
+ if restorer == nil {
+ return
+ }
+ if err := restorer.RestoreDNSFromSnapshot(pre); err != nil {
+ slog.Warn("RestoreDNSBestEffort failed", "error", err)
+ }
+ m.ClearPreModDNS()
+}
+
+func copyServiceMap(in map[string][]string) map[string][]string {
+ if in == nil {
+ return nil
+ }
+ out := make(map[string][]string, len(in))
+ for k, v := range in {
+ out[k] = append([]string(nil), v...)
+ }
+ return out
+}
+
// allDNSServersLocked is AllDNSServers without the lock — for callers
// that already hold m.mu (e.g. inside the Phase-3 commit of Connect).
// Today no caller needs it, but exposing the locked variant means a
diff --git a/internal/tunnel/manager_test.go b/internal/tunnel/manager_test.go
index 608d151..4d366ff 100644
--- a/internal/tunnel/manager_test.go
+++ b/internal/tunnel/manager_test.go
@@ -29,13 +29,15 @@ type mockNetworkManager struct {
cleanupErr error
// Call tracking
- mu sync.Mutex
- mtuCalls int
- addressCalls int
- bringUpCalls int
- routeCalls int
- dnsCalls int
- cleanupCalls int
+ mu sync.Mutex
+ mtuCalls int
+ addressCalls int
+ bringUpCalls int
+ routeCalls int
+ dnsCalls int
+ cleanupCalls int
+ removedRoutes []string
+ persistentDir string
}
func (m *mockNetworkManager) AssignAddress(string, []string) error {
@@ -66,9 +68,10 @@ func (m *mockNetworkManager) AddRoutes(string, []string, bool, []string, string,
return m.addRoutesErr
}
-func (m *mockNetworkManager) RemoveRoutes(string, []string, bool) error {
+func (m *mockNetworkManager) RemoveRoutes(_ string, allowedIPs []string, _ bool) error {
m.mu.Lock()
m.cleanupCalls++ // counts as cleanup-related
+ m.removedRoutes = append([]string(nil), allowedIPs...)
m.mu.Unlock()
return m.removeRoutesErr
}
@@ -80,8 +83,13 @@ func (m *mockNetworkManager) SetDNS(string, []string) error {
return m.setDNSErr
}
-func (m *mockNetworkManager) RestoreDNS(string) error { return m.restoreDNSErr }
+func (m *mockNetworkManager) RestoreDNS(string) error { return m.restoreDNSErr }
func (m *mockNetworkManager) ResetDNSToSystemDefault() error { return nil }
+func (m *mockNetworkManager) SetPersistentStateDir(dir string) {
+ m.mu.Lock()
+ m.persistentDir = dir
+ m.mu.Unlock()
+}
func (m *mockNetworkManager) Cleanup(string) error {
m.mu.Lock()
m.cleanupCalls++
@@ -154,9 +162,9 @@ func testConfig(name string) *domain.WireGuardConfig {
},
Peers: []domain.PeerConfig{
{
- PublicKey: "not-used-in-tests",
- AllowedIPs: []string{"10.0.0.0/24"},
- Endpoint: "1.2.3.4:51820",
+ PublicKey: "not-used-in-tests",
+ AllowedIPs: []string{"10.0.0.0/24"},
+ Endpoint: "1.2.3.4:51820",
},
},
}
@@ -272,6 +280,25 @@ func TestConnect_Success(t *testing.T) {
if tunnelConnectedAt(mgr, "vpn1").IsZero() {
t.Fatal("connectedAt should be set after Connect")
}
+ if net.persistentDir != dir {
+ t.Fatalf("platform manager persistent dir = %q, want %q", net.persistentDir, dir)
+ }
+}
+
+func TestPlatformTUNNameIsStableAndDistinct(t *testing.T) {
+ a := platformTUNName("linux", "vpn-a")
+ if a != platformTUNName("linux", "vpn-a") {
+ t.Fatal("same tunnel name produced different interface names")
+ }
+ if a == platformTUNName("linux", "vpn-b") {
+ t.Fatal("different tunnel names produced the same interface name")
+ }
+ if len(a) > 15 {
+ t.Fatalf("Linux interface name %q exceeds IFNAMSIZ", a)
+ }
+ if got := platformTUNName("darwin", "vpn-a"); got != "utun" {
+ t.Fatalf("darwin TUN name = %q, want utun", got)
+ }
}
func TestConnect_AlreadyConnected(t *testing.T) {
@@ -456,6 +483,9 @@ func TestConnect_DNSFailure_FatalWhenServersConfigured(t *testing.T) {
if tunnelState(mgr, "vpn1") != domain.StateDisconnected {
t.Fatalf("expected disconnected, got %s", tunnelState(mgr, "vpn1"))
}
+ if len(net.removedRoutes) != 1 || net.removedRoutes[0] != "10.0.0.0/24" {
+ t.Fatalf("rollback removed routes %v, want configured AllowedIPs", net.removedRoutes)
+ }
}
func TestConnect_DNSFailure_NonFatalWhenNoServers(t *testing.T) {
@@ -1025,6 +1055,10 @@ func TestConnect_FullTunnelConflict(t *testing.T) {
// Second full-tunnel connect should be rejected.
err := mgr.Connect(testFullTunnelConfig("vpn-full-2"))
assertTunnelError(t, err, ErrFullTunnelConflict)
+ statuses := mgr.AllStatuses()
+ if len(statuses) != 1 || statuses[0].TunnelName != "vpn-full-1" {
+ t.Fatalf("rejected full tunnel left a ghost status: %+v", statuses)
+ }
// A split-tunnel should still be allowed alongside a full-tunnel.
if err := mgr.Connect(testConfig("vpn-split")); err != nil {
diff --git a/internal/tunnel/recovery.go b/internal/tunnel/recovery.go
index 36da876..1a31e37 100644
--- a/internal/tunnel/recovery.go
+++ b/internal/tunnel/recovery.go
@@ -23,17 +23,24 @@ type FirewallCleaner interface {
// ActiveTunnelState is persisted to disk while a tunnel is active.
// On startup, if this file exists, a previous crash is detected.
type ActiveTunnelState struct {
- TunnelName string `json:"tunnel_name"`
- InterfaceName string `json:"interface_name"`
- DNSServers []string `json:"dns_servers_original"`
- FullTunnel bool `json:"full_tunnel"`
- Table string `json:"table,omitempty"`
- FwMark string `json:"fwmark,omitempty"`
- // PreModDNS stores the original DNS settings per network service
+ TunnelName string `json:"tunnel_name"`
+ InterfaceName string `json:"interface_name"`
+ DNSServers []string `json:"dns_servers_original"`
+ FullTunnel bool `json:"full_tunnel"`
+ Table string `json:"table,omitempty"`
+ FwMark string `json:"fwmark,omitempty"`
+ EndpointRoutes []string `json:"endpoint_routes,omitempty"`
+ // PreModDNS stores the original DNS servers per network service
// captured BEFORE any modification. Used for precise crash recovery
// instead of the blunt ResetDNSToSystemDefault which loses custom
// user preferences.
PreModDNS map[string][]string `json:"pre_mod_dns,omitempty"`
+ // PreModSearch stores the original search domains per network
+ // service. Absent in journals written before issue #34's fix; the
+ // restore then clears search domains to "Empty" (DHCP defaults),
+ // which is the correct behaviour for the common no-custom-domains
+ // setup and strictly better than leaking tunnel domains.
+ PreModSearch map[string][]string `json:"pre_mod_search,omitempty"`
}
// Legacy single-tunnel state file (kept for backward-compatible migration).
@@ -71,15 +78,6 @@ func ClearActiveState(dataDir string, tunnelName string) error {
return nil
}
-// ClearAllActiveStates removes all per-tunnel state files.
-func ClearAllActiveStates(dataDir string) error {
- dir := filepath.Join(dataDir, tunnelStatesDir)
- if err := os.RemoveAll(dir); err != nil && !os.IsNotExist(err) {
- return err
- }
- return nil
-}
-
// LoadActiveState reads all active tunnel states from the tunnel-states
// directory. Falls back to the legacy single-file format for migration.
func LoadActiveState(dataDir string) []*ActiveTunnelState {
@@ -171,6 +169,9 @@ func RecoverFromCrash(dataDir string, fw FirewallCleaner) []string {
// per-tunnel state across iterations and double-restored
// overlapping services).
mgr := network.NewPlatformManager()
+ if setter, mok := mgr.(network.PersistentStateDirSetter); mok {
+ setter.SetPersistentStateDir(dataDir)
+ }
ok := true
// Restore routing state (table/fwmark) from persisted values so that
@@ -178,13 +179,17 @@ func RecoverFromCrash(dataDir string, fw FirewallCleaner) []string {
if rs, mok := mgr.(network.RoutingStateRestorer); mok {
rs.RestoreRoutingState(state.Table, state.FwMark)
}
+ if ers, mok := mgr.(network.EndpointRouteStateRestorer); mok {
+ ers.RestoreEndpointRoutes(state.EndpointRoutes)
+ }
// DNS: if we have pre-modification DNS state, restore it precisely.
// Otherwise fall back to the blunt ResetDNSToSystemDefault which
// clears everything to DHCP defaults (loses custom user preferences).
if len(state.PreModDNS) > 0 {
if restorer, mok := mgr.(network.DNSStateRestorer); mok {
- if err := restorer.RestoreDNSFromSnapshot(state.PreModDNS); err != nil {
+ snap := network.DNSSnapshot{Servers: state.PreModDNS, Search: state.PreModSearch}
+ if err := restorer.RestoreDNSFromSnapshot(snap); err != nil {
slog.Warn("crash recovery: precise DNS restore failed, falling back to reset", "error", err)
if err := mgr.ResetDNSToSystemDefault(); err != nil {
ok = false
@@ -215,6 +220,14 @@ func RecoverFromCrash(dataDir string, fw FirewallCleaner) []string {
slog.Warn("crash recovery: network cleanup failed", "error", err)
ok = false
}
+ // A process crash closes the UAPI file descriptor but does not
+ // necessarily unlink /var/run/wireguard/.sock. Leaving it
+ // behind makes `wg show all` discover a dead phantom interface and
+ // can confuse external diagnostics after recovery.
+ if err := cleanupStaleUAPI(state.InterfaceName); err != nil {
+ slog.Warn("crash recovery: stale UAPI cleanup failed", "error", err)
+ ok = false
+ }
}
recovered = append(recovered, state.TunnelName)
@@ -235,8 +248,8 @@ func RecoverFromCrash(dataDir string, fw FirewallCleaner) []string {
// Clear ONLY the state files whose recovery fully succeeded.
// States that hit any error stay on disk so the next boot has
- // another chance — silently dropping them via ClearAllActiveStates
- // stranded the bug for users.
+ // another chance — silently dropping them all at once stranded
+ // the bug for users.
for _, name := range fullySucceeded {
ClearActiveState(dataDir, name)
}
diff --git a/internal/tunnel/recovery_test.go b/internal/tunnel/recovery_test.go
index 781153d..f68ab6a 100644
--- a/internal/tunnel/recovery_test.go
+++ b/internal/tunnel/recovery_test.go
@@ -190,16 +190,3 @@ func TestRecoverFromCrashNilFirewall(t *testing.T) {
}
}
-func TestClearAllActiveStates(t *testing.T) {
- dir := t.TempDir()
- SaveActiveState(dir, &ActiveTunnelState{TunnelName: "vpn1"})
- SaveActiveState(dir, &ActiveTunnelState{TunnelName: "vpn2"})
-
- if err := ClearAllActiveStates(dir); err != nil {
- t.Fatalf("ClearAllActiveStates failed: %v", err)
- }
-
- if len(LoadActiveState(dir)) != 0 {
- t.Error("all states should be cleared")
- }
-}
diff --git a/internal/tunnel/recovery_uapi_unix.go b/internal/tunnel/recovery_uapi_unix.go
new file mode 100644
index 0000000..fc5139b
--- /dev/null
+++ b/internal/tunnel/recovery_uapi_unix.go
@@ -0,0 +1,20 @@
+//go:build !windows
+
+package tunnel
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+)
+
+func cleanupStaleUAPI(interfaceName string) error {
+ if interfaceName == "" || filepath.Base(interfaceName) != interfaceName {
+ return fmt.Errorf("unsafe interface name %q", interfaceName)
+ }
+ path := filepath.Join("/var/run/wireguard", interfaceName+".sock")
+ if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ return nil
+}
diff --git a/internal/tunnel/recovery_uapi_windows.go b/internal/tunnel/recovery_uapi_windows.go
new file mode 100644
index 0000000..a1564ab
--- /dev/null
+++ b/internal/tunnel/recovery_uapi_windows.go
@@ -0,0 +1,5 @@
+//go:build windows
+
+package tunnel
+
+func cleanupStaleUAPI(string) error { return nil }
diff --git a/internal/update/checker.go b/internal/update/checker.go
index e2c4dea..4f3fec7 100644
--- a/internal/update/checker.go
+++ b/internal/update/checker.go
@@ -238,16 +238,6 @@ func parseReleaseBody(ctx context.Context, resp *http.Response, client *http.Cli
}, nil
}
-// BrewUpgradeCommand returns the shell command a Homebrew user should
-// run to upgrade WireGuide. Returned as a string (not executed) so the
-// UI can show it next to a Copy button — the cross-platform-app
-// convention is that the user runs the package-manager command, not the
-// app itself. See research-update-patterns notes (Tailscale, OrbStack)
-// for context.
-func BrewUpgradeCommand() string {
- return "brew upgrade --cask wireguide"
-}
-
// Release represents a GitHub release.
type Release struct {
TagName string `json:"tag_name"`
diff --git a/internal/update/checker_test.go b/internal/update/checker_test.go
index a1b6773..f73df96 100644
--- a/internal/update/checker_test.go
+++ b/internal/update/checker_test.go
@@ -636,8 +636,12 @@ func TestDownloadUpdate_HTTPError(t *testing.T) {
func TestMatchAsset_FindsPlatformAsset(t *testing.T) {
name := fmt.Sprintf("WireGuide-%s-%s.dmg", runtime.GOOS, runtime.GOARCH)
+ // The decoy must never match the running platform. A linux-amd64
+ // decoy broke this test the first time it ran on Linux CI: both
+ // assets matched and the decoy's .tar.gz is the preferred extension
+ // there, so matchAsset (correctly) returned the decoy.
assets := []Asset{
- {Name: "WireGuide-linux-amd64.tar.gz"},
+ {Name: "WireGuide-plan9-mips.tar.gz"},
{Name: name},
}
got := matchAsset(assets)
diff --git a/internal/wifi/detect_linux.go b/internal/wifi/detect_linux.go
index 8c34112..baa9e30 100644
--- a/internal/wifi/detect_linux.go
+++ b/internal/wifi/detect_linux.go
@@ -29,12 +29,12 @@ import (
// We must NOT call conn.Close() — that would terminate the connection
// shared with internal/reconnect/sleep_linux.go. We only unsubscribe our
// own matcher + signal channel on stop.
-func startLinuxDBusWatcher(onChange func()) (stop func()) {
+func startLinuxDBusWatcher(onChange func()) (stop func(), attached bool) {
noop := func() {}
conn, err := dbus.SystemBus()
if err != nil {
slog.Debug("wifi: dbus unavailable, no event-driven SSID watcher", "error", err)
- return noop
+ return noop, false
}
matchOpts := []dbus.MatchOption{
@@ -43,7 +43,7 @@ func startLinuxDBusWatcher(onChange func()) (stop func()) {
}
if err := conn.AddMatchSignal(matchOpts...); err != nil {
slog.Debug("wifi: NM AddMatchSignal failed", "error", err)
- return noop
+ return noop, false
}
ch := make(chan *dbus.Signal, 16)
@@ -89,5 +89,5 @@ func startLinuxDBusWatcher(onChange func()) (stop func()) {
slog.Info("wifi: NetworkManager DBus watcher started (Wireless.StateChanged only)")
return func() {
once.Do(func() { close(stopCh) })
- }
+ }, true
}
diff --git a/internal/wifi/detect_linux_stub.go b/internal/wifi/detect_linux_stub.go
index 5a38bb8..f0d3b77 100644
--- a/internal/wifi/detect_linux_stub.go
+++ b/internal/wifi/detect_linux_stub.go
@@ -7,6 +7,6 @@ package wifi
// changes instantly; on other OSes the wifi.Monitor's per-platform
// detection (CoreWLAN events on macOS, Wlanapi notifications on Windows)
// covers the same ground.
-func startLinuxDBusWatcher(onChange func()) (stop func()) {
- return func() {}
+func startLinuxDBusWatcher(onChange func()) (stop func(), attached bool) {
+ return func() {}, false
}
diff --git a/internal/wifi/detect_windows.go b/internal/wifi/detect_windows.go
index c22e728..7f225bc 100644
--- a/internal/wifi/detect_windows.go
+++ b/internal/wifi/detect_windows.go
@@ -15,12 +15,12 @@ import (
// notifications and invokes onChange on every transition. Returns a stop
// function. Falls back to no-op (returns nil stop) when wlanapi.dll is
// unavailable (server SKUs without WLAN service, headless containers).
-func startWindowsWlanWatcher(onChange func()) (stop func()) {
+func startWindowsWlanWatcher(onChange func()) (stop func(), attached bool) {
noop := func() {}
if err := wlanLazyOpenHandle(); err != nil {
slog.Debug("wifi: wlanapi.dll OpenHandle failed", "error", err)
- return noop
+ return noop, false
}
cb := syscall.NewCallback(func(notif uintptr, _ uintptr) uintptr {
@@ -43,7 +43,7 @@ func startWindowsWlanWatcher(onChange func()) (stop func()) {
)
if ret != 0 {
slog.Debug("wifi: WlanRegisterNotification failed", "status", ret)
- return noop
+ return noop, false
}
slog.Info("wifi: Wlanapi notification subscribed")
@@ -62,7 +62,7 @@ func startWindowsWlanWatcher(onChange func()) (stop func()) {
uintptr(unsafe.Pointer(&prev)),
)
})
- }
+ }, true
}
var (
diff --git a/internal/wifi/detect_windows_stub.go b/internal/wifi/detect_windows_stub.go
index 1ec5d94..1c589df 100644
--- a/internal/wifi/detect_windows_stub.go
+++ b/internal/wifi/detect_windows_stub.go
@@ -5,6 +5,6 @@ package wifi
// startWindowsWlanWatcher is a no-op on non-Windows platforms. The Windows
// build uses wlanapi's WlanRegisterNotification to react instantly to SSID
// changes; the per-OS poll handles the same role elsewhere.
-func startWindowsWlanWatcher(onChange func()) (stop func()) {
- return func() {}
+func startWindowsWlanWatcher(onChange func()) (stop func(), attached bool) {
+ return func() {}, false
}
diff --git a/internal/wifi/gateway_darwin.go b/internal/wifi/gateway_darwin.go
index 1307466..2666d26 100644
--- a/internal/wifi/gateway_darwin.go
+++ b/internal/wifi/gateway_darwin.go
@@ -71,15 +71,3 @@ func parseARPMAC(out string) string {
return normalizeMAC(m)
}
-// normalizeMAC lower-cases and zero-pads each octet so BSD's "0:1e:..."
-// and Linux's "00:1e:..." compare equal.
-func normalizeMAC(mac string) string {
- parts := strings.Split(mac, ":")
- for i, p := range parts {
- if len(p) == 1 {
- parts[i] = "0" + p
- }
- parts[i] = strings.ToLower(parts[i])
- }
- return strings.Join(parts, ":")
-}
diff --git a/internal/wifi/gateway_linux.go b/internal/wifi/gateway_linux.go
index 5afa838..57e25b9 100644
--- a/internal/wifi/gateway_linux.go
+++ b/internal/wifi/gateway_linux.go
@@ -3,96 +3,49 @@
package wifi
import (
- "bufio"
- "encoding/binary"
- "net"
"os"
- "strconv"
"strings"
)
// GatewayMAC returns the lower-cased MAC of the IPv4 default gateway,
// read straight from /proc (no exec, locale-independent). "" when
-// unavailable.
+// unavailable. Route selection honours flags/metric/netmask and skips
+// tunnel and virtual interfaces; the ARP lookup is scoped to the route's
+// device and requires a completed entry (issue #22).
func GatewayMAC() string {
- gw := defaultGatewayIPLinux()
+ routeTable, err := os.ReadFile("/proc/net/route")
+ if err != nil {
+ return ""
+ }
+ gw, iface := bestDefaultRoute(routeTable, func(name string) bool {
+ return isTunnelIface(name) || isVirtualIface(name)
+ })
if gw == "" {
return ""
}
- return arpMACForIP(gw)
-}
-
-// defaultGatewayIPLinux parses /proc/net/route for the default route
-// (destination 00000000) and returns its gateway as a dotted IPv4.
-func defaultGatewayIPLinux() string {
- f, err := os.Open("/proc/net/route")
+ arpTable, err := os.ReadFile("/proc/net/arp")
if err != nil {
return ""
}
- defer f.Close()
- sc := bufio.NewScanner(f)
- sc.Scan() // header
- for sc.Scan() {
- fields := strings.Fields(sc.Text())
- if len(fields) < 3 {
- continue
- }
- // fields: Iface Destination Gateway ...
- if fields[1] != "00000000" {
- continue
- }
- gwHex := fields[2]
- v, err := strconv.ParseUint(gwHex, 16, 32)
- if err != nil {
- continue
- }
- // The value is little-endian in /proc.
- ip := make(net.IP, 4)
- binary.LittleEndian.PutUint32(ip, uint32(v))
- if ip.IsUnspecified() {
- continue
- }
- return ip.String()
- }
- return ""
+ return arpMACForIPOnIface(arpTable, gw, iface)
}
-// arpMACForIP looks up ip in /proc/net/arp and returns its normalised MAC.
-func arpMACForIP(ip string) string {
- f, err := os.Open("/proc/net/arp")
- if err != nil {
- return ""
+// isVirtualIface reports whether the named interface has no backing
+// hardware device. /sys/class/net//device is a symlink to the
+// PCI/USB/SDIO device and is absent for every bridge, veth, tun/tap,
+// bond and WireGuard interface — a single check that catches docker0,
+// virbr0, tailscale0, vmnet*, CNI bridges and the rest of the
+// name-denylist's blind spots. The tun_flags probe additionally catches
+// tun/tap devices, mirroring internal/reconnect's isTunnel.
+func isVirtualIface(name string) bool {
+ if name == "" || strings.ContainsAny(name, "/\\") {
+ return true
}
- defer f.Close()
- sc := bufio.NewScanner(f)
- sc.Scan() // header
- for sc.Scan() {
- fields := strings.Fields(sc.Text())
- // fields: IPaddress HWtype Flags HWaddress Mask Device
- if len(fields) < 4 {
- continue
- }
- if fields[0] != ip {
- continue
- }
- mac := fields[3]
- if mac == "00:00:00:00:00:00" {
- return ""
- }
- return normalizeMAC(mac)
+ if _, err := os.Stat("/sys/class/net/" + name + "/tun_flags"); err == nil {
+ return true
}
- return ""
-}
-
-// normalizeMAC lower-cases and zero-pads each octet so platforms that
-// drop leading zeros compare equal.
-func normalizeMAC(mac string) string {
- parts := strings.Split(mac, ":")
- for i, p := range parts {
- if len(p) == 1 {
- parts[i] = "0" + p
- }
- parts[i] = strings.ToLower(parts[i])
+ if _, err := os.Stat("/sys/class/net/" + name + "/device"); err == nil {
+ return false
}
- return strings.Join(parts, ":")
+ return true
}
diff --git a/internal/wifi/gateway_parse.go b/internal/wifi/gateway_parse.go
new file mode 100644
index 0000000..5a72b39
--- /dev/null
+++ b/internal/wifi/gateway_parse.go
@@ -0,0 +1,111 @@
+package wifi
+
+import (
+ "bufio"
+ "bytes"
+ "encoding/binary"
+ "net"
+ "strconv"
+ "strings"
+)
+
+// Pure parsers for Linux /proc/net/route and /proc/net/arp, kept in an
+// untagged file so their unit tests run on every development platform.
+// The linux-only wrappers in gateway_linux.go feed them the real files.
+//
+// These replaced a first-match parser that ignored route flags, metric,
+// the netmask column, and tunnel interfaces, and an ARP lookup keyed by
+// IP alone (issue #22): a wg0 default route, a higher-metric secondary
+// uplink, or a stale/incomplete ARP entry on the wrong device could all
+// fingerprint the wrong network — and gateway-MAC is a *trust* signal
+// for Automation rules.
+
+// rtfUp is Linux's RTF_UP route flag (route is usable). The value is
+// ABI-stable; defined locally so this file stays platform-untagged.
+const rtfUp = 0x1
+
+// atfCom is Linux's ATF_COM ARP flag (lookup complete). Entries without
+// it are in-progress or failed resolutions whose HW address is garbage.
+const atfCom = 0x2
+
+// bestDefaultRoute picks the usable IPv4 default route with the lowest
+// metric from /proc/net/route content and returns its gateway (dotted
+// form) and interface. isTunnel filters interfaces that must never count
+// (VPN adapters — including our own — and virtual devices).
+func bestDefaultRoute(routeTable []byte, isTunnel func(string) bool) (gw, iface string) {
+ sc := bufio.NewScanner(bytes.NewReader(routeTable))
+ sc.Scan() // header
+ bestMetric := uint64(0)
+ found := false
+ for sc.Scan() {
+ f := strings.Fields(sc.Text())
+ // Iface Destination Gateway Flags RefCnt Use Metric Mask ...
+ if len(f) < 8 || f[1] != "00000000" || f[7] != "00000000" {
+ continue
+ }
+ flags, err1 := strconv.ParseUint(f[3], 16, 64)
+ metric, err2 := strconv.ParseUint(f[6], 10, 64)
+ if err1 != nil || err2 != nil || flags&rtfUp == 0 {
+ continue
+ }
+ if isTunnel != nil && isTunnel(f[0]) {
+ continue
+ }
+ v, err := strconv.ParseUint(f[2], 16, 32)
+ if err != nil {
+ continue
+ }
+ ip := make(net.IP, 4)
+ binary.LittleEndian.PutUint32(ip, uint32(v)) // little-endian in /proc
+ if ip.IsUnspecified() {
+ continue
+ }
+ if !found || metric < bestMetric {
+ found = true
+ bestMetric = metric
+ gw = ip.String()
+ iface = f[0]
+ }
+ }
+ return gw, iface
+}
+
+// arpMACForIPOnIface finds ip's completed ARP entry on the given device
+// in /proc/net/arp content and returns its normalised MAC. Filtering by
+// device matters on hosts where two networks share a gateway IP (a
+// docker bridge and the LAN both using 192.168.x.1); requiring ATF_COM
+// rejects incomplete/stale entries whose HW address is meaningless.
+func arpMACForIPOnIface(arpTable []byte, ip, iface string) string {
+ sc := bufio.NewScanner(bytes.NewReader(arpTable))
+ sc.Scan() // header
+ for sc.Scan() {
+ f := strings.Fields(sc.Text())
+ // IPaddress HWtype Flags HWaddress Mask Device
+ if len(f) < 6 || f[0] != ip || f[5] != iface {
+ continue
+ }
+ flags, err := strconv.ParseUint(strings.TrimPrefix(f[2], "0x"), 16, 32)
+ if err != nil || flags&atfCom == 0 {
+ continue
+ }
+ mac := f[3]
+ if mac == "00:00:00:00:00:00" {
+ continue
+ }
+ return normalizeMAC(mac)
+ }
+ return ""
+}
+
+// normalizeMAC lower-cases and zero-pads each octet so BSD's "0:1e:..."
+// and Linux's "00:1e:..." compare equal.
+func normalizeMAC(mac string) string {
+ parts := strings.Split(mac, ":")
+ for i, p := range parts {
+ if len(p) == 1 {
+ parts[i] = "0" + p
+ }
+ parts[i] = strings.ToLower(parts[i])
+ }
+ return strings.Join(parts, ":")
+}
diff --git a/internal/wifi/gateway_parse_test.go b/internal/wifi/gateway_parse_test.go
new file mode 100644
index 0000000..38a4593
--- /dev/null
+++ b/internal/wifi/gateway_parse_test.go
@@ -0,0 +1,66 @@
+package wifi
+
+import "testing"
+
+// /proc/net/route fixture columns:
+// Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT
+const routeFixture = "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n" +
+ // wg0 default route, metric 0 — must be excluded (tunnel)
+ "wg0\t00000000\t00000000\t0001\t0\t0\t0\t00000000\t0\t0\t0\n" +
+ // eth1 default route, DOWN (flags lack RTF_UP) — must be excluded
+ "eth1\t00000000\t0100A8C0\t0002\t0\t0\t50\t00000000\t0\t0\t0\n" +
+ // eth0 default route, metric 100, gateway 192.168.1.1 (little-endian hex)
+ "eth0\t00000000\t0101A8C0\t0003\t0\t0\t100\t00000000\t0\t0\t0\n" +
+ // wlan0 default route, metric 600, gateway 192.168.1.1
+ "wlan0\t00000000\t0101A8C0\t0003\t0\t0\t600\t00000000\t0\t0\t0\n" +
+ // eth0 non-default route (mask not /0) — must be ignored
+ "eth0\t0001A8C0\t00000000\t0001\t0\t0\t100\t00FFFFFF\t0\t0\t0\n"
+
+func TestBestDefaultRoutePicksLowestMetricRealIface(t *testing.T) {
+ isTunnel := func(name string) bool { return name == "wg0" }
+ gw, iface := bestDefaultRoute([]byte(routeFixture), isTunnel)
+ if gw != "192.168.1.1" {
+ t.Errorf("gw: got %q, want 192.168.1.1", gw)
+ }
+ if iface != "eth0" {
+ t.Errorf("iface: got %q, want eth0 (metric 100 beats wlan0's 600)", iface)
+ }
+}
+
+func TestBestDefaultRouteAllFiltered(t *testing.T) {
+ gw, iface := bestDefaultRoute([]byte(routeFixture), func(string) bool { return true })
+ if gw != "" || iface != "" {
+ t.Errorf("got (%q,%q), want empty when every iface is a tunnel", gw, iface)
+ }
+}
+
+// /proc/net/arp fixture columns:
+// IPaddress HWtype Flags HWaddress Mask Device
+const arpFixture = "IP address HW type Flags HW address Mask Device\n" +
+ // same gateway IP known via docker0 — wrong device, must be skipped
+ "192.168.1.1 0x1 0x2 aa:bb:cc:dd:ee:99 * docker0\n" +
+ // incomplete entry on the right device — must be skipped (no ATF_COM)
+ "192.168.1.1 0x1 0x0 00:00:00:00:00:00 * eth0\n" +
+ // completed entry on the right device
+ "192.168.1.1 0x1 0x2 B0:38:6C:54:8B:AB * eth0\n"
+
+func TestArpMACForIPOnIface(t *testing.T) {
+ got := arpMACForIPOnIface([]byte(arpFixture), "192.168.1.1", "eth0")
+ if got != "b0:38:6c:54:8b:ab" {
+ t.Errorf("got %q, want b0:38:6c:54:8b:ab (normalised, right device, completed)", got)
+ }
+}
+
+func TestArpMACForIPOnIfaceWrongDevice(t *testing.T) {
+ if got := arpMACForIPOnIface([]byte(arpFixture), "192.168.1.1", "wlan0"); got != "" {
+ t.Errorf("got %q, want empty for a device with no entry", got)
+ }
+}
+
+func TestArpMACIncompleteOnly(t *testing.T) {
+ incomplete := "IP address HW type Flags HW address Mask Device\n" +
+ "10.0.0.1 0x1 0x0 aa:bb:cc:dd:ee:ff * eth0\n"
+ if got := arpMACForIPOnIface([]byte(incomplete), "10.0.0.1", "eth0"); got != "" {
+ t.Errorf("got %q, want empty for an incomplete (non-ATF_COM) entry", got)
+ }
+}
diff --git a/internal/wifi/monitor.go b/internal/wifi/monitor.go
index 15883fe..fa8e90a 100644
--- a/internal/wifi/monitor.go
+++ b/internal/wifi/monitor.go
@@ -64,20 +64,17 @@ func (m *Monitor) Start() {
}
m.running = true
m.stopCh = make(chan struct{})
- m.wg.Add(1)
m.mu.Unlock()
- go func() {
- defer m.wg.Done()
- m.poll()
- }()
- // Linux-only: wake the poller immediately on NetworkManager
+ // Event watchers start BEFORE the poll goroutine so the poll cadence
+ // can depend on whether one attached.
+ // Linux: wake the poller immediately on NetworkManager
// DeviceStateChanged so users see SSID transitions react in <1s instead
- // of waiting for the 5s tick. No-op on non-Linux.
- stopDBus := startLinuxDBusWatcher(func() { m.checkNow() })
- // Windows-only: Wlanapi notifications for instant SSID react. No-op on
+ // of waiting for the poll tick. No-op on non-Linux.
+ stopDBus, dbusAttached := startLinuxDBusWatcher(func() { m.checkNow() })
+ // Windows: Wlanapi notifications for instant SSID react. No-op on
// other platforms.
- stopWlan := startWindowsWlanWatcher(func() { m.checkNow() })
+ stopWlan, wlanAttached := startWindowsWlanWatcher(func() { m.checkNow() })
m.stopDBusWatcher = func() {
if stopDBus != nil {
stopDBus()
@@ -87,7 +84,27 @@ func (m *Monitor) Start() {
}
}
- slog.Info("WiFi monitor started (polling)")
+ // With an event-driven watcher attached the poll is only a safety net
+ // for missed signals, so it can run slowly. Without one (no
+ // NetworkManager, no wlanapi) it is the sole SSID source and keeps the
+ // 5 s cadence. On Linux the fast path matters doubly: CurrentSSID()
+ // shells out to nmcli, so a 5 s poll alongside a working DBus watcher
+ // burned ~17k subprocess spawns a day for nothing.
+ interval := 5 * time.Second
+ eventDriven := dbusAttached || wlanAttached
+ if eventDriven {
+ interval = 60 * time.Second
+ }
+
+ m.mu.Lock()
+ m.wg.Add(1)
+ m.mu.Unlock()
+ go func() {
+ defer m.wg.Done()
+ m.poll(interval)
+ }()
+
+ slog.Info("WiFi monitor started", "event_driven", eventDriven, "poll_interval", interval)
}
// checkNow forces an immediate SSID re-read outside the 5s tick. Used by the
@@ -147,7 +164,7 @@ func (m *Monitor) ReportExternalSSID(ssid string) {
}
}
-func (m *Monitor) poll() {
+func (m *Monitor) poll(interval time.Duration) {
// On macOS the root helper cannot read the SSID — Location Services is
// scoped to the GUI .app bundle, so CurrentSSID() returns "". Polling it
// would overwrite the authoritative GUI-reported SSID (via
@@ -161,7 +178,7 @@ func (m *Monitor) poll() {
m.mu.Lock()
m.lastSSID = CurrentSSID()
m.mu.Unlock()
- ticker := time.NewTicker(5 * time.Second)
+ ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
diff --git a/internal/wifi/physical.go b/internal/wifi/physical.go
index 4a32b99..1f5cf37 100644
--- a/internal/wifi/physical.go
+++ b/internal/wifi/physical.go
@@ -26,7 +26,12 @@ func PhysicalInterfaceIPs() []net.IP {
if ifi.Flags&net.FlagUp == 0 || ifi.Flags&net.FlagLoopback != 0 {
continue
}
- if isTunnelIface(ifi.Name) {
+ // isVirtualIface (Linux sysfs) catches what the name denylist
+ // can't: docker0, virbr0, tailscale0, veth*, CNI bridges — all
+ // up, non-loopback, carrying routable IPs that would otherwise
+ // satisfy subnet Automation rules for networks the machine isn't
+ // physically on (issue #22).
+ if isTunnelIface(ifi.Name) || isVirtualIface(ifi.Name) {
continue
}
addrs, err := ifi.Addrs()
@@ -65,7 +70,8 @@ func PhysicalSubnets() []string {
seen := map[string]bool{}
var out []string
for _, ifi := range ifaces {
- if ifi.Flags&net.FlagUp == 0 || ifi.Flags&net.FlagLoopback != 0 || isTunnelIface(ifi.Name) {
+ if ifi.Flags&net.FlagUp == 0 || ifi.Flags&net.FlagLoopback != 0 ||
+ isTunnelIface(ifi.Name) || isVirtualIface(ifi.Name) {
continue
}
addrs, err := ifi.Addrs()
diff --git a/internal/wifi/physical_other.go b/internal/wifi/physical_other.go
new file mode 100644
index 0000000..49f1b1a
--- /dev/null
+++ b/internal/wifi/physical_other.go
@@ -0,0 +1,10 @@
+//go:build !linux
+
+package wifi
+
+// isVirtualIface is Linux-only sysfs knowledge; macOS and Windows rely on
+// the name-based isTunnelIface filter alone. On macOS the tunnel/virtual
+// namespace is well-conventioned (utun*, bridge*, awdl* — the latter two
+// are excluded by the link-local check on their addresses), and Windows
+// virtual adapters are caught by the explicit wireguard/wintun match.
+func isVirtualIface(string) bool { return false }
diff --git a/internal/wifi/rules.go b/internal/wifi/rules.go
index 46cf284..29b8618 100644
--- a/internal/wifi/rules.go
+++ b/internal/wifi/rules.go
@@ -21,14 +21,6 @@ type TunnelSSIDs struct {
AutoConnectSSIDs []string `json:"auto_connect_ssids"`
}
-// DefaultRules returns empty rules with maps initialized so
-// JSON marshaling produces {} rather than null for empty per-tunnel.
-func DefaultRules() *Rules {
- return &Rules{
- PerTunnel: make(map[string]TunnelSSIDs),
- }
-}
-
// Action determines what to do when the system joins the given SSID.
// Returns:
//
diff --git a/main.go b/main.go
index 7b990f3..9bac3b1 100644
--- a/main.go
+++ b/main.go
@@ -47,6 +47,7 @@ func main() {
helperMode := flag.Bool("helper", false, "run as privileged helper")
socketPath := flag.String("socket", "", "socket path for IPC")
socketUID := flag.Int("uid", -1, "socket owner UID (Unix only)")
+ ownerSID := flag.String("owner-sid", "", "socket owner SID (Windows only)")
dataDir := flag.String("data-dir", "", "data directory for crash recovery")
flag.Parse()
@@ -87,7 +88,7 @@ func main() {
}
}
log.Println("WireGuide helper starting...")
- if err := helper.Run(*socketPath, *socketUID, *dataDir); err != nil {
+ if err := helper.Run(*socketPath, *socketUID, *ownerSID, *dataDir); err != nil {
log.Fatal("helper error:", err)
}
return
diff --git a/scripts/automation_integration_test.sh b/scripts/automation_integration_test.sh
new file mode 100755
index 0000000..469dd73
--- /dev/null
+++ b/scripts/automation_integration_test.sh
@@ -0,0 +1,147 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+
+binary=${1:?wireguide binary required}
+vpn_config=${2:?VPN config required}
+test_root=${3:?isolated test directory required}
+uid_num=${4:-$(id -u)}
+
+repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+recover="$repo_root/scripts/network_test_recover.sh"
+runtime_dir="$test_root/runtime"
+config_dir="$test_root/config"
+data_dir="$test_root/data"
+helper_data="$test_root/helper"
+backup_resolv="$test_root/resolv.conf.before"
+helper_pidfile="$test_root/helper.pid"
+recovery_log="$test_root/recovery.log"
+test_log="$test_root/test.log"
+socket="$runtime_dir/wireguide-${uid_num}.sock"
+split_config="$test_root/automation-split.conf"
+name=automation-audit
+
+mkdir -p "$runtime_dir" "$config_dir" "$data_dir" "$helper_data"
+install -m 0644 /etc/resolv.conf "$backup_resolv"
+awk '
+ /^[[:space:]]*DNS[[:space:]]*=/ { next }
+ /^[[:space:]]*AllowedIPs[[:space:]]*=/ { print "AllowedIPs = 10.255.252.1/32"; next }
+ { print }
+' "$vpn_config" >"$split_config"
+chmod 600 "$split_config"
+export XDG_RUNTIME_DIR="$runtime_dir" XDG_CONFIG_HOME="$config_dir" XDG_DATA_HOME="$data_dir"
+
+log() { printf '%s %s\n' "$(date -Is)" "$*" | tee -a "$test_log"; }
+cli() { timeout 45 "$binary" ctl "$@"; }
+http_ok() { curl --connect-timeout 5 --max-time 12 --silent --fail https://www.google.com/generate_204 >/dev/null; }
+
+cleanup() {
+ rc=$?
+ trap - EXIT INT TERM
+ [[ -n "${keepalive_pid:-}" ]] && kill "$keepalive_pid" 2>/dev/null || true
+ sudo -n "$recover" "$backup_resolv" "$helper_pidfile" "$recovery_log" || true
+ sudo -n systemctl stop wireguide-network-recovery.timer wireguide-network-recovery.service 2>/dev/null || true
+ if (( rc == 0 )); then
+ log "PASS: live automation SSID/subnet/MAC/else matrix completed"
+ else
+ log "FAIL: automation matrix exited rc=$rc; emergency recovery executed"
+ fi
+ exit "$rc"
+}
+trap cleanup EXIT INT TERM
+
+default_if=$(ip -4 route show default | awk 'NR==1 {for(i=1;i<=NF;i++) if($i=="dev") {print $(i+1); exit}}')
+gateway=$(ip -4 route show default | awk 'NR==1 {for(i=1;i<=NF;i++) if($i=="via") {print $(i+1); exit}}')
+ssid=$(nmcli -t -f ACTIVE,SSID dev wifi | sed -n 's/^yes://p' | head -1)
+subnet=$(ip -4 route show dev "$default_if" proto kernel scope link | awk '$1 ~ /\// {print $1; exit}')
+gateway_mac=$(ip neigh show "$gateway" dev "$default_if" | awk 'NR==1 {for(i=1;i<=NF;i++) if($i=="lladdr") {print $(i+1); exit}}')
+[[ -n "$ssid" && -n "$subnet" && -n "$gateway_mac" ]]
+
+sudo -n systemd-run --quiet --unit=wireguide-network-recovery --on-active=4m \
+ "$recover" "$backup_resolv" "$helper_pidfile" "$recovery_log"
+log "independent 4-minute emergency recovery armed"
+
+cli import "$split_config" "$name" >>"$test_log" 2>&1
+cli automation add "$name" connect "ssid:$ssid" >>"$test_log" 2>&1
+
+sudo -n systemd-run --quiet --unit=wireguide-fulltest-helper --service-type=exec \
+ --setenv="XDG_CONFIG_HOME=$config_dir" --setenv="XDG_DATA_HOME=$data_dir" \
+ "$binary" --helper --socket "$socket" --uid "$uid_num" --data-dir "$helper_data"
+for _ in {1..100}; do [[ -S "$socket" ]] && break; sleep 0.1; done
+[[ -S "$socket" ]]
+main_pid=$(sudo -n systemctl show -p MainPID --value wireguide-fulltest-helper.service)
+[[ "$main_pid" =~ ^[1-9][0-9]*$ ]]
+printf '%s\n' "$main_pid" >"$helper_pidfile"
+
+# The helper ties its lifetime to the GUI: with no GUI it self-exits after
+# the 60s startup grace, and ctl invocations are Transient so they neither
+# cancel nor re-arm it. This test has long windows where automation rules
+# have disconnected every tunnel, so hold ONE non-transient control
+# connection (a GUI stand-in) for the duration — otherwise the helper
+# vanishes mid-matrix exactly as the lifetime design intends.
+python3 - "$socket" <<'PYKEEPALIVE' &
+import json, socket, struct, sys, time
+s = socket.socket(socket.AF_UNIX)
+s.connect(sys.argv[1])
+body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "Helper.Ping"}).encode()
+s.sendall(struct.pack(">I", len(body)) + body)
+hdr = s.recv(4)
+if len(hdr) == 4:
+ n = struct.unpack(">I", hdr)[0]
+ while n > 0:
+ chunk = s.recv(min(n, 65536))
+ if not chunk:
+ break
+ n -= len(chunk)
+while True:
+ time.sleep(30)
+PYKEEPALIVE
+keepalive_pid=$!
+
+is_active() {
+ cli status --json 2>>"$test_log" | python3 -c 'import json,sys; x=json.load(sys.stdin); raise SystemExit(0 if any(v["tunnel_name"]=="automation-audit" for v in x) else 1)'
+}
+wait_active() {
+ local want=$1 timeout_secs=$2
+ for ((i=0; i>"$test_log" 2>&1
+cli automation add "$name" disconnect "subnet:$subnet" >>"$test_log" 2>&1
+cli automation | tee -a "$test_log" | grep -q 'decision=disconnect'
+wait_active no 40
+! ip -4 route show | grep -q '^10.255.252.1'
+http_ok
+log "subnet rule live auto-disconnect passed"
+
+cli automation rm "$name" 1 >>"$test_log" 2>&1
+cli automation add "$name" connect "mac:$gateway_mac" >>"$test_log" 2>&1
+cli automation | tee -a "$test_log" | grep -q 'decision=connect'
+wait_active yes 40
+ip -4 route show | grep -Eq '^10\.255\.252\.1(/32)? dev wg-'
+http_ok
+log "gateway-MAC rule live auto-connect passed"
+
+cli automation rm "$name" 1 >>"$test_log" 2>&1
+cli automation add "$name" disconnect else >>"$test_log" 2>&1
+cli automation | tee -a "$test_log" | grep -q 'decision=disconnect'
+wait_active no 40
+! ip -4 route show | grep -q '^10.255.252.1'
+http_ok
+log "else rule live auto-disconnect passed"
+
+cli automation rm "$name" 1 >>"$test_log" 2>&1
+cli delete "$name" >>"$test_log" 2>&1
+cmp -s "$backup_resolv" /etc/resolv.conf
diff --git a/scripts/cli_feature_matrix_test.sh b/scripts/cli_feature_matrix_test.sh
new file mode 100755
index 0000000..1239528
--- /dev/null
+++ b/scripts/cli_feature_matrix_test.sh
@@ -0,0 +1,238 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+
+binary=${1:?wireguide binary required}
+vpn_config=${2:?VPN config required}
+test_root=${3:?isolated test directory required}
+uid_num=${4:-$(id -u)}
+
+repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+recover="$repo_root/scripts/network_test_recover.sh"
+runtime_dir="$test_root/runtime"
+config_dir="$test_root/config"
+data_dir="$test_root/data"
+helper_data="$test_root/helper"
+generated_dir="$test_root/generated"
+backup_resolv="$test_root/resolv.conf.before"
+helper_pidfile="$test_root/helper.pid"
+recovery_log="$test_root/recovery.log"
+test_log="$test_root/test.log"
+socket="$runtime_dir/wireguide-${uid_num}.sock"
+
+mkdir -p "$runtime_dir" "$config_dir" "$data_dir" "$helper_data" "$generated_dir"
+chmod 700 "$test_root" "$generated_dir"
+install -m 0644 /etc/resolv.conf "$backup_resolv"
+export XDG_RUNTIME_DIR="$runtime_dir" XDG_CONFIG_HOME="$config_dir" XDG_DATA_HOME="$data_dir"
+
+log() { printf '%s %s\n' "$(date -Is)" "$*" | tee -a "$test_log"; }
+cli() { timeout 45 "$binary" ctl "$@"; }
+http_ok() { curl --connect-timeout 6 --max-time 15 --silent --fail "$1" >/dev/null; }
+public_ip() { curl --connect-timeout 6 --max-time 15 --silent --fail https://api.ipify.org; }
+
+cleanup() {
+ rc=$?
+ trap - EXIT INT TERM
+ cli set dns-protection off >>"$test_log" 2>&1 || true
+ cli set killswitch off >>"$test_log" 2>&1 || true
+ sudo -n "$recover" "$backup_resolv" "$helper_pidfile" "$recovery_log" || true
+ sudo -n systemctl stop wireguide-network-recovery.timer wireguide-network-recovery.service 2>/dev/null || true
+ if (( rc == 0 )); then
+ log "PASS: CLI feature matrix and recovery completed"
+ else
+ log "FAIL: CLI feature matrix exited rc=$rc; emergency recovery executed"
+ fi
+ exit "$rc"
+}
+trap cleanup EXIT INT TERM
+
+# Produce private, short-lived variants without ever printing key material.
+derive_config() {
+ local destination=$1 allowed=$2 table_cfg=$3 fwmark_cfg=$4 keep_dns=$5
+ awk -v allowed="$allowed" -v table_cfg="$table_cfg" -v fwmark_cfg="$fwmark_cfg" -v keep_dns="$keep_dns" '
+ /^\[Interface\][[:space:]]*$/ {
+ print
+ if (table_cfg != "") print "Table = " table_cfg
+ if (fwmark_cfg != "") print "FwMark = " fwmark_cfg
+ next
+ }
+ /^[[:space:]]*DNS[[:space:]]*=/ && keep_dns != "yes" { next }
+ /^[[:space:]]*AllowedIPs[[:space:]]*=/ { print "AllowedIPs = " allowed; next }
+ { print }
+ ' "$vpn_config" >"$destination"
+ chmod 600 "$destination"
+}
+
+install -m 0600 "$vpn_config" "$generated_dir/full.conf"
+derive_config "$generated_dir/full-custom.conf" "0.0.0.0/0" "51888" "0xca70" yes
+derive_config "$generated_dir/split-v4.conf" "10.255.254.1/32" "" "" no
+derive_config "$generated_dir/split-v6.conf" "fd42:4242::1/128" "" "" no
+derive_config "$generated_dir/split-custom.conf" "10.255.254.88/32" "51888" "0xca70" no
+derive_config "$generated_dir/table-off.conf" "10.255.254.99/32" "off" "" no
+
+sudo -n systemd-run --quiet --unit=wireguide-network-recovery --on-active=5m \
+ "$recover" "$backup_resolv" "$helper_pidfile" "$recovery_log"
+log "independent 5-minute emergency recovery armed"
+
+baseline_ip=$(public_ip)
+http_ok https://www.google.com/generate_204
+getent ahosts www.google.com >/dev/null
+
+# Offline config-store and automation mutations.
+cli import "$generated_dir/full.conf" full-a >>"$test_log" 2>&1
+cli import "$generated_dir/full.conf" full-b >>"$test_log" 2>&1
+cli import "$generated_dir/full-custom.conf" full-custom >>"$test_log" 2>&1
+cli import "$generated_dir/split-v4.conf" rename-source >>"$test_log" 2>&1
+cli import "$generated_dir/split-v6.conf" split-v6 >>"$test_log" 2>&1
+cli import "$generated_dir/split-custom.conf" split-custom >>"$test_log" 2>&1
+cli import "$generated_dir/table-off.conf" table-off >>"$test_log" 2>&1
+if cli import "$generated_dir/full.conf" full-a >>"$test_log" 2>&1; then
+ log "FAIL: duplicate import unexpectedly overwrote full-a"
+ exit 1
+fi
+cli automation add rename-source connect else >>"$test_log" 2>&1
+cli rename rename-source split-v4 >>"$test_log" 2>&1
+cli automation rules split-v4 | grep -Eq 'connect[[:space:]]+when otherwise'
+if ! cli automation rules rename-source | grep -q 'has no automation rules'; then
+ log "FAIL: rename left rules under the old name"
+ exit 1
+fi
+if cli automation add split-v4 connect nonsense:value >>"$test_log" 2>&1; then
+ log "FAIL: invalid automation condition was accepted"
+ exit 1
+fi
+cli automation rm split-v4 1 >>"$test_log" 2>&1
+log "import/duplicate rejection/rename/rule migration/validation passed"
+
+sudo -n systemd-run --quiet --unit=wireguide-fulltest-helper --service-type=exec \
+ --setenv="XDG_CONFIG_HOME=$config_dir" --setenv="XDG_DATA_HOME=$data_dir" \
+ "$binary" --helper --socket "$socket" --uid "$uid_num" --data-dir "$helper_data"
+for _ in {1..100}; do [[ -S "$socket" ]] && break; sleep 0.1; done
+[[ -S "$socket" ]]
+main_pid=$(sudo -n systemctl show -p MainPID --value wireguide-fulltest-helper.service)
+[[ "$main_pid" =~ ^[1-9][0-9]*$ ]]
+printf '%s\n' "$main_pid" >"$helper_pidfile"
+
+# Every live setting accepted by the helper, plus invalid-value rejection.
+for level in debug info warn error info; do cli set loglevel "$level" >>"$test_log" 2>&1; done
+if cli set loglevel verbose >>"$test_log" 2>&1; then
+ log "FAIL: invalid log level was accepted"
+ exit 1
+fi
+cli set healthcheck on >>"$test_log" 2>&1
+cli set healthcheck off >>"$test_log" 2>&1
+cli set pin-interface on >>"$test_log" 2>&1
+cli set pin-interface off >>"$test_log" 2>&1
+log "live loglevel/healthcheck/pin-interface toggles passed"
+
+# Two full tunnels must conflict without disturbing the first one.
+cli connect full-a >>"$test_log" 2>&1
+http_ok https://www.google.com/generate_204
+if cli connect full-b >>"$test_log" 2>&1; then
+ log "FAIL: a second simultaneous full tunnel was accepted"
+ exit 1
+fi
+status_json=$(cli status --json 2>>"$test_log")
+python3 -c 'import json,sys; x=json.load(sys.stdin); assert [v["tunnel_name"] for v in x] == ["full-a"]' <<<"$status_json"
+if cli rename full-a forbidden-active-name >>"$test_log" 2>&1; then
+ log "FAIL: connected tunnel rename was accepted"
+ exit 1
+fi
+
+# Active delete must first disconnect, restore the host, then delete config.
+cli delete full-a >>"$test_log" 2>&1
+[[ $(cli status --json 2>>"$test_log" | tr -d '[:space:]') == '[]' ]]
+cmp -s "$backup_resolv" /etc/resolv.conf
+http_ok https://www.google.com/generate_204
+[[ $(public_ip) == "$baseline_ip" ]]
+if cli connect full-a >>"$test_log" 2>&1; then
+ log "FAIL: active delete left full-a loadable"
+ exit 1
+fi
+cli import "$generated_dir/full.conf" full-a >>"$test_log" 2>&1
+log "full-tunnel conflict/active-rename rejection/active-delete restoration passed"
+
+# Four concurrent split tunnels exercise unique interfaces, IPv4 + IPv6,
+# explicit-table routing, and Table=off.
+cli connect split-v4 >>"$test_log" 2>&1
+cli connect split-v6 >>"$test_log" 2>&1
+cli connect split-custom >>"$test_log" 2>&1
+cli connect table-off >>"$test_log" 2>&1
+status_json=$(cli status --json 2>>"$test_log")
+python3 -c '
+import json,sys
+x=json.load(sys.stdin)
+assert sorted(v["tunnel_name"] for v in x) == ["split-custom","split-v4","split-v6","table-off"]
+ifaces=[v["interface_name"] for v in x]
+assert all(v.startswith("wg-") for v in ifaces) and len(set(ifaces)) == 4
+' <<<"$status_json"
+ip -4 route show | grep -Eq '^10\.255\.254\.1(/32)? dev wg-'
+ip -6 route show | grep -Eq '^fd42:4242::1(/128)? dev wg-'
+ip -4 route show table 51888 | grep -Eq '^10\.255\.254\.88(/32)? dev wg-'
+if ip -4 route show table all | grep -Eq '^10\.255\.254\.99(/32)? ' ; then
+ log "FAIL: Table=off installed its forbidden route"
+ exit 1
+fi
+cli routes >>"$test_log" 2>&1
+
+# Remove a middle tunnel: the other three must remain and only its route goes.
+split_v6_iface=$(python3 -c 'import json,sys; print(next(v["interface_name"] for v in json.load(sys.stdin) if v["tunnel_name"]=="split-v6"))' <<<"$status_json")
+cli disconnect split-v6 >>"$test_log" 2>&1
+! ip link show "$split_v6_iface" >/dev/null 2>&1
+! ip -6 route show | grep -q '^fd42:4242::1'
+status_json=$(cli status --json 2>>"$test_log")
+python3 -c 'import json,sys; assert sorted(v["tunnel_name"] for v in json.load(sys.stdin)) == ["split-custom","split-v4","table-off"]' <<<"$status_json"
+cli disconnect >>"$test_log" 2>&1
+[[ $(cli status --json 2>>"$test_log" | tr -d '[:space:]') == '[]' ]]
+! ip -brief link show | grep -q '^wg-'
+! ip -4 route show | grep -q '^10.255.254.1'
+! ip -4 route show table 51888 | grep -q '^10.255.254.88'
+log "four-way split/unique TUN/IPv4/IPv6/custom-table/Table=off/middle+all disconnect passed"
+
+# Custom full-tunnel Table/FwMark, live diagnostics, DNS, and exact teardown.
+cli connect full-custom >>"$test_log" 2>&1
+ip -4 route show table 51888 | grep -Eq '^default dev wg-'
+ip -4 rule show | grep -Eq '^29040:.*not.*fwmark 0xca70.*lookup 51888'
+sudo -n wg show all fwmark | grep -Eq '^wg-.*0xca70$'
+http_ok https://www.google.com/generate_204
+[[ $(public_ip) != "$baseline_ip" ]]
+cli dnsleak >>"$test_log" 2>&1
+cli routes >>"$test_log" 2>&1
+cli disconnect full-custom >>"$test_log" 2>&1
+if ip -4 rule show | grep -q '^29040:'; then
+ log "FAIL: custom full disconnect left priority 29040 rule"
+ ip -4 rule show >>"$test_log"
+ exit 1
+fi
+if ip -4 rule show | grep -q '^29050:'; then
+ log "FAIL: custom full disconnect left priority 29050 rule"
+ ip -4 rule show >>"$test_log"
+ exit 1
+fi
+if [[ -n $(ip -4 route show table 51888) ]]; then
+ log "FAIL: custom full disconnect left table 51888 routes"
+ ip -4 route show table 51888 >>"$test_log"
+ exit 1
+fi
+if ! cmp -s "$backup_resolv" /etc/resolv.conf; then
+ log "FAIL: custom full disconnect did not restore exact resolv.conf"
+ exit 1
+fi
+if ! http_ok https://www.google.com/generate_204; then
+ log "FAIL: custom full disconnect did not restore HTTPS"
+ exit 1
+fi
+restored_ip=$(public_ip)
+if [[ "$restored_ip" != "$baseline_ip" ]]; then
+ log "FAIL: custom full disconnect public IP mismatch baseline=$baseline_ip restored=$restored_ip"
+ exit 1
+fi
+log "custom full Table/FwMark/DNS-leak/routes/disconnect restoration passed"
+
+# Delete also removes associated automation state.
+cli automation add table-off disconnect else >>"$test_log" 2>&1
+cli delete table-off >>"$test_log" 2>&1
+if ! cli automation rules table-off | grep -q 'has no automation rules'; then
+ log "FAIL: delete left automation rules addressable"
+ exit 1
+fi
+log "delete rule cleanup passed"
diff --git a/scripts/crash_recovery_integration_test.sh b/scripts/crash_recovery_integration_test.sh
new file mode 100755
index 0000000..f3616c9
--- /dev/null
+++ b/scripts/crash_recovery_integration_test.sh
@@ -0,0 +1,134 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+
+binary=${1:?wireguide binary required}
+vpn_config=${2:?VPN config required}
+test_root=${3:?isolated test directory required}
+uid_num=${4:-$(id -u)}
+
+repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+recover="$repo_root/scripts/network_test_recover.sh"
+runtime_dir="$test_root/runtime"
+config_dir="$test_root/config"
+data_dir="$test_root/data"
+helper_data="$test_root/helper"
+backup_resolv="$test_root/resolv.conf.before"
+helper_pidfile="$test_root/helper.pid"
+recovery_log="$test_root/recovery.log"
+test_log="$test_root/test.log"
+socket="$runtime_dir/wireguide-${uid_num}.sock"
+unit_suffix="${BASHPID:-$$}"
+source_unit="wireguide-crash-source-$unit_suffix"
+recovered_unit="wireguide-crash-recovered-$unit_suffix"
+recovery_unit="wireguide-network-recovery-$unit_suffix"
+name=crash-recovery-audit
+
+mkdir -p "$runtime_dir" "$config_dir" "$data_dir" "$helper_data"
+install -m 0644 /etc/resolv.conf "$backup_resolv"
+export XDG_RUNTIME_DIR="$runtime_dir" XDG_CONFIG_HOME="$config_dir" XDG_DATA_HOME="$data_dir"
+
+log() { printf '%s %s\n' "$(date -Is)" "$*" | tee -a "$test_log"; }
+cli() { timeout 45 "$binary" ctl "$@"; }
+http_ok() { curl --connect-timeout 5 --max-time 12 --silent --fail https://www.google.com/generate_204 >/dev/null; }
+
+throw_route_present() {
+ local route=$1
+ local family=-4
+ [[ "$route" == *:* ]] && family=-6
+ ip "$family" route show table all | awk -v target="${route%/*}" '
+ $1 == "throw" {
+ destination = $2
+ sub(/\/.*/, "", destination)
+ if (destination == target) found = 1
+ }
+ END { exit !found }
+ '
+}
+
+cleanup() {
+ rc=$?
+ trap - EXIT INT TERM
+ sudo -n "$recover" "$backup_resolv" "$helper_pidfile" "$recovery_log" || true
+ sudo -n systemctl stop "$source_unit.service" "$recovered_unit.service" \
+ "$recovery_unit.timer" "$recovery_unit.service" 2>/dev/null || true
+ if (( rc == 0 )); then
+ log "PASS: product crash recovery restored network and removed stale UAPI"
+ else
+ log "FAIL: crash recovery test exited rc=$rc; emergency recovery executed"
+ fi
+ exit "$rc"
+}
+trap cleanup EXIT INT TERM
+
+sudo -n systemd-run --quiet --unit="$recovery_unit" --on-active=3m \
+ "$recover" "$backup_resolv" "$helper_pidfile" "$recovery_log"
+log "independent 3-minute emergency recovery armed"
+
+http_ok
+cli import "$vpn_config" "$name" >>"$test_log" 2>&1
+sudo -n systemd-run --quiet --unit="$source_unit" --service-type=exec \
+ --setenv="XDG_CONFIG_HOME=$config_dir" --setenv="XDG_DATA_HOME=$data_dir" \
+ "$binary" --helper --socket "$socket" --uid "$uid_num" --data-dir "$helper_data"
+for _ in {1..100}; do [[ -S "$socket" ]] && break; sleep 0.1; done
+[[ -S "$socket" ]]
+source_pid=$(sudo -n systemctl show -p MainPID --value "$source_unit.service")
+[[ "$source_pid" =~ ^[1-9][0-9]*$ ]]
+printf '%s\n' "$source_pid" >"$helper_pidfile"
+
+cli connect "$name" >>"$test_log" 2>&1
+status_json=$(cli status --json 2>>"$test_log")
+iface=$(python3 -c 'import json,sys; print(json.load(sys.stdin)[0]["interface_name"])' <<<"$status_json")
+[[ "$iface" == wg-* ]]
+[[ -f "$helper_data/tunnel-states/$name.json" ]]
+[[ -S "/var/run/wireguard/$iface.sock" ]]
+! cmp -s "$backup_resolv" /etc/resolv.conf
+http_ok
+mapfile -t endpoint_routes < <(sudo -n python3 -c '
+import json, sys
+with open(sys.argv[1], encoding="utf-8") as state_file:
+ print("\n".join(json.load(state_file).get("endpoint_routes", [])))
+' "$helper_data/tunnel-states/$name.json")
+(( ${#endpoint_routes[@]} > 0 ))
+for endpoint_route in "${endpoint_routes[@]}"; do
+ throw_route_present "$endpoint_route"
+done
+
+# Deliberately bypass graceful cleanup. The independent timer is the final
+# safety net if the replacement helper cannot recover the host on its own.
+sudo -n kill -KILL "$source_pid"
+for _ in {1..100}; do kill -0 "$source_pid" 2>/dev/null || break; sleep 0.1; done
+! kill -0 "$source_pid" 2>/dev/null
+[[ -f "$helper_data/tunnel-states/$name.json" ]]
+[[ -S "/var/run/wireguard/$iface.sock" ]]
+log "SIGKILL left recovery journal and stale UAPI as expected"
+
+# A fresh helper must consume the journal before accepting normal CLI work.
+sudo -n systemd-run --quiet --unit="$recovered_unit" --service-type=exec \
+ --setenv="XDG_CONFIG_HOME=$config_dir" --setenv="XDG_DATA_HOME=$data_dir" \
+ "$binary" --helper --socket "$socket" --uid "$uid_num" --data-dir "$helper_data"
+for _ in {1..150}; do
+ if [[ -S "$socket" ]] && cli status --json >/dev/null 2>>"$test_log"; then break; fi
+ sleep 0.1
+done
+recovered_pid=$(sudo -n systemctl show -p MainPID --value "$recovered_unit.service")
+[[ "$recovered_pid" =~ ^[1-9][0-9]*$ ]]
+printf '%s\n' "$recovered_pid" >"$helper_pidfile"
+
+[[ $(cli status --json 2>>"$test_log" | tr -d '[:space:]') == '[]' ]]
+[[ ! -e "$helper_data/tunnel-states/$name.json" ]]
+[[ ! -S "/var/run/wireguard/$iface.sock" ]]
+! ip -brief link show | grep -q '^wg-'
+! ip -4 rule show | grep -q '^29040:'
+! ip -4 rule show | grep -q '^29050:'
+for endpoint_route in "${endpoint_routes[@]}"; do
+ if throw_route_present "$endpoint_route"; then
+ log "FAIL: crash recovery left endpoint throw route: $endpoint_route"
+ exit 1
+ fi
+done
+cmp -s "$backup_resolv" /etc/resolv.conf
+getent ahosts www.google.com >/dev/null
+http_ok
+log "replacement helper consumed journal and restored DNS/routes/UAPI/internet"
+
+cli delete "$name" >>"$test_log" 2>&1
diff --git a/scripts/firewall_integration_test.sh b/scripts/firewall_integration_test.sh
new file mode 100755
index 0000000..089aa10
--- /dev/null
+++ b/scripts/firewall_integration_test.sh
@@ -0,0 +1,173 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+
+binary=${1:?wireguide binary required}
+vpn_config=${2:?VPN config required}
+test_root=${3:?isolated test directory required}
+uid_num=${4:-$(id -u)}
+
+repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+recover="$repo_root/scripts/network_test_recover.sh"
+runtime_dir="$test_root/runtime"
+config_dir="$test_root/config"
+data_dir="$test_root/data"
+helper_data="$test_root/helper"
+backup_resolv="$test_root/resolv.conf.before"
+helper_pidfile="$test_root/helper.pid"
+recovery_log="$test_root/recovery.log"
+test_log="$test_root/test.log"
+socket="$runtime_dir/wireguide-${uid_num}.sock"
+name=firewall-audit
+split_name=firewall-split-audit
+split_config="$test_root/split.conf"
+
+mkdir -p "$runtime_dir" "$config_dir" "$data_dir" "$helper_data"
+install -m 0644 /etc/resolv.conf "$backup_resolv"
+split_private=$(wg genkey)
+awk -v split_private="$split_private" '
+ /^[[:space:]]*PrivateKey[[:space:]]*=/ { print "PrivateKey = " split_private; next }
+ /^[[:space:]]*Address[[:space:]]*=/ { print "Address = 10.255.253.2/32"; next }
+ /^[[:space:]]*DNS[[:space:]]*=/ { next }
+ /^[[:space:]]*AllowedIPs[[:space:]]*=/ { print "AllowedIPs = 10.255.253.1/32"; next }
+ { print }
+' "$vpn_config" >"$split_config"
+chmod 600 "$split_config"
+export XDG_RUNTIME_DIR="$runtime_dir" XDG_CONFIG_HOME="$config_dir" XDG_DATA_HOME="$data_dir"
+
+log() { printf '%s %s\n' "$(date -Is)" "$*" | tee -a "$test_log"; }
+cli() { timeout 45 "$binary" ctl "$@"; }
+http_ok() { curl --connect-timeout 5 --max-time 12 --silent --fail "$1" >/dev/null; }
+
+cleanup() {
+ rc=$?
+ trap - EXIT INT TERM
+ # Product OFF is the primary recovery path. These are best-effort here
+ # because the helper may already have been killed by a failure.
+ cli set dns-protection off >>"$test_log" 2>&1 || true
+ cli set killswitch off >>"$test_log" 2>&1 || true
+ sudo -n "$recover" "$backup_resolv" "$helper_pidfile" "$recovery_log" || true
+ sudo -n systemctl stop wireguide-network-recovery.timer wireguide-network-recovery.service 2>/dev/null || true
+ if (( rc == 0 )); then
+ log "PASS: firewall integration and product-OFF recovery completed"
+ else
+ log "FAIL: test exited rc=$rc; emergency recovery executed"
+ fi
+ exit "$rc"
+}
+trap cleanup EXIT INT TERM
+
+sudo -n systemd-run --quiet --unit=wireguide-network-recovery --on-active=2m \
+ "$recover" "$backup_resolv" "$helper_pidfile" "$recovery_log"
+log "independent 2-minute emergency recovery armed"
+
+http_ok https://www.google.com/generate_204
+getent ahosts www.google.com >/dev/null
+cli import "$vpn_config" "$name" >>"$test_log" 2>&1
+cli import "$split_config" "$split_name" >>"$test_log" 2>&1
+
+sudo -n systemd-run --quiet --unit=wireguide-fulltest-helper --service-type=exec \
+ --setenv="XDG_CONFIG_HOME=$config_dir" --setenv="XDG_DATA_HOME=$data_dir" \
+ "$binary" --helper --socket "$socket" --uid "$uid_num" --data-dir "$helper_data"
+for _ in {1..100}; do [[ -S "$socket" ]] && break; sleep 0.1; done
+[[ -S "$socket" ]]
+main_pid=$(sudo -n systemctl show -p MainPID --value wireguide-fulltest-helper.service)
+[[ "$main_pid" =~ ^[1-9][0-9]*$ ]]
+printf '%s\n' "$main_pid" >"$helper_pidfile"
+
+cli connect "$name" >>"$test_log" 2>&1
+http_ok https://www.google.com/generate_204
+getent ahosts www.google.com >/dev/null
+
+# Pre-enabled kill switch: no tunnel means intentional blockade. Connecting
+# must temporarily make just enough room for the first WireGuard handshake,
+# then rebuild the blockade around the live interface. This caught a real
+# ordering bug where the endpoint permit was added only after Connect, so the
+# handshake could never leave the host.
+cli disconnect "$name" >>"$test_log" 2>&1
+cli set killswitch on >>"$test_log" 2>&1
+sudo -n nft list table inet wireguide >>"$test_log"
+if http_ok https://www.google.com/generate_204; then
+ log "FAIL: pre-enabled kill switch did not block with no VPN"
+ exit 1
+fi
+cli connect "$name" >>"$test_log" 2>&1
+status_json=$(cli status --json 2>>"$test_log")
+grep -q '"state": "connected"' <<<"$status_json"
+grep -q '"interface_name": "wg-' <<<"$status_json"
+http_ok https://www.google.com/generate_204
+getent ahosts www.google.com >/dev/null
+cli set killswitch off >>"$test_log" 2>&1
+if sudo -n nft list table inet wireguide >/dev/null 2>&1; then
+ log "FAIL: pre-enable sequence left kill-switch table after OFF"
+ exit 1
+fi
+http_ok https://www.google.com/generate_204
+log "pre-enabled kill switch blockade/connect/normal-OFF passed"
+
+# DNS protection: prove the table is installed, DNS still resolves through
+# the configured VPN resolver, and the normal OFF path removes the table.
+cli set dns-protection on >>"$test_log" 2>&1
+sudo -n nft list table inet wireguide_dns >>"$test_log"
+getent ahosts www.google.com >/dev/null
+cli set dns-protection off >>"$test_log" 2>&1
+if sudo -n nft list table inet wireguide_dns >/dev/null 2>&1; then
+ log "FAIL: DNS-protection table survived product OFF"
+ exit 1
+fi
+getent ahosts www.google.com >/dev/null
+log "DNS protection ON/allowed-resolution/OFF passed"
+
+# Full + split simultaneously: enabling the kill switch must permit both live
+# interfaces. Removing the split must remove only its permit and leave the
+# full tunnel usable.
+cli connect "$split_name" >>"$test_log" 2>&1
+http_ok https://www.google.com/generate_204
+status_json=$(cli status --json 2>>"$test_log")
+full_iface=$(python3 -c 'import json,sys; print(next(v["interface_name"] for v in json.load(sys.stdin) if v["tunnel_name"]=="firewall-audit"))' <<<"$status_json")
+split_iface=$(python3 -c 'import json,sys; print(next(v["interface_name"] for v in json.load(sys.stdin) if v["tunnel_name"]=="firewall-split-audit"))' <<<"$status_json")
+cli set killswitch on >>"$test_log" 2>&1
+nft_rules=$(sudo -n nft list table inet wireguide)
+grep -q "$full_iface" <<<"$nft_rules"
+grep -q "$split_iface" <<<"$nft_rules"
+http_ok https://www.google.com/generate_204
+cli disconnect "$split_name" >>"$test_log" 2>&1
+nft_rules=$(sudo -n nft list table inet wireguide)
+grep -q "$full_iface" <<<"$nft_rules"
+if grep -q "$split_iface" <<<"$nft_rules"; then
+ log "FAIL: disconnected split tunnel remained permitted by kill switch"
+ exit 1
+fi
+http_ok https://www.google.com/generate_204
+cli set killswitch off >>"$test_log" 2>&1
+log "multi-tunnel kill-switch add/selective-remove passed"
+
+# Kill switch while connected must preserve VPN traffic.
+cli set killswitch on >>"$test_log" 2>&1
+sudo -n nft list table inet wireguide >>"$test_log"
+http_ok https://www.google.com/generate_204
+log "kill switch ON preserved VPN HTTPS"
+
+# With the VPN removed but kill switch still ON, a fresh outbound connection
+# must fail. This is the intentional offline phase. IPC remains reachable over
+# loopback so the product's OFF command can restore connectivity.
+cli disconnect "$name" >>"$test_log" 2>&1
+if http_ok https://www.google.com/generate_204; then
+ log "FAIL: internet remained reachable with kill switch ON and VPN down"
+ exit 1
+fi
+log "kill switch intentionally blocked internet with VPN down"
+
+# This is the primary assertion requested by the test: normal feature OFF,
+# not the emergency script, must restore internet and remove nftables state.
+cli set killswitch off >>"$test_log" 2>&1
+if sudo -n nft list table inet wireguide >/dev/null 2>&1; then
+ log "FAIL: kill-switch table survived product OFF"
+ exit 1
+fi
+getent ahosts www.google.com >/dev/null
+http_ok https://www.google.com/generate_204
+cmp -s "$backup_resolv" /etc/resolv.conf
+log "kill switch product OFF restored DNS and internet"
+
+cli delete "$name" >>"$test_log" 2>&1
+cli delete "$split_name" >>"$test_log" 2>&1
diff --git a/scripts/full_tunnel_integration_test.sh b/scripts/full_tunnel_integration_test.sh
new file mode 100755
index 0000000..706bf74
--- /dev/null
+++ b/scripts/full_tunnel_integration_test.sh
@@ -0,0 +1,105 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+
+binary=${1:?wireguide binary required}
+vpn_config=${2:?VPN config required}
+test_root=${3:?isolated test directory required}
+uid_num=${4:-$(id -u)}
+
+repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+recover="$repo_root/scripts/network_test_recover.sh"
+runtime_dir="$test_root/runtime"
+config_dir="$test_root/config"
+data_dir="$test_root/data"
+helper_data="$test_root/helper"
+backup_resolv="$test_root/resolv.conf.before"
+helper_pidfile="$test_root/helper.pid"
+recovery_log="$test_root/recovery.log"
+test_log="$test_root/test.log"
+socket="$runtime_dir/wireguide-${uid_num}.sock"
+name=full-tunnel-audit
+
+mkdir -p "$runtime_dir" "$config_dir" "$data_dir" "$helper_data"
+install -m 0644 /etc/resolv.conf "$backup_resolv"
+export XDG_RUNTIME_DIR="$runtime_dir" XDG_CONFIG_HOME="$config_dir" XDG_DATA_HOME="$data_dir"
+
+log() { printf '%s %s\n' "$(date -Is)" "$*" | tee -a "$test_log"; }
+cli() { timeout 45 "$binary" ctl "$@"; }
+http_ok() { curl --connect-timeout 8 --max-time 20 --silent --fail "$1" >/dev/null; }
+public_ip() { curl --connect-timeout 8 --max-time 20 --silent --fail https://api.ipify.org; }
+
+cleanup() {
+ rc=$?
+ trap - EXIT INT TERM
+ sudo -n "$recover" "$backup_resolv" "$helper_pidfile" "$recovery_log" || true
+ sudo -n systemctl stop wireguide-network-recovery.timer wireguide-network-recovery.service 2>/dev/null || true
+ if (( rc == 0 )); then
+ log "PASS: full-tunnel integration and recovery completed"
+ else
+ log "FAIL: test exited rc=$rc; emergency recovery executed"
+ fi
+ exit "$rc"
+}
+trap cleanup EXIT INT TERM
+
+bash -n "$recover"
+sudo -n systemd-run --quiet --unit=wireguide-network-recovery --on-active=3m \
+ "$recover" "$backup_resolv" "$helper_pidfile" "$recovery_log"
+log "independent 3-minute recovery timer armed"
+
+baseline_ip=$(public_ip)
+http_ok https://www.google.com/generate_204
+http_ok https://www.cloudflare.com/cdn-cgi/trace
+getent ahosts www.google.com >/dev/null
+log "baseline HTTPS and DNS passed"
+
+cli import "$vpn_config" "$name" >>"$test_log" 2>&1
+sudo -n systemd-run --quiet --unit=wireguide-fulltest-helper --service-type=exec \
+ --setenv="XDG_CONFIG_HOME=$config_dir" --setenv="XDG_DATA_HOME=$data_dir" \
+ "$binary" --helper --socket "$socket" --uid "$uid_num" --data-dir "$helper_data"
+
+for _ in {1..100}; do
+ [[ -S "$socket" ]] && break
+ sleep 0.1
+done
+[[ -S "$socket" ]]
+main_pid=$(sudo -n systemctl show -p MainPID --value wireguide-fulltest-helper.service)
+[[ "$main_pid" =~ ^[1-9][0-9]*$ ]]
+printf '%s\n' "$main_pid" >"$helper_pidfile"
+
+cli connect "$name" >>"$test_log" 2>&1
+status_json=$(cli status --json 2>>"$test_log")
+grep -q '"state": "connected"' <<<"$status_json"
+grep -q '"interface_name": "wg-' <<<"$status_json"
+grep -Eq '"tx_bytes": [1-9][0-9]*' <<<"$status_json"
+grep -q '^29040:' < <(ip -4 rule show)
+grep -q '^29050:' < <(ip -4 rule show)
+ip -4 route show table 51820 | grep -q '^default dev wg-'
+if cmp -s "$backup_resolv" /etc/resolv.conf; then
+ log "FAIL: DNS file did not change while configured tunnel DNS was active"
+ exit 1
+fi
+log "full-tunnel policy route, TX, and DNS mutation verified"
+
+getent ahosts www.google.com >/dev/null
+http_ok https://www.google.com/generate_204
+http_ok https://www.cloudflare.com/cdn-cgi/trace
+vpn_ip=$(public_ip)
+if [[ "$vpn_ip" == "$baseline_ip" ]]; then
+ log "FAIL: public IP did not change through full tunnel"
+ exit 1
+fi
+log "VPN DNS, HTTPS, and public-IP change verified"
+
+cli disconnect "$name" >>"$test_log" 2>&1
+[[ $(cli status --json 2>>"$test_log" | tr -d '[:space:]') == '[]' ]]
+! ip -brief link show | grep -q '^wg-'
+! ip -4 rule show | grep -q '^29040:'
+! ip -4 rule show | grep -q '^29050:'
+cmp -s "$backup_resolv" /etc/resolv.conf
+getent ahosts www.google.com >/dev/null
+http_ok https://www.google.com/generate_204
+[[ $(public_ip) == "$baseline_ip" ]]
+log "disconnect route, DNS, HTTPS, and public-IP restoration verified"
+
+cli delete "$name" >>"$test_log" 2>&1
diff --git a/scripts/healthcheck_integration_test.sh b/scripts/healthcheck_integration_test.sh
new file mode 100755
index 0000000..7c43556
--- /dev/null
+++ b/scripts/healthcheck_integration_test.sh
@@ -0,0 +1,130 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+
+binary=${1:?wireguide binary required}
+vpn_config=${2:?VPN config required}
+test_root=${3:?isolated test directory required}
+uid_num=${4:-$(id -u)}
+
+repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+recover="$repo_root/scripts/network_test_recover.sh"
+runtime_dir="$test_root/runtime"
+config_dir="$test_root/config"
+data_dir="$test_root/data"
+helper_data="$test_root/helper"
+backup_resolv="$test_root/resolv.conf.before"
+helper_pidfile="$test_root/helper.pid"
+recovery_log="$test_root/recovery.log"
+test_log="$test_root/test.log"
+socket="$runtime_dir/wireguide-${uid_num}.sock"
+split_config="$test_root/health-split.conf"
+name=health-audit
+nft_table=wireguide_health_test
+
+mkdir -p "$runtime_dir" "$config_dir" "$data_dir" "$helper_data"
+install -m 0644 /etc/resolv.conf "$backup_resolv"
+awk '
+ /^[[:space:]]*DNS[[:space:]]*=/ { next }
+ /^[[:space:]]*AllowedIPs[[:space:]]*=/ { print "AllowedIPs = 10.255.251.1/32"; next }
+ { print }
+' "$vpn_config" >"$split_config"
+chmod 600 "$split_config"
+endpoint=$(awk -F= '/^[[:space:]]*Endpoint[[:space:]]*=/ {gsub(/[[:space:]]/, "", $2); print $2; exit}' "$vpn_config")
+endpoint_ip=${endpoint%:*}
+endpoint_port=${endpoint##*:}
+[[ "$endpoint_ip" =~ ^[0-9.]+$ && "$endpoint_port" =~ ^[0-9]+$ ]]
+export XDG_RUNTIME_DIR="$runtime_dir" XDG_CONFIG_HOME="$config_dir" XDG_DATA_HOME="$data_dir"
+
+log() { printf '%s %s\n' "$(date -Is)" "$*" | tee -a "$test_log"; }
+cli() { timeout 45 "$binary" ctl "$@"; }
+http_ok() { curl --connect-timeout 5 --max-time 12 --silent --fail https://www.google.com/generate_204 >/dev/null; }
+
+cleanup() {
+ rc=$?
+ trap - EXIT INT TERM
+ sudo -n nft delete table inet "$nft_table" 2>/dev/null || true
+ cli set healthcheck off >>"$test_log" 2>&1 || true
+ sudo -n "$recover" "$backup_resolv" "$helper_pidfile" "$recovery_log" || true
+ sudo -n systemctl stop wireguide-network-recovery.timer wireguide-network-recovery.service 2>/dev/null || true
+ if (( rc == 0 )); then
+ log "PASS: stale-handshake healthcheck reconnect completed"
+ else
+ log "FAIL: healthcheck test exited rc=$rc; endpoint block removed and emergency recovery executed"
+ fi
+ exit "$rc"
+}
+trap cleanup EXIT INT TERM
+
+sudo -n systemd-run --quiet --unit=wireguide-network-recovery --on-active=6m \
+ "$recover" "$backup_resolv" "$helper_pidfile" "$recovery_log"
+log "independent 6-minute emergency recovery armed"
+
+cli import "$split_config" "$name" >>"$test_log" 2>&1
+start_marker=$(date '+%Y-%m-%d %H:%M:%S')
+sudo -n systemd-run --quiet --unit=wireguide-fulltest-helper --service-type=exec \
+ --setenv="XDG_CONFIG_HOME=$config_dir" --setenv="XDG_DATA_HOME=$data_dir" \
+ "$binary" --helper --socket "$socket" --uid "$uid_num" --data-dir "$helper_data"
+for _ in {1..100}; do [[ -S "$socket" ]] && break; sleep 0.1; done
+[[ -S "$socket" ]]
+main_pid=$(sudo -n systemctl show -p MainPID --value wireguide-fulltest-helper.service)
+[[ "$main_pid" =~ ^[1-9][0-9]*$ ]]
+printf '%s\n' "$main_pid" >"$helper_pidfile"
+
+cli connect "$name" >>"$test_log" 2>&1
+for _ in {1..40}; do
+ status_json=$(cli status --json 2>>"$test_log")
+ if python3 -c 'import json,sys; x=json.load(sys.stdin); raise SystemExit(0 if x and x[0].get("last_handshake") else 1)' <<<"$status_json"; then break; fi
+ sleep 0.5
+done
+python3 -c 'import json,sys; x=json.load(sys.stdin); assert x and x[0].get("last_handshake")' <<<"$status_json"
+ip -4 route show | grep -Eq '^10\.255\.251\.1(/32)? dev wg-'
+http_ok
+cli set healthcheck on >>"$test_log" 2>&1
+
+# Block only the WireGuard peer UDP flow. This makes the split tunnel's
+# handshake stale while leaving the host's ordinary DNS/HTTPS untouched.
+sudo -n nft -f - <>"$test_log")
+ if python3 -c 'import json,sys; x=json.load(sys.stdin); raise SystemExit(0 if x and x[0].get("last_handshake") else 1)' <<<"$status_json"; then break; fi
+ sleep 0.5
+done
+python3 -c 'import json,sys; x=json.load(sys.stdin); assert x and x[0]["state"]=="connected" and x[0].get("last_handshake")' <<<"$status_json"
+ip -4 route show | grep -Eq '^10\.255\.251\.1(/32)? dev wg-'
+http_ok
+log "endpoint unblocked; fresh handshake and normal internet verified"
+
+cli set healthcheck off >>"$test_log" 2>&1
+cli disconnect "$name" >>"$test_log" 2>&1
+! ip -4 route show | grep -q '^10.255.251.1'
+http_ok
+cli delete "$name" >>"$test_log" 2>&1
+cmp -s "$backup_resolv" /etc/resolv.conf
diff --git a/scripts/network_test_recover.sh b/scripts/network_test_recover.sh
new file mode 100755
index 0000000..9640282
--- /dev/null
+++ b/scripts/network_test_recover.sh
@@ -0,0 +1,74 @@
+#!/usr/bin/env bash
+set -u
+
+# Emergency recovery for the destructive Linux full-tunnel integration test.
+# Designed to be run by an independent systemd transient unit, so it still
+# executes if the test shell, Codex connection, or VPN route is wedged.
+backup_resolv=${1:?backup resolv.conf path required}
+helper_pidfile=${2:?helper pidfile required}
+recovery_log=${3:?recovery log path required}
+
+exec >>"$recovery_log" 2>&1
+echo "$(date -Is) recovery starting"
+
+systemctl stop wireguide-fulltest-helper.service 2>/dev/null || true
+
+if [[ -s "$helper_pidfile" ]]; then
+ helper_pid=$(<"$helper_pidfile")
+ if [[ "$helper_pid" =~ ^[0-9]+$ ]]; then
+ kill -TERM "$helper_pid" 2>/dev/null || true
+ for _ in {1..20}; do
+ kill -0 "$helper_pid" 2>/dev/null || break
+ sleep 0.1
+ done
+ kill -KILL "$helper_pid" 2>/dev/null || true
+ fi
+fi
+
+# Removing the userspace-owned TUN also removes its device routes. Handle any
+# leftovers explicitly because a crash between route phases can leave rules.
+while IFS= read -r iface; do
+ [[ "$iface" == wg-* ]] && ip link delete dev "$iface" 2>/dev/null || true
+done < <(ip -o link show | awk -F': ' '{print $2}')
+
+# A killed wireguard-go process can leave its Unix UAPI socket behind even
+# after the TUN is gone. Restrict deletion to WireGuide's hashed wg-* names.
+find /var/run/wireguard -maxdepth 1 -type s -name 'wg-*.sock' -delete 2>/dev/null || true
+
+for family in -4 -6; do
+ for priority in 29040 29050; do
+ for _ in {1..50}; do
+ ip "$family" rule show | grep -q "^${priority}:" || break
+ ip "$family" rule delete priority "$priority" 2>/dev/null || break
+ done
+ done
+ for table in $(seq 51820 51919); do
+ ip "$family" route flush table "$table" 2>/dev/null || true
+ done
+done
+
+nft delete table inet wireguide 2>/dev/null || true
+nft delete table inet wireguide_dns 2>/dev/null || true
+
+if [[ -f "$backup_resolv" ]]; then
+ install -m 0644 "$backup_resolv" /etc/resolv.conf
+fi
+
+# Give the normal cleanup a chance first. If connectivity is still unavailable,
+# restart NetworkManager as a final self-healing fallback; the saved Wi-Fi
+# profile reconnects wlan0 without requiring this test session to be alive.
+if ! curl --connect-timeout 5 --max-time 10 --silent --fail https://www.google.com/generate_204 >/dev/null; then
+ echo "$(date -Is) primary recovery did not restore connectivity; restarting NetworkManager"
+ systemctl restart NetworkManager 2>/dev/null || true
+ sleep 10
+ if [[ -f "$backup_resolv" ]]; then
+ install -m 0644 "$backup_resolv" /etc/resolv.conf
+ fi
+fi
+
+if curl --connect-timeout 5 --max-time 15 --silent --fail https://www.google.com/generate_204 >/dev/null; then
+ echo "$(date -Is) recovery connectivity check passed"
+else
+ echo "$(date -Is) recovery connectivity check FAILED"
+fi
+echo "$(date -Is) recovery complete"
diff --git a/scripts/resource_stability_test.sh b/scripts/resource_stability_test.sh
new file mode 100755
index 0000000..674364f
--- /dev/null
+++ b/scripts/resource_stability_test.sh
@@ -0,0 +1,126 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+
+binary=${1:?wireguide binary required}
+vpn_config=${2:?VPN config required}
+test_root=${3:?isolated test directory required}
+uid_num=${4:-$(id -u)}
+
+repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+recover="$repo_root/scripts/network_test_recover.sh"
+runtime_dir="$test_root/runtime"
+config_dir="$test_root/config"
+data_dir="$test_root/data"
+helper_data="$test_root/helper"
+backup_resolv="$test_root/resolv.conf.before"
+helper_pidfile="$test_root/helper.pid"
+recovery_log="$test_root/recovery.log"
+test_log="$test_root/test.log"
+socket="$runtime_dir/wireguide-${uid_num}.sock"
+split_config="$test_root/resource-split.conf"
+name=resource-audit
+cycle_count=${WIREGUIDE_RESOURCE_CYCLES:-30}
+status_count=${WIREGUIDE_RESOURCE_STATUS_CALLS:-100}
+test_gogc=${WIREGUIDE_RESOURCE_GOGC:-}
+test_godebug=${WIREGUIDE_RESOURCE_GODEBUG:-}
+
+mkdir -p "$runtime_dir" "$config_dir" "$data_dir" "$helper_data"
+install -m 0644 /etc/resolv.conf "$backup_resolv"
+awk '
+ /^[[:space:]]*DNS[[:space:]]*=/ { next }
+ /^[[:space:]]*AllowedIPs[[:space:]]*=/ { print "AllowedIPs = 10.255.250.1/32"; next }
+ { print }
+' "$vpn_config" >"$split_config"
+chmod 600 "$split_config"
+export XDG_RUNTIME_DIR="$runtime_dir" XDG_CONFIG_HOME="$config_dir" XDG_DATA_HOME="$data_dir"
+
+log() { printf '%s %s\n' "$(date -Is)" "$*" | tee -a "$test_log"; }
+cli() { timeout 45 "$binary" ctl "$@"; }
+
+cleanup() {
+ rc=$?
+ trap - EXIT INT TERM
+ sudo -n "$recover" "$backup_resolv" "$helper_pidfile" "$recovery_log" || true
+ sudo -n systemctl stop wireguide-network-recovery.timer wireguide-network-recovery.service 2>/dev/null || true
+ if (( rc == 0 )); then
+ log "PASS: helper resource stability completed"
+ else
+ log "FAIL: resource stability exited rc=$rc; emergency recovery executed"
+ fi
+ exit "$rc"
+}
+trap cleanup EXIT INT TERM
+
+sudo -n systemd-run --quiet --unit=wireguide-network-recovery --on-active=5m \
+ "$recover" "$backup_resolv" "$helper_pidfile" "$recovery_log"
+
+cli import "$split_config" "$name" >>"$test_log" 2>&1
+service_env=(--setenv="XDG_CONFIG_HOME=$config_dir" --setenv="XDG_DATA_HOME=$data_dir")
+[[ -n "$test_gogc" ]] && service_env+=(--setenv="GOGC=$test_gogc")
+[[ -n "$test_godebug" ]] && service_env+=(--setenv="GODEBUG=$test_godebug")
+sudo -n systemd-run --quiet --unit=wireguide-fulltest-helper --service-type=exec \
+ "${service_env[@]}" \
+ "$binary" --helper --socket "$socket" --uid "$uid_num" --data-dir "$helper_data"
+for _ in {1..100}; do [[ -S "$socket" ]] && break; sleep 0.1; done
+[[ -S "$socket" ]]
+main_pid=$(sudo -n systemctl show -p MainPID --value wireguide-fulltest-helper.service)
+[[ "$main_pid" =~ ^[1-9][0-9]*$ ]]
+printf '%s\n' "$main_pid" >"$helper_pidfile"
+
+rss_kib() { awk '/^VmRSS:/ {print $2}' "/proc/$main_pid/status"; }
+threads() { awk '/^Threads:/ {print $2}' "/proc/$main_pid/status"; }
+fd_count() { sudo -n find "/proc/$main_pid/fd" -mindepth 1 -maxdepth 1 -printf . | wc -c; }
+cpu_ticks() { awk '{print $14+$15}' "/proc/$main_pid/stat"; }
+sample() {
+ local label=$1
+ log "resource $label rss_kib=$(rss_kib) fds=$(fd_count) threads=$(threads) cpu_ticks=$(cpu_ticks)"
+}
+
+# Warm runtime caches before choosing the leak baseline.
+for _ in {1..5}; do
+ cli connect "$name" >>"$test_log" 2>&1
+ cli status --json >/dev/null 2>>"$test_log"
+ cli disconnect "$name" >>"$test_log" 2>&1
+done
+baseline_rss=$(rss_kib)
+baseline_fds=$(fd_count)
+baseline_threads=$(threads)
+baseline_ticks=$(cpu_ticks)
+start_seconds=$(date +%s)
+sample warm-baseline
+
+for cycle in $(seq 1 "$cycle_count"); do
+ cli connect "$name" >>"$test_log" 2>&1
+ cli status --json >/dev/null 2>>"$test_log"
+ cli disconnect "$name" >>"$test_log" 2>&1
+ [[ $(cli status --json 2>>"$test_log" | tr -d '[:space:]') == '[]' ]]
+ if (( cycle % 10 == 0 )); then sample "cycle-$cycle"; fi
+done
+
+# Exercise short-lived IPC clients and wgctrl status acquisition repeatedly
+# while one engine is live, then verify all descriptors close again.
+cli connect "$name" >>"$test_log" 2>&1
+for _ in $(seq 1 "$status_count"); do cli status --json >/dev/null 2>>"$test_log"; done
+cli disconnect "$name" >>"$test_log" 2>&1
+sleep 1
+sample after-status-storm
+
+end_rss=$(rss_kib)
+end_fds=$(fd_count)
+end_threads=$(threads)
+end_ticks=$(cpu_ticks)
+elapsed=$(( $(date +%s) - start_seconds ))
+tick_hz=$(getconf CLK_TCK)
+cpu_millis=$(( (end_ticks - baseline_ticks) * 1000 / tick_hz ))
+
+(( end_rss <= baseline_rss + 32768 ))
+(( end_fds <= baseline_fds + 4 ))
+(( end_threads <= baseline_threads + 4 ))
+! ip -brief link show | grep -q '^wg-'
+! ip -4 route show | grep -q '^10.255.250.1'
+[[ -z $(sudo -n find /var/run/wireguard -maxdepth 1 -type s -name 'wg-*.sock' -printf x) ]]
+cmp -s "$backup_resolv" /etc/resolv.conf
+gogc_label=${test_gogc:-helper-default}
+log "$cycle_count lifecycle cycles + $status_count status calls (GOGC=$gogc_label): wall=${elapsed}s helper_cpu=${cpu_millis}ms rss_delta=$((end_rss-baseline_rss))KiB fd_delta=$((end_fds-baseline_fds)) thread_delta=$((end_threads-baseline_threads))"
+
+cli delete "$name" >>"$test_log" 2>&1