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:
@@ -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.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
bin/
|
||||||
|
dist/
|
||||||
|
Output/
|
||||||
|
*.exe
|
||||||
|
*.syso
|
||||||
|
SHA256SUMS
|
||||||
@@ -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)
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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 != ""
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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{}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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[:]))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
@@ -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) =>
|
||||||
|
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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}`));
|
||||||
@@ -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>
|
||||||
@@ -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 |
@@ -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()
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ""
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user