Scaffold ipswap: tray-driven Windows IP preset switcher

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 13:29:35 +10:00
commit 23dcfb393f
46 changed files with 5782 additions and 0 deletions
+61
View File
@@ -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.
+6
View File
@@ -0,0 +1,6 @@
bin/
dist/
Output/
*.exe
*.syso
SHA256SUMS
+67
View File
@@ -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)
+125
View File
@@ -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.
+252
View File
@@ -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:<random port>`, 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:<port>/?t=<session-token>`. 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="<adapter>" addr=<each existing>` for every
existing static address.
3. Set the primary:
`netsh interface ipv4 set address name="<adapter>" static <addr> <mask> <gateway> <gwmetric>`
4. Add each secondary:
`netsh interface ipv4 add address name="<adapter>" <addr> <mask>`
5. DNS static:
`netsh interface ipv4 set dnsservers name="<adapter>" static <first> primary validate=no`
then for each subsequent, `netsh interface ipv4 add dnsservers name="<adapter>" <addr> index=<n>`
6. DNS DHCP: `netsh interface ipv4 set dnsservers name="<adapter>" source=dhcp`
For `mode: "dhcp"`:
`netsh interface ipv4 set address name="<adapter>" 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 — <preset name>` (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://<gitea-host>/api/v1/repos/<owner>/<repo>/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
+13
View File
@@ -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)
}
+11
View File
@@ -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)
}
+58
View File
@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<assemblyIdentity
type="win32"
name="space.apointless.ipswap"
version="1.0.0.0"
processorArchitecture="*"/>
<!--
asInvoker, NOT requireAdministrator.
requireAdministrator would put a UAC prompt in front of every launch,
including the ones that start with Windows. asInvoker means zero prompts on
a machine with UAC relaxed or an already-elevated parent, and on a
locked-down machine ipswap still starts — it just offers "Relaunch as
administrator" in the tray and when an apply is refused.
-->
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
</requestedPrivileges>
</security>
</trustInfo>
<!-- Per-monitor v2 so the confirmation MessageBox is not blurry on a scaled
laptop panel driving an unscaled external monitor. -->
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness>
<activeCodePage xmlns="http://schemas.microsoft.com/SMI/2019/WindowsSettings">UTF-8</activeCodePage>
</windowsSettings>
</application>
<!-- Common Controls v6, so the message boxes use the modern theme. -->
<dependency>
<dependentAssembly>
<assemblyIdentity
type="win32"
name="Microsoft.Windows.Common-Controls"
version="6.0.0.0"
processorArchitecture="*"
publicKeyToken="6595b64144ccf1df"
language="*"/>
</dependentAssembly>
</dependency>
<!-- Declare support through Windows 11 so version APIs do not lie to us. -->
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/> <!-- 10 / 11 -->
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/> <!-- 8.1 -->
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/> <!-- 8 -->
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/> <!-- 7 -->
</application>
</compatibility>
</assembly>
+96
View File
@@ -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)
}
+25
View File
@@ -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"
}
+72
View File
@@ -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"
}
]
}
+10
View File
@@ -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
+6
View File
@@ -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=
+131
View File
@@ -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
}
+130
View File
@@ -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
}
+23
View File
@@ -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 }
+100
View File
@@ -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 != ""
}
+22
View File
@@ -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
}
+80
View File
@@ -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
}
+14
View File
@@ -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")
}
+105
View File
@@ -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
}
+92
View File
@@ -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
}
+251
View File
@@ -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
}
+48
View File
@@ -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))
}
+234
View File
@@ -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{}
+188
View File
@@ -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)
}
}
}
+101
View File
@@ -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
}
+71
View File
@@ -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<<uint(31-ones)) != 0 {
ones++
}
// Everything below the leading run of ones must be zero.
if ones < 32 && mask<<uint(ones) != 0 {
return 0, fmt.Errorf("%q is not a contiguous netmask", s)
}
return ones, nil
}
n, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("%q is not a prefix length", s)
}
if n < 0 || n > 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)
}
+66
View File
@@ -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)
}
}
}
+162
View File
@@ -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
}
+238
View File
@@ -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[:]))
}
+228
View File
@@ -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)
}
}
+439
View File
@@ -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
}
+214
View File
@@ -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")
}
}
+333
View File
@@ -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; }
+435
View File
@@ -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 = `<div class="alert ${kind}">${escapeHTML(message)}</div>`;
if (kind === "ok") setTimeout(() => (host.innerHTML = ""), 4000);
}
function escapeHTML(s) {
return String(s).replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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 = `
<div class="stat-card"><div class="label">Presets</div><div class="value">${state.presets.length}</div></div>
<div class="stat-card"><div class="label">Groups</div><div class="value">${state.groups.length}</div></div>
<div class="stat-card"><div class="label">Adapters in use</div><div class="value">${adapters.size}</div></div>
`;
}
function renderPresets() {
const host = document.getElementById("groups");
if (!state.presets.length) {
host.innerHTML = `<div class="card"><div class="empty">No presets yet. Create one, or import a pack from a colleague.</div></div>`;
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 += `
<div class="group-head">
<h2>${escapeHTML(group)}</h2>
<span class="pill">${presets.length}</span>
</div>
<div class="card" style="padding:0">
<div class="table-scroll">
<table>
<thead><tr>
<th>Name</th><th>Adapter</th><th>Address</th><th>Gateway</th><th>DNS</th><th></th>
</tr></thead>
<tbody>`;
for (const p of presets) {
const isDHCP = p.mode === "dhcp";
const addr = isDHCP
? `<span class="badge accent">DHCP</span>`
: escapeHTML(addrText(p.primary)) +
(p.secondary && p.secondary.length
? ` <span class="pill">+${p.secondary.length}</span>`
: "");
const dns =
p.dns.mode === "dhcp"
? `<span class="badge">from DHCP</span>`
: escapeHTML((p.dns.servers || []).join(", "));
html += `<tr>
<td>${escapeHTML(p.name)}${p.notes ? `<div class="dim">${escapeHTML(p.notes)}</div>` : ""}</td>
<td class="mono">${escapeHTML(p.adapter)}</td>
<td class="addr">${addr}</td>
<td class="addr">${isDHCP ? "" : escapeHTML(p.primary.gateway || "—")}</td>
<td class="addr">${dns}</td>
<td style="text-align:right; white-space:nowrap">
<button class="small" data-edit="${escapeHTML(p.id)}">Edit</button>
<button class="small danger" data-del="${escapeHTML(p.id)}">Delete</button>
</td>
</tr>`;
}
html += `</tbody></table></div></div>`;
}
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 = `<tr><td colspan="4" class="dim">No adapters found.</td></tr>`;
return;
}
body.innerHTML = state.adapters
.map(
(a) => `<tr>
<td class="mono">${escapeHTML(a.name)}</td>
<td class="dim">${escapeHTML(a.description || "")}</td>
<td class="addr">${escapeHTML(a.current || "—")}</td>
<td>${a.up ? '<span class="badge ok">up</span>' : '<span class="badge">down</span>'}</td>
</tr>`
)
.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) => `<option value="${escapeHTML(g)}">`)
.join("");
document.getElementById("f-adapter").innerHTML = state.adapters
.map((a) => `<option value="${escapeHTML(a.name)}">${escapeHTML(a.name)}</option>`)
.join("");
}
// --- editor ---
const editor = document.getElementById("editor");
function secondaryRow(addr = "", prefix = "") {
const div = document.createElement("div");
div.className = "sec-row";
div.innerHTML = `
<input class="sec-addr" placeholder="10.0.10.50" value="${escapeHTML(addr)}">
<input class="sec-prefix" placeholder="/24" value="${escapeHTML(prefix)}" style="max-width:150px">
<button type="button" class="small ghost sec-del">✕</button>`;
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}`));
+193
View File
@@ -0,0 +1,193 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ipswap — presets</title>
<link rel="stylesheet" href="/app.css">
</head>
<body>
<div class="wrap">
<header class="top">
<div>
<h1>ipswap</h1>
<div class="dim">Preset editor · this page closes itself when you stop using it</div>
</div>
<div class="btn-row">
<button id="theme-toggle" class="ghost small">Light</button>
<button id="btn-settings" class="ghost small">Settings</button>
</div>
</header>
<div id="alerts"></div>
<div class="stat-row" id="stats"></div>
<div class="card">
<div class="btn-row" style="justify-content: space-between;">
<h2 style="margin:0">Presets</h2>
<div class="btn-row">
<button id="btn-import">Import…</button>
<button id="btn-export">Export</button>
<button id="btn-new" class="primary">New preset</button>
</div>
</div>
</div>
<div id="groups"></div>
<div class="card">
<h2>Adapters on this machine</h2>
<div class="table-scroll">
<table>
<thead>
<tr><th>Name</th><th>Description</th><th>Current IPv4</th><th>State</th></tr>
</thead>
<tbody id="adapters"><tr><td colspan="4"><span class="spinner"></span></td></tr></tbody>
</table>
</div>
</div>
</div>
<!-- preset editor -->
<dialog id="editor">
<form method="dialog" id="editor-form">
<h2 id="editor-title">New preset</h2>
<div id="editor-error"></div>
<div class="field-row">
<div class="field">
<label for="f-name">Name</label>
<input id="f-name" required placeholder="Artist frame — control">
</div>
<div class="field">
<label for="f-group">Group</label>
<input id="f-group" list="group-list" placeholder="Riedel">
<datalist id="group-list"></datalist>
</div>
</div>
<div class="field-row">
<div class="field">
<label for="f-adapter">Adapter</label>
<select id="f-adapter"></select>
</div>
<div class="field">
<label for="f-mode">Mode</label>
<select id="f-mode">
<option value="static">Static</option>
<option value="dhcp">DHCP</option>
</select>
</div>
</div>
<div id="static-fields">
<div class="field-row">
<div class="field">
<label for="f-address">Address</label>
<input id="f-address" placeholder="192.168.42.100">
</div>
<div class="field">
<label for="f-prefix">Mask <span class="dim">(/24 or 255.255.255.0)</span></label>
<input id="f-prefix" placeholder="/24">
</div>
</div>
<div class="field-row">
<div class="field">
<label for="f-gateway">Gateway <span class="dim">(optional)</span></label>
<input id="f-gateway" placeholder="192.168.42.1">
</div>
<div class="field">
<label for="f-metric">Gateway metric <span class="dim">(0 = automatic)</span></label>
<input id="f-metric" type="number" min="0" value="0">
</div>
</div>
<div class="field">
<label>Secondary addresses</label>
<div class="sec-list" id="secondaries"></div>
<button type="button" id="btn-add-sec" class="small" style="margin-top:8px">Add secondary</button>
</div>
</div>
<div class="field">
<label for="f-dns-mode">DNS</label>
<select id="f-dns-mode">
<option value="static">Static</option>
<option value="dhcp">From DHCP</option>
</select>
</div>
<div class="field" id="dns-servers-field">
<label for="f-dns">DNS servers <span class="dim">(comma separated, order matters)</span></label>
<input id="f-dns" placeholder="192.168.42.1, 1.1.1.1">
</div>
<div class="field">
<label for="f-notes">Notes</label>
<input id="f-notes" placeholder="Frame A, rack 3">
</div>
<div class="btn-row" style="justify-content: flex-end; margin-top: 16px;">
<button type="button" id="btn-cancel" class="ghost">Cancel</button>
<button type="button" id="btn-save" class="primary">Save</button>
</div>
</form>
</dialog>
<!-- settings -->
<dialog id="settings">
<h2>Settings</h2>
<div class="field">
<label for="s-mask">Mask display</label>
<select id="s-mask">
<option value="prefix">Prefix (/24)</option>
<option value="dotted">Dotted (255.255.255.0)</option>
</select>
</div>
<div class="field check">
<input type="checkbox" id="s-startup">
<label for="s-startup">Start ipswap when Windows starts</label>
</div>
<div class="field check">
<input type="checkbox" id="s-updates">
<label for="s-updates">Check for updates on startup</label>
</div>
<div class="field">
<label for="s-repo">Update repository <span class="dim">(https://host/owner/repo — blank disables)</span></label>
<input id="s-repo" placeholder="https://gitea.apointless.space/bsncubed/ipswap">
</div>
<div class="btn-row" style="justify-content: flex-end; margin-top: 16px;">
<button type="button" id="btn-settings-cancel" class="ghost">Cancel</button>
<button type="button" id="btn-settings-save" class="primary">Save</button>
</div>
</dialog>
<!-- import -->
<dialog id="import">
<h2>Import presets</h2>
<div class="alert info">
The file is validated in full before anything is written. If it is rejected, nothing changes.
</div>
<div class="field">
<label for="i-file">Preset pack (.json)</label>
<input type="file" id="i-file" accept="application/json,.json">
</div>
<div class="field">
<label for="i-mode">Mode</label>
<select id="i-mode">
<option value="merge">Merge — keep what I have, add these</option>
<option value="replace">Replace — discard my existing presets</option>
</select>
</div>
<div id="import-error"></div>
<div class="btn-row" style="justify-content: flex-end; margin-top: 16px;">
<button type="button" id="btn-import-cancel" class="ghost">Cancel</button>
<button type="button" id="btn-import-go" class="primary">Import</button>
</div>
</dialog>
<script src="/app.js"></script>
</body>
</html>
+21
View File
@@ -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)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

+430
View File
@@ -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()
}
+11
View File
@@ -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")
}
+95
View File
@@ -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
)
+102
View File
@@ -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
}
+63
View File
@@ -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])
}
}
}
+266
View File
@@ -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 ""
}
+94
View File
@@ -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")
}
}