d93ae63156
Settles the first open question in claude.md: the update endpoint is gitea.apointless.space/bsncubed/ipswap. It was left empty — which disables the checker — only because the host was unconfirmed. Load unmarshals over the defaults, so an absent update_repo takes the new default while an explicitly empty one stays empty. That distinction is the off switch, and forks depend on it, so it is now pinned by a test. Adds the config package's first tests while here: the defaulting rules above, the repair path for a nonsensical mask style or poll interval, and the guarantee that a corrupt config.json still yields usable defaults rather than stopping the app from starting. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
130 lines
5.6 KiB
Markdown
130 lines
5.6 KiB
Markdown
# 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
|
||
|
||
ipswap checks `https://gitea.apointless.space/bsncubed/ipswap` by default. The
|
||
check runs at startup in a goroutine, behind a short timeout, and fails
|
||
silently to the log — a laptop on a customer site usually cannot reach the
|
||
host, and that is not an error worth showing.
|
||
|
||
Point it elsewhere, or clear it to switch the check off entirely, in Settings
|
||
(`update_repo` in `config.json`). Forks should change it: otherwise they will
|
||
offer their users an upstream binary.
|
||
|
||
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.
|