From 23dcfb393f3d10974a2c891d9888a5ab49d0316a Mon Sep 17 00:00:00 2001 From: bsncubed Date: Thu, 20 Aug 2026 13:29:35 +1000 Subject: [PATCH] Scaffold ipswap: tray-driven Windows IP preset switcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the design in claude.md as a building skeleton: pure Go, no cgo, cross-compiles to a single Windows .exe from Linux. Architecture follows the spec's deliberate split. The fast path is native — tray icon, grouped submenus, a Win32 MessageBox showing a live before/after diff, then netsh. The slow path is an embedded web editor served on a random loopback port and opened in the default browser. Two decisions worth recording: Reads use GetAdaptersAddresses, writes use netsh. The spec left the enumeration mechanism open; parsing `netsh show config` breaks on a non-English Windows because the output is localised. DNS static-vs-DHCP origin is not exposed by that API, so it comes from one registry read. The netsh command plan is built in portable code. That puts the delete-every- existing-address step — the one that stops secondary addresses leaking across switches — under test without needing a Windows box. The editor requires the session token in a header for mutations, not just the cookie: any page in the browser can make it POST to 127.0.0.1 with the cookie attached, but it cannot set a header. The updater refuses to install a release that publishes no SHA256. Not yet done: no group picker for export (the API supports it), no single-instance guard, and internal/server/web/app.css is reconstructed from the description in claude.md rather than the canonical apointless.css. Co-Authored-By: Claude Opus 5 --- .gitea/workflows/release.yml | 61 ++++ .gitignore | 6 + Makefile | 67 +++++ README.md | 125 ++++++++ claude.md | 252 ++++++++++++++++ cmd/ipswap/fatal_other.go | 13 + cmd/ipswap/fatal_windows.go | 11 + cmd/ipswap/ipswap.manifest | 58 ++++ cmd/ipswap/main.go | 96 ++++++ cmd/ipswap/versioninfo.json | 25 ++ examples/preset-pack.json | 72 +++++ go.mod | 10 + go.sum | 6 + internal/applog/applog.go | 131 +++++++++ internal/config/config.go | 130 ++++++++ internal/desktop/desktop_other.go | 23 ++ internal/desktop/desktop_windows.go | 100 +++++++ internal/dialog/dialog_other.go | 22 ++ internal/dialog/dialog_windows.go | 80 +++++ internal/elevate/elevate_other.go | 14 + internal/elevate/elevate_windows.go | 105 +++++++ internal/netcfg/diff.go | 92 ++++++ internal/netcfg/netcfg.go | 251 ++++++++++++++++ internal/netcfg/netcfg_other.go | 48 +++ internal/netcfg/netcfg_windows.go | 234 +++++++++++++++ internal/netcfg/plan_test.go | 188 ++++++++++++ internal/preset/portio.go | 101 +++++++ internal/preset/prefix.go | 71 +++++ internal/preset/prefix_test.go | 66 +++++ internal/preset/preset.go | 162 ++++++++++ internal/preset/store.go | 238 +++++++++++++++ internal/preset/store_test.go | 228 +++++++++++++++ internal/server/server.go | 439 ++++++++++++++++++++++++++++ internal/server/server_test.go | 214 ++++++++++++++ internal/server/web/app.css | 333 +++++++++++++++++++++ internal/server/web/app.js | 435 +++++++++++++++++++++++++++ internal/server/web/index.html | 193 ++++++++++++ internal/tray/helpers.go | 21 ++ internal/tray/icon.ico | Bin 0 -> 2525 bytes internal/tray/tray.go | 430 +++++++++++++++++++++++++++ internal/updater/install_other.go | 11 + internal/updater/install_windows.go | 95 ++++++ internal/updater/semver.go | 102 +++++++ internal/updater/semver_test.go | 63 ++++ internal/updater/updater.go | 266 +++++++++++++++++ internal/updater/updater_test.go | 94 ++++++ 46 files changed, 5782 insertions(+) create mode 100644 .gitea/workflows/release.yml create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 claude.md create mode 100644 cmd/ipswap/fatal_other.go create mode 100644 cmd/ipswap/fatal_windows.go create mode 100644 cmd/ipswap/ipswap.manifest create mode 100644 cmd/ipswap/main.go create mode 100644 cmd/ipswap/versioninfo.json create mode 100644 examples/preset-pack.json create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/applog/applog.go create mode 100644 internal/config/config.go create mode 100644 internal/desktop/desktop_other.go create mode 100644 internal/desktop/desktop_windows.go create mode 100644 internal/dialog/dialog_other.go create mode 100644 internal/dialog/dialog_windows.go create mode 100644 internal/elevate/elevate_other.go create mode 100644 internal/elevate/elevate_windows.go create mode 100644 internal/netcfg/diff.go create mode 100644 internal/netcfg/netcfg.go create mode 100644 internal/netcfg/netcfg_other.go create mode 100644 internal/netcfg/netcfg_windows.go create mode 100644 internal/netcfg/plan_test.go create mode 100644 internal/preset/portio.go create mode 100644 internal/preset/prefix.go create mode 100644 internal/preset/prefix_test.go create mode 100644 internal/preset/preset.go create mode 100644 internal/preset/store.go create mode 100644 internal/preset/store_test.go create mode 100644 internal/server/server.go create mode 100644 internal/server/server_test.go create mode 100644 internal/server/web/app.css create mode 100644 internal/server/web/app.js create mode 100644 internal/server/web/index.html create mode 100644 internal/tray/helpers.go create mode 100644 internal/tray/icon.ico create mode 100644 internal/tray/tray.go create mode 100644 internal/updater/install_other.go create mode 100644 internal/updater/install_windows.go create mode 100644 internal/updater/semver.go create mode 100644 internal/updater/semver_test.go create mode 100644 internal/updater/updater.go create mode 100644 internal/updater/updater_test.go diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..1b2e713 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,61 @@ +# Builds ipswap.exe on every tag and attaches it, with its checksum, to the +# Gitea release. +# +# There is no Wine step and no mingw step: ipswap is pure Go with no cgo, so a +# Linux runner cross-compiles the Windows binary directly. That constraint is +# why the tray is fyne.io/systray and the dialogs are raw user32 calls — keep +# it that way and this file stays this short. +# +# The SHA256SUMS artifact is not decoration. The in-app updater refuses to +# install a release that publishes no checksum, so a release built without this +# step cannot be auto-updated to. + +name: release + +on: + push: + tags: + - "v*" + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Check out + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.26" + cache: true + + - name: Derive version from the tag + id: version + run: echo "version=${GITEA_REF_NAME#v}" >> "$GITHUB_OUTPUT" + env: + GITEA_REF_NAME: ${{ gitea.ref_name }} + + - name: Test + run: go test ./... + + - name: Vet both targets + run: | + go vet ./... + GOOS=windows GOARCH=amd64 go vet ./... + + - name: Build + run: make dist VERSION=${{ steps.version.outputs.version }} + + - name: Publish the release + uses: akkuman/gitea-release-action@v1 + with: + token: ${{ secrets.GITEA_TOKEN }} + files: | + dist/ipswap.exe + dist/SHA256SUMS + body: | + ipswap v${{ steps.version.outputs.version }} + + SHA256 of `ipswap.exe` is published in the `SHA256SUMS` asset; + the in-app updater verifies against it before installing. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3fbfa1c --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +bin/ +dist/ +Output/ +*.exe +*.syso +SHA256SUMS diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3b2245e --- /dev/null +++ b/Makefile @@ -0,0 +1,67 @@ +VERSION ?= 0.1.0 + +# -H windowsgui suppresses the console window that would otherwise sit behind +# the tray icon. -s -w strips the symbol table and DWARF; nothing here needs a +# Go stack trace off a customer's laptop, and it roughly halves the binary. +LDFLAGS := -s -w -H windowsgui -X main.version=$(VERSION) + +# Go is installed at ~/.local/go on this box and deliberately kept off PATH, so +# prefer that toolchain and fall back to whatever `go` is in PATH elsewhere. +GO ?= $(shell test -x $(HOME)/.local/go/bin/go && echo $(HOME)/.local/go/bin/go || echo go) + +# Pinned and run through `go run` so CI needs no separate install step. +GOVERSIONINFO ?= $(GO) run github.com/josephspurrier/goversioninfo/cmd/goversioninfo@v1.4.1 + +SYSO := cmd/ipswap/resource_windows.syso + +.PHONY: build syso test vet check dist sums clean help + +help: ## List targets + @grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN{FS=":.*?## "}{printf " %-14s %s\n", $$1, $$2}' + +# ipswap is pure Go with no cgo anywhere, which is the whole reason the tray is +# fyne.io/systray and the dialogs are raw user32 rather than a GUI toolkit: +# `GOOS=windows go build` cross-compiles straight from Linux, no Wine, no +# mingw, no fyne-cross. +build: syso ## Cross-compile bin/ipswap.exe from any host + @mkdir -p bin + GOOS=windows GOARCH=amd64 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o bin/ipswap.exe ./cmd/ipswap + +# The .syso carries the icon, the version resource and — the part that matters +# — the manifest that asks for asInvoker and per-monitor DPI. A plain `go +# build` with no .syso produces a binary with the generic executable icon and +# the default (unaware, asInvoker-by-omission) manifest. +# +# -64 is not optional: goversioninfo emits a 386 object by default, and linking +# that into an amd64 build fails with "unknown relocation type 7". +syso: ## Generate the Windows resource object + cd cmd/ipswap && $(GOVERSIONINFO) \ + -64 \ + -o resource_windows.syso \ + -platform-specific=false \ + versioninfo.json + +test: ## Run tests + $(GO) test ./... + +vet: ## Run go vet for both targets + $(GO) vet ./... + GOOS=windows GOARCH=amd64 $(GO) vet ./... + +# The Windows-only files (netsh, user32, the registry) are the bulk of the +# risk, and a Linux `go build` never looks at them. This is the cheap way to +# keep them compiling. +check: test vet ## Typecheck both targets and run tests + GOOS=windows GOARCH=amd64 $(GO) build ./... + +dist: build sums ## Build the release artifacts into dist/ + @echo "dist/ contains:" && ls -1 dist + +sums: ## Write dist/SHA256SUMS for the built binary + @mkdir -p dist + @cp bin/ipswap.exe dist/ipswap.exe + cd dist && sha256sum ipswap.exe > SHA256SUMS + @cat dist/SHA256SUMS + +clean: ## Remove build artifacts + rm -rf bin dist $(SYSO) diff --git a/README.md b/README.md new file mode 100644 index 0000000..b2bb020 --- /dev/null +++ b/README.md @@ -0,0 +1,125 @@ +# ipswap + +A tray-resident Windows utility for switching a network adapter between saved +static-IP configurations. Built for field and support work, where a laptop hops +between customer subnets — broadcast control networks, media networks, +management VLANs — many times a day. + +Switching a preset is two clicks and no browser. Editing fifty presets happens +in a real UI. Those are different problems, so they use different interfaces. + +## How it works + +**Fast path (native, no browser).** The tray icon holds a menu of presets +grouped into submenus. Clicking one reads the adapter's live configuration, +shows a Win32 message box with a before/after diff, and on Yes runs the netsh +sequence. The tray tooltip then names the preset that is actually applied. + +**Slow path (browser).** "Manage presets…" starts a local HTTP server on a +random loopback port and opens your default browser to it. Full CRUD, adapter +picker, import/export, settings. The server shuts itself down five minutes +after the browser stops sending heartbeats, and on exit. + +## Building + +Pure Go, no cgo anywhere, so a Linux or macOS host cross-compiles the Windows +binary directly — no Wine, no mingw, no fyne-cross. + +``` +make build # -> bin/ipswap.exe +make check # tests + vet + typecheck for both targets +make dist # bin/ipswap.exe plus dist/SHA256SUMS +``` + +`make build` regenerates `cmd/ipswap/resource_windows.syso` first. That object +carries the icon, the version resource and the manifest — the manifest is the +part that matters, because it is what asks for `asInvoker` and per-monitor DPI. + +`GOOS=windows go vet ./...` is worth running on its own during development: the +Windows-only files (netsh, user32, the registry) are most of the risk and a +plain Linux build never looks at them. `make vet` does both targets. + +## Layout + +``` +cmd/ipswap/ entrypoint, manifest, version resource +internal/preset/ data model, store, prefix parsing, import/export +internal/netcfg/ adapter reads (Win32) and the netsh command plan +internal/tray/ tray menu, confirm-then-apply, icon +internal/server/ the local editor server and its embedded web app +internal/updater/ Gitea release check, download, verify, swap +internal/dialog/ Win32 MessageBoxW wrappers +internal/elevate/ token elevation check, relaunch via ShellExecuteW runas +internal/desktop/ open-in-browser, HKCU Run key +internal/config/ paths and settings +internal/applog/ rotating log +``` + +Two rules shape the split. Anything portable lives in portable code and is +tested on any host — most usefully `netcfg.Plan`, which builds the exact netsh +sequence and is covered without a Windows box. Anything Windows-specific has a +`_windows.go` and an `_other.go`, so the whole tree stays buildable and +vettable during development on Linux. + +## Elevation + +The manifest asks for `asInvoker`, not `requireAdministrator`. On a machine +with UAC relaxed, or when the parent process is already elevated, that means +zero prompts — including at login, if start-with-Windows is on. + +Membership of the Administrators group is not the same as holding an elevated +token: with UAC on, a member's process gets a filtered token and `netsh +interface ipv4 set address` fails with "The requested operation requires +elevation." So ipswap checks the token at startup, and if it is not elevated it +adds a "Relaunch as administrator" item to the tray. A failed apply offers the +same thing. Nothing blocks startup either way. + +## Data + +``` +%APPDATA%\ipswap\presets.json presets +%APPDATA%\ipswap\config.json settings +%APPDATA%\ipswap\ipswap.log rotating, 5 × 1 MB +``` + +`presets.json` is the export format too, so a preset pack is just this file and +stays hand-editable. See `examples/preset-pack.json`. + +Masks are stored as an integer prefix. Both `/24` and `255.255.255.0` are +accepted on input, in the editor and in a hand-written file; the display style +is a single global setting. + +## Why applies are destructive + +After an apply, the adapter has exactly the addresses in the preset and nothing +else. + +`netsh interface ipv4 set address … static` replaces the primary address but +leaves previously-added secondary addresses attached. A naive implementation +therefore leaks addresses across switches — after visiting three presets the +adapter is still carrying secondaries from the first two. So every apply +enumerates the live addresses, deletes each one, then sets the primary and adds +the preset's secondaries. `internal/netcfg/plan_test.go` pins that ordering. + +Reads use `GetAdaptersAddresses`, not `netsh show config`: netsh's output is +localised and parsing it breaks on a non-English Windows. + +## Updates + +Set an update repository in Settings (`https://host/owner/repo`) and ipswap +checks its Gitea releases API at startup, in a goroutine, behind a short +timeout, failing silently to the log — a laptop on a customer site usually +cannot reach the host, and that is not an error worth showing. + +A newer tag adds "Update available — vX.Y.Z" to the top of the tray menu. +Clicking it downloads the `.exe`, verifies its SHA256 against the release, and +hands off to a small batch helper that waits for ipswap to exit, swaps the +binary and relaunches. **A release that publishes no checksum is refused** — an +unverified binary that is about to be run is not worth the convenience. It +never installs on its own. + +## Not in v1 + +Revert / restore-previous / timed auto-revert; post-apply connectivity checks +(the confirmation diff is the check); global hotkeys; IPv6; adapter matching by +MAC address; per-preset scripts. diff --git a/claude.md b/claude.md new file mode 100644 index 0000000..568a4bf --- /dev/null +++ b/claude.md @@ -0,0 +1,252 @@ +# ipswap — Windows IP preset switcher + +## Purpose + +A tray-resident Windows utility for rapidly switching a network adapter between saved +static-IP configurations. Built for field/support work where a laptop needs to hop +between customer subnets (broadcast control networks, media networks, management VLANs) +many times a day. + +Working name `ipswap` — rename freely. + +## Stack + +- **Go**, single static `.exe`, no runtime dependency, no CGO. +- **Tray**: `fyne.io/systray` (pure-Go on Windows — keeps `GOOS=windows go build` cross-compiling from Linux CI without Wine). +- **Fast-path dialogs**: native Win32 `MessageBoxW` via `syscall.NewLazyDLL("user32.dll")`. No GUI toolkit. +- **Editor UI**: embedded static web app (`embed.FS`) served on `127.0.0.1:`, opened in the default browser. +- **No CGO anywhere.** If a dependency needs it, pick another dependency. + +## Architecture + +Two interaction paths, deliberately split: + +**Fast path (native, no browser)** +Tray icon → menu of presets, grouped into submenus → click → confirmation MessageBox +showing a before/after diff → Yes → apply → tray tooltip updates to the active preset name. + +**Slow path (browser)** +Tray → "Manage presets…" → spawns local HTTP server, opens default browser to +`http://127.0.0.1:/?t=`. Full CRUD on presets, adapter picker, +import/export, settings. Server shuts down when the browser session goes idle (no +heartbeat for 5 min) or on app exit. + +Rationale: switching must be two clicks and zero browser. Editing 50 presets in a +MessageBox would be miserable. + +### Web UI styling + +Use the **apointless.css** design system: dark-first, JetBrains Mono + DM Sans, blue +accent `#3b82f6`, bg `#0b0d11`, surfaces `#12151b`/`#1a1e28`/`#232838`, subtle blue grid +via `body::before`, semantic colour tokens, light mode via `html.light` class + +localStorage. Use its existing components (cards, stat cards, badges, pills, buttons, +inputs, tables, alerts, code blocks, spinners) rather than inventing new ones. + +## Data model + +Stored at `%APPDATA%\ipswap\presets.json`. Settings at `%APPDATA%\ipswap\config.json`. +Log at `%APPDATA%\ipswap\ipswap.log` (rotating, keep last 5 × 1 MB). + +```json +{ + "version": 1, + "presets": [ + { + "id": "01J8X...", + "name": "Artist frame — control", + "group": "Riedel", + "adapter": "Ethernet", + "mode": "static", + "primary": { + "address": "192.168.42.100", + "prefix": 24, + "gateway": "192.168.42.1", + "gateway_metric": 0 + }, + "secondary": [ + { "address": "10.0.10.50", "prefix": 24 } + ], + "dns": { + "mode": "static", + "servers": ["192.168.42.1", "1.1.1.1"] + }, + "notes": "Frame A, rack 3" + }, + { + "id": "01J8Y...", + "name": "DHCP", + "group": "General", + "adapter": "Ethernet", + "mode": "dhcp", + "dns": { "mode": "dhcp" } + } + ] +} +``` + +Notes: + +- `adapter` is the Windows friendly name (`Ethernet`, `Wi-Fi`, `Ethernet 3`). Bound per + preset and editable in the preset editor. `net.Interfaces()` on Windows returns these + names and they match what `netsh` expects. +- Subnet mask stored internally as an integer prefix. The editor must **accept both** + `/24` and `255.255.255.0` on input and display whichever the user last used + (per-preset display preference is overkill — a single global setting is fine). +- `gateway` optional. `gateway_metric` 0 = automatic. +- Wi-Fi adapters are supported and treated identically. No special-casing. + +## Applying a preset + +Applies are **destructive**: after apply, the adapter has exactly the addresses in the +preset and nothing else. + +`netsh interface ipv4 set address … static` replaces the primary but leaves previously +added secondary addresses in place, so a naive implementation leaks addresses across +switches. Sequence: + +1. Enumerate current IPv4 addresses on the target adapter. +2. `netsh interface ipv4 delete address name="" addr=` for every + existing static address. +3. Set the primary: + `netsh interface ipv4 set address name="" static ` +4. Add each secondary: + `netsh interface ipv4 add address name="" ` +5. DNS static: + `netsh interface ipv4 set dnsservers name="" static primary validate=no` + then for each subsequent, `netsh interface ipv4 add dnsservers name="" index=` +6. DNS DHCP: `netsh interface ipv4 set dnsservers name="" source=dhcp` + +For `mode: "dhcp"`: +`netsh interface ipv4 set address name="" source=dhcp` +(this also clears statics, so step 2 can be skipped). + +Fallback if step 2 proves unreliable: set the adapter to DHCP first to flush statics, +then immediately apply the static config. Costs ~1s and a brief DHCP solicit — use only +if needed. + +Run every `netsh` invocation **off the UI goroutine**. Applies take 1–3 s. Capture +stdout/stderr and exit code, log all of it, surface failures in a MessageBox with the +raw netsh output included. + +### Confirmation prompt + +Before applying, show a Win32 MessageBox (`MB_YESNO | MB_ICONQUESTION`) containing: + +``` +Apply preset "Artist frame — control" to adapter "Ethernet"? + +CURRENT + 192.168.1.87/24 (DHCP) + Gateway: 192.168.1.1 + DNS: 192.168.1.1 + +NEW + 192.168.42.100/24 + + 10.0.10.50/24 + Gateway: 192.168.42.1 + DNS: 192.168.42.1, 1.1.1.1 +``` + +Read the current config live at prompt time, not from cache. A "don't ask again for this +session" checkbox is out of scope — the prompt is the safety net. + +### Active preset detection + +On startup, after any apply, and every 30 s, read each adapter's live config and mark any +preset that matches exactly. Show a check/radio mark next to it in the tray menu and set +the tray tooltip to `ipswap — ` (or `ipswap — unmatched`). + +## Elevation + +Manifest as `asInvoker`, **not** `requireAdministrator`. On a machine with UAC relaxed or +an already-elevated parent, this means zero prompts. + +At startup, check whether the process token is actually elevated +(`windows.Token.IsElevated()` or `GetTokenInformation`/`TokenElevation`). If it is not: + +- Do not fail, do not block startup. +- Add a tray menu item "Relaunch as administrator" that re-execs via `ShellExecuteW` with + the `runas` verb. +- If an apply fails with an elevation error, the failure MessageBox offers the same + relaunch action. + +Being in the Administrators group is not the same as holding an elevated token — with UAC +on, the process gets a filtered token and `netsh set address` returns "The requested +operation requires elevation." This design costs nothing on a permissive machine and +degrades cleanly on a locked-down one. + +## Tray menu layout + +``` +ipswap — Artist frame — control +───────────────────────────── +Riedel ▸ [submenu of presets in this group] +Herespace ▸ +General ▸ +───────────────────────────── +Manage presets… +Check for updates +───────────────────────────── +Relaunch as administrator [only shown if not elevated] +Exit +``` + +Presets with no `group` go into a top-level "Ungrouped" submenu. Design for ~50 presets: +submenus are mandatory, a flat list is not acceptable. No global hotkeys. + +## Import / export + +- Export: write the full `presets.json`, or a filtered subset by group, to a user-chosen + path. Include `version`. +- Import: merge or replace, user's choice. On merge, collide on `id` → keep both, suffix + the incoming name with `(imported)`. Validate the schema and reject with a clear error + rather than partially importing. + +This is the mechanism for shipping preset packs to colleagues, so keep the file format +clean and hand-editable. + +## Update checker + +Gitea-hosted, same pattern as ShippingTracker. + +- On startup (and on demand from the tray), GET + `https:///api/v1/repos///releases/latest`. +- Compare the release tag against the compiled-in version using semver. +- If newer: tray menu gains "Update available — v1.2.0" as the top item. +- On click: download the `.exe` asset to `%TEMP%`, verify the SHA256 against a checksum + published in the release body or as a sibling asset, then write a small batch/helper + that waits for the parent to exit, swaps the binary, and relaunches. +- Never auto-install. Never block startup on the network call — run it in a goroutine + with a short timeout and fail silently to the log. + +## Build & CI + +- `GOOS=windows GOARCH=amd64 go build -ldflags="-H windowsgui -X main.version=$VERSION"`. + `-H windowsgui` suppresses the console window. +- Embed an icon and a manifest (`asInvoker`, `dpiAware`) — `goversioninfo` or a `.syso`. +- Gitea Actions: build on tag, attach the `.exe` and a `SHA256SUMS` file to the release. + Pure Go means no Wine step is needed. + +## Out of scope for v1 + +- Revert / restore-previous / timed auto-revert. +- Post-apply connectivity verification (ping/ARP). The confirmation diff is the check. +- Global hotkeys. +- IPv6. +- Adapter matching by MAC address (name only for now — worth revisiting if dock/USB-NIC + name drift becomes a problem). +- Per-preset scripts or hooks. + +## Open questions for Ben + +1. Gitea host/owner/repo for the update endpoint. +2. Whether the app should start with Windows (registry `Run` key, toggleable in settings) + — assumed **yes, off by default**. +3. Confirm the split UI (native tray + browser editor) is right. You picked "menu only" + for hotkeys but also wanted a confirmation prompt and 50 presets — those pull toward + needing a real editor window, hence the browser. Say if you'd rather have a native + window instead. + +# Side note +Claude will be running in a tmux session + diff --git a/cmd/ipswap/fatal_other.go b/cmd/ipswap/fatal_other.go new file mode 100644 index 0000000..cb44297 --- /dev/null +++ b/cmd/ipswap/fatal_other.go @@ -0,0 +1,13 @@ +//go:build !windows + +package main + +import ( + "fmt" + "os" +) + +// showFatal prints, since a development build on Linux has a console. +func showFatal(msg string) { + fmt.Fprintln(os.Stderr, msg) +} diff --git a/cmd/ipswap/fatal_windows.go b/cmd/ipswap/fatal_windows.go new file mode 100644 index 0000000..ef9705e --- /dev/null +++ b/cmd/ipswap/fatal_windows.go @@ -0,0 +1,11 @@ +//go:build windows + +package main + +import "gitea.apointless.space/bsncubed/ipswap/internal/dialog" + +// showFatal puts a startup failure on screen. Without this the binary would +// simply not appear: -H windowsgui means there is no console to print to. +func showFatal(msg string) { + dialog.Error("ipswap", msg) +} diff --git a/cmd/ipswap/ipswap.manifest b/cmd/ipswap/ipswap.manifest new file mode 100644 index 0000000..7e7d10c --- /dev/null +++ b/cmd/ipswap/ipswap.manifest @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + true/pm + PerMonitorV2, PerMonitor + UTF-8 + + + + + + + + + + + + + + + + + + + + diff --git a/cmd/ipswap/main.go b/cmd/ipswap/main.go new file mode 100644 index 0000000..7cf4680 --- /dev/null +++ b/cmd/ipswap/main.go @@ -0,0 +1,96 @@ +// Command ipswap is a tray-resident switcher for saved static-IP presets. +// +// Build for Windows with: +// +// GOOS=windows GOARCH=amd64 go build -ldflags "-H windowsgui -X main.version=1.0.0" ./cmd/ipswap +// +// -H windowsgui is what stops a console window appearing behind the tray icon. +package main + +import ( + "flag" + "fmt" + "log" + "os" + "runtime" + + "gitea.apointless.space/bsncubed/ipswap/internal/applog" + "gitea.apointless.space/bsncubed/ipswap/internal/config" + "gitea.apointless.space/bsncubed/ipswap/internal/desktop" + "gitea.apointless.space/bsncubed/ipswap/internal/elevate" + "gitea.apointless.space/bsncubed/ipswap/internal/netcfg" + "gitea.apointless.space/bsncubed/ipswap/internal/preset" + "gitea.apointless.space/bsncubed/ipswap/internal/server" + "gitea.apointless.space/bsncubed/ipswap/internal/tray" +) + +// version is set at link time with -X main.version=... +var version = "dev" + +func main() { + showVersion := flag.Bool("version", false, "print the version and exit") + flag.Parse() + + if *showVersion { + fmt.Printf("%s %s\n", config.AppName, version) + return + } + + if err := run(); err != nil { + // With -H windowsgui there is no stderr to read, so anything fatal has + // to be shown rather than printed. + fatal(err) + } +} + +func run() error { + paths, err := config.ResolvePaths() + if err != nil { + return err + } + + // The log is the only diagnostic available on a customer site, so it is + // set up before anything that could fail interestingly. + logw, err := applog.Setup(paths.Log, runtime.GOOS != "windows") + if err != nil { + return err + } + defer logw.Close() + + log.Printf("--- %s %s starting (elevated=%v) ---", config.AppName, version, elevate.IsElevated()) + + settings, err := config.Load(paths.Config) + if err != nil { + // Settings are conveniences; a broken config.json must not stop the + // app from starting with defaults. + log.Printf("using default settings: %v", err) + } + + store, err := preset.NewStore(paths.Presets) + if err != nil { + return fmt.Errorf("loading presets: %w", err) + } + log.Printf("loaded %d preset(s) from %s", len(store.All()), paths.Presets) + + // Keep the Run key in step with the setting: the user may have removed the + // entry by other means, or copied config.json onto a new machine. + if desktop.RunAtLogin() != settings.StartWithWindows { + if err := desktop.SetRunAtLogin(settings.StartWithWindows); err != nil { + log.Printf("could not update the start-with-Windows entry: %v", err) + } + } + + mgr := netcfg.New() + srv := server.New(store, mgr, paths, settings) + app := tray.New(store, mgr, srv, paths, settings, version) + + // systray.Run takes over this goroutine and does not return until Exit. + app.Run() + return nil +} + +func fatal(err error) { + log.Printf("fatal: %v", err) + showFatal(fmt.Sprintf("%s could not start.\n\n%v", config.AppName, err)) + os.Exit(1) +} diff --git a/cmd/ipswap/versioninfo.json b/cmd/ipswap/versioninfo.json new file mode 100644 index 0000000..735356d --- /dev/null +++ b/cmd/ipswap/versioninfo.json @@ -0,0 +1,25 @@ +{ + "FixedFileInfo": { + "FileVersion": { "Major": 0, "Minor": 1, "Patch": 0, "Build": 0 }, + "ProductVersion": { "Major": 0, "Minor": 1, "Patch": 0, "Build": 0 }, + "FileFlagsMask": "3f", + "FileFlags": "00", + "FileOS": "040004", + "FileType": "01", + "FileSubType": "00" + }, + "StringFileInfo": { + "CompanyName": "apointless.space", + "FileDescription": "ipswap — network preset switcher", + "InternalName": "ipswap", + "LegalCopyright": "", + "OriginalFilename": "ipswap.exe", + "ProductName": "ipswap", + "ProductVersion": "0.1.0" + }, + "VarFileInfo": { + "Translation": { "LangID": "0409", "CharsetID": "04B0" } + }, + "IconPath": "../../internal/tray/icon.ico", + "ManifestPath": "ipswap.manifest" +} diff --git a/examples/preset-pack.json b/examples/preset-pack.json new file mode 100644 index 0000000..bb5b562 --- /dev/null +++ b/examples/preset-pack.json @@ -0,0 +1,72 @@ +{ + "version": 1, + "presets": [ + { + "id": "0198f2a1c4d07f3b2e5a9c81", + "name": "Artist frame — control", + "group": "Riedel", + "adapter": "Ethernet", + "mode": "static", + "primary": { + "address": "192.168.42.100", + "prefix": 24, + "gateway": "192.168.42.1" + }, + "secondary": [ + { "address": "10.0.10.50", "prefix": 24 } + ], + "dns": { + "mode": "static", + "servers": ["192.168.42.1", "1.1.1.1"] + }, + "notes": "Frame A, rack 3" + }, + { + "id": "0198f2a1c4d17a9e4b02d135", + "name": "Artist frame — media", + "group": "Riedel", + "adapter": "Ethernet", + "mode": "static", + "primary": { + "address": "10.20.0.100", + "prefix": 16 + }, + "dns": { "mode": "dhcp" }, + "notes": "No gateway: media VLAN is deliberately non-routed" + }, + { + "id": "0198f2a1c4d2b60c7f318ea4", + "name": "Management VLAN", + "group": "Herespace", + "adapter": "Ethernet", + "mode": "static", + "primary": { + "address": "172.16.5.200", + "prefix": 24, + "gateway": "172.16.5.1", + "gateway_metric": 10 + }, + "dns": { + "mode": "static", + "servers": ["172.16.5.10"] + } + }, + { + "id": "0198f2a1c4d3e8175c60ba92", + "name": "DHCP", + "group": "General", + "adapter": "Ethernet", + "mode": "dhcp", + "dns": { "mode": "dhcp" } + }, + { + "id": "0198f2a1c4d4fa38096c2d7e", + "name": "Wi-Fi — DHCP", + "group": "General", + "adapter": "Wi-Fi", + "mode": "dhcp", + "dns": { "mode": "dhcp" }, + "notes": "Wi-Fi adapters are treated identically to wired ones" + } + ] +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4bd9e73 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module gitea.apointless.space/bsncubed/ipswap + +go 1.26.5 + +require ( + fyne.io/systray v1.12.2 + golang.org/x/sys v0.31.0 +) + +require github.com/godbus/dbus/v5 v5.1.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e8563b4 --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA= +fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= diff --git a/internal/applog/applog.go b/internal/applog/applog.go new file mode 100644 index 0000000..93d9338 --- /dev/null +++ b/internal/applog/applog.go @@ -0,0 +1,131 @@ +// Package applog gives ipswap a rotating log file. Every netsh invocation and +// its raw output lands here, which is the only forensic trail available when a +// switch misbehaves on a customer site. +package applog + +import ( + "fmt" + "io" + "log" + "os" + "path/filepath" + "sync" +) + +const ( + maxBytes = 1 << 20 // 1 MB per file + maxFiles = 5 // ipswap.log plus ipswap.log.1 .. .4 +) + +// Writer is an io.Writer that rotates at maxBytes and keeps maxFiles files. +// It is small on purpose: a rotation dependency for one log file is not worth +// the supply chain. +type Writer struct { + path string + + mu sync.Mutex + f *os.File + size int64 +} + +// New opens the log at path, creating the directory if needed. +func New(path string) (*Writer, error) { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, fmt.Errorf("creating log directory: %w", err) + } + w := &Writer{path: path} + if err := w.open(); err != nil { + return nil, err + } + return w, nil +} + +func (w *Writer) open() error { + f, err := os.OpenFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return fmt.Errorf("opening %s: %w", w.path, err) + } + info, err := f.Stat() + if err != nil { + f.Close() + return fmt.Errorf("stat %s: %w", w.path, err) + } + w.f, w.size = f, info.Size() + return nil +} + +func (w *Writer) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + + if w.f == nil { + return len(p), nil // closed; drop rather than error out a log call + } + if w.size+int64(len(p)) > maxBytes { + if err := w.rotate(); err != nil { + return 0, err + } + } + n, err := w.f.Write(p) + w.size += int64(n) + return n, err +} + +// rotate shifts ipswap.log.N to N+1 and starts a fresh ipswap.log. The caller +// holds w.mu. +func (w *Writer) rotate() error { + if err := w.f.Close(); err != nil { + return err + } + w.f = nil + + // Walk downwards so nothing is overwritten before it has been moved. + for i := maxFiles - 1; i >= 1; i-- { + older := fmt.Sprintf("%s.%d", w.path, i) + if i == maxFiles-1 { + os.Remove(older) // the oldest file falls off the end + } + var newer string + if i == 1 { + newer = w.path + } else { + newer = fmt.Sprintf("%s.%d", w.path, i-1) + } + if _, err := os.Stat(newer); err == nil { + os.Rename(newer, older) + } + } + return w.open() +} + +// Close closes the underlying file. +func (w *Writer) Close() error { + w.mu.Lock() + defer w.mu.Unlock() + if w.f == nil { + return nil + } + err := w.f.Close() + w.f = nil + return err +} + +// Setup points the standard logger at the rotating file and returns it so the +// caller can close it on exit. +// +// With -H windowsgui there is no console, so stderr goes nowhere on Windows and +// the file is the only output that survives. During development on Linux the +// log is also echoed to stderr. +func Setup(path string, alsoStderr bool) (*Writer, error) { + w, err := New(path) + if err != nil { + return nil, err + } + var out io.Writer = w + if alsoStderr { + out = io.MultiWriter(w, os.Stderr) + } + log.SetOutput(out) + log.SetFlags(log.LstdFlags | log.Lmsgprefix) + return w, nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..426fd62 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,130 @@ +// Package config resolves where ipswap keeps its files and loads the settings +// that are not presets. +package config + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// AppName is the single place the product name appears. It decides the data +// directory, the log file name and the tray tooltip prefix, so renaming the app +// is a one-line change here. +const AppName = "ipswap" + +// Paths are the absolute locations of everything ipswap reads or writes. +type Paths struct { + Dir string + Presets string + Config string + Log string +} + +// ResolvePaths returns the data locations under %APPDATA%\ipswap on Windows. +// +// os.UserConfigDir maps to %APPDATA% on Windows and to ~/.config elsewhere, +// which keeps development on Linux from scribbling in odd places. +func ResolvePaths() (Paths, error) { + base, err := os.UserConfigDir() + if err != nil { + return Paths{}, fmt.Errorf("locating the application data directory: %w", err) + } + dir := filepath.Join(base, AppName) + return Paths{ + Dir: dir, + Presets: filepath.Join(dir, "presets.json"), + Config: filepath.Join(dir, "config.json"), + Log: filepath.Join(dir, AppName+".log"), + }, nil +} + +// MaskStyle is how subnet masks are displayed in the editor. Input always +// accepts both spellings; this only controls what is rendered back. +type MaskStyle string + +const ( + MaskPrefix MaskStyle = "prefix" // /24 + MaskDotted MaskStyle = "dotted" // 255.255.255.0 +) + +// Settings is config.json. +type Settings struct { + // MaskStyle is global rather than per-preset: a per-preset display + // preference would be more state than the feature is worth. + MaskStyle MaskStyle `json:"mask_style"` + + // StartWithWindows drives the HKCU Run key. Assumed default off; see the + // open questions at the bottom of claude.md. + StartWithWindows bool `json:"start_with_windows"` + + // CheckUpdates gates the startup call to the Gitea releases API. + CheckUpdates bool `json:"check_updates"` + + // UpdateRepo is the Gitea repo polled for releases, as + // "https://host/owner/repo". Empty disables the update checker entirely, + // which is the default until the host is confirmed. + UpdateRepo string `json:"update_repo"` + + // ActivePollSeconds is how often the live adapter state is re-read to work + // out which preset is currently applied. + ActivePollSeconds int `json:"active_poll_seconds"` +} + +// Default is the settings a fresh install starts with. +func Default() Settings { + return Settings{ + MaskStyle: MaskPrefix, + StartWithWindows: false, + CheckUpdates: true, + UpdateRepo: "", + ActivePollSeconds: 30, + } +} + +// DottedMasks reports whether masks should render as dotted quads. +func (s Settings) DottedMasks() bool { return s.MaskStyle == MaskDotted } + +// Load reads config.json, falling back to defaults for a missing file or any +// field left unset. Settings are conveniences, so a corrupt config should not +// stop the app from starting — the caller logs the error and carries on. +func Load(path string) (Settings, error) { + s := Default() + + b, err := os.ReadFile(path) + if os.IsNotExist(err) { + return s, nil + } + if err != nil { + return s, fmt.Errorf("reading %s: %w", path, err) + } + + if err := json.Unmarshal(b, &s); err != nil { + return Default(), fmt.Errorf("parsing %s: %w", path, err) + } + + // Repair anything nonsensical rather than refusing to run. + if s.MaskStyle != MaskPrefix && s.MaskStyle != MaskDotted { + s.MaskStyle = MaskPrefix + } + if s.ActivePollSeconds < 5 { + s.ActivePollSeconds = 30 + } + return s, nil +} + +// Save writes config.json. +func (s Settings) Save(path string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("creating config directory: %w", err) + } + b, err := json.MarshalIndent(s, "", " ") + if err != nil { + return fmt.Errorf("encoding settings: %w", err) + } + if err := os.WriteFile(path, append(b, '\n'), 0o644); err != nil { + return fmt.Errorf("writing %s: %w", path, err) + } + return nil +} diff --git a/internal/desktop/desktop_other.go b/internal/desktop/desktop_other.go new file mode 100644 index 0000000..980bc7f --- /dev/null +++ b/internal/desktop/desktop_other.go @@ -0,0 +1,23 @@ +//go:build !windows + +package desktop + +import ( + "fmt" + "os/exec" +) + +// OpenURL shells out to xdg-open so the editor can be exercised during +// development on Linux. +func OpenURL(url string) error { + if err := exec.Command("xdg-open", url).Start(); err != nil { + return fmt.Errorf("opening %s: %w", url, err) + } + return nil +} + +// SetRunAtLogin is a no-op: the Run key is a Windows concept. +func SetRunAtLogin(enabled bool) error { return nil } + +// RunAtLogin always reports false off Windows. +func RunAtLogin() bool { return false } diff --git a/internal/desktop/desktop_windows.go b/internal/desktop/desktop_windows.go new file mode 100644 index 0000000..c993a2c --- /dev/null +++ b/internal/desktop/desktop_windows.go @@ -0,0 +1,100 @@ +//go:build windows + +// Package desktop holds the small Windows shell integrations that do not +// belong to any one feature: opening a URL in the default browser, and the +// HKCU Run key that starts ipswap with Windows. +package desktop + +import ( + "fmt" + "os" + "path/filepath" + "syscall" + "unsafe" + + "golang.org/x/sys/windows/registry" + + "gitea.apointless.space/bsncubed/ipswap/internal/config" +) + +var ( + shell32 = syscall.NewLazyDLL("shell32.dll") + shellExecuteW = shell32.NewProc("ShellExecuteW") +) + +const runKeyPath = `Software\Microsoft\Windows\CurrentVersion\Run` + +// OpenURL opens a URL in the default browser. +// +// ShellExecuteW rather than `cmd /c start`: no console window to suppress and +// no quoting rules to get wrong around a URL that already contains "?" and "&". +func OpenURL(url string) error { + verb, err := syscall.UTF16PtrFromString("open") + if err != nil { + return err + } + target, err := syscall.UTF16PtrFromString(url) + if err != nil { + return err + } + ret, _, callErr := shellExecuteW.Call( + 0, + uintptr(unsafe.Pointer(verb)), + uintptr(unsafe.Pointer(target)), + 0, 0, 1, // no args, no cwd, SW_SHOWNORMAL + ) + if ret <= 32 { + return fmt.Errorf("could not open %s (ShellExecuteW returned %d: %v)", url, ret, callErr) + } + return nil +} + +// SetRunAtLogin adds or removes ipswap from the current user's Run key. +// +// HKCU rather than HKLM deliberately: HKLM needs elevation, and ipswap is +// designed to run unelevated. It also keeps the setting per-user on a shared +// support laptop. +func SetRunAtLogin(enabled bool) error { + key, _, err := registry.CreateKey(registry.CURRENT_USER, runKeyPath, registry.SET_VALUE) + if err != nil { + return fmt.Errorf("opening the Run key: %w", err) + } + defer key.Close() + + if !enabled { + err := key.DeleteValue(config.AppName) + if err != nil && err != registry.ErrNotExist { + return fmt.Errorf("removing the Run key value: %w", err) + } + return nil + } + + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("locating the running executable: %w", err) + } + exe, err = filepath.Abs(exe) + if err != nil { + return err + } + + // Quoted: the path routinely contains spaces (Program Files, or a user + // profile with a space in the name) and the Run key value is a command + // line, not a path. + if err := key.SetStringValue(config.AppName, `"`+exe+`"`); err != nil { + return fmt.Errorf("writing the Run key value: %w", err) + } + return nil +} + +// RunAtLogin reports whether the Run key entry is present. +func RunAtLogin() bool { + key, err := registry.OpenKey(registry.CURRENT_USER, runKeyPath, registry.QUERY_VALUE) + if err != nil { + return false + } + defer key.Close() + + v, _, err := key.GetStringValue(config.AppName) + return err == nil && v != "" +} diff --git a/internal/dialog/dialog_other.go b/internal/dialog/dialog_other.go new file mode 100644 index 0000000..809f8ed --- /dev/null +++ b/internal/dialog/dialog_other.go @@ -0,0 +1,22 @@ +//go:build !windows + +package dialog + +import "log" + +// The non-Windows build logs instead of showing a box, so the tray and apply +// paths can be exercised during development on Linux. + +// Confirm auto-declines: nothing destructive should run unattended off Windows. +func Confirm(title, text string) bool { + log.Printf("[dialog] confirm %q:\n%s\n(auto-declined on non-Windows build)", title, text) + return false +} + +func Info(title, text string) { log.Printf("[dialog] info %q: %s", title, text) } +func Error(title, text string) { log.Printf("[dialog] error %q: %s", title, text) } + +func ErrorWithRetryAsAdmin(title, text string) bool { + log.Printf("[dialog] error %q: %s (no elevation off Windows)", title, text) + return false +} diff --git a/internal/dialog/dialog_windows.go b/internal/dialog/dialog_windows.go new file mode 100644 index 0000000..75f4526 --- /dev/null +++ b/internal/dialog/dialog_windows.go @@ -0,0 +1,80 @@ +//go:build windows + +// Package dialog wraps the handful of Win32 message boxes ipswap needs. +// +// This is raw user32 rather than a GUI toolkit on purpose: the fast path must +// stay native and dependency-free, and a confirmation prompt is the only +// blocking UI the tray side has. +package dialog + +import ( + "syscall" + "unsafe" +) + +var ( + user32 = syscall.NewLazyDLL("user32.dll") + messageBoxW = user32.NewProc("MessageBoxW") +) + +// MessageBoxW uType flags. +const ( + mbOK = 0x00000000 + mbOKCancel = 0x00000001 + mbYesNo = 0x00000004 + mbIconError = 0x00000010 + mbIconQuestion = 0x00000020 + mbIconWarning = 0x00000030 + mbIconInformation = 0x00000040 + // The tray has no window to own the dialog, so without TOPMOST the box can + // open behind whatever is focused and look like a hang. + mbSetForeground = 0x00010000 + mbTopMost = 0x00040000 +) + +// MessageBoxW return values. +const ( + idOK = 1 + idYes = 6 +) + +func show(title, text string, flags uint32) int { + t, err := syscall.UTF16PtrFromString(text) + if err != nil { + return 0 + } + c, err := syscall.UTF16PtrFromString(title) + if err != nil { + return 0 + } + ret, _, _ := messageBoxW.Call( + 0, + uintptr(unsafe.Pointer(t)), + uintptr(unsafe.Pointer(c)), + uintptr(flags|mbSetForeground|mbTopMost), + ) + return int(ret) +} + +// Confirm asks a yes/no question and reports whether the user said yes. +func Confirm(title, text string) bool { + return show(title, text, mbYesNo|mbIconQuestion) == idYes +} + +// Info shows a plain acknowledgement box. +func Info(title, text string) { + show(title, text, mbOK|mbIconInformation) +} + +// Error shows a failure. The caller is expected to include the raw netsh +// output — that text is the only diagnostic the user has on site. +func Error(title, text string) { + show(title, text, mbOK|mbIconError) +} + +// ErrorWithRetryAsAdmin reports a failure and offers to relaunch elevated. +// It reports whether the user accepted. +func ErrorWithRetryAsAdmin(title, text string) bool { + return show(title, text+"\n\nRelaunch ipswap as administrator and try again?", + mbOKCancel|mbIconWarning) == idOK +} diff --git a/internal/elevate/elevate_other.go b/internal/elevate/elevate_other.go new file mode 100644 index 0000000..c554827 --- /dev/null +++ b/internal/elevate/elevate_other.go @@ -0,0 +1,14 @@ +//go:build !windows + +package elevate + +import "errors" + +// IsElevated reports true off Windows so development builds do not show the +// "Relaunch as administrator" item that could not work anyway. +func IsElevated() bool { return true } + +// RelaunchAsAdmin has no meaning outside Windows. +func RelaunchAsAdmin() error { + return errors.New("elevation is only supported on Windows") +} diff --git a/internal/elevate/elevate_windows.go b/internal/elevate/elevate_windows.go new file mode 100644 index 0000000..fefeb72 --- /dev/null +++ b/internal/elevate/elevate_windows.go @@ -0,0 +1,105 @@ +//go:build windows + +// Package elevate answers "is this process actually elevated?" and, if not, +// can re-exec it through the UAC prompt. +// +// The manifest asks for asInvoker rather than requireAdministrator: on a +// machine with UAC relaxed, or when the parent is already elevated, that means +// ipswap starts with zero prompts. The cost is that on a locked-down machine +// we start unprivileged and have to offer the relaunch, which is what this +// package is for. +package elevate + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +var ( + shell32 = syscall.NewLazyDLL("shell32.dll") + shellExecuteW = shell32.NewProc("ShellExecuteW") +) + +const swShowNormal = 1 + +// IsElevated reports whether the current process token carries administrator +// rights right now. +// +// Membership of the Administrators group is not the same thing: with UAC on, +// a member's process gets a filtered token, and `netsh interface ipv4 set +// address` then fails with "The requested operation requires elevation." +// Checking the token is the only answer that predicts whether an apply works. +func IsElevated() bool { + return windows.GetCurrentProcessToken().IsElevated() +} + +// RelaunchAsAdmin re-execs the current binary through the UAC prompt and +// returns once the new process has been started. The caller is expected to +// exit immediately afterwards so the two copies do not both own a tray icon. +// +// A user who clicks "No" on the UAC prompt produces ERROR_CANCELLED, which is +// reported as an error but is not a failure worth a message box. +func RelaunchAsAdmin() error { + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("locating the running executable: %w", err) + } + exe, err = filepath.Abs(exe) + if err != nil { + return fmt.Errorf("resolving %s: %w", exe, err) + } + + verb, err := syscall.UTF16PtrFromString("runas") + if err != nil { + return err + } + file, err := syscall.UTF16PtrFromString(exe) + if err != nil { + return err + } + args, err := syscall.UTF16PtrFromString(strings.Join(quoteArgs(os.Args[1:]), " ")) + if err != nil { + return err + } + cwd, err := syscall.UTF16PtrFromString(filepath.Dir(exe)) + if err != nil { + return err + } + + // ShellExecuteW signals failure with a return value of 32 or less. + ret, _, callErr := shellExecuteW.Call( + 0, + uintptr(unsafe.Pointer(verb)), + uintptr(unsafe.Pointer(file)), + uintptr(unsafe.Pointer(args)), + uintptr(unsafe.Pointer(cwd)), + swShowNormal, + ) + if ret <= 32 { + if ret == uintptr(windows.ERROR_CANCELLED) { + return fmt.Errorf("the elevation prompt was cancelled") + } + return fmt.Errorf("ShellExecuteW returned %d: %v", ret, callErr) + } + return nil +} + +// quoteArgs re-quotes arguments for the single command-line string +// ShellExecuteW takes, since it does not accept an argv. +func quoteArgs(args []string) []string { + out := make([]string, 0, len(args)) + for _, a := range args { + if strings.ContainsAny(a, ` "`) { + out = append(out, `"`+strings.ReplaceAll(a, `"`, `\"`)+`"`) + } else { + out = append(out, a) + } + } + return out +} diff --git a/internal/netcfg/diff.go b/internal/netcfg/diff.go new file mode 100644 index 0000000..b5362b2 --- /dev/null +++ b/internal/netcfg/diff.go @@ -0,0 +1,92 @@ +package netcfg + +import ( + "fmt" + "strings" + + "gitea.apointless.space/bsncubed/ipswap/internal/preset" +) + +// ConfirmText renders the before/after body of the confirmation MessageBox. +// +// The live side must be read at prompt time rather than taken from the cached +// active-preset poll: the prompt is the only safety net before a destructive +// change, and a 30-second-stale "CURRENT" block would undermine it. +func ConfirmText(p preset.Preset, live LiveConfig) string { + var b strings.Builder + + fmt.Fprintf(&b, "Apply preset %q to adapter %q?\n\n", p.Name, p.Adapter) + + b.WriteString("CURRENT\n") + writeLive(&b, live) + + b.WriteString("\nNEW\n") + writePreset(&b, p) + + return b.String() +} + +func writeLive(b *strings.Builder, live LiveConfig) { + if len(live.Addresses) == 0 { + b.WriteString(" (no IPv4 address)\n") + } + for i, a := range live.Addresses { + suffix := "" + if live.DHCP { + suffix = " (DHCP)" + } + prefix := " " + if i > 0 { + prefix = " + " + } + fmt.Fprintf(b, "%s%s%s\n", prefix, a.String(), suffix) + } + if len(live.Gateways) > 0 { + fmt.Fprintf(b, " Gateway: %s\n", strings.Join(live.Gateways, ", ")) + } + if len(live.DNS) > 0 { + suffix := "" + if live.DNSFromDHCP { + suffix = " (DHCP)" + } + fmt.Fprintf(b, " DNS: %s%s\n", strings.Join(live.DNS, ", "), suffix) + } +} + +func writePreset(b *strings.Builder, p preset.Preset) { + if p.Mode == preset.ModeDHCP { + b.WriteString(" DHCP\n") + } else if p.Primary != nil { + fmt.Fprintf(b, " %s\n", p.Primary.String()) + for _, s := range p.Secondary { + fmt.Fprintf(b, " + %s\n", s.String()) + } + if p.Primary.Gateway != "" { + fmt.Fprintf(b, " Gateway: %s\n", p.Primary.Gateway) + } + } + + switch p.DNS.Mode { + case preset.ModeDHCP: + b.WriteString(" DNS: from DHCP\n") + default: + fmt.Fprintf(b, " DNS: %s\n", strings.Join(p.DNS.Servers, ", ")) + } +} + +// Summary is the one-line description of a live config, used for the editor's +// adapter picker. +func (l LiveConfig) Summary() string { + if len(l.Addresses) == 0 { + return "no IPv4 address" + } + parts := make([]string, 0, len(l.Addresses)) + for _, a := range l.Addresses { + parts = append(parts, a.String()) + } + s := strings.Join(parts, ", ") + if l.DHCP { + s += " (DHCP)" + } + return s +} diff --git a/internal/netcfg/netcfg.go b/internal/netcfg/netcfg.go new file mode 100644 index 0000000..b4f8f82 --- /dev/null +++ b/internal/netcfg/netcfg.go @@ -0,0 +1,251 @@ +// Package netcfg reads and writes IPv4 adapter configuration. +// +// Reading and writing deliberately use different mechanisms: +// +// - Reading goes through GetAdaptersAddresses (plus one registry read for +// DNS origin). netsh's "show config" output is localised, so parsing it +// breaks on a German or French Windows; the Win32 API does not. +// - Writing goes through netsh, because the equivalent APIs are a much larger +// surface for no benefit when the whole operation is six shell commands. +// +// The command plan is built in this file, in portable code, so the exact netsh +// sequence can be tested without a Windows box. +package netcfg + +import ( + "fmt" + "sort" + "strconv" + "strings" + + "gitea.apointless.space/bsncubed/ipswap/internal/preset" +) + +// Adapter is a network interface as Windows names it. +type Adapter struct { + // Name is the friendly name ("Ethernet", "Wi-Fi 2"). This is what presets + // bind to and what netsh expects. + Name string `json:"name"` + // Description is the hardware description, shown in the adapter picker to + // tell three identically-named USB NICs apart. + Description string `json:"description"` + // GUID is the adapter instance id, used for the registry DNS lookup. + GUID string `json:"-"` + Up bool `json:"up"` +} + +// LiveConfig is an adapter's current IPv4 state. +type LiveConfig struct { + Adapter string `json:"adapter"` + DHCP bool `json:"dhcp"` + Addresses []preset.Address `json:"addresses"` + Gateways []string `json:"gateways"` + DNS []string `json:"dns"` + DNSFromDHCP bool `json:"dns_from_dhcp"` +} + +// Manager reads and writes adapter configuration. +type Manager interface { + // Adapters lists the IPv4-capable adapters on this machine. + Adapters() ([]Adapter, error) + // Current reads one adapter's live configuration. + Current(adapter string) (LiveConfig, error) + // Apply runs a plan, returning the first command that failed along with + // its raw output. + Apply(plan []Command) error +} + +// Command is one netsh invocation. Args excludes the "netsh" itself. +type Command struct { + Args []string + // Desc is what gets written to the log before the command runs. + Desc string +} + +func (c Command) String() string { return "netsh " + strings.Join(c.Args, " ") } + +// Plan builds the exact netsh sequence that takes an adapter from current to +// the preset's configuration. +// +// The delete step in the middle is the whole reason this is not a one-liner: +// `netsh interface ipv4 set address ... static` replaces the primary address +// but leaves any previously-added secondary addresses attached, so switching +// between presets without deleting first leaks addresses from every preset +// visited so far. Applies are destructive by design — after this runs the +// adapter has exactly what the preset says and nothing else. +func Plan(p preset.Preset, current LiveConfig) []Command { + name := p.Adapter + var cmds []Command + + if p.Mode == preset.ModeDHCP { + // Switching the adapter to DHCP clears the static addresses on its + // own, so the explicit deletes are unnecessary here. + cmds = append(cmds, Command{ + Args: []string{"interface", "ipv4", "set", "address", nameArg(name), "source=dhcp"}, + Desc: "set " + name + " to DHCP", + }) + cmds = append(cmds, dnsCommands(name, p.DNS)...) + return cmds + } + + // 1. Drop every address currently on the adapter. Addresses handed out by + // DHCP are not deletable this way and do not need to be: the `set + // address ... static` below replaces the lease outright. + if !current.DHCP { + for _, a := range current.Addresses { + cmds = append(cmds, Command{ + Args: []string{"interface", "ipv4", "delete", "address", nameArg(name), "addr=" + a.Address}, + Desc: "remove existing address " + a.String(), + }) + } + } + + // 2. Set the primary, with its gateway if the preset carries one. + args := []string{ + "interface", "ipv4", "set", "address", nameArg(name), "static", + p.Primary.Address, preset.MaskString(p.Primary.Prefix), + } + if p.Primary.Gateway != "" { + args = append(args, p.Primary.Gateway) + // netsh only accepts a metric once a gateway is present, and 0 means + // "automatic", which is what an unset metric should mean. + if p.Primary.GatewayMetric > 0 { + args = append(args, strconv.Itoa(p.Primary.GatewayMetric)) + } else { + args = append(args, "1") + } + } + cmds = append(cmds, Command{Args: args, Desc: "set primary address " + p.Primary.String()}) + + // 3. Add the secondaries. + for _, s := range p.Secondary { + cmds = append(cmds, Command{ + Args: []string{"interface", "ipv4", "add", "address", nameArg(name), s.Address, preset.MaskString(s.Prefix)}, + Desc: "add secondary address " + s.String(), + }) + } + + // 4. DNS. + cmds = append(cmds, dnsCommands(name, p.DNS)...) + return cmds +} + +func dnsCommands(adapter string, d preset.DNS) []Command { + if d.Mode == preset.ModeDHCP { + return []Command{{ + Args: []string{"interface", "ipv4", "set", "dnsservers", nameArg(adapter), "source=dhcp"}, + Desc: "set DNS to DHCP", + }} + } + + var cmds []Command + for i, s := range d.Servers { + if i == 0 { + // validate=no skips the several-second reachability probe netsh + // otherwise runs against a server that is often not up yet. + cmds = append(cmds, Command{ + Args: []string{"interface", "ipv4", "set", "dnsservers", nameArg(adapter), "static", s, "primary", "validate=no"}, + Desc: "set primary DNS " + s, + }) + continue + } + cmds = append(cmds, Command{ + Args: []string{"interface", "ipv4", "add", "dnsservers", nameArg(adapter), s, "index=" + strconv.Itoa(i+1), "validate=no"}, + Desc: "add DNS " + s, + }) + } + return cmds +} + +// nameArg builds netsh's name= argument. The value is passed as a single +// argv element rather than a quoted shell string: os/exec does not go through +// a shell, so adapter names containing spaces need no quoting here, and adding +// quotes would make them part of the name. +func nameArg(adapter string) string { return "name=" + adapter } + +// Matches reports whether a preset is exactly what the adapter currently has. +// Used to put the check mark next to the active preset in the tray menu, so it +// has to be an exact match in both directions — a preset that is a subset of +// the live config is not the active preset. +func Matches(p preset.Preset, live LiveConfig) bool { + if !strings.EqualFold(p.Adapter, live.Adapter) { + return false + } + + if p.Mode == preset.ModeDHCP { + if !live.DHCP { + return false + } + } else { + if live.DHCP || p.Primary == nil { + return false + } + want := append([]preset.Address{*p.Primary}, p.Secondary...) + if !sameAddresses(want, live.Addresses) { + return false + } + // A preset with no gateway means "no gateway", so a live default + // route disqualifies it. + if p.Primary.Gateway == "" { + if len(live.Gateways) > 0 { + return false + } + } else if !containsFold(live.Gateways, p.Primary.Gateway) { + return false + } + } + + if p.DNS.Mode == preset.ModeDHCP { + return live.DNSFromDHCP + } + if live.DNSFromDHCP { + return false + } + return sameStrings(p.DNS.Servers, live.DNS) +} + +func sameAddresses(a, b []preset.Address) bool { + if len(a) != len(b) { + return false + } + key := func(x preset.Address) string { return fmt.Sprintf("%s/%d", x.Address, x.Prefix) } + as := make([]string, len(a)) + bs := make([]string, len(b)) + for i := range a { + as[i] = key(a[i]) + } + for i := range b { + bs[i] = key(b[i]) + } + sort.Strings(as) + sort.Strings(bs) + for i := range as { + if as[i] != bs[i] { + return false + } + } + return true +} + +// sameStrings compares DNS server lists. Order matters: 1.1.1.1 as the primary +// resolver is a different configuration from 1.1.1.1 as the fallback. +func sameStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if !strings.EqualFold(strings.TrimSpace(a[i]), strings.TrimSpace(b[i])) { + return false + } + } + return true +} + +func containsFold(hay []string, needle string) bool { + for _, h := range hay { + if strings.EqualFold(strings.TrimSpace(h), strings.TrimSpace(needle)) { + return true + } + } + return false +} diff --git a/internal/netcfg/netcfg_other.go b/internal/netcfg/netcfg_other.go new file mode 100644 index 0000000..1f64f29 --- /dev/null +++ b/internal/netcfg/netcfg_other.go @@ -0,0 +1,48 @@ +//go:build !windows + +package netcfg + +import ( + "errors" + "fmt" + + "gitea.apointless.space/bsncubed/ipswap/internal/preset" +) + +// ErrElevationRequired keeps the non-Windows build type-compatible so the rest +// of the tree compiles and `go vet ./...` is useful during development on +// Linux. Nothing here touches a real adapter. +var ErrElevationRequired = errors.New("this operation requires an elevated process") + +// ErrUnsupported is returned by every write path off Windows. +var ErrUnsupported = errors.New("adapter configuration is only supported on Windows") + +// Stub is a Manager that reports a plausible fixture instead of touching the +// host. It exists so the tray, the editor server and the confirmation text can +// be exercised on the development machine. +type Stub struct{} + +// New returns the platform Manager. +func New() Manager { return Stub{} } + +func (Stub) Adapters() ([]Adapter, error) { + return []Adapter{ + {Name: "Ethernet", Description: "Stub adapter (non-Windows build)", Up: true}, + {Name: "Wi-Fi", Description: "Stub adapter (non-Windows build)", Up: false}, + }, nil +} + +func (Stub) Current(adapter string) (LiveConfig, error) { + return LiveConfig{ + Adapter: adapter, + DHCP: true, + Addresses: []preset.Address{{Address: "192.168.1.87", Prefix: 24}}, + Gateways: []string{"192.168.1.1"}, + DNS: []string{"192.168.1.1"}, + DNSFromDHCP: true, + }, nil +} + +func (Stub) Apply(plan []Command) error { + return fmt.Errorf("%w (plan had %d commands)", ErrUnsupported, len(plan)) +} diff --git a/internal/netcfg/netcfg_windows.go b/internal/netcfg/netcfg_windows.go new file mode 100644 index 0000000..32460bb --- /dev/null +++ b/internal/netcfg/netcfg_windows.go @@ -0,0 +1,234 @@ +//go:build windows + +package netcfg + +import ( + "errors" + "fmt" + "log" + "net" + "os/exec" + "strings" + "syscall" + "time" + "unsafe" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" + + "gitea.apointless.space/bsncubed/ipswap/internal/preset" +) + +// ErrElevationRequired is returned when netsh refuses because the process token +// is not elevated. The caller turns this into the "Relaunch as administrator" +// offer rather than a bare failure message. +var ErrElevationRequired = errors.New("this operation requires an elevated process") + +// ipAdapterDHCPEnabled is IP_ADAPTER_DHCP_ENABLED from iptypes.h. +const ipAdapterDHCPEnabled = 0x00000004 + +// Interface types we never want in the adapter picker. +const ( + ifTypeSoftwareLoopback = 24 + ifTypeTunnel = 131 +) + +// applyTimeout bounds a whole plan. A single netsh call is normally well under +// a second; anything past this is a hung call, not a slow one. +const applyTimeout = 30 * time.Second + +// Windows is the real Manager. +type Windows struct{} + +// New returns the platform Manager. +func New() Manager { return Windows{} } + +// Adapters lists IPv4-capable, non-loopback, non-tunnel adapters. +func (Windows) Adapters() ([]Adapter, error) { + rows, err := adapterRows() + if err != nil { + return nil, err + } + + var out []Adapter + for _, a := range rows { + if a.IfType == ifTypeSoftwareLoopback || a.IfType == ifTypeTunnel { + continue + } + out = append(out, Adapter{ + Name: windows.UTF16PtrToString(a.FriendlyName), + Description: windows.UTF16PtrToString(a.Description), + GUID: windows.BytePtrToString(a.AdapterName), + Up: a.OperStatus == windows.IfOperStatusUp, + }) + } + return out, nil +} + +// Current reads one adapter's live IPv4 configuration. +func (Windows) Current(adapter string) (LiveConfig, error) { + rows, err := adapterRows() + if err != nil { + return LiveConfig{}, err + } + + for _, a := range rows { + if !strings.EqualFold(windows.UTF16PtrToString(a.FriendlyName), adapter) { + continue + } + + cfg := LiveConfig{ + Adapter: adapter, + DHCP: a.Flags&ipAdapterDHCPEnabled != 0, + } + + for ua := a.FirstUnicastAddress; ua != nil; ua = ua.Next { + ip := ua.Address.IP() + if ip == nil || ip.To4() == nil { + continue // v1 is IPv4 only + } + cfg.Addresses = append(cfg.Addresses, preset.Address{ + Address: ip.String(), + Prefix: int(ua.OnLinkPrefixLength), + }) + } + + for ga := a.FirstGatewayAddress; ga != nil; ga = ga.Next { + if ip := ga.Address.IP(); ip != nil && ip.To4() != nil { + cfg.Gateways = append(cfg.Gateways, ip.String()) + } + } + + for da := a.FirstDnsServerAddress; da != nil; da = da.Next { + if ip := da.Address.IP(); ip != nil && ip.To4() != nil { + cfg.DNS = append(cfg.DNS, ip.String()) + } + } + + // GetAdaptersAddresses reports the resolvers in use but not whether + // they were configured or leased, and that distinction decides whether + // a DNS-from-DHCP preset matches. The registry holds it: a non-empty + // NameServer value means statically configured. + cfg.DNSFromDHCP = dnsIsFromDHCP(windows.BytePtrToString(a.AdapterName)) + + return cfg, nil + } + + return LiveConfig{}, fmt.Errorf("adapter %q not found", adapter) +} + +// Apply runs each command in order, stopping at the first failure. +func (Windows) Apply(plan []Command) error { + for _, c := range plan { + log.Printf("netsh: %s (%s)", c, c.Desc) + + out, err := runNetsh(c) + if out != "" { + log.Printf("netsh output: %s", out) + } + if err != nil { + if isElevationError(out) { + return fmt.Errorf("%w\n\ncommand: %s\n%s", ErrElevationRequired, c, out) + } + return fmt.Errorf("%s failed: %w\n\n%s", c.Desc, err, out) + } + } + return nil +} + +func runNetsh(c Command) (string, error) { + cmd := exec.Command("netsh", c.Args...) + // The binary is linked with -H windowsgui and has no console of its own, so + // without this every netsh call flashes a console window on screen. + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + + done := make(chan struct{}) + var out []byte + var err error + go func() { + out, err = cmd.CombinedOutput() + close(done) + }() + + select { + case <-done: + case <-time.After(applyTimeout): + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + <-done + return string(out), fmt.Errorf("timed out after %s", applyTimeout) + } + + return strings.TrimSpace(string(out)), err +} + +// isElevationError sniffs netsh's refusal. netsh exits non-zero with a message +// rather than a distinguishable code, and the message is localised, so this +// also accepts the English text as the common case and falls back to the +// Win32 error number that appears in several translations. +func isElevationError(out string) bool { + l := strings.ToLower(out) + return strings.Contains(l, "requires elevation") || + strings.Contains(l, "requested operation requires") || + strings.Contains(l, "access is denied") || + strings.Contains(l, "740") +} + +func dnsIsFromDHCP(guid string) bool { + if guid == "" { + return true + } + key, err := registry.OpenKey( + registry.LOCAL_MACHINE, + `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces\`+guid, + registry.QUERY_VALUE, + ) + if err != nil { + // Not readable without admin on some machines; assume DHCP rather + // than falsely reporting a static configuration. + return true + } + defer key.Close() + + ns, _, err := key.GetStringValue("NameServer") + if err != nil { + return true + } + return strings.TrimSpace(ns) == "" +} + +// adapterRows walks the GetAdaptersAddresses linked list into a slice. +func adapterRows() ([]*windows.IpAdapterAddresses, error) { + flags := uint32(windows.GAA_FLAG_INCLUDE_GATEWAYS | + windows.GAA_FLAG_SKIP_ANYCAST | + windows.GAA_FLAG_SKIP_MULTICAST) + + size := uint32(15 * 1024) + var buf []byte + for attempt := 0; attempt < 4; attempt++ { + buf = make([]byte, size) + err := windows.GetAdaptersAddresses( + windows.AF_INET, flags, 0, + (*windows.IpAdapterAddresses)(unsafe.Pointer(&buf[0])), + &size, + ) + if err == nil { + break + } + // The call reports the required size in `size`; retry with it. + if err == windows.ERROR_BUFFER_OVERFLOW { + continue + } + return nil, fmt.Errorf("GetAdaptersAddresses: %w", err) + } + + var out []*windows.IpAdapterAddresses + for a := (*windows.IpAdapterAddresses)(unsafe.Pointer(&buf[0])); a != nil; a = a.Next { + out = append(out, a) + } + return out, nil +} + +// compile-time assertion that net is used even if the address helpers change. +var _ = net.IP{} diff --git a/internal/netcfg/plan_test.go b/internal/netcfg/plan_test.go new file mode 100644 index 0000000..5807933 --- /dev/null +++ b/internal/netcfg/plan_test.go @@ -0,0 +1,188 @@ +package netcfg + +import ( + "strings" + "testing" + + "gitea.apointless.space/bsncubed/ipswap/internal/preset" +) + +func staticPreset() preset.Preset { + return preset.Preset{ + ID: "test", + Name: "Artist frame — control", + Adapter: "Ethernet", + Mode: preset.ModeStatic, + Primary: &preset.Address{ + Address: "192.168.42.100", + Prefix: 24, + Gateway: "192.168.42.1", + }, + Secondary: []preset.Address{{Address: "10.0.10.50", Prefix: 24}}, + DNS: preset.DNS{Mode: preset.ModeStatic, Servers: []string{"192.168.42.1", "1.1.1.1"}}, + } +} + +func staticLive() LiveConfig { + return LiveConfig{ + Adapter: "Ethernet", + Addresses: []preset.Address{{Address: "192.168.1.87", Prefix: 24}, {Address: "172.16.0.5", Prefix: 16}}, + Gateways: []string{"192.168.1.1"}, + DNS: []string{"192.168.1.1"}, + } +} + +// The leak this guards against is the reason Plan exists: without a delete for +// every live address, secondaries from the previous preset stay attached. +func TestPlanDeletesEveryExistingAddress(t *testing.T) { + cmds := Plan(staticPreset(), staticLive()) + + var deletes []string + for _, c := range cmds { + if len(c.Args) > 3 && c.Args[2] == "delete" && c.Args[3] == "address" { + deletes = append(deletes, strings.Join(c.Args, " ")) + } + } + if len(deletes) != 2 { + t.Fatalf("expected 2 delete commands, got %d: %v", len(deletes), deletes) + } + for _, want := range []string{"addr=192.168.1.87", "addr=172.16.0.5"} { + found := false + for _, d := range deletes { + if strings.Contains(d, want) { + found = true + } + } + if !found { + t.Errorf("no delete command for %s: %v", want, deletes) + } + } +} + +func TestPlanOrdering(t *testing.T) { + cmds := Plan(staticPreset(), staticLive()) + + // deletes, then set primary, then add secondary, then DNS. + var kinds []string + for _, c := range cmds { + switch { + case c.Args[2] == "delete": + kinds = append(kinds, "delete") + case c.Args[2] == "set" && c.Args[3] == "address": + kinds = append(kinds, "set-addr") + case c.Args[2] == "add" && c.Args[3] == "address": + kinds = append(kinds, "add-addr") + case c.Args[3] == "dnsservers": + kinds = append(kinds, "dns") + } + } + want := []string{"delete", "delete", "set-addr", "add-addr", "dns", "dns"} + if strings.Join(kinds, ",") != strings.Join(want, ",") { + t.Errorf("plan order = %v, want %v", kinds, want) + } +} + +func TestPlanUsesDottedMaskNotPrefix(t *testing.T) { + // netsh rejects "/24" outright. + cmds := Plan(staticPreset(), staticLive()) + for _, c := range cmds { + for _, a := range c.Args { + if strings.HasPrefix(a, "/") { + t.Errorf("command passes a prefix-notation mask: %v", c.Args) + } + } + } + joined := strings.Join(cmds[2].Args, " ") + if !strings.Contains(joined, "255.255.255.0") { + t.Errorf("set address command lacks a dotted mask: %s", joined) + } +} + +func TestPlanDHCPSkipsDeletes(t *testing.T) { + p := preset.Preset{ + ID: "d", Name: "DHCP", Adapter: "Ethernet", + Mode: preset.ModeDHCP, + DNS: preset.DNS{Mode: preset.ModeDHCP}, + } + cmds := Plan(p, staticLive()) + if len(cmds) != 2 { + t.Fatalf("expected 2 commands for a DHCP preset, got %d: %v", len(cmds), cmds) + } + for _, c := range cmds { + if c.Args[2] == "delete" { + t.Errorf("DHCP plan should not delete addresses: %v", c.Args) + } + } +} + +// A DHCP lease is not deletable with `netsh delete address`, and the static set +// replaces it anyway, so a live-DHCP adapter gets no delete commands. +func TestPlanSkipsDeletesWhenLiveIsDHCP(t *testing.T) { + live := staticLive() + live.DHCP = true + for _, c := range Plan(staticPreset(), live) { + if c.Args[2] == "delete" { + t.Errorf("should not delete DHCP-assigned addresses: %v", c.Args) + } + } +} + +func TestPlanAdapterNameIsNotShellQuoted(t *testing.T) { + p := staticPreset() + p.Adapter = "Ethernet 3" + for _, c := range Plan(p, LiveConfig{Adapter: "Ethernet 3"}) { + for _, a := range c.Args { + if strings.HasPrefix(a, "name=") && strings.Contains(a, `"`) { + t.Errorf("adapter name should not carry quotes through os/exec: %q", a) + } + } + } +} + +func TestMatches(t *testing.T) { + p := staticPreset() + live := LiveConfig{ + Adapter: "Ethernet", + Addresses: []preset.Address{{Address: "10.0.10.50", Prefix: 24}, {Address: "192.168.42.100", Prefix: 24}}, + Gateways: []string{"192.168.42.1"}, + DNS: []string{"192.168.42.1", "1.1.1.1"}, + } + if !Matches(p, live) { + t.Error("preset should match an adapter carrying exactly its addresses") + } + + extra := live + extra.Addresses = append(append([]preset.Address{}, live.Addresses...), preset.Address{Address: "10.9.9.9", Prefix: 24}) + if Matches(p, extra) { + t.Error("a preset that is a subset of the live config is not the active preset") + } + + reordered := live + reordered.DNS = []string{"1.1.1.1", "192.168.42.1"} + if Matches(p, reordered) { + t.Error("DNS order is significant and should not match") + } + + dhcp := live + dhcp.DNSFromDHCP = true + if Matches(p, dhcp) { + t.Error("static-DNS preset should not match a DHCP-DNS adapter") + } +} + +func TestConfirmTextShapes(t *testing.T) { + got := ConfirmText(staticPreset(), staticLive()) + for _, want := range []string{ + `Apply preset "Artist frame — control" to adapter "Ethernet"?`, + "CURRENT", "NEW", + "192.168.1.87/24", + "192.168.42.100/24", + "+ 10.0.10.50/24", + "Gateway: 192.168.42.1", + "DNS: 192.168.42.1, 1.1.1.1", + } { + if !strings.Contains(got, want) { + t.Errorf("confirm text missing %q:\n%s", want, got) + } + } +} diff --git a/internal/preset/portio.go b/internal/preset/portio.go new file mode 100644 index 0000000..f26acb9 --- /dev/null +++ b/internal/preset/portio.go @@ -0,0 +1,101 @@ +package preset + +import ( + "encoding/json" + "fmt" + "strings" +) + +// ImportMode is what to do with the presets already on disk. +type ImportMode string + +const ( + // ImportMerge keeps existing presets and adds the incoming ones. + ImportMerge ImportMode = "merge" + // ImportReplace discards everything already saved. + ImportReplace ImportMode = "replace" +) + +// Export renders the presets as a shippable preset pack. Passing one or more +// group names filters to just those groups; passing none exports everything. +// +// The output is the same shape as presets.json so a pack can be dropped +// straight in as a starting file, and it stays hand-editable. +func (s *Store) Export(groups ...string) ([]byte, error) { + want := make(map[string]bool, len(groups)) + for _, g := range groups { + want[strings.ToLower(strings.TrimSpace(g))] = true + } + + out := File{Version: SchemaVersion} + for _, p := range s.All() { + if len(want) > 0 && !want[strings.ToLower(p.GroupOrUngrouped())] { + continue + } + out.Presets = append(out.Presets, p) + } + + b, err := json.MarshalIndent(out, "", " ") + if err != nil { + return nil, fmt.Errorf("encoding export: %w", err) + } + return append(b, '\n'), nil +} + +// ImportResult reports what an import did, for the summary shown afterwards. +type ImportResult struct { + Added int `json:"added"` + Replaced int `json:"replaced"` + Renamed []string `json:"renamed,omitempty"` +} + +// Import validates an incoming preset file in full before touching anything, +// then applies it. A file that fails validation changes nothing on disk. +// +// On merge, an incoming preset whose id already exists is kept alongside the +// existing one: it gets a fresh id and "(imported)" appended to its name, so a +// colleague's pack can never silently overwrite what you already had. +func (s *Store) Import(data []byte, mode ImportMode) (ImportResult, error) { + incoming, err := Decode(data) + if err != nil { + return ImportResult{}, err + } + + var res ImportResult + + switch mode { + case ImportReplace: + s.mu.Lock() + s.file = File{Version: SchemaVersion, Presets: incoming.Presets} + res.Added = len(incoming.Presets) + s.mu.Unlock() + + case ImportMerge: + s.mu.Lock() + existing := make(map[string]bool, len(s.file.Presets)) + for _, p := range s.file.Presets { + existing[p.ID] = true + } + for _, p := range incoming.Presets { + if existing[p.ID] { + p.ID = NewID() + p.Name = p.Name + " (imported)" + res.Renamed = append(res.Renamed, p.Name) + } + existing[p.ID] = true + s.file.Presets = append(s.file.Presets, p) + res.Added++ + } + s.mu.Unlock() + + default: + return ImportResult{}, fmt.Errorf("unknown import mode %q", mode) + } + + if err := s.Save(); err != nil { + // Reload so the in-memory copy matches whatever is actually on disk. + _ = s.Load() + return ImportResult{}, err + } + return res, nil +} diff --git a/internal/preset/prefix.go b/internal/preset/prefix.go new file mode 100644 index 0000000..53a2b34 --- /dev/null +++ b/internal/preset/prefix.go @@ -0,0 +1,71 @@ +package preset + +import ( + "fmt" + "net/netip" + "strconv" + "strings" +) + +// ParsePrefix accepts every spelling of a subnet mask the editor is required to +// take — "24", "/24" and "255.255.255.0" — and returns the prefix length. +// +// Only contiguous masks are accepted. 255.255.0.255 is a typo, not a netmask, +// and letting it through would produce a netsh call that fails confusingly. +func ParsePrefix(s string) (int, error) { + s = strings.TrimSpace(s) + if s == "" { + return 0, fmt.Errorf("empty mask") + } + s = strings.TrimPrefix(s, "/") + + if strings.Contains(s, ".") { + addr, err := netip.ParseAddr(s) + if err != nil || !addr.Is4() { + return 0, fmt.Errorf("%q is not a dotted-quad netmask", s) + } + b := addr.As4() + mask := uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3]) + + ones := 0 + for ones < 32 && mask&(1< 32 { + return 0, fmt.Errorf("prefix /%d out of range", n) + } + return n, nil +} + +// MaskString renders a prefix length as the dotted-quad netmask netsh expects +// on the command line. netsh will not take "/24". +func MaskString(prefix int) string { + if prefix < 0 || prefix > 32 { + prefix = 32 + } + var mask uint32 + if prefix > 0 { + mask = ^uint32(0) << uint(32-prefix) + } + return fmt.Sprintf("%d.%d.%d.%d", mask>>24&0xff, mask>>16&0xff, mask>>8&0xff, mask&0xff) +} + +// FormatPrefix renders a prefix in whichever notation the user last chose. +// dotted controls the style; see config.Settings.MaskStyle. +func FormatPrefix(prefix int, dotted bool) string { + if dotted { + return MaskString(prefix) + } + return "/" + strconv.Itoa(prefix) +} diff --git a/internal/preset/prefix_test.go b/internal/preset/prefix_test.go new file mode 100644 index 0000000..9317720 --- /dev/null +++ b/internal/preset/prefix_test.go @@ -0,0 +1,66 @@ +package preset + +import "testing" + +func TestParsePrefix(t *testing.T) { + ok := []struct { + in string + want int + }{ + {"24", 24}, + {"/24", 24}, + {" /24 ", 24}, + {"255.255.255.0", 24}, + {"255.255.255.255", 32}, + {"0.0.0.0", 0}, + {"255.255.254.0", 23}, + {"128.0.0.0", 1}, + {"32", 32}, + {"0", 0}, + } + for _, tc := range ok { + got, err := ParsePrefix(tc.in) + if err != nil { + t.Errorf("ParsePrefix(%q) returned error: %v", tc.in, err) + continue + } + if got != tc.want { + t.Errorf("ParsePrefix(%q) = %d, want %d", tc.in, got, tc.want) + } + } + + bad := []string{"", "33", "-1", "255.255.0.255", "1.2.3", "abc", "255.0.255.0", "/", "24.5"} + for _, in := range bad { + if got, err := ParsePrefix(in); err == nil { + t.Errorf("ParsePrefix(%q) = %d, want error", in, got) + } + } +} + +func TestMaskStringRoundTrip(t *testing.T) { + for p := 0; p <= 32; p++ { + got, err := ParsePrefix(MaskString(p)) + if err != nil { + t.Fatalf("ParsePrefix(MaskString(%d)) errored: %v", p, err) + } + if got != p { + t.Errorf("round trip of /%d gave /%d (mask %s)", p, got, MaskString(p)) + } + } +} + +func TestMaskStringKnown(t *testing.T) { + cases := map[int]string{ + 0: "0.0.0.0", + 8: "255.0.0.0", + 16: "255.255.0.0", + 24: "255.255.255.0", + 30: "255.255.255.252", + 32: "255.255.255.255", + } + for prefix, want := range cases { + if got := MaskString(prefix); got != want { + t.Errorf("MaskString(%d) = %s, want %s", prefix, got, want) + } + } +} diff --git a/internal/preset/preset.go b/internal/preset/preset.go new file mode 100644 index 0000000..8eb2579 --- /dev/null +++ b/internal/preset/preset.go @@ -0,0 +1,162 @@ +// Package preset holds the on-disk preset model and everything that reads or +// writes it. It is deliberately free of Windows-specific code so the whole +// model can be exercised by tests on any platform. +package preset + +import ( + "fmt" + "net/netip" + "strings" +) + +// SchemaVersion is written into every file we save and checked on every file we +// load. Bump it only for a breaking change, and add a migration when you do. +const SchemaVersion = 1 + +// Mode is how an adapter gets its addresses. +type Mode string + +const ( + ModeStatic Mode = "static" + ModeDHCP Mode = "dhcp" +) + +// File is the top-level shape of presets.json. +type File struct { + Version int `json:"version"` + Presets []Preset `json:"presets"` +} + +// Preset is one saved adapter configuration. +type Preset struct { + ID string `json:"id"` + Name string `json:"name"` + // Group drives the tray submenus. Empty means "Ungrouped". + Group string `json:"group,omitempty"` + // Adapter is the Windows friendly name, e.g. "Ethernet" or "Wi-Fi 2". + Adapter string `json:"adapter"` + Mode Mode `json:"mode"` + + // Primary is required when Mode is static and ignored when it is dhcp. + Primary *Address `json:"primary,omitempty"` + // Secondary addresses are added after the primary is set. + Secondary []Address `json:"secondary,omitempty"` + + DNS DNS `json:"dns"` + Notes string `json:"notes,omitempty"` +} + +// Address is a single IPv4 address with its prefix length, and optionally the +// default gateway reached through it. +type Address struct { + Address string `json:"address"` + // Prefix is the mask stored as a prefix length. The editor accepts + // dotted-quad on input and converts here; see ParsePrefix. + Prefix int `json:"prefix"` + Gateway string `json:"gateway,omitempty"` + // GatewayMetric 0 means automatic, which is what netsh wants for "let + // Windows decide". + GatewayMetric int `json:"gateway_metric,omitempty"` +} + +// DNS is the resolver configuration for a preset. +type DNS struct { + Mode Mode `json:"mode"` + Servers []string `json:"servers,omitempty"` +} + +// GroupOrUngrouped is the submenu a preset belongs under. +func (p Preset) GroupOrUngrouped() string { + if strings.TrimSpace(p.Group) == "" { + return "Ungrouped" + } + return p.Group +} + +// Validate reports the first structural problem with a preset. Import rejects +// a whole file rather than partially applying it, so this needs to be strict. +func (p Preset) Validate() error { + if strings.TrimSpace(p.ID) == "" { + return fmt.Errorf("preset has no id") + } + if strings.TrimSpace(p.Name) == "" { + return fmt.Errorf("preset %s has no name", p.ID) + } + if strings.TrimSpace(p.Adapter) == "" { + return fmt.Errorf("preset %q has no adapter", p.Name) + } + + switch p.Mode { + case ModeDHCP: + // Nothing else to check: any addresses present are ignored on apply. + case ModeStatic: + if p.Primary == nil { + return fmt.Errorf("preset %q is static but has no primary address", p.Name) + } + if err := p.Primary.validate(); err != nil { + return fmt.Errorf("preset %q primary: %w", p.Name, err) + } + for i, s := range p.Secondary { + if err := s.validate(); err != nil { + return fmt.Errorf("preset %q secondary %d: %w", p.Name, i+1, err) + } + // A gateway on a secondary address is silently dropped by + // `netsh add address`, so reject it rather than pretend. + if s.Gateway != "" { + return fmt.Errorf("preset %q secondary %d: secondary addresses cannot carry a gateway", p.Name, i+1) + } + } + default: + return fmt.Errorf("preset %q has unknown mode %q", p.Name, p.Mode) + } + + switch p.DNS.Mode { + case ModeDHCP: + case ModeStatic: + if len(p.DNS.Servers) == 0 { + return fmt.Errorf("preset %q has static DNS but no servers", p.Name) + } + for _, s := range p.DNS.Servers { + if _, err := parseV4(s); err != nil { + return fmt.Errorf("preset %q dns server %q: %w", p.Name, s, err) + } + } + default: + return fmt.Errorf("preset %q has unknown dns mode %q", p.Name, p.DNS.Mode) + } + + return nil +} + +func (a Address) validate() error { + if _, err := parseV4(a.Address); err != nil { + return fmt.Errorf("address %q: %w", a.Address, err) + } + if a.Prefix < 0 || a.Prefix > 32 { + return fmt.Errorf("prefix /%d out of range", a.Prefix) + } + if a.Gateway != "" { + if _, err := parseV4(a.Gateway); err != nil { + return fmt.Errorf("gateway %q: %w", a.Gateway, err) + } + } + if a.GatewayMetric < 0 { + return fmt.Errorf("gateway metric %d is negative", a.GatewayMetric) + } + return nil +} + +// String renders an address the way the confirmation dialog shows it. +func (a Address) String() string { return fmt.Sprintf("%s/%d", a.Address, a.Prefix) } + +func parseV4(s string) (netip.Addr, error) { + addr, err := netip.ParseAddr(strings.TrimSpace(s)) + if err != nil { + return netip.Addr{}, fmt.Errorf("not an IP address") + } + if !addr.Is4() { + // v1 is IPv4 only; see "Out of scope" in claude.md. + return netip.Addr{}, fmt.Errorf("not an IPv4 address") + } + return addr, nil +} diff --git a/internal/preset/store.go b/internal/preset/store.go new file mode 100644 index 0000000..11b987e --- /dev/null +++ b/internal/preset/store.go @@ -0,0 +1,238 @@ +package preset + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +// Store owns presets.json. Every mutation goes through it so the file is +// written atomically and the in-memory copy never drifts from disk. +type Store struct { + path string + + mu sync.RWMutex + file File +} + +// NewStore opens (or creates) the preset file at path. +func NewStore(path string) (*Store, error) { + s := &Store{path: path, file: File{Version: SchemaVersion}} + if err := s.Load(); err != nil { + return nil, err + } + return s, nil +} + +// Load re-reads the file from disk. A missing file is not an error: a fresh +// install has no presets yet and should start with an empty list rather than +// refusing to launch. +func (s *Store) Load() error { + b, err := os.ReadFile(s.path) + if os.IsNotExist(err) { + s.mu.Lock() + s.file = File{Version: SchemaVersion} + s.mu.Unlock() + return nil + } + if err != nil { + return fmt.Errorf("reading %s: %w", s.path, err) + } + + f, err := Decode(b) + if err != nil { + return fmt.Errorf("reading %s: %w", s.path, err) + } + + s.mu.Lock() + s.file = f + s.mu.Unlock() + return nil +} + +// Decode parses and fully validates a preset file. Import uses it too, which is +// why it rejects the whole document on the first bad preset rather than +// dropping the offender and continuing. +func Decode(b []byte) (File, error) { + var f File + dec := json.NewDecoder(strings.NewReader(string(b))) + dec.DisallowUnknownFields() + if err := dec.Decode(&f); err != nil { + return File{}, fmt.Errorf("not a valid preset file: %w", err) + } + + if f.Version != SchemaVersion { + return File{}, fmt.Errorf("preset file is version %d, this build understands version %d", f.Version, SchemaVersion) + } + + seen := make(map[string]bool, len(f.Presets)) + for _, p := range f.Presets { + if err := p.Validate(); err != nil { + return File{}, err + } + if seen[p.ID] { + return File{}, fmt.Errorf("duplicate preset id %q", p.ID) + } + seen[p.ID] = true + } + return f, nil +} + +// Save writes the file atomically: a temp file in the same directory, then a +// rename. A half-written presets.json after a crash would lose every preset, +// and this is the user's only copy. +func (s *Store) Save() error { + s.mu.RLock() + b, err := json.MarshalIndent(s.file, "", " ") + s.mu.RUnlock() + if err != nil { + return fmt.Errorf("encoding presets: %w", err) + } + b = append(b, '\n') + + if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { + return fmt.Errorf("creating preset directory: %w", err) + } + + tmp, err := os.CreateTemp(filepath.Dir(s.path), ".presets-*.json") + if err != nil { + return fmt.Errorf("creating temp file: %w", err) + } + tmpName := tmp.Name() + defer os.Remove(tmpName) // no-op once the rename below succeeds + + if _, err := tmp.Write(b); err != nil { + tmp.Close() + return fmt.Errorf("writing temp file: %w", err) + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return fmt.Errorf("syncing temp file: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("closing temp file: %w", err) + } + if err := os.Rename(tmpName, s.path); err != nil { + return fmt.Errorf("replacing %s: %w", s.path, err) + } + return nil +} + +// All returns a copy of the presets, sorted by group then name so the tray menu +// and the editor agree on ordering. +func (s *Store) All() []Preset { + s.mu.RLock() + out := make([]Preset, len(s.file.Presets)) + copy(out, s.file.Presets) + s.mu.RUnlock() + + sort.SliceStable(out, func(i, j int) bool { + gi, gj := out[i].GroupOrUngrouped(), out[j].GroupOrUngrouped() + if gi != gj { + // Ungrouped sinks to the bottom of the tray menu. + if gi == "Ungrouped" { + return false + } + if gj == "Ungrouped" { + return true + } + return strings.ToLower(gi) < strings.ToLower(gj) + } + return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) + }) + return out +} + +// Get returns the preset with the given id. +func (s *Store) Get(id string) (Preset, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + for _, p := range s.file.Presets { + if p.ID == id { + return p, true + } + } + return Preset{}, false +} + +// Put inserts or replaces a preset and saves. An empty ID means "new". +func (s *Store) Put(p Preset) (Preset, error) { + if strings.TrimSpace(p.ID) == "" { + p.ID = NewID() + } + if err := p.Validate(); err != nil { + return Preset{}, err + } + + s.mu.Lock() + replaced := false + for i := range s.file.Presets { + if s.file.Presets[i].ID == p.ID { + s.file.Presets[i] = p + replaced = true + break + } + } + if !replaced { + s.file.Presets = append(s.file.Presets, p) + } + s.mu.Unlock() + + return p, s.Save() +} + +// Delete removes a preset by id and saves. +func (s *Store) Delete(id string) error { + s.mu.Lock() + kept := s.file.Presets[:0] + found := false + for _, p := range s.file.Presets { + if p.ID == id { + found = true + continue + } + kept = append(kept, p) + } + s.file.Presets = kept + s.mu.Unlock() + + if !found { + return fmt.Errorf("no preset with id %q", id) + } + return s.Save() +} + +// Groups lists the distinct groups in tray-menu order. +func (s *Store) Groups() []string { + seen := map[string]bool{} + var out []string + for _, p := range s.All() { + g := p.GroupOrUngrouped() + if !seen[g] { + seen[g] = true + out = append(out, g) + } + } + return out +} + +// NewID mints a preset id. claude.md shows ULID-shaped ids in the example file, +// but nothing depends on them being sortable or on their length — an id only +// has to be unique and stable — so this stays dependency-free: a millisecond +// timestamp for rough ordering plus 64 bits of randomness. +func NewID() string { + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + // crypto/rand does not fail in practice on Windows; if it somehow + // does, a timestamp-only id is still unique enough to save with. + return fmt.Sprintf("%013x", time.Now().UnixMilli()) + } + return fmt.Sprintf("%013x%s", time.Now().UnixMilli(), hex.EncodeToString(b[:])) +} diff --git a/internal/preset/store_test.go b/internal/preset/store_test.go new file mode 100644 index 0000000..220ff74 --- /dev/null +++ b/internal/preset/store_test.go @@ -0,0 +1,228 @@ +package preset + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func newTestStore(t *testing.T) *Store { + t.Helper() + s, err := NewStore(filepath.Join(t.TempDir(), "presets.json")) + if err != nil { + t.Fatal(err) + } + return s +} + +func sample() Preset { + return Preset{ + Name: "Artist frame — control", + Group: "Riedel", + Adapter: "Ethernet", + Mode: ModeStatic, + Primary: &Address{Address: "192.168.42.100", Prefix: 24, Gateway: "192.168.42.1"}, + DNS: DNS{Mode: ModeStatic, Servers: []string{"192.168.42.1"}}, + } +} + +// The shipped example doubles as the documented export format, so a change +// that breaks it breaks every preset pack already in circulation. +func TestExamplePackDecodes(t *testing.T) { + b, err := os.ReadFile(filepath.Join("..", "..", "examples", "preset-pack.json")) + if err != nil { + t.Skipf("example pack not present: %v", err) + } + f, err := Decode(b) + if err != nil { + t.Fatalf("examples/preset-pack.json does not validate: %v", err) + } + if len(f.Presets) == 0 { + t.Fatal("example pack is empty") + } +} + +func TestMissingFileStartsEmpty(t *testing.T) { + s := newTestStore(t) + if len(s.All()) != 0 { + t.Errorf("a fresh store should have no presets, got %d", len(s.All())) + } +} + +func TestPutAssignsIDAndPersists(t *testing.T) { + path := filepath.Join(t.TempDir(), "presets.json") + s, err := NewStore(path) + if err != nil { + t.Fatal(err) + } + + saved, err := s.Put(sample()) + if err != nil { + t.Fatal(err) + } + if saved.ID == "" { + t.Fatal("Put should have assigned an id") + } + + reopened, err := NewStore(path) + if err != nil { + t.Fatalf("saved file did not reload: %v", err) + } + if got, ok := reopened.Get(saved.ID); !ok || got.Name != saved.Name { + t.Errorf("preset did not survive a reload: %+v", got) + } +} + +func TestPutRejectsInvalid(t *testing.T) { + s := newTestStore(t) + + bad := sample() + bad.Primary.Address = "not-an-ip" + if _, err := s.Put(bad); err == nil { + t.Error("expected a validation error for a bad address") + } + + // A secondary carrying a gateway is silently dropped by netsh, so it is + // rejected rather than saved as a lie. + withGW := sample() + withGW.Secondary = []Address{{Address: "10.0.0.5", Prefix: 24, Gateway: "10.0.0.1"}} + if _, err := s.Put(withGW); err == nil { + t.Error("expected a validation error for a secondary with a gateway") + } +} + +func TestDecodeRejectsWrongVersion(t *testing.T) { + _, err := Decode([]byte(`{"version": 99, "presets": []}`)) + if err == nil || !strings.Contains(err.Error(), "version") { + t.Errorf("expected a version error, got %v", err) + } +} + +func TestDecodeRejectsDuplicateIDs(t *testing.T) { + doc := `{"version":1,"presets":[ + {"id":"a","name":"one","adapter":"Ethernet","mode":"dhcp","dns":{"mode":"dhcp"}}, + {"id":"a","name":"two","adapter":"Ethernet","mode":"dhcp","dns":{"mode":"dhcp"}}]}` + if _, err := Decode([]byte(doc)); err == nil { + t.Error("expected a duplicate-id error") + } +} + +func TestImportMergeKeepsBothOnIDCollision(t *testing.T) { + s := newTestStore(t) + saved, err := s.Put(sample()) + if err != nil { + t.Fatal(err) + } + + pack, err := s.Export() + if err != nil { + t.Fatal(err) + } + + res, err := s.Import(pack, ImportMerge) + if err != nil { + t.Fatal(err) + } + if res.Added != 1 || len(res.Renamed) != 1 { + t.Fatalf("expected 1 added and 1 renamed, got %+v", res) + } + if len(s.All()) != 2 { + t.Fatalf("merge should keep both, got %d presets", len(s.All())) + } + + // The original must be untouched: a colleague's pack silently overwriting + // a working preset is the failure this guards against. + orig, ok := s.Get(saved.ID) + if !ok || orig.Name != saved.Name { + t.Errorf("original preset was modified: %+v", orig) + } + + var found bool + for _, p := range s.All() { + if strings.HasSuffix(p.Name, "(imported)") { + found = true + } + } + if !found { + t.Error("the colliding import should have been suffixed with (imported)") + } +} + +func TestImportReplaceDiscardsExisting(t *testing.T) { + s := newTestStore(t) + if _, err := s.Put(sample()); err != nil { + t.Fatal(err) + } + + doc := `{"version":1,"presets":[{"id":"z","name":"only","adapter":"Ethernet","mode":"dhcp","dns":{"mode":"dhcp"}}]}` + if _, err := s.Import([]byte(doc), ImportReplace); err != nil { + t.Fatal(err) + } + + all := s.All() + if len(all) != 1 || all[0].ID != "z" { + t.Errorf("replace should leave exactly the incoming presets, got %+v", all) + } +} + +// A rejected import must change nothing: partial application would leave the +// user with a half-imported pack and no way to tell what landed. +func TestImportRejectsWithoutMutating(t *testing.T) { + s := newTestStore(t) + if _, err := s.Put(sample()); err != nil { + t.Fatal(err) + } + before := len(s.All()) + + bad := `{"version":1,"presets":[{"id":"x","name":"broken","adapter":"Ethernet","mode":"static","dns":{"mode":"dhcp"}}]}` + if _, err := s.Import([]byte(bad), ImportMerge); err == nil { + t.Fatal("expected the import to be rejected") + } + if len(s.All()) != before { + t.Errorf("a rejected import changed the store: %d -> %d", before, len(s.All())) + } +} + +func TestExportFiltersByGroup(t *testing.T) { + s := newTestStore(t) + if _, err := s.Put(sample()); err != nil { + t.Fatal(err) + } + other := sample() + other.Name = "Management" + other.Group = "Herespace" + if _, err := s.Put(other); err != nil { + t.Fatal(err) + } + + b, err := s.Export("Riedel") + if err != nil { + t.Fatal(err) + } + f, err := Decode(b) + if err != nil { + t.Fatalf("a filtered export must still be a valid pack: %v", err) + } + if len(f.Presets) != 1 || f.Presets[0].Group != "Riedel" { + t.Errorf("group filter returned %+v", f.Presets) + } +} + +func TestUngroupedSortsLast(t *testing.T) { + s := newTestStore(t) + loose := sample() + loose.Name = "Loose" + loose.Group = "" + if _, err := s.Put(loose); err != nil { + t.Fatal(err) + } + if _, err := s.Put(sample()); err != nil { + t.Fatal(err) + } + + groups := s.Groups() + if len(groups) != 2 || groups[len(groups)-1] != "Ungrouped" { + t.Errorf("Ungrouped should sort last, got %v", groups) + } +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..e466f4a --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,439 @@ +// Package server is the "slow path": a local HTTP server hosting the preset +// editor, opened in the user's default browser. +// +// It exists because the fast path must not involve a browser and the editor +// must not involve a MessageBox. Switching a preset is two clicks in the tray; +// managing fifty of them needs a real UI, and an embedded web app is the only +// way to get one without linking a GUI toolkit and giving up cross-compilation. +// +// The server is not running most of the time. The tray starts it on demand and +// it shuts itself down once the browser stops sending heartbeats. +package server + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "embed" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "io/fs" + "log" + "net" + "net/http" + "strings" + "sync" + "time" + + "gitea.apointless.space/bsncubed/ipswap/internal/config" + "gitea.apointless.space/bsncubed/ipswap/internal/netcfg" + "gitea.apointless.space/bsncubed/ipswap/internal/preset" +) + +//go:embed web +var webFS embed.FS + +const ( + // idleTimeout is how long the server survives without a heartbeat. The + // browser beats every 30s, so this tolerates a few missed beats before + // concluding the tab is gone. + idleTimeout = 5 * time.Minute + // tokenHeader carries the session token on mutating requests. + tokenHeader = "X-Ipswap-Token" +) + +// Server hosts the editor. +type Server struct { + store *preset.Store + mgr netcfg.Manager + paths config.Paths + + // onSettingsChange lets the tray react to a settings save, e.g. to + // rewrite the Run key when start-with-Windows is toggled. + onSettingsChange func(config.Settings) + // onPresetsChange asks the tray to rebuild its menu. + onPresetsChange func() + + mu sync.Mutex + settings config.Settings + token string + srv *http.Server + ln net.Listener + lastBeat time.Time + stop chan struct{} +} + +// New builds a server. It does not listen until Start is called. +func New(store *preset.Store, mgr netcfg.Manager, paths config.Paths, settings config.Settings) *Server { + return &Server{store: store, mgr: mgr, paths: paths, settings: settings} +} + +// OnSettingsChange registers a callback fired after settings are saved. +func (s *Server) OnSettingsChange(f func(config.Settings)) { s.onSettingsChange = f } + +// OnPresetsChange registers a callback fired after any preset mutation. +func (s *Server) OnPresetsChange(f func()) { s.onPresetsChange = f } + +// URL returns the address to open, including the session token. It is empty +// when the server is not running. +func (s *Server) URL() string { + s.mu.Lock() + defer s.mu.Unlock() + if s.ln == nil { + return "" + } + return fmt.Sprintf("http://127.0.0.1:%d/?t=%s", s.ln.Addr().(*net.TCPAddr).Port, s.token) +} + +// Start binds a random loopback port and serves until Stop or an idle timeout. +// Calling it while already running just returns the existing URL, so clicking +// "Manage presets…" twice reuses the one session. +func (s *Server) Start() (string, error) { + s.mu.Lock() + if s.ln != nil { + url := fmt.Sprintf("http://127.0.0.1:%d/?t=%s", s.ln.Addr().(*net.TCPAddr).Port, s.token) + s.lastBeat = time.Now() + s.mu.Unlock() + return url, nil + } + + // Loopback only. This binds no external interface at any point, which + // matters given the app spends its life on customer networks. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + s.mu.Unlock() + return "", fmt.Errorf("binding a local port: %w", err) + } + + token, err := newToken() + if err != nil { + ln.Close() + s.mu.Unlock() + return "", err + } + + s.ln = ln + s.token = token + s.lastBeat = time.Now() + s.stop = make(chan struct{}) + s.srv = &http.Server{ + Handler: s.routes(), + ReadHeaderTimeout: 10 * time.Second, + } + stop := s.stop + srv := s.srv + port := ln.Addr().(*net.TCPAddr).Port + s.mu.Unlock() + + go func() { + if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed { + log.Printf("editor server stopped: %v", err) + } + }() + go s.watchIdle(stop) + + log.Printf("editor server listening on 127.0.0.1:%d", port) + return fmt.Sprintf("http://127.0.0.1:%d/?t=%s", port, token), nil +} + +// Stop shuts the server down. It is safe to call when not running. +func (s *Server) Stop() { + s.mu.Lock() + srv, stop := s.srv, s.stop + s.srv, s.ln, s.stop, s.token = nil, nil, nil, "" + s.mu.Unlock() + + if stop != nil { + close(stop) + } + if srv == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _ = srv.Shutdown(ctx) + log.Printf("editor server shut down") +} + +func (s *Server) watchIdle(stop <-chan struct{}) { + t := time.NewTicker(30 * time.Second) + defer t.Stop() + for { + select { + case <-stop: + return + case <-t.C: + s.mu.Lock() + idle := time.Since(s.lastBeat) + s.mu.Unlock() + if idle > idleTimeout { + log.Printf("editor server idle for %s, shutting down", idle.Round(time.Second)) + s.Stop() + return + } + } + } +} + +func newToken() (string, error) { + var b [32]byte + if _, err := rand.Read(b[:]); err != nil { + return "", fmt.Errorf("generating a session token: %w", err) + } + return hex.EncodeToString(b[:]), nil +} + +// Settings returns the current settings. +func (s *Server) Settings() config.Settings { + s.mu.Lock() + defer s.mu.Unlock() + return s.settings +} + +// SetSettings replaces the settings the server serves, for when something +// outside the editor changes them. +func (s *Server) SetSettings(c config.Settings) { + s.mu.Lock() + s.settings = c + s.mu.Unlock() +} + +func (s *Server) beat() { + s.mu.Lock() + s.lastBeat = time.Now() + s.mu.Unlock() +} + +// --- routing --- + +func (s *Server) routes() http.Handler { + mux := http.NewServeMux() + + assets, err := fs.Sub(webFS, "web") + if err != nil { + // Only reachable if the embed directive and the directory disagree, + // which is a build-time mistake, not a runtime condition. + panic(err) + } + mux.Handle("GET /", http.FileServer(http.FS(assets))) + + mux.HandleFunc("POST /api/heartbeat", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + }) + + mux.HandleFunc("GET /api/presets", s.handleListPresets) + mux.HandleFunc("POST /api/presets", s.handlePutPreset) + mux.HandleFunc("PUT /api/presets/{id}", s.handlePutPreset) + mux.HandleFunc("DELETE /api/presets/{id}", s.handleDeletePreset) + + mux.HandleFunc("GET /api/adapters", s.handleAdapters) + mux.HandleFunc("GET /api/settings", s.handleGetSettings) + mux.HandleFunc("PUT /api/settings", s.handlePutSettings) + + mux.HandleFunc("GET /api/export", s.handleExport) + mux.HandleFunc("POST /api/import", s.handleImport) + + return s.withAuth(mux) +} + +// withAuth gates every request on the session token and refreshes the idle +// timer. +// +// The token arrives once in the URL and is then stored in a SameSite=Strict +// cookie. Mutating requests additionally require the token in a header, which +// a cross-site page cannot set: a cookie alone would let any website in the +// browser POST to this port and rewrite the user's presets. +func (s *Server) withAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + token := s.token + s.mu.Unlock() + if token == "" { + http.Error(w, "server is shutting down", http.StatusServiceUnavailable) + return + } + + if q := r.URL.Query().Get("t"); q != "" && tokenEqual(q, token) { + http.SetCookie(w, &http.Cookie{ + Name: "ipswap_session", + Value: token, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + }) + s.beat() + next.ServeHTTP(w, r) + return + } + + c, err := r.Cookie("ipswap_session") + if err != nil || !tokenEqual(c.Value, token) { + http.Error(w, "unauthorised: reopen the editor from the tray menu", http.StatusUnauthorized) + return + } + + if r.Method != http.MethodGet && r.Method != http.MethodHead { + if !tokenEqual(r.Header.Get(tokenHeader), token) { + http.Error(w, "missing session header", http.StatusForbidden) + return + } + } + + s.beat() + next.ServeHTTP(w, r) + }) +} + +func tokenEqual(got, want string) bool { + return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1 +} + +// --- handlers --- + +func (s *Server) handleListPresets(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "presets": s.store.All(), + "groups": s.store.Groups(), + }) +} + +func (s *Server) handlePutPreset(w http.ResponseWriter, r *http.Request) { + var p preset.Preset + if err := readJSON(r, &p); err != nil { + writeErr(w, http.StatusBadRequest, err) + return + } + if id := r.PathValue("id"); id != "" { + p.ID = id + } + + saved, err := s.store.Put(p) + if err != nil { + writeErr(w, http.StatusBadRequest, err) + return + } + s.notifyPresets() + writeJSON(w, http.StatusOK, saved) +} + +func (s *Server) handleDeletePreset(w http.ResponseWriter, r *http.Request) { + if err := s.store.Delete(r.PathValue("id")); err != nil { + writeErr(w, http.StatusNotFound, err) + return + } + s.notifyPresets() + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (s *Server) handleAdapters(w http.ResponseWriter, r *http.Request) { + adapters, err := s.mgr.Adapters() + if err != nil { + writeErr(w, http.StatusInternalServerError, err) + return + } + + type row struct { + netcfg.Adapter + Current string `json:"current"` + } + out := make([]row, 0, len(adapters)) + for _, a := range adapters { + r := row{Adapter: a} + if live, err := s.mgr.Current(a.Name); err == nil { + r.Current = live.Summary() + } + out = append(out, r) + } + writeJSON(w, http.StatusOK, map[string]any{"adapters": out}) +} + +func (s *Server) handleGetSettings(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, s.Settings()) +} + +func (s *Server) handlePutSettings(w http.ResponseWriter, r *http.Request) { + c := s.Settings() + if err := readJSON(r, &c); err != nil { + writeErr(w, http.StatusBadRequest, err) + return + } + if err := c.Save(s.paths.Config); err != nil { + writeErr(w, http.StatusInternalServerError, err) + return + } + s.SetSettings(c) + if s.onSettingsChange != nil { + s.onSettingsChange(c) + } + writeJSON(w, http.StatusOK, c) +} + +func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) { + var groups []string + if g := strings.TrimSpace(r.URL.Query().Get("groups")); g != "" { + groups = strings.Split(g, ",") + } + + b, err := s.store.Export(groups...) + if err != nil { + writeErr(w, http.StatusInternalServerError, err) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Disposition", `attachment; filename="ipswap-presets.json"`) + w.Write(b) +} + +func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) { + mode := preset.ImportMode(r.URL.Query().Get("mode")) + if mode == "" { + mode = preset.ImportMerge + } + + body, err := io.ReadAll(io.LimitReader(r.Body, 8<<20)) + if err != nil { + writeErr(w, http.StatusBadRequest, err) + return + } + + res, err := s.store.Import(body, mode) + if err != nil { + // Import validates the whole file first, so a rejection here means + // nothing on disk changed. + writeErr(w, http.StatusBadRequest, err) + return + } + s.notifyPresets() + writeJSON(w, http.StatusOK, res) +} + +func (s *Server) notifyPresets() { + if s.onPresetsChange != nil { + s.onPresetsChange() + } +} + +// --- helpers --- + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(v); err != nil { + log.Printf("writing response: %v", err) + } +} + +func writeErr(w http.ResponseWriter, status int, err error) { + writeJSON(w, status, map[string]string{"error": err.Error()}) +} + +func readJSON(r *http.Request, v any) error { + dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20)) + if err := dec.Decode(v); err != nil { + return fmt.Errorf("invalid request body: %w", err) + } + return nil +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..96b03b5 --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,214 @@ +package server + +import ( + "encoding/json" + "io" + "net/http" + "net/url" + "path/filepath" + "strings" + "testing" + + "gitea.apointless.space/bsncubed/ipswap/internal/config" + "gitea.apointless.space/bsncubed/ipswap/internal/netcfg" + "gitea.apointless.space/bsncubed/ipswap/internal/preset" +) + +func newTestServer(t *testing.T) (*Server, string, string) { + t.Helper() + + dir := t.TempDir() + store, err := preset.NewStore(filepath.Join(dir, "presets.json")) + if err != nil { + t.Fatal(err) + } + paths := config.Paths{Dir: dir, Presets: filepath.Join(dir, "presets.json"), Config: filepath.Join(dir, "config.json")} + + s := New(store, netcfg.New(), paths, config.Default()) + raw, err := s.Start() + if err != nil { + t.Fatal(err) + } + t.Cleanup(s.Stop) + + u, err := url.Parse(raw) + if err != nil { + t.Fatal(err) + } + return s, "http://" + u.Host, u.Query().Get("t") +} + +// do issues a request with no cookie jar, so each call is judged purely on +// what it carries. +func do(t *testing.T, method, url, token, header, body string) *http.Response { + t.Helper() + var r io.Reader + if body != "" { + r = strings.NewReader(body) + } + req, err := http.NewRequest(method, url, r) + if err != nil { + t.Fatal(err) + } + if token != "" { + req.AddCookie(&http.Cookie{Name: "ipswap_session", Value: token}) + } + if header != "" { + req.Header.Set(tokenHeader, header) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + return resp +} + +func TestServesOnLoopbackOnly(t *testing.T) { + _, base, _ := newTestServer(t) + if !strings.HasPrefix(base, "http://127.0.0.1:") { + t.Errorf("editor must bind loopback only, got %s", base) + } +} + +func TestRejectsMissingToken(t *testing.T) { + _, base, _ := newTestServer(t) + + resp := do(t, "GET", base+"/api/presets", "", "", "") + defer resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("no token should be 401, got %d", resp.StatusCode) + } +} + +func TestRejectsWrongToken(t *testing.T) { + _, base, _ := newTestServer(t) + + resp := do(t, "GET", base+"/api/presets", strings.Repeat("a", 64), "", "") + defer resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("a wrong token should be 401, got %d", resp.StatusCode) + } +} + +func TestQueryTokenSetsCookie(t *testing.T) { + _, base, token := newTestServer(t) + + resp := do(t, "GET", base+"/api/presets?t="+token, "", "", "") + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("query token should authenticate, got %d", resp.StatusCode) + } + + var found bool + for _, c := range resp.Cookies() { + if c.Name == "ipswap_session" { + found = true + if c.SameSite != http.SameSiteStrictMode { + t.Error("session cookie must be SameSite=Strict") + } + if !c.HttpOnly { + t.Error("session cookie must be HttpOnly") + } + } + } + if !found { + t.Error("expected a session cookie to be set") + } +} + +// A cookie alone must not be enough to mutate: any page in the browser can +// make the browser send this cookie to 127.0.0.1, but it cannot set a header. +func TestMutationRequiresHeaderNotJustCookie(t *testing.T) { + _, base, token := newTestServer(t) + + body := `{"name":"x","adapter":"Ethernet","mode":"dhcp","dns":{"mode":"dhcp"}}` + + resp := do(t, "POST", base+"/api/presets", token, "", body) + defer resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Errorf("cookie-only mutation should be 403, got %d", resp.StatusCode) + } + + resp2 := do(t, "POST", base+"/api/presets", token, token, body) + defer resp2.Body.Close() + if resp2.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp2.Body) + t.Errorf("cookie plus header should succeed, got %d: %s", resp2.StatusCode, b) + } +} + +func TestPresetCRUD(t *testing.T) { + _, base, token := newTestServer(t) + + body := `{"name":"Control","group":"Riedel","adapter":"Ethernet","mode":"static", + "primary":{"address":"192.168.42.100","prefix":24,"gateway":"192.168.42.1"}, + "dns":{"mode":"static","servers":["192.168.42.1"]}}` + + resp := do(t, "POST", base+"/api/presets", token, token, body) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("create failed: %d %s", resp.StatusCode, b) + } + + var created preset.Preset + if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { + t.Fatal(err) + } + if created.ID == "" { + t.Fatal("server should assign an id") + } + + list := do(t, "GET", base+"/api/presets", token, "", "") + defer list.Body.Close() + var got struct { + Presets []preset.Preset `json:"presets"` + Groups []string `json:"groups"` + } + if err := json.NewDecoder(list.Body).Decode(&got); err != nil { + t.Fatal(err) + } + if len(got.Presets) != 1 || got.Presets[0].Name != "Control" { + t.Fatalf("list returned %+v", got.Presets) + } + + del := do(t, "DELETE", base+"/api/presets/"+created.ID, token, token, "") + defer del.Body.Close() + if del.StatusCode != http.StatusOK { + t.Errorf("delete failed: %d", del.StatusCode) + } +} + +func TestInvalidPresetIsRejected(t *testing.T) { + _, base, token := newTestServer(t) + + body := `{"name":"bad","adapter":"Ethernet","mode":"static","dns":{"mode":"dhcp"}}` + resp := do(t, "POST", base+"/api/presets", token, token, body) + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("a static preset with no primary should be 400, got %d", resp.StatusCode) + } +} + +func TestStopRejectsSubsequentRequests(t *testing.T) { + s, base, token := newTestServer(t) + s.Stop() + + if _, err := http.Get(base + "/api/presets?t=" + token); err == nil { + t.Error("expected the listener to be closed after Stop") + } +} + +func TestIndexIsServed(t *testing.T) { + _, base, token := newTestServer(t) + + resp := do(t, "GET", base+"/?t="+token, "", "", "") + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("index should be served, got %d", resp.StatusCode) + } + b, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(b), "ipswap") { + t.Error("index.html does not look like the editor") + } +} diff --git a/internal/server/web/app.css b/internal/server/web/app.css new file mode 100644 index 0000000..2a5fff8 --- /dev/null +++ b/internal/server/web/app.css @@ -0,0 +1,333 @@ +/* + * apointless.css — reconstructed from the description in claude.md. + * + * If you have the canonical apointless.css, drop it in over this file: the + * markup below only uses the components the spec lists (cards, stat cards, + * badges, pills, buttons, inputs, tables, alerts, code blocks, spinners), so a + * real copy should be a straight swap. + * + * Fonts are declared as stacks with system fallbacks rather than pulled from a + * CDN: ipswap runs on laptops sitting on customer networks with no route to + * the internet, and a webfont that 404s would leave the editor unstyled. + */ + +:root { + --bg: #0b0d11; + --surface-1: #12151b; + --surface-2: #1a1e28; + --surface-3: #232838; + + --text: #e6e9ef; + --text-muted: #9aa3b2; + --text-dim: #6b7382; + + --accent: #3b82f6; + --accent-hover: #60a5fa; + --accent-dim: rgba(59, 130, 246, 0.15); + + --ok: #22c55e; + --warn: #f59e0b; + --danger: #ef4444; + --info: #38bdf8; + + --border: #232838; + --border-strong: #2f3648; + + --mono: "JetBrains Mono", ui-monospace, "Cascadia Mono", "Consolas", monospace; + --sans: "DM Sans", ui-sans-serif, system-ui, "Segoe UI", sans-serif; + + --radius: 10px; + --radius-sm: 6px; + --gap: 16px; +} + +html.light { + --bg: #f6f7f9; + --surface-1: #ffffff; + --surface-2: #f0f2f5; + --surface-3: #e4e7ec; + + --text: #12151b; + --text-muted: #4b5563; + --text-dim: #6b7382; + + --accent-dim: rgba(59, 130, 246, 0.12); + --border: #dfe3e9; + --border-strong: #c8ced8; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: var(--sans); + font-size: 15px; + line-height: 1.55; + min-height: 100vh; + position: relative; +} + +/* Subtle blue grid behind everything. */ +body::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 0; + background-image: + linear-gradient(to right, rgba(59, 130, 246, 0.05) 1px, transparent 1px), + linear-gradient(to bottom, rgba(59, 130, 246, 0.05) 1px, transparent 1px); + background-size: 40px 40px; +} + +.wrap { + position: relative; + z-index: 1; + max-width: 1100px; + margin: 0 auto; + padding: 28px 20px 64px; +} + +/* --- typography --- */ + +h1, h2, h3 { font-weight: 600; line-height: 1.25; margin: 0 0 8px; } +h1 { font-size: 24px; letter-spacing: -0.02em; } +h2 { font-size: 18px; } +h3 { font-size: 15px; color: var(--text-muted); } + +.mono { font-family: var(--mono); } +.muted { color: var(--text-muted); } +.dim { color: var(--text-dim); font-size: 13px; } + +header.top { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--gap); + margin-bottom: 24px; + flex-wrap: wrap; +} + +/* --- cards --- */ + +.card { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 18px; + margin-bottom: var(--gap); +} + +.card > h2 { margin-bottom: 12px; } + +.stat-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + margin-bottom: var(--gap); +} + +.stat-card { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px 16px; +} + +.stat-card .label { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-dim); +} + +.stat-card .value { + font-family: var(--mono); + font-size: 18px; + margin-top: 4px; + word-break: break-all; +} + +/* --- badges and pills --- */ + +.badge, .pill { + display: inline-block; + font-size: 12px; + font-family: var(--mono); + padding: 2px 8px; + border-radius: 999px; + border: 1px solid var(--border-strong); + color: var(--text-muted); + white-space: nowrap; +} + +.badge.accent { background: var(--accent-dim); border-color: var(--accent); color: var(--accent-hover); } +.badge.ok { background: rgba(34, 197, 94, 0.12); border-color: var(--ok); color: var(--ok); } +.badge.warn { background: rgba(245, 158, 11, 0.12); border-color: var(--warn); color: var(--warn); } +.badge.danger { background: rgba(239, 68, 68, 0.12); border-color: var(--danger); color: var(--danger); } + +.pill { background: var(--surface-2); } + +/* --- buttons --- */ + +button, .btn { + font-family: var(--sans); + font-size: 14px; + font-weight: 500; + padding: 7px 14px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-strong); + background: var(--surface-2); + color: var(--text); + cursor: pointer; + transition: background 0.12s ease, border-color 0.12s ease; +} + +button:hover, .btn:hover { background: var(--surface-3); border-color: var(--accent); } +button:disabled { opacity: 0.5; cursor: not-allowed; } +button:disabled:hover { background: var(--surface-2); border-color: var(--border-strong); } + +button.primary { background: var(--accent); border-color: var(--accent); color: #fff; } +button.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); } +button.danger { border-color: var(--danger); color: var(--danger); } +button.danger:hover { background: rgba(239, 68, 68, 0.12); } +button.ghost { background: transparent; border-color: transparent; color: var(--text-muted); } +button.ghost:hover { background: var(--surface-2); border-color: var(--border); } +button.small { font-size: 12px; padding: 4px 9px; } + +.btn-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } + +/* --- inputs --- */ + +label { display: block; font-size: 13px; color: var(--text-muted); margin-bottom: 4px; } + +input, select, textarea { + width: 100%; + font-family: var(--mono); + font-size: 14px; + padding: 7px 10px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-strong); + background: var(--surface-2); + color: var(--text); +} + +input:focus, select:focus, textarea:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-dim); +} + +input[type="checkbox"] { width: auto; accent-color: var(--accent); } + +.field { margin-bottom: 12px; } +.field-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 12px; +} + +.check { display: flex; align-items: center; gap: 8px; } +.check label { margin: 0; } + +/* --- tables --- */ + +table { width: 100%; border-collapse: collapse; font-size: 14px; } + +th { + text-align: left; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-dim); + font-weight: 500; + padding: 8px 10px; + border-bottom: 1px solid var(--border-strong); +} + +td { padding: 9px 10px; border-bottom: 1px solid var(--border); vertical-align: middle; } +tbody tr:hover { background: var(--surface-2); } +td.mono, .table-scroll td.addr { font-family: var(--mono); font-size: 13px; } +.table-scroll { overflow-x: auto; } + +/* --- alerts --- */ + +.alert { + border-radius: var(--radius-sm); + border: 1px solid var(--border-strong); + background: var(--surface-2); + padding: 10px 14px; + margin-bottom: 12px; + font-size: 14px; +} + +.alert.ok { border-color: var(--ok); background: rgba(34, 197, 94, 0.10); } +.alert.warn { border-color: var(--warn); background: rgba(245, 158, 11, 0.10); } +.alert.danger { border-color: var(--danger); background: rgba(239, 68, 68, 0.10); } +.alert.info { border-color: var(--info); background: rgba(56, 189, 248, 0.10); } + +/* --- code --- */ + +pre, code { + font-family: var(--mono); + font-size: 13px; +} + +pre { + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 12px; + overflow-x: auto; +} + +/* --- spinner --- */ + +.spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid var(--border-strong); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.7s linear infinite; + vertical-align: -2px; +} + +@keyframes spin { to { transform: rotate(360deg); } } + +/* --- layout odds and ends --- */ + +.group-head { + display: flex; + align-items: center; + gap: 10px; + margin: 20px 0 8px; +} + +.group-head h2 { margin: 0; } + +.empty { + text-align: center; + color: var(--text-dim); + padding: 32px 16px; +} + +dialog { + background: var(--surface-1); + color: var(--text); + border: 1px solid var(--border-strong); + border-radius: var(--radius); + padding: 20px; + width: min(680px, 92vw); +} + +dialog::backdrop { background: rgba(0, 0, 0, 0.6); } + +.sec-list { display: flex; flex-direction: column; gap: 8px; } +.sec-row { display: flex; gap: 8px; align-items: center; } +.sec-row input { flex: 1; } + +.hidden { display: none !important; } diff --git a/internal/server/web/app.js b/internal/server/web/app.js new file mode 100644 index 0000000..8caed6a --- /dev/null +++ b/internal/server/web/app.js @@ -0,0 +1,435 @@ +/* + * ipswap preset editor. + * + * Plain ES modules-free JavaScript on purpose: the whole app is served from an + * embed.FS inside a single .exe, and a build step for the front end would mean + * a Node toolchain in CI for a few hundred lines of DOM code. + */ + +// The token arrives once in the query string. The server also sets it as a +// SameSite=Strict cookie, but mutations additionally require it as a header — +// a cookie alone would let any page in the browser POST to this port. +const TOKEN = new URLSearchParams(location.search).get("t") || ""; + +const state = { + presets: [], + groups: [], + adapters: [], + settings: null, + editing: null, +}; + +// --- theme --- + +const themeToggle = document.getElementById("theme-toggle"); + +function applyTheme(light) { + document.documentElement.classList.toggle("light", light); + themeToggle.textContent = light ? "Dark" : "Light"; + localStorage.setItem("ipswap-theme", light ? "light" : "dark"); +} + +applyTheme(localStorage.getItem("ipswap-theme") === "light"); +themeToggle.addEventListener("click", () => + applyTheme(!document.documentElement.classList.contains("light")) +); + +// --- api --- + +async function api(path, opts = {}) { + const headers = Object.assign({}, opts.headers); + if (opts.method && opts.method !== "GET") { + headers["X-Ipswap-Token"] = TOKEN; + if (opts.body && !headers["Content-Type"]) { + headers["Content-Type"] = "application/json"; + } + } + + const res = await fetch(path, Object.assign({}, opts, { headers })); + const text = await res.text(); + let data = null; + try { + data = text ? JSON.parse(text) : null; + } catch { + // Non-JSON bodies come from the auth middleware, which writes plain text. + if (!res.ok) throw new Error(text || res.statusText); + } + if (!res.ok) throw new Error((data && data.error) || text || res.statusText); + return data; +} + +// The server shuts down after five minutes without one of these. +setInterval(() => { + api("/api/heartbeat", { method: "POST" }).catch(() => {}); +}, 30_000); + +// --- alerts --- + +function alertBox(kind, message, container = "alerts") { + const host = document.getElementById(container); + host.innerHTML = `
${escapeHTML(message)}
`; + if (kind === "ok") setTimeout(() => (host.innerHTML = ""), 4000); +} + +function escapeHTML(s) { + return String(s).replace(/[&<>"']/g, (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] + ); +} + +// --- rendering --- + +function maskOf(prefix) { + if (state.settings && state.settings.mask_style === "dotted") { + let m = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0; + return [m >>> 24, (m >>> 16) & 255, (m >>> 8) & 255, m & 255].join("."); + } + return "/" + prefix; +} + +function addrText(a) { + return a.address + maskOf(a.prefix).replace(/^(?!\/)/, " "); +} + +function renderStats() { + const adapters = new Set(state.presets.map((p) => p.adapter)); + document.getElementById("stats").innerHTML = ` +
Presets
${state.presets.length}
+
Groups
${state.groups.length}
+
Adapters in use
${adapters.size}
+ `; +} + +function renderPresets() { + const host = document.getElementById("groups"); + if (!state.presets.length) { + host.innerHTML = `
No presets yet. Create one, or import a pack from a colleague.
`; + return; + } + + const byGroup = new Map(); + for (const p of state.presets) { + const g = p.group && p.group.trim() ? p.group : "Ungrouped"; + if (!byGroup.has(g)) byGroup.set(g, []); + byGroup.get(g).push(p); + } + + let html = ""; + for (const [group, presets] of byGroup) { + html += ` +
+

${escapeHTML(group)}

+ ${presets.length} +
+
+
+ + + + + `; + + for (const p of presets) { + const isDHCP = p.mode === "dhcp"; + const addr = isDHCP + ? `DHCP` + : escapeHTML(addrText(p.primary)) + + (p.secondary && p.secondary.length + ? ` +${p.secondary.length}` + : ""); + const dns = + p.dns.mode === "dhcp" + ? `from DHCP` + : escapeHTML((p.dns.servers || []).join(", ")); + + html += ` + + + + + + + `; + } + html += `
NameAdapterAddressGatewayDNS
${escapeHTML(p.name)}${p.notes ? `
${escapeHTML(p.notes)}
` : ""}
${escapeHTML(p.adapter)}${addr}${isDHCP ? "" : escapeHTML(p.primary.gateway || "—")}${dns} + + +
`; + } + host.innerHTML = html; + + host.querySelectorAll("[data-edit]").forEach((b) => + b.addEventListener("click", () => openEditor(b.dataset.edit)) + ); + host.querySelectorAll("[data-del]").forEach((b) => + b.addEventListener("click", () => deletePreset(b.dataset.del)) + ); +} + +function renderAdapters() { + const body = document.getElementById("adapters"); + if (!state.adapters.length) { + body.innerHTML = `No adapters found.`; + return; + } + body.innerHTML = state.adapters + .map( + (a) => ` + ${escapeHTML(a.name)} + ${escapeHTML(a.description || "")} + ${escapeHTML(a.current || "—")} + ${a.up ? 'up' : 'down'} + ` + ) + .join(""); +} + +// --- loading --- + +async function loadAll() { + const [presets, adapters, settings] = await Promise.all([ + api("/api/presets"), + api("/api/adapters"), + api("/api/settings"), + ]); + state.presets = presets.presets || []; + state.groups = presets.groups || []; + state.adapters = adapters.adapters || []; + state.settings = settings; + + renderStats(); + renderPresets(); + renderAdapters(); + + document.getElementById("group-list").innerHTML = state.groups + .map((g) => ``) + .join(""); +} + +// --- editor --- + +const editor = document.getElementById("editor"); + +function secondaryRow(addr = "", prefix = "") { + const div = document.createElement("div"); + div.className = "sec-row"; + div.innerHTML = ` + + + `; + div.querySelector(".sec-del").addEventListener("click", () => div.remove()); + return div; +} + +function openEditor(id) { + const p = id ? state.presets.find((x) => x.id === id) : null; + state.editing = p ? p.id : null; + + document.getElementById("editor-title").textContent = p ? "Edit preset" : "New preset"; + document.getElementById("editor-error").innerHTML = ""; + document.getElementById("f-name").value = p ? p.name : ""; + document.getElementById("f-group").value = p ? p.group || "" : ""; + document.getElementById("f-adapter").value = p ? p.adapter : (state.adapters[0] || {}).name || ""; + document.getElementById("f-mode").value = p ? p.mode : "static"; + document.getElementById("f-notes").value = p ? p.notes || "" : ""; + + const prim = (p && p.primary) || {}; + document.getElementById("f-address").value = prim.address || ""; + document.getElementById("f-prefix").value = prim.prefix !== undefined ? maskOf(prim.prefix) : ""; + document.getElementById("f-gateway").value = prim.gateway || ""; + document.getElementById("f-metric").value = prim.gateway_metric || 0; + + const secs = document.getElementById("secondaries"); + secs.innerHTML = ""; + for (const s of (p && p.secondary) || []) secs.appendChild(secondaryRow(s.address, maskOf(s.prefix))); + + document.getElementById("f-dns-mode").value = p ? p.dns.mode : "static"; + document.getElementById("f-dns").value = p ? (p.dns.servers || []).join(", ") : ""; + + syncModeVisibility(); + editor.showModal(); +} + +function syncModeVisibility() { + const isDHCP = document.getElementById("f-mode").value === "dhcp"; + document.getElementById("static-fields").classList.toggle("hidden", isDHCP); + const dnsStatic = document.getElementById("f-dns-mode").value === "static"; + document.getElementById("dns-servers-field").classList.toggle("hidden", !dnsStatic); +} + +document.getElementById("f-mode").addEventListener("change", syncModeVisibility); +document.getElementById("f-dns-mode").addEventListener("change", syncModeVisibility); +document.getElementById("btn-add-sec").addEventListener("click", () => + document.getElementById("secondaries").appendChild(secondaryRow()) +); +document.getElementById("btn-new").addEventListener("click", () => openEditor(null)); +document.getElementById("btn-cancel").addEventListener("click", () => editor.close()); + +// Masks are parsed here so the editor accepts both spellings, matching the Go +// side's ParsePrefix. Anything malformed is reported before the request goes +// out rather than coming back as a server-side validation error. +function parsePrefix(s) { + s = (s || "").trim().replace(/^\//, ""); + if (!s) throw new Error("mask is required"); + if (s.includes(".")) { + const parts = s.split(".").map(Number); + if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) { + throw new Error(`"${s}" is not a dotted-quad netmask`); + } + const m = ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0; + let ones = 0; + while (ones < 32 && m & (1 << (31 - ones))) ones++; + if (ones < 32 && (m << ones) >>> 0) throw new Error(`"${s}" is not a contiguous netmask`); + return ones; + } + const n = Number(s); + if (!Number.isInteger(n) || n < 0 || n > 32) throw new Error(`"${s}" is not a prefix length`); + return n; +} + +async function savePreset() { + const mode = document.getElementById("f-mode").value; + const dnsMode = document.getElementById("f-dns-mode").value; + + let body; + try { + body = { + id: state.editing || "", + name: document.getElementById("f-name").value.trim(), + group: document.getElementById("f-group").value.trim(), + adapter: document.getElementById("f-adapter").value, + mode, + notes: document.getElementById("f-notes").value.trim(), + dns: { + mode: dnsMode, + servers: + dnsMode === "static" + ? document.getElementById("f-dns").value.split(",").map((s) => s.trim()).filter(Boolean) + : undefined, + }, + }; + + if (mode === "static") { + body.primary = { + address: document.getElementById("f-address").value.trim(), + prefix: parsePrefix(document.getElementById("f-prefix").value), + gateway: document.getElementById("f-gateway").value.trim() || undefined, + gateway_metric: Number(document.getElementById("f-metric").value) || undefined, + }; + const secs = []; + for (const row of document.querySelectorAll("#secondaries .sec-row")) { + const a = row.querySelector(".sec-addr").value.trim(); + if (!a) continue; + secs.push({ address: a, prefix: parsePrefix(row.querySelector(".sec-prefix").value) }); + } + if (secs.length) body.secondary = secs; + } + } catch (e) { + alertBox("danger", e.message, "editor-error"); + return; + } + + try { + const path = state.editing ? `/api/presets/${encodeURIComponent(state.editing)}` : "/api/presets"; + await api(path, { method: state.editing ? "PUT" : "POST", body: JSON.stringify(body) }); + editor.close(); + await loadAll(); + alertBox("ok", "Preset saved."); + } catch (e) { + alertBox("danger", e.message, "editor-error"); + } +} + +document.getElementById("btn-save").addEventListener("click", savePreset); + +async function deletePreset(id) { + const p = state.presets.find((x) => x.id === id); + if (!confirm(`Delete preset "${p ? p.name : id}"?`)) return; + try { + await api(`/api/presets/${encodeURIComponent(id)}`, { method: "DELETE" }); + await loadAll(); + alertBox("ok", "Preset deleted."); + } catch (e) { + alertBox("danger", e.message); + } +} + +// --- settings --- + +const settingsDlg = document.getElementById("settings"); + +document.getElementById("btn-settings").addEventListener("click", () => { + const s = state.settings || {}; + document.getElementById("s-mask").value = s.mask_style || "prefix"; + document.getElementById("s-startup").checked = !!s.start_with_windows; + document.getElementById("s-updates").checked = !!s.check_updates; + document.getElementById("s-repo").value = s.update_repo || ""; + settingsDlg.showModal(); +}); + +document.getElementById("btn-settings-cancel").addEventListener("click", () => settingsDlg.close()); + +document.getElementById("btn-settings-save").addEventListener("click", async () => { + try { + await api("/api/settings", { + method: "PUT", + body: JSON.stringify({ + mask_style: document.getElementById("s-mask").value, + start_with_windows: document.getElementById("s-startup").checked, + check_updates: document.getElementById("s-updates").checked, + update_repo: document.getElementById("s-repo").value.trim(), + }), + }); + settingsDlg.close(); + await loadAll(); + alertBox("ok", "Settings saved."); + } catch (e) { + alertBox("danger", e.message); + } +}); + +// --- import / export --- + +document.getElementById("btn-export").addEventListener("click", () => { + // A plain navigation, so the browser's own download UI picks the location. + location.href = "/api/export"; +}); + +const importDlg = document.getElementById("import"); +document.getElementById("btn-import").addEventListener("click", () => { + document.getElementById("import-error").innerHTML = ""; + importDlg.showModal(); +}); +document.getElementById("btn-import-cancel").addEventListener("click", () => importDlg.close()); + +document.getElementById("btn-import-go").addEventListener("click", async () => { + const file = document.getElementById("i-file").files[0]; + if (!file) { + alertBox("danger", "Choose a file first.", "import-error"); + return; + } + const mode = document.getElementById("i-mode").value; + if (mode === "replace" && !confirm("Replace discards every preset you currently have. Continue?")) { + return; + } + + try { + const res = await api(`/api/import?mode=${mode}`, { method: "POST", body: await file.text() }); + importDlg.close(); + await loadAll(); + let msg = `Imported ${res.added} preset${res.added === 1 ? "" : "s"}.`; + if (res.renamed && res.renamed.length) { + msg += ` ${res.renamed.length} had an id collision and were kept alongside the originals.`; + } + alertBox("ok", msg); + } catch (e) { + alertBox("danger", e.message, "import-error"); + } +}); + +// --- go --- + +loadAll().catch((e) => alertBox("danger", `Could not load: ${e.message}`)); diff --git a/internal/server/web/index.html b/internal/server/web/index.html new file mode 100644 index 0000000..a0697ca --- /dev/null +++ b/internal/server/web/index.html @@ -0,0 +1,193 @@ + + + + + + ipswap — presets + + + +
+
+
+

ipswap

+
Preset editor · this page closes itself when you stop using it
+
+
+ + +
+
+ +
+ +
+ +
+
+

Presets

+
+ + + +
+
+
+ +
+ +
+

Adapters on this machine

+
+ + + + + +
NameDescriptionCurrent IPv4State
+
+
+
+ + + +
+

New preset

+
+ +
+
+ + +
+
+ + + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ +
+ +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + + +

Settings

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + +

Import presets

+
+ The file is validated in full before anything is written. If it is rejected, nothing changes. +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + + + diff --git a/internal/tray/helpers.go b/internal/tray/helpers.go new file mode 100644 index 0000000..61ea77f --- /dev/null +++ b/internal/tray/helpers.go @@ -0,0 +1,21 @@ +package tray + +import ( + "errors" + "os" + "path/filepath" + + "gitea.apointless.space/bsncubed/ipswap/internal/config" + "gitea.apointless.space/bsncubed/ipswap/internal/netcfg" +) + +// isElevationErr reports whether a failed apply was refused for lack of +// administrator rights, which is the one failure that has a useful next step. +func isElevationErr(err error) bool { + return errors.Is(err, netcfg.ErrElevationRequired) +} + +// tempDir is where update downloads land. +func tempDir() string { + return filepath.Join(os.TempDir(), config.AppName) +} diff --git a/internal/tray/icon.ico b/internal/tray/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..bceaeb821178574017dc6114d29f8308aa93bf96 GIT binary patch literal 2525 zcmcIleK?fq8o%Fn#+bomhBRo)n8-)T6x!MiV~xS_5w?7UOo;Lkl?oYNb{oV>wUkwv z64`BcLMbZSh&mOeRvBMu)b=4MN}49koOjeY*E!d9uKnj+=e@4yeV+Tif6sm0_x(J- z`vE|J2s9etGZX3nfQJtz^OLN=*YEJbW`B~S5P)%fs6RD6p9HXq0FdwJ>rT}&*22|P zPY-SYKI0yNM#k-6@o5Ol?_2i1N+TmS zz4Dodjm0oY*2S&Y9y7|*BenI!bxj>!!H}Wea&L=XNP#7^UcZD{d_%0=ps}s)JT~(b z%^**%)3y=n#QdDc)?2{CR-X{iZMNDjk3u*?U$?zD!)6JJF{)V6PUjk<0 zX6AIjThr4gaG~W>z%LH?XkF&$@VaEYQ^$k4cCGa}b=<2;M0;p|vuz6NIM?O-VXmie z-BL-t^Mi^h=gtA&3;D{c-l+xIWn;6nPY;Df{(^P!%`?c2+F^V8*B}L45AAfb%Q2h< zkygfxt72<*&>c*^gPtZ6gCc2I1Z@9|b6D@C5n zfMf9_mf88-+o8uY7&_VMNM=xbM+a|1nBi#|C$!kgC8TkJNpN~e7L0VWJAGZ+-OPP`jZQbS*Y34`O4DN zcDAdsm6I>8)Oz~iLukD7Zt19fNnbtHvgO+?EK>nyd_=4Fa<_?SZ;VbADY`VoRUy${ z{3ec?8ufaa!fPOiPY$*t{%V8<9Fb;~WO^?UnXYr%wZ^lSwBd$UOXB3ZmDsZ`I)mED)#~{z`U*BayPe2*C>bkCgj?hZyvK_t&knYkC&(e z>6SBU!daDevF{>~zQ!Bw<}ovd3AzhU3j+w&8?W9o=9FGre^ThyF#sgL&n52nEY+79@A5YM_1%*Sd%NxYm)(mXiHKbwb>J@NRciY{bz&l{*e0Ozv)D@IJ zb(?qnw^1d(W$b80v&oF59}75=;O!cclg<+jJH9I?@Tb^9$IyY9@F=$~;r^dp2s_iT zt2EKcK&^1R;D1R211;Xi<`}pse7os>BPxJP4fHFOg(rYu~LeU!oWvg_cZB z$$MQR-*f*IiMSY2RK5e@u#ss15ppO%+&MlQu08&=<&#Dq$n$~bJ?->Ap#Q5uRGy_& zM7(m(vw#$HR6v3I`5b3dLi^pFL+;ZDAq!2UK^XExcR08&UZjKY<5;zOfbA%iY)2(axU#fYJDoX+O26pkN|jReKsA~9qg5jW z?;CUGL2zj&>7_oDTA)%2(8CA^DB#bP*&xiKcv%V1yof|Bd02`8XUu^KV_pyOfAV}Q z6a~g{E(c^eKicY3;E<=0%5-Q%GFEeZ$fOCFDN~vZ7!JtI`V(aaD&)G0K+j0IfC2u6 zDjNpSbyQ0j&~B$334?|0W3RR%wPug9I$kp*9$7T8pe6cvY{Ji18JhxBp_>{qfLHwb zX8mBHDh>538<)n)`zaiA;RE6Lt9f8xm?aQK2w`TlJkc;~IqH|E20W%neANJ{DSSN* zH~!5_!({r-iMC_97P4233{yU&A;$>8oLe65^G$FFKT3oRb2n$g2zfhHX* zh(wd+28byZ%09|qNcI<&l#nGPr`dc1wDvnP`)j1}c2xF1DA#A&3#4IZ*SVl8#*sBNTGsD>}ks!km0)rf_0eoRv5s z{?E62np1;<_Y*@^?_6P2@ZfjtX@dQWH9dPk?gP=wHx1(C#e?}c13QNl52cJOnkL)} zAGk=x8V)B7d^Xb|iNN( z3M3B-*LBX?As}0#x21zrg$)oFOlm2|-$;|Hlp&JDD)FmG26m31Zhi&=Z6@j$H2i~s zN{2%f6&d&c^4e5H=BOK{pvLKo??>Y<9Sl@Ch~nYw#=67Rk5Z@-flY*cxzdd(g@Nw$ zdG|@f##m7E+Al6#Vre7S(<83jCnTE@&4wMPDoy7;JY-mV%%7JP*QoiG1*s#U?M{WU pV(3;rVrM9rI8mK;JM{bPxiQ8H2bG5UA&Mg2l$F?qo&5KQ=pXGr!f5~i literal 0 HcmV?d00001 diff --git a/internal/tray/tray.go b/internal/tray/tray.go new file mode 100644 index 0000000..3f13b51 --- /dev/null +++ b/internal/tray/tray.go @@ -0,0 +1,430 @@ +// Package tray is the fast path: the tray icon, its grouped preset menu, and +// the confirm-then-apply flow. +// +// Everything here is driven by systray's callbacks, which run on the UI +// goroutine. Nothing slow may happen inline — a netsh apply takes one to three +// seconds and would freeze the menu — so every click handler hands off to a +// goroutine immediately. +package tray + +import ( + "context" + _ "embed" + "fmt" + "log" + "sync" + "time" + + "fyne.io/systray" + + "gitea.apointless.space/bsncubed/ipswap/internal/config" + "gitea.apointless.space/bsncubed/ipswap/internal/desktop" + "gitea.apointless.space/bsncubed/ipswap/internal/dialog" + "gitea.apointless.space/bsncubed/ipswap/internal/elevate" + "gitea.apointless.space/bsncubed/ipswap/internal/netcfg" + "gitea.apointless.space/bsncubed/ipswap/internal/preset" + "gitea.apointless.space/bsncubed/ipswap/internal/server" + "gitea.apointless.space/bsncubed/ipswap/internal/updater" +) + +//go:embed icon.ico +var iconICO []byte + +// App owns the tray and everything it talks to. +type App struct { + Store *preset.Store + Manager netcfg.Manager + Server *server.Server + Paths config.Paths + Version string + + mu sync.Mutex + settings config.Settings + // activeID is the preset currently matching the live adapter state, empty + // when nothing matches. + activeID string + // pendingUpdate is set once the update check finds a newer release. + pendingUpdate *updater.Update + + // menuGen is closed and replaced on every menu rebuild, so the click + // listeners attached to the previous set of items exit instead of leaking + // a goroutine per rebuild. + menuGen chan struct{} + + titleItem *systray.MenuItem + updateItem *systray.MenuItem + // presetItems maps preset id to its menu entry, for the check marks. + presetItems map[string]*systray.MenuItem + + elevated bool +} + +// New builds the tray application. +func New(store *preset.Store, mgr netcfg.Manager, srv *server.Server, paths config.Paths, settings config.Settings, version string) *App { + return &App{ + Store: store, + Manager: mgr, + Server: srv, + Paths: paths, + Version: version, + settings: settings, + elevated: elevate.IsElevated(), + } +} + +// Run blocks until the user exits. It must be called from the main goroutine. +func (a *App) Run() { + a.Server.OnPresetsChange(func() { + // The editor changed something; rebuild on the UI goroutine. + go a.rebuild() + }) + a.Server.OnSettingsChange(a.applySettings) + + systray.Run(a.onReady, a.onExit) +} + +func (a *App) onReady() { + systray.SetIcon(iconICO) + systray.SetTitle(config.AppName) + systray.SetTooltip(config.AppName) + + a.build() + + go a.pollActive() + go a.checkUpdatesOnStartup() +} + +func (a *App) onExit() { + a.Server.Stop() + log.Printf("%s exiting", config.AppName) +} + +// --- menu construction --- + +func (a *App) rebuild() { + systray.ResetMenu() + a.build() + a.refreshActive() +} + +func (a *App) build() { + a.mu.Lock() + if a.menuGen != nil { + close(a.menuGen) // retire the previous generation's listeners + } + gen := make(chan struct{}) + a.menuGen = gen + pending := a.pendingUpdate + a.mu.Unlock() + + // Update offer sits at the very top when there is one. + updateItem := systray.AddMenuItem("", "A newer release is available") + updateItem.Hide() + if pending != nil { + updateItem.SetTitle(fmt.Sprintf("Update available — v%s", pending.Version)) + updateItem.Show() + } + a.onClick(gen, updateItem, a.doUpdate) + + titleItem := systray.AddMenuItem(config.AppName, "The preset currently applied") + titleItem.Disable() + systray.AddSeparator() + + // Groups become submenus. A flat list of fifty presets is unusable, so + // this is not optional even when there are only a handful. + presets := a.Store.All() + if len(presets) == 0 { + empty := systray.AddMenuItem("No presets yet — use Manage presets…", "") + empty.Disable() + } + + items := make(map[string]*systray.MenuItem, len(presets)) + var currentGroup string + var sub *systray.MenuItem + for _, p := range presets { + g := p.GroupOrUngrouped() + if g != currentGroup || sub == nil { + currentGroup = g + sub = systray.AddMenuItem(g, "") + } + item := sub.AddSubMenuItemCheckbox(p.Name, p.Notes, false) + items[p.ID] = item + + id := p.ID + a.onClick(gen, item, func() { a.applyPreset(id) }) + } + + // The menu is built on the UI goroutine but read by the active-preset + // poller, so every handle it touches is published under the lock. + a.mu.Lock() + a.presetItems = items + a.titleItem = titleItem + a.updateItem = updateItem + a.mu.Unlock() + + systray.AddSeparator() + + manage := systray.AddMenuItem("Manage presets…", "Open the preset editor in your browser") + a.onClick(gen, manage, a.openEditor) + + check := systray.AddMenuItem("Check for updates", "") + a.onClick(gen, check, a.checkUpdatesNow) + + if !a.elevated { + systray.AddSeparator() + relaunch := systray.AddMenuItem("Relaunch as administrator", "Applying a preset needs an elevated process") + a.onClick(gen, relaunch, a.relaunch) + } + + systray.AddSeparator() + quit := systray.AddMenuItem("Exit", "") + a.onClick(gen, quit, func() { systray.Quit() }) +} + +// onClick runs fn on its own goroutine for every click, until this menu +// generation is retired. +func (a *App) onClick(gen <-chan struct{}, item *systray.MenuItem, fn func()) { + go func() { + for { + select { + case <-gen: + return + case _, ok := <-item.ClickedCh: + if !ok { + return + } + go fn() + } + } + }() +} + +// --- actions --- + +// applyPreset is the whole fast path: read the live config, show the diff, +// and on a Yes run the netsh plan. +func (a *App) applyPreset(id string) { + p, ok := a.Store.Get(id) + if !ok { + dialog.Error(config.AppName, "That preset no longer exists.") + return + } + + // Read live rather than reusing the poll's cached view: this text is the + // only safety net before a destructive change and it must not be stale. + live, err := a.Manager.Current(p.Adapter) + if err != nil { + dialog.Error(config.AppName, fmt.Sprintf( + "Could not read the current configuration of adapter %q.\n\n%v", p.Adapter, err)) + return + } + + if !dialog.Confirm(config.AppName, netcfg.ConfirmText(p, live)) { + log.Printf("apply of %q cancelled at the prompt", p.Name) + return + } + + log.Printf("applying preset %q to %q", p.Name, p.Adapter) + plan := netcfg.Plan(p, live) + + if err := a.Manager.Apply(plan); err != nil { + log.Printf("apply of %q failed: %v", p.Name, err) + + if isElevationErr(err) && !a.elevated { + if dialog.ErrorWithRetryAsAdmin(config.AppName, + fmt.Sprintf("Applying %q needs administrator rights.", p.Name)) { + a.relaunch() + } + return + } + dialog.Error(config.AppName, fmt.Sprintf("Applying %q failed.\n\n%v", p.Name, err)) + return + } + + log.Printf("applied preset %q", p.Name) + a.refreshActive() +} + +func (a *App) openEditor() { + url, err := a.Server.Start() + if err != nil { + dialog.Error(config.AppName, fmt.Sprintf("Could not start the preset editor.\n\n%v", err)) + return + } + if err := desktop.OpenURL(url); err != nil { + // The server is up, so give the user the address rather than just failing. + dialog.Error(config.AppName, fmt.Sprintf( + "Could not open your browser. Paste this address into it:\n\n%s\n\n%v", url, err)) + } +} + +func (a *App) relaunch() { + if err := elevate.RelaunchAsAdmin(); err != nil { + log.Printf("relaunch as admin failed: %v", err) + return + } + systray.Quit() +} + +func (a *App) applySettings(c config.Settings) { + a.mu.Lock() + a.settings = c + a.mu.Unlock() + + if err := desktop.SetRunAtLogin(c.StartWithWindows); err != nil { + log.Printf("updating the start-with-Windows setting: %v", err) + } +} + +func (a *App) currentSettings() config.Settings { + a.mu.Lock() + defer a.mu.Unlock() + return a.settings +} + +// --- active preset detection --- + +// pollActive re-reads the live adapter state on a timer so the check mark and +// tooltip stay right when something outside ipswap changes the network. +func (a *App) pollActive() { + a.refreshActive() + + interval := time.Duration(a.currentSettings().ActivePollSeconds) * time.Second + t := time.NewTicker(interval) + defer t.Stop() + for range t.C { + a.refreshActive() + } +} + +func (a *App) refreshActive() { + presets := a.Store.All() + + // One read per distinct adapter rather than one per preset: fifty presets + // across three adapters is three reads, not fifty. + live := map[string]netcfg.LiveConfig{} + for _, p := range presets { + if _, done := live[p.Adapter]; done { + continue + } + cfg, err := a.Manager.Current(p.Adapter) + if err != nil { + continue + } + live[p.Adapter] = cfg + } + + activeID, activeName := "", "" + for _, p := range presets { + cfg, ok := live[p.Adapter] + if ok && netcfg.Matches(p, cfg) { + activeID, activeName = p.ID, p.Name + break + } + } + + a.mu.Lock() + a.activeID = activeID + items := a.presetItems + title := a.titleItem + a.mu.Unlock() + + for id, item := range items { + if id == activeID { + item.Check() + } else { + item.Uncheck() + } + } + + label := config.AppName + " — unmatched" + if activeName != "" { + label = config.AppName + " — " + activeName + } + systray.SetTooltip(label) + if title != nil { + title.SetTitle(label) + } +} + +// --- updates --- + +func (a *App) checkUpdatesOnStartup() { + if !a.currentSettings().CheckUpdates { + return + } + // Never block startup on the network, and never surface a failure: a + // laptop on a customer site usually cannot reach the Gitea host. + if _, err := a.checkUpdates(); err != nil { + log.Printf("startup update check failed (ignored): %v", err) + } +} + +// checkUpdatesNow is the menu-driven check, which does report its result. +func (a *App) checkUpdatesNow() { + up, err := a.checkUpdates() + switch { + case err != nil: + dialog.Error(config.AppName, fmt.Sprintf("Could not check for updates.\n\n%v", err)) + case up == nil: + dialog.Info(config.AppName, fmt.Sprintf("%s v%s is up to date.", config.AppName, a.Version)) + default: + dialog.Info(config.AppName, fmt.Sprintf( + "%s v%s is available.\n\nChoose \"Update available\" in the tray menu to install it.", config.AppName, up.Version)) + } +} + +func (a *App) checkUpdates() (*updater.Update, error) { + s := a.currentSettings() + if s.UpdateRepo == "" { + return nil, fmt.Errorf("no update repository is configured (set one in Settings)") + } + + c := &updater.Checker{Repo: s.UpdateRepo, Current: a.Version} + up, err := c.Check(context.Background()) + if err != nil { + return nil, err + } + + a.mu.Lock() + a.pendingUpdate = up + item := a.updateItem + a.mu.Unlock() + + if up != nil && item != nil { + item.SetTitle(fmt.Sprintf("Update available — v%s", up.Version)) + item.Show() + } + return up, nil +} + +// doUpdate downloads, verifies and installs. Nothing here happens without the +// user having clicked the update item first. +func (a *App) doUpdate() { + a.mu.Lock() + up := a.pendingUpdate + repo := a.settings.UpdateRepo + a.mu.Unlock() + if up == nil { + return + } + + if !dialog.Confirm(config.AppName, fmt.Sprintf( + "Download and install %s v%s?\n\n%s will close and reopen once the update is in place.", + config.AppName, up.Version, config.AppName)) { + return + } + + c := &updater.Checker{Repo: repo, Current: a.Version} + path, err := c.Download(context.Background(), up, tempDir()) + if err != nil { + dialog.Error(config.AppName, fmt.Sprintf("The update could not be downloaded.\n\n%v", err)) + return + } + + if err := updater.InstallAndRestart(path); err != nil { + dialog.Error(config.AppName, fmt.Sprintf("The update could not be installed.\n\n%v", err)) + return + } + systray.Quit() +} diff --git a/internal/updater/install_other.go b/internal/updater/install_other.go new file mode 100644 index 0000000..e51639c --- /dev/null +++ b/internal/updater/install_other.go @@ -0,0 +1,11 @@ +//go:build !windows + +package updater + +import "errors" + +// InstallAndRestart is Windows-only: the swap helper is a batch file and the +// thing being replaced is an .exe. +func InstallAndRestart(downloadedExe string) error { + return errors.New("in-place update is only supported on Windows") +} diff --git a/internal/updater/install_windows.go b/internal/updater/install_windows.go new file mode 100644 index 0000000..b23fbe7 --- /dev/null +++ b/internal/updater/install_windows.go @@ -0,0 +1,95 @@ +//go:build windows + +package updater + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "syscall" +) + +// swapScript waits for ipswap to exit, replaces the binary, restarts it and +// deletes itself. +// +// A running .exe on Windows is locked against overwriting, so the swap cannot +// be done by the process being replaced — it has to outlive it. A tiny batch +// file is the least machinery that does this without shipping a second binary. +// +// %1 is the PID to wait for, %2 the freshly downloaded exe, %3 the exe to +// replace. The tasklist/find pair is the standard "is this PID still alive" +// idiom; `timeout` rather than `ping -n` keeps it readable. +const swapScript = `@echo off +setlocal +set PID=%~1 +set NEWEXE=%~2 +set TARGET=%~3 + +rem Give up after ~30s rather than spinning forever if the old process hangs. +set /a TRIES=0 +:wait +tasklist /FI "PID eq %PID%" 2>nul | find "%PID%" >nul +if errorlevel 1 goto swap +set /a TRIES+=1 +if %TRIES% GEQ 30 goto giveup +timeout /t 1 /nobreak >nul +goto wait + +:swap +copy /y "%NEWEXE%" "%TARGET%" >nul +if errorlevel 1 goto giveup +del /q "%NEWEXE%" >nul 2>&1 +start "" "%TARGET%" +goto done + +:giveup +rem Leave the download in place so the user can swap it in by hand. +exit /b 1 + +:done +endlocal +rem Delete this script last; cmd tolerates a batch file removing itself. +del /q "%~f0" >nul 2>&1 +` + +// InstallAndRestart writes the swap helper, launches it detached, and returns. +// The caller must exit promptly afterwards — the helper is already waiting on +// this process to go away. +// +// This is only ever reached from an explicit click on "Update available": the +// updater never installs on its own. +func InstallAndRestart(downloadedExe string) error { + target, err := os.Executable() + if err != nil { + return fmt.Errorf("locating the running executable: %w", err) + } + target, err = filepath.Abs(target) + if err != nil { + return err + } + + script := filepath.Join(os.TempDir(), "ipswap-update.cmd") + if err := os.WriteFile(script, []byte(swapScript), 0o755); err != nil { + return fmt.Errorf("writing the update helper: %w", err) + } + + cmd := exec.Command("cmd.exe", "/c", script, + strconv.Itoa(os.Getpid()), downloadedExe, target) + // Detach: the helper has to survive this process exiting, and it must not + // flash a console window while it waits. + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: windowsCreateNoWindow | windowsDetachedProcess, + } + if err := cmd.Start(); err != nil { + return fmt.Errorf("starting the update helper: %w", err) + } + return cmd.Process.Release() +} + +const ( + windowsDetachedProcess = 0x00000008 + windowsCreateNoWindow = 0x08000000 +) diff --git a/internal/updater/semver.go b/internal/updater/semver.go new file mode 100644 index 0000000..217acab --- /dev/null +++ b/internal/updater/semver.go @@ -0,0 +1,102 @@ +package updater + +import ( + "fmt" + "strconv" + "strings" +) + +// Version is a parsed semantic version. Only what a release-tag comparison +// needs is modelled: major.minor.patch plus an optional pre-release, which +// sorts before the same version without one. +type Version struct { + Major, Minor, Patch int + Pre string +} + +// ParseVersion accepts "v1.2.0", "1.2.0", "1.2", "1" and "1.2.0-rc1". Build +// metadata after "+" is ignored, as semver says it must be for ordering. +func ParseVersion(s string) (Version, error) { + s = strings.TrimSpace(s) + s = strings.TrimPrefix(s, "v") + if s == "" { + return Version{}, fmt.Errorf("empty version") + } + if i := strings.IndexByte(s, '+'); i >= 0 { + s = s[:i] + } + + var v Version + if i := strings.IndexByte(s, '-'); i >= 0 { + v.Pre = s[i+1:] + s = s[:i] + } + + parts := strings.Split(s, ".") + if len(parts) > 3 { + return Version{}, fmt.Errorf("%q has too many components", s) + } + dst := []*int{&v.Major, &v.Minor, &v.Patch} + for i, p := range parts { + n, err := strconv.Atoi(p) + if err != nil || n < 0 { + return Version{}, fmt.Errorf("%q is not a version number", s) + } + *dst[i] = n + } + return v, nil +} + +// Compare returns -1, 0 or 1 as v sorts before, equal to, or after other. +func (v Version) Compare(other Version) int { + for _, pair := range [][2]int{ + {v.Major, other.Major}, + {v.Minor, other.Minor}, + {v.Patch, other.Patch}, + } { + if pair[0] != pair[1] { + if pair[0] < pair[1] { + return -1 + } + return 1 + } + } + + // A pre-release sorts before the release it leads up to. + switch { + case v.Pre == "" && other.Pre == "": + return 0 + case v.Pre == "": + return 1 + case other.Pre == "": + return -1 + case v.Pre < other.Pre: + return -1 + case v.Pre > other.Pre: + return 1 + } + return 0 +} + +func (v Version) String() string { + s := fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) + if v.Pre != "" { + s += "-" + v.Pre + } + return s +} + +// IsNewer reports whether candidate is a strictly later version than current. +// An unparseable version on either side means "no update", because offering a +// download on the strength of a tag we could not read is worse than silence. +func IsNewer(candidate, current string) bool { + c, err := ParseVersion(candidate) + if err != nil { + return false + } + cur, err := ParseVersion(current) + if err != nil { + return false + } + return c.Compare(cur) > 0 +} diff --git a/internal/updater/semver_test.go b/internal/updater/semver_test.go new file mode 100644 index 0000000..90436fc --- /dev/null +++ b/internal/updater/semver_test.go @@ -0,0 +1,63 @@ +package updater + +import "testing" + +func TestParseVersion(t *testing.T) { + cases := []struct { + in string + want Version + }{ + {"v1.2.0", Version{1, 2, 0, ""}}, + {"1.2.0", Version{1, 2, 0, ""}}, + {"1.2", Version{1, 2, 0, ""}}, + {"1", Version{1, 0, 0, ""}}, + {"v1.2.0-rc1", Version{1, 2, 0, "rc1"}}, + {"1.2.0+build7", Version{1, 2, 0, ""}}, + {" v0.1.0 ", Version{0, 1, 0, ""}}, + } + for _, tc := range cases { + got, err := ParseVersion(tc.in) + if err != nil { + t.Errorf("ParseVersion(%q) errored: %v", tc.in, err) + continue + } + if got != tc.want { + t.Errorf("ParseVersion(%q) = %+v, want %+v", tc.in, got, tc.want) + } + } + + for _, in := range []string{"", "v", "abc", "1.2.3.4", "1.x", "-1.0.0"} { + if got, err := ParseVersion(in); err == nil { + t.Errorf("ParseVersion(%q) = %+v, want error", in, got) + } + } +} + +func TestIsNewer(t *testing.T) { + yes := [][2]string{ + {"v1.2.1", "v1.2.0"}, + {"v1.3.0", "v1.2.9"}, + {"v2.0.0", "v1.99.99"}, + {"v1.2.0", "v1.2.0-rc1"}, + } + for _, tc := range yes { + if !IsNewer(tc[0], tc[1]) { + t.Errorf("IsNewer(%q, %q) = false, want true", tc[0], tc[1]) + } + } + + no := [][2]string{ + {"v1.2.0", "v1.2.0"}, + {"v1.2.0", "v1.2.1"}, + {"v1.2.0-rc1", "v1.2.0"}, + // An unreadable tag must never trigger an update offer. + {"garbage", "v1.2.0"}, + {"v1.2.0", "garbage"}, + {"", "v1.0.0"}, + } + for _, tc := range no { + if IsNewer(tc[0], tc[1]) { + t.Errorf("IsNewer(%q, %q) = true, want false", tc[0], tc[1]) + } + } +} diff --git a/internal/updater/updater.go b/internal/updater/updater.go new file mode 100644 index 0000000..2b21761 --- /dev/null +++ b/internal/updater/updater.go @@ -0,0 +1,266 @@ +// Package updater checks a Gitea repository for a newer release and, on +// request, downloads and verifies the new binary. +// +// Two rules shape the whole package: it never installs on its own, and it never +// blocks startup. The check runs in a goroutine behind a short timeout and +// fails silently into the log, because a laptop on a customer site frequently +// has no route to the Gitea host and that must not be a visible error. +package updater + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "regexp" + "strings" + "time" +) + +// checkTimeout bounds the startup release check. +const checkTimeout = 8 * time.Second + +// maxAssetBytes caps a download. The binary is a few MB; anything near this is +// a wrong URL or a captive portal serving a login page. +const maxAssetBytes = 128 << 20 + +// Release is the subset of Gitea's release JSON that matters here. +type Release struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + Body string `json:"body"` + Assets []Asset `json:"assets"` + HTMLURL string `json:"html_url"` +} + +// Asset is one file attached to a release. +type Asset struct { + Name string `json:"name"` + Size int64 `json:"size"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +// Checker polls one repository. +type Checker struct { + // Repo is the repository as "https://gitea.example.com/owner/repo". + Repo string + // Current is the compiled-in version, from -X main.version. + Current string + + HTTP *http.Client +} + +// Update describes an available newer release. +type Update struct { + Version string + Release Release + Asset Asset + // SHA256 is the expected checksum, empty if the release published none. + SHA256 string +} + +// apiURL turns a repo browse URL into its releases/latest API endpoint. +func apiURL(repo string) (string, error) { + repo = strings.TrimSuffix(strings.TrimSpace(repo), "/") + if repo == "" { + return "", fmt.Errorf("no update repository configured") + } + u, err := url.Parse(repo) + if err != nil { + return "", fmt.Errorf("update repo %q is not a URL: %w", repo, err) + } + if u.Scheme != "https" && u.Scheme != "http" { + return "", fmt.Errorf("update repo %q must be an http(s) URL", repo) + } + + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", fmt.Errorf("update repo %q should look like https://host/owner/repo", repo) + } + + u.Path = path.Join("/api/v1/repos", parts[0], parts[1], "releases/latest") + return u.String(), nil +} + +func (c *Checker) client() *http.Client { + if c.HTTP != nil { + return c.HTTP + } + return &http.Client{Timeout: checkTimeout} +} + +// Check asks for the latest release and returns an Update if it is newer than +// the running build. A nil Update with a nil error means "up to date". +func (c *Checker) Check(ctx context.Context) (*Update, error) { + endpoint, err := apiURL(c.Repo) + if err != nil { + return nil, err + } + + ctx, cancel := context.WithTimeout(ctx, checkTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + + resp, err := c.client().Do(req) + if err != nil { + return nil, fmt.Errorf("contacting %s: %w", endpoint, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s returned %s", endpoint, resp.Status) + } + + var rel Release + if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&rel); err != nil { + return nil, fmt.Errorf("parsing the release response: %w", err) + } + + if !IsNewer(rel.TagName, c.Current) { + return nil, nil + } + + asset, ok := pickExe(rel.Assets) + if !ok { + return nil, fmt.Errorf("release %s has no .exe asset", rel.TagName) + } + + return &Update{ + Version: strings.TrimPrefix(rel.TagName, "v"), + Release: rel, + Asset: asset, + SHA256: findChecksum(rel, asset.Name), + }, nil +} + +func pickExe(assets []Asset) (Asset, bool) { + for _, a := range assets { + if strings.HasSuffix(strings.ToLower(a.Name), ".exe") { + return a, true + } + } + return Asset{}, false +} + +var sha256Re = regexp.MustCompile(`\b([a-fA-F0-9]{64})\b`) + +// findChecksum digs the expected SHA256 out of the release body. The body is +// scanned for a line naming the asset; a bare 64-hex string anywhere is +// accepted as a fallback for a release that publishes only the one checksum. +// +// A SHA256SUMS sibling asset is handled by the caller, which can fetch it — it +// is not available from the release JSON alone. +func findChecksum(rel Release, assetName string) string { + for _, line := range strings.Split(rel.Body, "\n") { + if strings.Contains(line, assetName) { + if m := sha256Re.FindStringSubmatch(line); m != nil { + return strings.ToLower(m[1]) + } + } + } + if m := sha256Re.FindStringSubmatch(rel.Body); m != nil { + return strings.ToLower(m[1]) + } + return "" +} + +// SumsAsset finds a SHA256SUMS-style asset in the release, if there is one. +func (u *Update) SumsAsset() (Asset, bool) { + for _, a := range u.Release.Assets { + n := strings.ToUpper(a.Name) + if strings.Contains(n, "SHA256") { + return a, true + } + } + return Asset{}, false +} + +// Download fetches the update's .exe into dir and verifies its SHA256 before +// returning the path. A release with no published checksum is refused: an +// unverified binary that we are about to swap in and run is not worth the +// convenience. +func (c *Checker) Download(ctx context.Context, u *Update, dir string) (string, error) { + want := u.SHA256 + if want == "" { + // Try the sibling SHA256SUMS asset before giving up. + if sums, ok := u.SumsAsset(); ok { + body, err := c.fetch(ctx, sums.BrowserDownloadURL, 1<<20) + if err != nil { + return "", fmt.Errorf("fetching %s: %w", sums.Name, err) + } + want = checksumFor(string(body), u.Asset.Name) + } + } + if want == "" { + return "", fmt.Errorf("release %s publishes no SHA256 for %s; refusing to install an unverified binary", u.Release.TagName, u.Asset.Name) + } + + body, err := c.fetch(ctx, u.Asset.BrowserDownloadURL, maxAssetBytes) + if err != nil { + return "", fmt.Errorf("downloading %s: %w", u.Asset.Name, err) + } + + sum := sha256.Sum256(body) + got := hex.EncodeToString(sum[:]) + if !strings.EqualFold(got, want) { + return "", fmt.Errorf("checksum mismatch for %s:\n expected %s\n got %s", u.Asset.Name, want, got) + } + + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", err + } + dest := filepath.Join(dir, u.Asset.Name) + if err := os.WriteFile(dest, body, 0o755); err != nil { + return "", fmt.Errorf("writing %s: %w", dest, err) + } + return dest, nil +} + +func (c *Checker) fetch(ctx context.Context, rawURL string, limit int64) ([]byte, error) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Minute) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + resp, err := c.client().Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("server returned %s", resp.Status) + } + return io.ReadAll(io.LimitReader(resp.Body, limit)) +} + +// checksumFor pulls one file's hash out of a `sha256sum` style listing. +func checksumFor(sums, name string) string { + for _, line := range strings.Split(sums, "\n") { + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + // The name may carry sha256sum's binary-mode "*" prefix. + if strings.TrimPrefix(fields[len(fields)-1], "*") == name { + if sha256Re.MatchString(fields[0]) { + return strings.ToLower(fields[0]) + } + } + } + return "" +} diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go new file mode 100644 index 0000000..15084f4 --- /dev/null +++ b/internal/updater/updater_test.go @@ -0,0 +1,94 @@ +package updater + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestAPIURL(t *testing.T) { + got, err := apiURL("https://gitea.apointless.space/bsncubed/ipswap") + if err != nil { + t.Fatal(err) + } + want := "https://gitea.apointless.space/api/v1/repos/bsncubed/ipswap/releases/latest" + if got != want { + t.Errorf("apiURL = %q, want %q", got, want) + } + + if _, err := apiURL("https://gitea.apointless.space/bsncubed/ipswap/"); err != nil { + t.Errorf("a trailing slash should be tolerated: %v", err) + } + for _, bad := range []string{"", "not a url", "https://host", "https://host/a/b/c"} { + if _, err := apiURL(bad); err == nil { + t.Errorf("apiURL(%q) should have failed", bad) + } + } +} + +func TestCheckFindsNewerRelease(t *testing.T) { + rel := Release{ + TagName: "v1.2.0", + Body: "ipswap.exe e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n", + Assets: []Asset{ + {Name: "SHA256SUMS", BrowserDownloadURL: "http://x/sums"}, + {Name: "ipswap.exe", BrowserDownloadURL: "http://x/ipswap.exe"}, + }, + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(rel) + })) + defer srv.Close() + + c := &Checker{Repo: srv.URL + "/owner/repo", Current: "v1.1.0"} + up, err := c.Check(context.Background()) + if err != nil { + t.Fatal(err) + } + if up == nil { + t.Fatal("expected an update, got nil") + } + if up.Version != "1.2.0" { + t.Errorf("version = %q, want 1.2.0", up.Version) + } + if up.Asset.Name != "ipswap.exe" { + t.Errorf("picked asset %q, want ipswap.exe", up.Asset.Name) + } + if up.SHA256 != "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" { + t.Errorf("checksum = %q", up.SHA256) + } +} + +func TestCheckIgnoresOlderRelease(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(Release{TagName: "v1.0.0"}) + })) + defer srv.Close() + + c := &Checker{Repo: srv.URL + "/owner/repo", Current: "v1.2.0"} + up, err := c.Check(context.Background()) + if err != nil { + t.Fatal(err) + } + if up != nil { + t.Errorf("expected no update, got %+v", up) + } +} + +func TestChecksumFor(t *testing.T) { + sums := "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ipswap.exe\n" + + "aaaabbbbccccddddeeeeffff00001111222233334444555566667777888899990 other.zip\n" + got := checksumFor(sums, "ipswap.exe") + if got != "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" { + t.Errorf("checksumFor = %q", got) + } + if checksumFor(sums, "missing.exe") != "" { + t.Error("expected empty checksum for an absent file") + } + // sha256sum's binary-mode marker must not defeat the lookup. + if checksumFor("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 *ipswap.exe", "ipswap.exe") == "" { + t.Error("binary-mode '*' prefix should be tolerated") + } +}