Scaffold ipswap: tray-driven Windows IP preset switcher

Implements the design in claude.md as a building skeleton: pure Go, no cgo,
cross-compiles to a single Windows .exe from Linux.

Architecture follows the spec's deliberate split. The fast path is native —
tray icon, grouped submenus, a Win32 MessageBox showing a live before/after
diff, then netsh. The slow path is an embedded web editor served on a random
loopback port and opened in the default browser.

Two decisions worth recording:

Reads use GetAdaptersAddresses, writes use netsh. The spec left the
enumeration mechanism open; parsing `netsh show config` breaks on a
non-English Windows because the output is localised. DNS static-vs-DHCP
origin is not exposed by that API, so it comes from one registry read.

The netsh command plan is built in portable code. That puts the delete-every-
existing-address step — the one that stops secondary addresses leaking across
switches — under test without needing a Windows box.

The editor requires the session token in a header for mutations, not just the
cookie: any page in the browser can make it POST to 127.0.0.1 with the cookie
attached, but it cannot set a header. The updater refuses to install a release
that publishes no SHA256.

Not yet done: no group picker for export (the API supports it), no
single-instance guard, and internal/server/web/app.css is reconstructed from
the description in claude.md rather than the canonical apointless.css.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 13:29:35 +10:00
commit 23dcfb393f
46 changed files with 5782 additions and 0 deletions
+131
View File
@@ -0,0 +1,131 @@
// Package applog gives ipswap a rotating log file. Every netsh invocation and
// its raw output lands here, which is the only forensic trail available when a
// switch misbehaves on a customer site.
package applog
import (
"fmt"
"io"
"log"
"os"
"path/filepath"
"sync"
)
const (
maxBytes = 1 << 20 // 1 MB per file
maxFiles = 5 // ipswap.log plus ipswap.log.1 .. .4
)
// Writer is an io.Writer that rotates at maxBytes and keeps maxFiles files.
// It is small on purpose: a rotation dependency for one log file is not worth
// the supply chain.
type Writer struct {
path string
mu sync.Mutex
f *os.File
size int64
}
// New opens the log at path, creating the directory if needed.
func New(path string) (*Writer, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, fmt.Errorf("creating log directory: %w", err)
}
w := &Writer{path: path}
if err := w.open(); err != nil {
return nil, err
}
return w, nil
}
func (w *Writer) open() error {
f, err := os.OpenFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return fmt.Errorf("opening %s: %w", w.path, err)
}
info, err := f.Stat()
if err != nil {
f.Close()
return fmt.Errorf("stat %s: %w", w.path, err)
}
w.f, w.size = f, info.Size()
return nil
}
func (w *Writer) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
if w.f == nil {
return len(p), nil // closed; drop rather than error out a log call
}
if w.size+int64(len(p)) > maxBytes {
if err := w.rotate(); err != nil {
return 0, err
}
}
n, err := w.f.Write(p)
w.size += int64(n)
return n, err
}
// rotate shifts ipswap.log.N to N+1 and starts a fresh ipswap.log. The caller
// holds w.mu.
func (w *Writer) rotate() error {
if err := w.f.Close(); err != nil {
return err
}
w.f = nil
// Walk downwards so nothing is overwritten before it has been moved.
for i := maxFiles - 1; i >= 1; i-- {
older := fmt.Sprintf("%s.%d", w.path, i)
if i == maxFiles-1 {
os.Remove(older) // the oldest file falls off the end
}
var newer string
if i == 1 {
newer = w.path
} else {
newer = fmt.Sprintf("%s.%d", w.path, i-1)
}
if _, err := os.Stat(newer); err == nil {
os.Rename(newer, older)
}
}
return w.open()
}
// Close closes the underlying file.
func (w *Writer) Close() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.f == nil {
return nil
}
err := w.f.Close()
w.f = nil
return err
}
// Setup points the standard logger at the rotating file and returns it so the
// caller can close it on exit.
//
// With -H windowsgui there is no console, so stderr goes nowhere on Windows and
// the file is the only output that survives. During development on Linux the
// log is also echoed to stderr.
func Setup(path string, alsoStderr bool) (*Writer, error) {
w, err := New(path)
if err != nil {
return nil, err
}
var out io.Writer = w
if alsoStderr {
out = io.MultiWriter(w, os.Stderr)
}
log.SetOutput(out)
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
return w, nil
}
+130
View File
@@ -0,0 +1,130 @@
// Package config resolves where ipswap keeps its files and loads the settings
// that are not presets.
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
// AppName is the single place the product name appears. It decides the data
// directory, the log file name and the tray tooltip prefix, so renaming the app
// is a one-line change here.
const AppName = "ipswap"
// Paths are the absolute locations of everything ipswap reads or writes.
type Paths struct {
Dir string
Presets string
Config string
Log string
}
// ResolvePaths returns the data locations under %APPDATA%\ipswap on Windows.
//
// os.UserConfigDir maps to %APPDATA% on Windows and to ~/.config elsewhere,
// which keeps development on Linux from scribbling in odd places.
func ResolvePaths() (Paths, error) {
base, err := os.UserConfigDir()
if err != nil {
return Paths{}, fmt.Errorf("locating the application data directory: %w", err)
}
dir := filepath.Join(base, AppName)
return Paths{
Dir: dir,
Presets: filepath.Join(dir, "presets.json"),
Config: filepath.Join(dir, "config.json"),
Log: filepath.Join(dir, AppName+".log"),
}, nil
}
// MaskStyle is how subnet masks are displayed in the editor. Input always
// accepts both spellings; this only controls what is rendered back.
type MaskStyle string
const (
MaskPrefix MaskStyle = "prefix" // /24
MaskDotted MaskStyle = "dotted" // 255.255.255.0
)
// Settings is config.json.
type Settings struct {
// MaskStyle is global rather than per-preset: a per-preset display
// preference would be more state than the feature is worth.
MaskStyle MaskStyle `json:"mask_style"`
// StartWithWindows drives the HKCU Run key. Assumed default off; see the
// open questions at the bottom of claude.md.
StartWithWindows bool `json:"start_with_windows"`
// CheckUpdates gates the startup call to the Gitea releases API.
CheckUpdates bool `json:"check_updates"`
// UpdateRepo is the Gitea repo polled for releases, as
// "https://host/owner/repo". Empty disables the update checker entirely,
// which is the default until the host is confirmed.
UpdateRepo string `json:"update_repo"`
// ActivePollSeconds is how often the live adapter state is re-read to work
// out which preset is currently applied.
ActivePollSeconds int `json:"active_poll_seconds"`
}
// Default is the settings a fresh install starts with.
func Default() Settings {
return Settings{
MaskStyle: MaskPrefix,
StartWithWindows: false,
CheckUpdates: true,
UpdateRepo: "",
ActivePollSeconds: 30,
}
}
// DottedMasks reports whether masks should render as dotted quads.
func (s Settings) DottedMasks() bool { return s.MaskStyle == MaskDotted }
// Load reads config.json, falling back to defaults for a missing file or any
// field left unset. Settings are conveniences, so a corrupt config should not
// stop the app from starting — the caller logs the error and carries on.
func Load(path string) (Settings, error) {
s := Default()
b, err := os.ReadFile(path)
if os.IsNotExist(err) {
return s, nil
}
if err != nil {
return s, fmt.Errorf("reading %s: %w", path, err)
}
if err := json.Unmarshal(b, &s); err != nil {
return Default(), fmt.Errorf("parsing %s: %w", path, err)
}
// Repair anything nonsensical rather than refusing to run.
if s.MaskStyle != MaskPrefix && s.MaskStyle != MaskDotted {
s.MaskStyle = MaskPrefix
}
if s.ActivePollSeconds < 5 {
s.ActivePollSeconds = 30
}
return s, nil
}
// Save writes config.json.
func (s Settings) Save(path string) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("creating config directory: %w", err)
}
b, err := json.MarshalIndent(s, "", " ")
if err != nil {
return fmt.Errorf("encoding settings: %w", err)
}
if err := os.WriteFile(path, append(b, '\n'), 0o644); err != nil {
return fmt.Errorf("writing %s: %w", path, err)
}
return nil
}
+23
View File
@@ -0,0 +1,23 @@
//go:build !windows
package desktop
import (
"fmt"
"os/exec"
)
// OpenURL shells out to xdg-open so the editor can be exercised during
// development on Linux.
func OpenURL(url string) error {
if err := exec.Command("xdg-open", url).Start(); err != nil {
return fmt.Errorf("opening %s: %w", url, err)
}
return nil
}
// SetRunAtLogin is a no-op: the Run key is a Windows concept.
func SetRunAtLogin(enabled bool) error { return nil }
// RunAtLogin always reports false off Windows.
func RunAtLogin() bool { return false }
+100
View File
@@ -0,0 +1,100 @@
//go:build windows
// Package desktop holds the small Windows shell integrations that do not
// belong to any one feature: opening a URL in the default browser, and the
// HKCU Run key that starts ipswap with Windows.
package desktop
import (
"fmt"
"os"
"path/filepath"
"syscall"
"unsafe"
"golang.org/x/sys/windows/registry"
"gitea.apointless.space/bsncubed/ipswap/internal/config"
)
var (
shell32 = syscall.NewLazyDLL("shell32.dll")
shellExecuteW = shell32.NewProc("ShellExecuteW")
)
const runKeyPath = `Software\Microsoft\Windows\CurrentVersion\Run`
// OpenURL opens a URL in the default browser.
//
// ShellExecuteW rather than `cmd /c start`: no console window to suppress and
// no quoting rules to get wrong around a URL that already contains "?" and "&".
func OpenURL(url string) error {
verb, err := syscall.UTF16PtrFromString("open")
if err != nil {
return err
}
target, err := syscall.UTF16PtrFromString(url)
if err != nil {
return err
}
ret, _, callErr := shellExecuteW.Call(
0,
uintptr(unsafe.Pointer(verb)),
uintptr(unsafe.Pointer(target)),
0, 0, 1, // no args, no cwd, SW_SHOWNORMAL
)
if ret <= 32 {
return fmt.Errorf("could not open %s (ShellExecuteW returned %d: %v)", url, ret, callErr)
}
return nil
}
// SetRunAtLogin adds or removes ipswap from the current user's Run key.
//
// HKCU rather than HKLM deliberately: HKLM needs elevation, and ipswap is
// designed to run unelevated. It also keeps the setting per-user on a shared
// support laptop.
func SetRunAtLogin(enabled bool) error {
key, _, err := registry.CreateKey(registry.CURRENT_USER, runKeyPath, registry.SET_VALUE)
if err != nil {
return fmt.Errorf("opening the Run key: %w", err)
}
defer key.Close()
if !enabled {
err := key.DeleteValue(config.AppName)
if err != nil && err != registry.ErrNotExist {
return fmt.Errorf("removing the Run key value: %w", err)
}
return nil
}
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("locating the running executable: %w", err)
}
exe, err = filepath.Abs(exe)
if err != nil {
return err
}
// Quoted: the path routinely contains spaces (Program Files, or a user
// profile with a space in the name) and the Run key value is a command
// line, not a path.
if err := key.SetStringValue(config.AppName, `"`+exe+`"`); err != nil {
return fmt.Errorf("writing the Run key value: %w", err)
}
return nil
}
// RunAtLogin reports whether the Run key entry is present.
func RunAtLogin() bool {
key, err := registry.OpenKey(registry.CURRENT_USER, runKeyPath, registry.QUERY_VALUE)
if err != nil {
return false
}
defer key.Close()
v, _, err := key.GetStringValue(config.AppName)
return err == nil && v != ""
}
+22
View File
@@ -0,0 +1,22 @@
//go:build !windows
package dialog
import "log"
// The non-Windows build logs instead of showing a box, so the tray and apply
// paths can be exercised during development on Linux.
// Confirm auto-declines: nothing destructive should run unattended off Windows.
func Confirm(title, text string) bool {
log.Printf("[dialog] confirm %q:\n%s\n(auto-declined on non-Windows build)", title, text)
return false
}
func Info(title, text string) { log.Printf("[dialog] info %q: %s", title, text) }
func Error(title, text string) { log.Printf("[dialog] error %q: %s", title, text) }
func ErrorWithRetryAsAdmin(title, text string) bool {
log.Printf("[dialog] error %q: %s (no elevation off Windows)", title, text)
return false
}
+80
View File
@@ -0,0 +1,80 @@
//go:build windows
// Package dialog wraps the handful of Win32 message boxes ipswap needs.
//
// This is raw user32 rather than a GUI toolkit on purpose: the fast path must
// stay native and dependency-free, and a confirmation prompt is the only
// blocking UI the tray side has.
package dialog
import (
"syscall"
"unsafe"
)
var (
user32 = syscall.NewLazyDLL("user32.dll")
messageBoxW = user32.NewProc("MessageBoxW")
)
// MessageBoxW uType flags.
const (
mbOK = 0x00000000
mbOKCancel = 0x00000001
mbYesNo = 0x00000004
mbIconError = 0x00000010
mbIconQuestion = 0x00000020
mbIconWarning = 0x00000030
mbIconInformation = 0x00000040
// The tray has no window to own the dialog, so without TOPMOST the box can
// open behind whatever is focused and look like a hang.
mbSetForeground = 0x00010000
mbTopMost = 0x00040000
)
// MessageBoxW return values.
const (
idOK = 1
idYes = 6
)
func show(title, text string, flags uint32) int {
t, err := syscall.UTF16PtrFromString(text)
if err != nil {
return 0
}
c, err := syscall.UTF16PtrFromString(title)
if err != nil {
return 0
}
ret, _, _ := messageBoxW.Call(
0,
uintptr(unsafe.Pointer(t)),
uintptr(unsafe.Pointer(c)),
uintptr(flags|mbSetForeground|mbTopMost),
)
return int(ret)
}
// Confirm asks a yes/no question and reports whether the user said yes.
func Confirm(title, text string) bool {
return show(title, text, mbYesNo|mbIconQuestion) == idYes
}
// Info shows a plain acknowledgement box.
func Info(title, text string) {
show(title, text, mbOK|mbIconInformation)
}
// Error shows a failure. The caller is expected to include the raw netsh
// output — that text is the only diagnostic the user has on site.
func Error(title, text string) {
show(title, text, mbOK|mbIconError)
}
// ErrorWithRetryAsAdmin reports a failure and offers to relaunch elevated.
// It reports whether the user accepted.
func ErrorWithRetryAsAdmin(title, text string) bool {
return show(title, text+"\n\nRelaunch ipswap as administrator and try again?",
mbOKCancel|mbIconWarning) == idOK
}
+14
View File
@@ -0,0 +1,14 @@
//go:build !windows
package elevate
import "errors"
// IsElevated reports true off Windows so development builds do not show the
// "Relaunch as administrator" item that could not work anyway.
func IsElevated() bool { return true }
// RelaunchAsAdmin has no meaning outside Windows.
func RelaunchAsAdmin() error {
return errors.New("elevation is only supported on Windows")
}
+105
View File
@@ -0,0 +1,105 @@
//go:build windows
// Package elevate answers "is this process actually elevated?" and, if not,
// can re-exec it through the UAC prompt.
//
// The manifest asks for asInvoker rather than requireAdministrator: on a
// machine with UAC relaxed, or when the parent is already elevated, that means
// ipswap starts with zero prompts. The cost is that on a locked-down machine
// we start unprivileged and have to offer the relaunch, which is what this
// package is for.
package elevate
import (
"fmt"
"os"
"path/filepath"
"strings"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
var (
shell32 = syscall.NewLazyDLL("shell32.dll")
shellExecuteW = shell32.NewProc("ShellExecuteW")
)
const swShowNormal = 1
// IsElevated reports whether the current process token carries administrator
// rights right now.
//
// Membership of the Administrators group is not the same thing: with UAC on,
// a member's process gets a filtered token, and `netsh interface ipv4 set
// address` then fails with "The requested operation requires elevation."
// Checking the token is the only answer that predicts whether an apply works.
func IsElevated() bool {
return windows.GetCurrentProcessToken().IsElevated()
}
// RelaunchAsAdmin re-execs the current binary through the UAC prompt and
// returns once the new process has been started. The caller is expected to
// exit immediately afterwards so the two copies do not both own a tray icon.
//
// A user who clicks "No" on the UAC prompt produces ERROR_CANCELLED, which is
// reported as an error but is not a failure worth a message box.
func RelaunchAsAdmin() error {
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("locating the running executable: %w", err)
}
exe, err = filepath.Abs(exe)
if err != nil {
return fmt.Errorf("resolving %s: %w", exe, err)
}
verb, err := syscall.UTF16PtrFromString("runas")
if err != nil {
return err
}
file, err := syscall.UTF16PtrFromString(exe)
if err != nil {
return err
}
args, err := syscall.UTF16PtrFromString(strings.Join(quoteArgs(os.Args[1:]), " "))
if err != nil {
return err
}
cwd, err := syscall.UTF16PtrFromString(filepath.Dir(exe))
if err != nil {
return err
}
// ShellExecuteW signals failure with a return value of 32 or less.
ret, _, callErr := shellExecuteW.Call(
0,
uintptr(unsafe.Pointer(verb)),
uintptr(unsafe.Pointer(file)),
uintptr(unsafe.Pointer(args)),
uintptr(unsafe.Pointer(cwd)),
swShowNormal,
)
if ret <= 32 {
if ret == uintptr(windows.ERROR_CANCELLED) {
return fmt.Errorf("the elevation prompt was cancelled")
}
return fmt.Errorf("ShellExecuteW returned %d: %v", ret, callErr)
}
return nil
}
// quoteArgs re-quotes arguments for the single command-line string
// ShellExecuteW takes, since it does not accept an argv.
func quoteArgs(args []string) []string {
out := make([]string, 0, len(args))
for _, a := range args {
if strings.ContainsAny(a, ` "`) {
out = append(out, `"`+strings.ReplaceAll(a, `"`, `\"`)+`"`)
} else {
out = append(out, a)
}
}
return out
}
+92
View File
@@ -0,0 +1,92 @@
package netcfg
import (
"fmt"
"strings"
"gitea.apointless.space/bsncubed/ipswap/internal/preset"
)
// ConfirmText renders the before/after body of the confirmation MessageBox.
//
// The live side must be read at prompt time rather than taken from the cached
// active-preset poll: the prompt is the only safety net before a destructive
// change, and a 30-second-stale "CURRENT" block would undermine it.
func ConfirmText(p preset.Preset, live LiveConfig) string {
var b strings.Builder
fmt.Fprintf(&b, "Apply preset %q to adapter %q?\n\n", p.Name, p.Adapter)
b.WriteString("CURRENT\n")
writeLive(&b, live)
b.WriteString("\nNEW\n")
writePreset(&b, p)
return b.String()
}
func writeLive(b *strings.Builder, live LiveConfig) {
if len(live.Addresses) == 0 {
b.WriteString(" (no IPv4 address)\n")
}
for i, a := range live.Addresses {
suffix := ""
if live.DHCP {
suffix = " (DHCP)"
}
prefix := " "
if i > 0 {
prefix = " + "
}
fmt.Fprintf(b, "%s%s%s\n", prefix, a.String(), suffix)
}
if len(live.Gateways) > 0 {
fmt.Fprintf(b, " Gateway: %s\n", strings.Join(live.Gateways, ", "))
}
if len(live.DNS) > 0 {
suffix := ""
if live.DNSFromDHCP {
suffix = " (DHCP)"
}
fmt.Fprintf(b, " DNS: %s%s\n", strings.Join(live.DNS, ", "), suffix)
}
}
func writePreset(b *strings.Builder, p preset.Preset) {
if p.Mode == preset.ModeDHCP {
b.WriteString(" DHCP\n")
} else if p.Primary != nil {
fmt.Fprintf(b, " %s\n", p.Primary.String())
for _, s := range p.Secondary {
fmt.Fprintf(b, " + %s\n", s.String())
}
if p.Primary.Gateway != "" {
fmt.Fprintf(b, " Gateway: %s\n", p.Primary.Gateway)
}
}
switch p.DNS.Mode {
case preset.ModeDHCP:
b.WriteString(" DNS: from DHCP\n")
default:
fmt.Fprintf(b, " DNS: %s\n", strings.Join(p.DNS.Servers, ", "))
}
}
// Summary is the one-line description of a live config, used for the editor's
// adapter picker.
func (l LiveConfig) Summary() string {
if len(l.Addresses) == 0 {
return "no IPv4 address"
}
parts := make([]string, 0, len(l.Addresses))
for _, a := range l.Addresses {
parts = append(parts, a.String())
}
s := strings.Join(parts, ", ")
if l.DHCP {
s += " (DHCP)"
}
return s
}
+251
View File
@@ -0,0 +1,251 @@
// Package netcfg reads and writes IPv4 adapter configuration.
//
// Reading and writing deliberately use different mechanisms:
//
// - Reading goes through GetAdaptersAddresses (plus one registry read for
// DNS origin). netsh's "show config" output is localised, so parsing it
// breaks on a German or French Windows; the Win32 API does not.
// - Writing goes through netsh, because the equivalent APIs are a much larger
// surface for no benefit when the whole operation is six shell commands.
//
// The command plan is built in this file, in portable code, so the exact netsh
// sequence can be tested without a Windows box.
package netcfg
import (
"fmt"
"sort"
"strconv"
"strings"
"gitea.apointless.space/bsncubed/ipswap/internal/preset"
)
// Adapter is a network interface as Windows names it.
type Adapter struct {
// Name is the friendly name ("Ethernet", "Wi-Fi 2"). This is what presets
// bind to and what netsh expects.
Name string `json:"name"`
// Description is the hardware description, shown in the adapter picker to
// tell three identically-named USB NICs apart.
Description string `json:"description"`
// GUID is the adapter instance id, used for the registry DNS lookup.
GUID string `json:"-"`
Up bool `json:"up"`
}
// LiveConfig is an adapter's current IPv4 state.
type LiveConfig struct {
Adapter string `json:"adapter"`
DHCP bool `json:"dhcp"`
Addresses []preset.Address `json:"addresses"`
Gateways []string `json:"gateways"`
DNS []string `json:"dns"`
DNSFromDHCP bool `json:"dns_from_dhcp"`
}
// Manager reads and writes adapter configuration.
type Manager interface {
// Adapters lists the IPv4-capable adapters on this machine.
Adapters() ([]Adapter, error)
// Current reads one adapter's live configuration.
Current(adapter string) (LiveConfig, error)
// Apply runs a plan, returning the first command that failed along with
// its raw output.
Apply(plan []Command) error
}
// Command is one netsh invocation. Args excludes the "netsh" itself.
type Command struct {
Args []string
// Desc is what gets written to the log before the command runs.
Desc string
}
func (c Command) String() string { return "netsh " + strings.Join(c.Args, " ") }
// Plan builds the exact netsh sequence that takes an adapter from current to
// the preset's configuration.
//
// The delete step in the middle is the whole reason this is not a one-liner:
// `netsh interface ipv4 set address ... static` replaces the primary address
// but leaves any previously-added secondary addresses attached, so switching
// between presets without deleting first leaks addresses from every preset
// visited so far. Applies are destructive by design — after this runs the
// adapter has exactly what the preset says and nothing else.
func Plan(p preset.Preset, current LiveConfig) []Command {
name := p.Adapter
var cmds []Command
if p.Mode == preset.ModeDHCP {
// Switching the adapter to DHCP clears the static addresses on its
// own, so the explicit deletes are unnecessary here.
cmds = append(cmds, Command{
Args: []string{"interface", "ipv4", "set", "address", nameArg(name), "source=dhcp"},
Desc: "set " + name + " to DHCP",
})
cmds = append(cmds, dnsCommands(name, p.DNS)...)
return cmds
}
// 1. Drop every address currently on the adapter. Addresses handed out by
// DHCP are not deletable this way and do not need to be: the `set
// address ... static` below replaces the lease outright.
if !current.DHCP {
for _, a := range current.Addresses {
cmds = append(cmds, Command{
Args: []string{"interface", "ipv4", "delete", "address", nameArg(name), "addr=" + a.Address},
Desc: "remove existing address " + a.String(),
})
}
}
// 2. Set the primary, with its gateway if the preset carries one.
args := []string{
"interface", "ipv4", "set", "address", nameArg(name), "static",
p.Primary.Address, preset.MaskString(p.Primary.Prefix),
}
if p.Primary.Gateway != "" {
args = append(args, p.Primary.Gateway)
// netsh only accepts a metric once a gateway is present, and 0 means
// "automatic", which is what an unset metric should mean.
if p.Primary.GatewayMetric > 0 {
args = append(args, strconv.Itoa(p.Primary.GatewayMetric))
} else {
args = append(args, "1")
}
}
cmds = append(cmds, Command{Args: args, Desc: "set primary address " + p.Primary.String()})
// 3. Add the secondaries.
for _, s := range p.Secondary {
cmds = append(cmds, Command{
Args: []string{"interface", "ipv4", "add", "address", nameArg(name), s.Address, preset.MaskString(s.Prefix)},
Desc: "add secondary address " + s.String(),
})
}
// 4. DNS.
cmds = append(cmds, dnsCommands(name, p.DNS)...)
return cmds
}
func dnsCommands(adapter string, d preset.DNS) []Command {
if d.Mode == preset.ModeDHCP {
return []Command{{
Args: []string{"interface", "ipv4", "set", "dnsservers", nameArg(adapter), "source=dhcp"},
Desc: "set DNS to DHCP",
}}
}
var cmds []Command
for i, s := range d.Servers {
if i == 0 {
// validate=no skips the several-second reachability probe netsh
// otherwise runs against a server that is often not up yet.
cmds = append(cmds, Command{
Args: []string{"interface", "ipv4", "set", "dnsservers", nameArg(adapter), "static", s, "primary", "validate=no"},
Desc: "set primary DNS " + s,
})
continue
}
cmds = append(cmds, Command{
Args: []string{"interface", "ipv4", "add", "dnsservers", nameArg(adapter), s, "index=" + strconv.Itoa(i+1), "validate=no"},
Desc: "add DNS " + s,
})
}
return cmds
}
// nameArg builds netsh's name= argument. The value is passed as a single
// argv element rather than a quoted shell string: os/exec does not go through
// a shell, so adapter names containing spaces need no quoting here, and adding
// quotes would make them part of the name.
func nameArg(adapter string) string { return "name=" + adapter }
// Matches reports whether a preset is exactly what the adapter currently has.
// Used to put the check mark next to the active preset in the tray menu, so it
// has to be an exact match in both directions — a preset that is a subset of
// the live config is not the active preset.
func Matches(p preset.Preset, live LiveConfig) bool {
if !strings.EqualFold(p.Adapter, live.Adapter) {
return false
}
if p.Mode == preset.ModeDHCP {
if !live.DHCP {
return false
}
} else {
if live.DHCP || p.Primary == nil {
return false
}
want := append([]preset.Address{*p.Primary}, p.Secondary...)
if !sameAddresses(want, live.Addresses) {
return false
}
// A preset with no gateway means "no gateway", so a live default
// route disqualifies it.
if p.Primary.Gateway == "" {
if len(live.Gateways) > 0 {
return false
}
} else if !containsFold(live.Gateways, p.Primary.Gateway) {
return false
}
}
if p.DNS.Mode == preset.ModeDHCP {
return live.DNSFromDHCP
}
if live.DNSFromDHCP {
return false
}
return sameStrings(p.DNS.Servers, live.DNS)
}
func sameAddresses(a, b []preset.Address) bool {
if len(a) != len(b) {
return false
}
key := func(x preset.Address) string { return fmt.Sprintf("%s/%d", x.Address, x.Prefix) }
as := make([]string, len(a))
bs := make([]string, len(b))
for i := range a {
as[i] = key(a[i])
}
for i := range b {
bs[i] = key(b[i])
}
sort.Strings(as)
sort.Strings(bs)
for i := range as {
if as[i] != bs[i] {
return false
}
}
return true
}
// sameStrings compares DNS server lists. Order matters: 1.1.1.1 as the primary
// resolver is a different configuration from 1.1.1.1 as the fallback.
func sameStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if !strings.EqualFold(strings.TrimSpace(a[i]), strings.TrimSpace(b[i])) {
return false
}
}
return true
}
func containsFold(hay []string, needle string) bool {
for _, h := range hay {
if strings.EqualFold(strings.TrimSpace(h), strings.TrimSpace(needle)) {
return true
}
}
return false
}
+48
View File
@@ -0,0 +1,48 @@
//go:build !windows
package netcfg
import (
"errors"
"fmt"
"gitea.apointless.space/bsncubed/ipswap/internal/preset"
)
// ErrElevationRequired keeps the non-Windows build type-compatible so the rest
// of the tree compiles and `go vet ./...` is useful during development on
// Linux. Nothing here touches a real adapter.
var ErrElevationRequired = errors.New("this operation requires an elevated process")
// ErrUnsupported is returned by every write path off Windows.
var ErrUnsupported = errors.New("adapter configuration is only supported on Windows")
// Stub is a Manager that reports a plausible fixture instead of touching the
// host. It exists so the tray, the editor server and the confirmation text can
// be exercised on the development machine.
type Stub struct{}
// New returns the platform Manager.
func New() Manager { return Stub{} }
func (Stub) Adapters() ([]Adapter, error) {
return []Adapter{
{Name: "Ethernet", Description: "Stub adapter (non-Windows build)", Up: true},
{Name: "Wi-Fi", Description: "Stub adapter (non-Windows build)", Up: false},
}, nil
}
func (Stub) Current(adapter string) (LiveConfig, error) {
return LiveConfig{
Adapter: adapter,
DHCP: true,
Addresses: []preset.Address{{Address: "192.168.1.87", Prefix: 24}},
Gateways: []string{"192.168.1.1"},
DNS: []string{"192.168.1.1"},
DNSFromDHCP: true,
}, nil
}
func (Stub) Apply(plan []Command) error {
return fmt.Errorf("%w (plan had %d commands)", ErrUnsupported, len(plan))
}
+234
View File
@@ -0,0 +1,234 @@
//go:build windows
package netcfg
import (
"errors"
"fmt"
"log"
"net"
"os/exec"
"strings"
"syscall"
"time"
"unsafe"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
"gitea.apointless.space/bsncubed/ipswap/internal/preset"
)
// ErrElevationRequired is returned when netsh refuses because the process token
// is not elevated. The caller turns this into the "Relaunch as administrator"
// offer rather than a bare failure message.
var ErrElevationRequired = errors.New("this operation requires an elevated process")
// ipAdapterDHCPEnabled is IP_ADAPTER_DHCP_ENABLED from iptypes.h.
const ipAdapterDHCPEnabled = 0x00000004
// Interface types we never want in the adapter picker.
const (
ifTypeSoftwareLoopback = 24
ifTypeTunnel = 131
)
// applyTimeout bounds a whole plan. A single netsh call is normally well under
// a second; anything past this is a hung call, not a slow one.
const applyTimeout = 30 * time.Second
// Windows is the real Manager.
type Windows struct{}
// New returns the platform Manager.
func New() Manager { return Windows{} }
// Adapters lists IPv4-capable, non-loopback, non-tunnel adapters.
func (Windows) Adapters() ([]Adapter, error) {
rows, err := adapterRows()
if err != nil {
return nil, err
}
var out []Adapter
for _, a := range rows {
if a.IfType == ifTypeSoftwareLoopback || a.IfType == ifTypeTunnel {
continue
}
out = append(out, Adapter{
Name: windows.UTF16PtrToString(a.FriendlyName),
Description: windows.UTF16PtrToString(a.Description),
GUID: windows.BytePtrToString(a.AdapterName),
Up: a.OperStatus == windows.IfOperStatusUp,
})
}
return out, nil
}
// Current reads one adapter's live IPv4 configuration.
func (Windows) Current(adapter string) (LiveConfig, error) {
rows, err := adapterRows()
if err != nil {
return LiveConfig{}, err
}
for _, a := range rows {
if !strings.EqualFold(windows.UTF16PtrToString(a.FriendlyName), adapter) {
continue
}
cfg := LiveConfig{
Adapter: adapter,
DHCP: a.Flags&ipAdapterDHCPEnabled != 0,
}
for ua := a.FirstUnicastAddress; ua != nil; ua = ua.Next {
ip := ua.Address.IP()
if ip == nil || ip.To4() == nil {
continue // v1 is IPv4 only
}
cfg.Addresses = append(cfg.Addresses, preset.Address{
Address: ip.String(),
Prefix: int(ua.OnLinkPrefixLength),
})
}
for ga := a.FirstGatewayAddress; ga != nil; ga = ga.Next {
if ip := ga.Address.IP(); ip != nil && ip.To4() != nil {
cfg.Gateways = append(cfg.Gateways, ip.String())
}
}
for da := a.FirstDnsServerAddress; da != nil; da = da.Next {
if ip := da.Address.IP(); ip != nil && ip.To4() != nil {
cfg.DNS = append(cfg.DNS, ip.String())
}
}
// GetAdaptersAddresses reports the resolvers in use but not whether
// they were configured or leased, and that distinction decides whether
// a DNS-from-DHCP preset matches. The registry holds it: a non-empty
// NameServer value means statically configured.
cfg.DNSFromDHCP = dnsIsFromDHCP(windows.BytePtrToString(a.AdapterName))
return cfg, nil
}
return LiveConfig{}, fmt.Errorf("adapter %q not found", adapter)
}
// Apply runs each command in order, stopping at the first failure.
func (Windows) Apply(plan []Command) error {
for _, c := range plan {
log.Printf("netsh: %s (%s)", c, c.Desc)
out, err := runNetsh(c)
if out != "" {
log.Printf("netsh output: %s", out)
}
if err != nil {
if isElevationError(out) {
return fmt.Errorf("%w\n\ncommand: %s\n%s", ErrElevationRequired, c, out)
}
return fmt.Errorf("%s failed: %w\n\n%s", c.Desc, err, out)
}
}
return nil
}
func runNetsh(c Command) (string, error) {
cmd := exec.Command("netsh", c.Args...)
// The binary is linked with -H windowsgui and has no console of its own, so
// without this every netsh call flashes a console window on screen.
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
done := make(chan struct{})
var out []byte
var err error
go func() {
out, err = cmd.CombinedOutput()
close(done)
}()
select {
case <-done:
case <-time.After(applyTimeout):
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
<-done
return string(out), fmt.Errorf("timed out after %s", applyTimeout)
}
return strings.TrimSpace(string(out)), err
}
// isElevationError sniffs netsh's refusal. netsh exits non-zero with a message
// rather than a distinguishable code, and the message is localised, so this
// also accepts the English text as the common case and falls back to the
// Win32 error number that appears in several translations.
func isElevationError(out string) bool {
l := strings.ToLower(out)
return strings.Contains(l, "requires elevation") ||
strings.Contains(l, "requested operation requires") ||
strings.Contains(l, "access is denied") ||
strings.Contains(l, "740")
}
func dnsIsFromDHCP(guid string) bool {
if guid == "" {
return true
}
key, err := registry.OpenKey(
registry.LOCAL_MACHINE,
`SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces\`+guid,
registry.QUERY_VALUE,
)
if err != nil {
// Not readable without admin on some machines; assume DHCP rather
// than falsely reporting a static configuration.
return true
}
defer key.Close()
ns, _, err := key.GetStringValue("NameServer")
if err != nil {
return true
}
return strings.TrimSpace(ns) == ""
}
// adapterRows walks the GetAdaptersAddresses linked list into a slice.
func adapterRows() ([]*windows.IpAdapterAddresses, error) {
flags := uint32(windows.GAA_FLAG_INCLUDE_GATEWAYS |
windows.GAA_FLAG_SKIP_ANYCAST |
windows.GAA_FLAG_SKIP_MULTICAST)
size := uint32(15 * 1024)
var buf []byte
for attempt := 0; attempt < 4; attempt++ {
buf = make([]byte, size)
err := windows.GetAdaptersAddresses(
windows.AF_INET, flags, 0,
(*windows.IpAdapterAddresses)(unsafe.Pointer(&buf[0])),
&size,
)
if err == nil {
break
}
// The call reports the required size in `size`; retry with it.
if err == windows.ERROR_BUFFER_OVERFLOW {
continue
}
return nil, fmt.Errorf("GetAdaptersAddresses: %w", err)
}
var out []*windows.IpAdapterAddresses
for a := (*windows.IpAdapterAddresses)(unsafe.Pointer(&buf[0])); a != nil; a = a.Next {
out = append(out, a)
}
return out, nil
}
// compile-time assertion that net is used even if the address helpers change.
var _ = net.IP{}
+188
View File
@@ -0,0 +1,188 @@
package netcfg
import (
"strings"
"testing"
"gitea.apointless.space/bsncubed/ipswap/internal/preset"
)
func staticPreset() preset.Preset {
return preset.Preset{
ID: "test",
Name: "Artist frame — control",
Adapter: "Ethernet",
Mode: preset.ModeStatic,
Primary: &preset.Address{
Address: "192.168.42.100",
Prefix: 24,
Gateway: "192.168.42.1",
},
Secondary: []preset.Address{{Address: "10.0.10.50", Prefix: 24}},
DNS: preset.DNS{Mode: preset.ModeStatic, Servers: []string{"192.168.42.1", "1.1.1.1"}},
}
}
func staticLive() LiveConfig {
return LiveConfig{
Adapter: "Ethernet",
Addresses: []preset.Address{{Address: "192.168.1.87", Prefix: 24}, {Address: "172.16.0.5", Prefix: 16}},
Gateways: []string{"192.168.1.1"},
DNS: []string{"192.168.1.1"},
}
}
// The leak this guards against is the reason Plan exists: without a delete for
// every live address, secondaries from the previous preset stay attached.
func TestPlanDeletesEveryExistingAddress(t *testing.T) {
cmds := Plan(staticPreset(), staticLive())
var deletes []string
for _, c := range cmds {
if len(c.Args) > 3 && c.Args[2] == "delete" && c.Args[3] == "address" {
deletes = append(deletes, strings.Join(c.Args, " "))
}
}
if len(deletes) != 2 {
t.Fatalf("expected 2 delete commands, got %d: %v", len(deletes), deletes)
}
for _, want := range []string{"addr=192.168.1.87", "addr=172.16.0.5"} {
found := false
for _, d := range deletes {
if strings.Contains(d, want) {
found = true
}
}
if !found {
t.Errorf("no delete command for %s: %v", want, deletes)
}
}
}
func TestPlanOrdering(t *testing.T) {
cmds := Plan(staticPreset(), staticLive())
// deletes, then set primary, then add secondary, then DNS.
var kinds []string
for _, c := range cmds {
switch {
case c.Args[2] == "delete":
kinds = append(kinds, "delete")
case c.Args[2] == "set" && c.Args[3] == "address":
kinds = append(kinds, "set-addr")
case c.Args[2] == "add" && c.Args[3] == "address":
kinds = append(kinds, "add-addr")
case c.Args[3] == "dnsservers":
kinds = append(kinds, "dns")
}
}
want := []string{"delete", "delete", "set-addr", "add-addr", "dns", "dns"}
if strings.Join(kinds, ",") != strings.Join(want, ",") {
t.Errorf("plan order = %v, want %v", kinds, want)
}
}
func TestPlanUsesDottedMaskNotPrefix(t *testing.T) {
// netsh rejects "/24" outright.
cmds := Plan(staticPreset(), staticLive())
for _, c := range cmds {
for _, a := range c.Args {
if strings.HasPrefix(a, "/") {
t.Errorf("command passes a prefix-notation mask: %v", c.Args)
}
}
}
joined := strings.Join(cmds[2].Args, " ")
if !strings.Contains(joined, "255.255.255.0") {
t.Errorf("set address command lacks a dotted mask: %s", joined)
}
}
func TestPlanDHCPSkipsDeletes(t *testing.T) {
p := preset.Preset{
ID: "d", Name: "DHCP", Adapter: "Ethernet",
Mode: preset.ModeDHCP,
DNS: preset.DNS{Mode: preset.ModeDHCP},
}
cmds := Plan(p, staticLive())
if len(cmds) != 2 {
t.Fatalf("expected 2 commands for a DHCP preset, got %d: %v", len(cmds), cmds)
}
for _, c := range cmds {
if c.Args[2] == "delete" {
t.Errorf("DHCP plan should not delete addresses: %v", c.Args)
}
}
}
// A DHCP lease is not deletable with `netsh delete address`, and the static set
// replaces it anyway, so a live-DHCP adapter gets no delete commands.
func TestPlanSkipsDeletesWhenLiveIsDHCP(t *testing.T) {
live := staticLive()
live.DHCP = true
for _, c := range Plan(staticPreset(), live) {
if c.Args[2] == "delete" {
t.Errorf("should not delete DHCP-assigned addresses: %v", c.Args)
}
}
}
func TestPlanAdapterNameIsNotShellQuoted(t *testing.T) {
p := staticPreset()
p.Adapter = "Ethernet 3"
for _, c := range Plan(p, LiveConfig{Adapter: "Ethernet 3"}) {
for _, a := range c.Args {
if strings.HasPrefix(a, "name=") && strings.Contains(a, `"`) {
t.Errorf("adapter name should not carry quotes through os/exec: %q", a)
}
}
}
}
func TestMatches(t *testing.T) {
p := staticPreset()
live := LiveConfig{
Adapter: "Ethernet",
Addresses: []preset.Address{{Address: "10.0.10.50", Prefix: 24}, {Address: "192.168.42.100", Prefix: 24}},
Gateways: []string{"192.168.42.1"},
DNS: []string{"192.168.42.1", "1.1.1.1"},
}
if !Matches(p, live) {
t.Error("preset should match an adapter carrying exactly its addresses")
}
extra := live
extra.Addresses = append(append([]preset.Address{}, live.Addresses...), preset.Address{Address: "10.9.9.9", Prefix: 24})
if Matches(p, extra) {
t.Error("a preset that is a subset of the live config is not the active preset")
}
reordered := live
reordered.DNS = []string{"1.1.1.1", "192.168.42.1"}
if Matches(p, reordered) {
t.Error("DNS order is significant and should not match")
}
dhcp := live
dhcp.DNSFromDHCP = true
if Matches(p, dhcp) {
t.Error("static-DNS preset should not match a DHCP-DNS adapter")
}
}
func TestConfirmTextShapes(t *testing.T) {
got := ConfirmText(staticPreset(), staticLive())
for _, want := range []string{
`Apply preset "Artist frame — control" to adapter "Ethernet"?`,
"CURRENT", "NEW",
"192.168.1.87/24",
"192.168.42.100/24",
"+ 10.0.10.50/24",
"Gateway: 192.168.42.1",
"DNS: 192.168.42.1, 1.1.1.1",
} {
if !strings.Contains(got, want) {
t.Errorf("confirm text missing %q:\n%s", want, got)
}
}
}
+101
View File
@@ -0,0 +1,101 @@
package preset
import (
"encoding/json"
"fmt"
"strings"
)
// ImportMode is what to do with the presets already on disk.
type ImportMode string
const (
// ImportMerge keeps existing presets and adds the incoming ones.
ImportMerge ImportMode = "merge"
// ImportReplace discards everything already saved.
ImportReplace ImportMode = "replace"
)
// Export renders the presets as a shippable preset pack. Passing one or more
// group names filters to just those groups; passing none exports everything.
//
// The output is the same shape as presets.json so a pack can be dropped
// straight in as a starting file, and it stays hand-editable.
func (s *Store) Export(groups ...string) ([]byte, error) {
want := make(map[string]bool, len(groups))
for _, g := range groups {
want[strings.ToLower(strings.TrimSpace(g))] = true
}
out := File{Version: SchemaVersion}
for _, p := range s.All() {
if len(want) > 0 && !want[strings.ToLower(p.GroupOrUngrouped())] {
continue
}
out.Presets = append(out.Presets, p)
}
b, err := json.MarshalIndent(out, "", " ")
if err != nil {
return nil, fmt.Errorf("encoding export: %w", err)
}
return append(b, '\n'), nil
}
// ImportResult reports what an import did, for the summary shown afterwards.
type ImportResult struct {
Added int `json:"added"`
Replaced int `json:"replaced"`
Renamed []string `json:"renamed,omitempty"`
}
// Import validates an incoming preset file in full before touching anything,
// then applies it. A file that fails validation changes nothing on disk.
//
// On merge, an incoming preset whose id already exists is kept alongside the
// existing one: it gets a fresh id and "(imported)" appended to its name, so a
// colleague's pack can never silently overwrite what you already had.
func (s *Store) Import(data []byte, mode ImportMode) (ImportResult, error) {
incoming, err := Decode(data)
if err != nil {
return ImportResult{}, err
}
var res ImportResult
switch mode {
case ImportReplace:
s.mu.Lock()
s.file = File{Version: SchemaVersion, Presets: incoming.Presets}
res.Added = len(incoming.Presets)
s.mu.Unlock()
case ImportMerge:
s.mu.Lock()
existing := make(map[string]bool, len(s.file.Presets))
for _, p := range s.file.Presets {
existing[p.ID] = true
}
for _, p := range incoming.Presets {
if existing[p.ID] {
p.ID = NewID()
p.Name = p.Name + " (imported)"
res.Renamed = append(res.Renamed, p.Name)
}
existing[p.ID] = true
s.file.Presets = append(s.file.Presets, p)
res.Added++
}
s.mu.Unlock()
default:
return ImportResult{}, fmt.Errorf("unknown import mode %q", mode)
}
if err := s.Save(); err != nil {
// Reload so the in-memory copy matches whatever is actually on disk.
_ = s.Load()
return ImportResult{}, err
}
return res, nil
}
+71
View File
@@ -0,0 +1,71 @@
package preset
import (
"fmt"
"net/netip"
"strconv"
"strings"
)
// ParsePrefix accepts every spelling of a subnet mask the editor is required to
// take — "24", "/24" and "255.255.255.0" — and returns the prefix length.
//
// Only contiguous masks are accepted. 255.255.0.255 is a typo, not a netmask,
// and letting it through would produce a netsh call that fails confusingly.
func ParsePrefix(s string) (int, error) {
s = strings.TrimSpace(s)
if s == "" {
return 0, fmt.Errorf("empty mask")
}
s = strings.TrimPrefix(s, "/")
if strings.Contains(s, ".") {
addr, err := netip.ParseAddr(s)
if err != nil || !addr.Is4() {
return 0, fmt.Errorf("%q is not a dotted-quad netmask", s)
}
b := addr.As4()
mask := uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3])
ones := 0
for ones < 32 && mask&(1<<uint(31-ones)) != 0 {
ones++
}
// Everything below the leading run of ones must be zero.
if ones < 32 && mask<<uint(ones) != 0 {
return 0, fmt.Errorf("%q is not a contiguous netmask", s)
}
return ones, nil
}
n, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("%q is not a prefix length", s)
}
if n < 0 || n > 32 {
return 0, fmt.Errorf("prefix /%d out of range", n)
}
return n, nil
}
// MaskString renders a prefix length as the dotted-quad netmask netsh expects
// on the command line. netsh will not take "/24".
func MaskString(prefix int) string {
if prefix < 0 || prefix > 32 {
prefix = 32
}
var mask uint32
if prefix > 0 {
mask = ^uint32(0) << uint(32-prefix)
}
return fmt.Sprintf("%d.%d.%d.%d", mask>>24&0xff, mask>>16&0xff, mask>>8&0xff, mask&0xff)
}
// FormatPrefix renders a prefix in whichever notation the user last chose.
// dotted controls the style; see config.Settings.MaskStyle.
func FormatPrefix(prefix int, dotted bool) string {
if dotted {
return MaskString(prefix)
}
return "/" + strconv.Itoa(prefix)
}
+66
View File
@@ -0,0 +1,66 @@
package preset
import "testing"
func TestParsePrefix(t *testing.T) {
ok := []struct {
in string
want int
}{
{"24", 24},
{"/24", 24},
{" /24 ", 24},
{"255.255.255.0", 24},
{"255.255.255.255", 32},
{"0.0.0.0", 0},
{"255.255.254.0", 23},
{"128.0.0.0", 1},
{"32", 32},
{"0", 0},
}
for _, tc := range ok {
got, err := ParsePrefix(tc.in)
if err != nil {
t.Errorf("ParsePrefix(%q) returned error: %v", tc.in, err)
continue
}
if got != tc.want {
t.Errorf("ParsePrefix(%q) = %d, want %d", tc.in, got, tc.want)
}
}
bad := []string{"", "33", "-1", "255.255.0.255", "1.2.3", "abc", "255.0.255.0", "/", "24.5"}
for _, in := range bad {
if got, err := ParsePrefix(in); err == nil {
t.Errorf("ParsePrefix(%q) = %d, want error", in, got)
}
}
}
func TestMaskStringRoundTrip(t *testing.T) {
for p := 0; p <= 32; p++ {
got, err := ParsePrefix(MaskString(p))
if err != nil {
t.Fatalf("ParsePrefix(MaskString(%d)) errored: %v", p, err)
}
if got != p {
t.Errorf("round trip of /%d gave /%d (mask %s)", p, got, MaskString(p))
}
}
}
func TestMaskStringKnown(t *testing.T) {
cases := map[int]string{
0: "0.0.0.0",
8: "255.0.0.0",
16: "255.255.0.0",
24: "255.255.255.0",
30: "255.255.255.252",
32: "255.255.255.255",
}
for prefix, want := range cases {
if got := MaskString(prefix); got != want {
t.Errorf("MaskString(%d) = %s, want %s", prefix, got, want)
}
}
}
+162
View File
@@ -0,0 +1,162 @@
// Package preset holds the on-disk preset model and everything that reads or
// writes it. It is deliberately free of Windows-specific code so the whole
// model can be exercised by tests on any platform.
package preset
import (
"fmt"
"net/netip"
"strings"
)
// SchemaVersion is written into every file we save and checked on every file we
// load. Bump it only for a breaking change, and add a migration when you do.
const SchemaVersion = 1
// Mode is how an adapter gets its addresses.
type Mode string
const (
ModeStatic Mode = "static"
ModeDHCP Mode = "dhcp"
)
// File is the top-level shape of presets.json.
type File struct {
Version int `json:"version"`
Presets []Preset `json:"presets"`
}
// Preset is one saved adapter configuration.
type Preset struct {
ID string `json:"id"`
Name string `json:"name"`
// Group drives the tray submenus. Empty means "Ungrouped".
Group string `json:"group,omitempty"`
// Adapter is the Windows friendly name, e.g. "Ethernet" or "Wi-Fi 2".
Adapter string `json:"adapter"`
Mode Mode `json:"mode"`
// Primary is required when Mode is static and ignored when it is dhcp.
Primary *Address `json:"primary,omitempty"`
// Secondary addresses are added after the primary is set.
Secondary []Address `json:"secondary,omitempty"`
DNS DNS `json:"dns"`
Notes string `json:"notes,omitempty"`
}
// Address is a single IPv4 address with its prefix length, and optionally the
// default gateway reached through it.
type Address struct {
Address string `json:"address"`
// Prefix is the mask stored as a prefix length. The editor accepts
// dotted-quad on input and converts here; see ParsePrefix.
Prefix int `json:"prefix"`
Gateway string `json:"gateway,omitempty"`
// GatewayMetric 0 means automatic, which is what netsh wants for "let
// Windows decide".
GatewayMetric int `json:"gateway_metric,omitempty"`
}
// DNS is the resolver configuration for a preset.
type DNS struct {
Mode Mode `json:"mode"`
Servers []string `json:"servers,omitempty"`
}
// GroupOrUngrouped is the submenu a preset belongs under.
func (p Preset) GroupOrUngrouped() string {
if strings.TrimSpace(p.Group) == "" {
return "Ungrouped"
}
return p.Group
}
// Validate reports the first structural problem with a preset. Import rejects
// a whole file rather than partially applying it, so this needs to be strict.
func (p Preset) Validate() error {
if strings.TrimSpace(p.ID) == "" {
return fmt.Errorf("preset has no id")
}
if strings.TrimSpace(p.Name) == "" {
return fmt.Errorf("preset %s has no name", p.ID)
}
if strings.TrimSpace(p.Adapter) == "" {
return fmt.Errorf("preset %q has no adapter", p.Name)
}
switch p.Mode {
case ModeDHCP:
// Nothing else to check: any addresses present are ignored on apply.
case ModeStatic:
if p.Primary == nil {
return fmt.Errorf("preset %q is static but has no primary address", p.Name)
}
if err := p.Primary.validate(); err != nil {
return fmt.Errorf("preset %q primary: %w", p.Name, err)
}
for i, s := range p.Secondary {
if err := s.validate(); err != nil {
return fmt.Errorf("preset %q secondary %d: %w", p.Name, i+1, err)
}
// A gateway on a secondary address is silently dropped by
// `netsh add address`, so reject it rather than pretend.
if s.Gateway != "" {
return fmt.Errorf("preset %q secondary %d: secondary addresses cannot carry a gateway", p.Name, i+1)
}
}
default:
return fmt.Errorf("preset %q has unknown mode %q", p.Name, p.Mode)
}
switch p.DNS.Mode {
case ModeDHCP:
case ModeStatic:
if len(p.DNS.Servers) == 0 {
return fmt.Errorf("preset %q has static DNS but no servers", p.Name)
}
for _, s := range p.DNS.Servers {
if _, err := parseV4(s); err != nil {
return fmt.Errorf("preset %q dns server %q: %w", p.Name, s, err)
}
}
default:
return fmt.Errorf("preset %q has unknown dns mode %q", p.Name, p.DNS.Mode)
}
return nil
}
func (a Address) validate() error {
if _, err := parseV4(a.Address); err != nil {
return fmt.Errorf("address %q: %w", a.Address, err)
}
if a.Prefix < 0 || a.Prefix > 32 {
return fmt.Errorf("prefix /%d out of range", a.Prefix)
}
if a.Gateway != "" {
if _, err := parseV4(a.Gateway); err != nil {
return fmt.Errorf("gateway %q: %w", a.Gateway, err)
}
}
if a.GatewayMetric < 0 {
return fmt.Errorf("gateway metric %d is negative", a.GatewayMetric)
}
return nil
}
// String renders an address the way the confirmation dialog shows it.
func (a Address) String() string { return fmt.Sprintf("%s/%d", a.Address, a.Prefix) }
func parseV4(s string) (netip.Addr, error) {
addr, err := netip.ParseAddr(strings.TrimSpace(s))
if err != nil {
return netip.Addr{}, fmt.Errorf("not an IP address")
}
if !addr.Is4() {
// v1 is IPv4 only; see "Out of scope" in claude.md.
return netip.Addr{}, fmt.Errorf("not an IPv4 address")
}
return addr, nil
}
+238
View File
@@ -0,0 +1,238 @@
package preset
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// Store owns presets.json. Every mutation goes through it so the file is
// written atomically and the in-memory copy never drifts from disk.
type Store struct {
path string
mu sync.RWMutex
file File
}
// NewStore opens (or creates) the preset file at path.
func NewStore(path string) (*Store, error) {
s := &Store{path: path, file: File{Version: SchemaVersion}}
if err := s.Load(); err != nil {
return nil, err
}
return s, nil
}
// Load re-reads the file from disk. A missing file is not an error: a fresh
// install has no presets yet and should start with an empty list rather than
// refusing to launch.
func (s *Store) Load() error {
b, err := os.ReadFile(s.path)
if os.IsNotExist(err) {
s.mu.Lock()
s.file = File{Version: SchemaVersion}
s.mu.Unlock()
return nil
}
if err != nil {
return fmt.Errorf("reading %s: %w", s.path, err)
}
f, err := Decode(b)
if err != nil {
return fmt.Errorf("reading %s: %w", s.path, err)
}
s.mu.Lock()
s.file = f
s.mu.Unlock()
return nil
}
// Decode parses and fully validates a preset file. Import uses it too, which is
// why it rejects the whole document on the first bad preset rather than
// dropping the offender and continuing.
func Decode(b []byte) (File, error) {
var f File
dec := json.NewDecoder(strings.NewReader(string(b)))
dec.DisallowUnknownFields()
if err := dec.Decode(&f); err != nil {
return File{}, fmt.Errorf("not a valid preset file: %w", err)
}
if f.Version != SchemaVersion {
return File{}, fmt.Errorf("preset file is version %d, this build understands version %d", f.Version, SchemaVersion)
}
seen := make(map[string]bool, len(f.Presets))
for _, p := range f.Presets {
if err := p.Validate(); err != nil {
return File{}, err
}
if seen[p.ID] {
return File{}, fmt.Errorf("duplicate preset id %q", p.ID)
}
seen[p.ID] = true
}
return f, nil
}
// Save writes the file atomically: a temp file in the same directory, then a
// rename. A half-written presets.json after a crash would lose every preset,
// and this is the user's only copy.
func (s *Store) Save() error {
s.mu.RLock()
b, err := json.MarshalIndent(s.file, "", " ")
s.mu.RUnlock()
if err != nil {
return fmt.Errorf("encoding presets: %w", err)
}
b = append(b, '\n')
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
return fmt.Errorf("creating preset directory: %w", err)
}
tmp, err := os.CreateTemp(filepath.Dir(s.path), ".presets-*.json")
if err != nil {
return fmt.Errorf("creating temp file: %w", err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName) // no-op once the rename below succeeds
if _, err := tmp.Write(b); err != nil {
tmp.Close()
return fmt.Errorf("writing temp file: %w", err)
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return fmt.Errorf("syncing temp file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("closing temp file: %w", err)
}
if err := os.Rename(tmpName, s.path); err != nil {
return fmt.Errorf("replacing %s: %w", s.path, err)
}
return nil
}
// All returns a copy of the presets, sorted by group then name so the tray menu
// and the editor agree on ordering.
func (s *Store) All() []Preset {
s.mu.RLock()
out := make([]Preset, len(s.file.Presets))
copy(out, s.file.Presets)
s.mu.RUnlock()
sort.SliceStable(out, func(i, j int) bool {
gi, gj := out[i].GroupOrUngrouped(), out[j].GroupOrUngrouped()
if gi != gj {
// Ungrouped sinks to the bottom of the tray menu.
if gi == "Ungrouped" {
return false
}
if gj == "Ungrouped" {
return true
}
return strings.ToLower(gi) < strings.ToLower(gj)
}
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
})
return out
}
// Get returns the preset with the given id.
func (s *Store) Get(id string) (Preset, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, p := range s.file.Presets {
if p.ID == id {
return p, true
}
}
return Preset{}, false
}
// Put inserts or replaces a preset and saves. An empty ID means "new".
func (s *Store) Put(p Preset) (Preset, error) {
if strings.TrimSpace(p.ID) == "" {
p.ID = NewID()
}
if err := p.Validate(); err != nil {
return Preset{}, err
}
s.mu.Lock()
replaced := false
for i := range s.file.Presets {
if s.file.Presets[i].ID == p.ID {
s.file.Presets[i] = p
replaced = true
break
}
}
if !replaced {
s.file.Presets = append(s.file.Presets, p)
}
s.mu.Unlock()
return p, s.Save()
}
// Delete removes a preset by id and saves.
func (s *Store) Delete(id string) error {
s.mu.Lock()
kept := s.file.Presets[:0]
found := false
for _, p := range s.file.Presets {
if p.ID == id {
found = true
continue
}
kept = append(kept, p)
}
s.file.Presets = kept
s.mu.Unlock()
if !found {
return fmt.Errorf("no preset with id %q", id)
}
return s.Save()
}
// Groups lists the distinct groups in tray-menu order.
func (s *Store) Groups() []string {
seen := map[string]bool{}
var out []string
for _, p := range s.All() {
g := p.GroupOrUngrouped()
if !seen[g] {
seen[g] = true
out = append(out, g)
}
}
return out
}
// NewID mints a preset id. claude.md shows ULID-shaped ids in the example file,
// but nothing depends on them being sortable or on their length — an id only
// has to be unique and stable — so this stays dependency-free: a millisecond
// timestamp for rough ordering plus 64 bits of randomness.
func NewID() string {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
// crypto/rand does not fail in practice on Windows; if it somehow
// does, a timestamp-only id is still unique enough to save with.
return fmt.Sprintf("%013x", time.Now().UnixMilli())
}
return fmt.Sprintf("%013x%s", time.Now().UnixMilli(), hex.EncodeToString(b[:]))
}
+228
View File
@@ -0,0 +1,228 @@
package preset
import (
"os"
"path/filepath"
"strings"
"testing"
)
func newTestStore(t *testing.T) *Store {
t.Helper()
s, err := NewStore(filepath.Join(t.TempDir(), "presets.json"))
if err != nil {
t.Fatal(err)
}
return s
}
func sample() Preset {
return Preset{
Name: "Artist frame — control",
Group: "Riedel",
Adapter: "Ethernet",
Mode: ModeStatic,
Primary: &Address{Address: "192.168.42.100", Prefix: 24, Gateway: "192.168.42.1"},
DNS: DNS{Mode: ModeStatic, Servers: []string{"192.168.42.1"}},
}
}
// The shipped example doubles as the documented export format, so a change
// that breaks it breaks every preset pack already in circulation.
func TestExamplePackDecodes(t *testing.T) {
b, err := os.ReadFile(filepath.Join("..", "..", "examples", "preset-pack.json"))
if err != nil {
t.Skipf("example pack not present: %v", err)
}
f, err := Decode(b)
if err != nil {
t.Fatalf("examples/preset-pack.json does not validate: %v", err)
}
if len(f.Presets) == 0 {
t.Fatal("example pack is empty")
}
}
func TestMissingFileStartsEmpty(t *testing.T) {
s := newTestStore(t)
if len(s.All()) != 0 {
t.Errorf("a fresh store should have no presets, got %d", len(s.All()))
}
}
func TestPutAssignsIDAndPersists(t *testing.T) {
path := filepath.Join(t.TempDir(), "presets.json")
s, err := NewStore(path)
if err != nil {
t.Fatal(err)
}
saved, err := s.Put(sample())
if err != nil {
t.Fatal(err)
}
if saved.ID == "" {
t.Fatal("Put should have assigned an id")
}
reopened, err := NewStore(path)
if err != nil {
t.Fatalf("saved file did not reload: %v", err)
}
if got, ok := reopened.Get(saved.ID); !ok || got.Name != saved.Name {
t.Errorf("preset did not survive a reload: %+v", got)
}
}
func TestPutRejectsInvalid(t *testing.T) {
s := newTestStore(t)
bad := sample()
bad.Primary.Address = "not-an-ip"
if _, err := s.Put(bad); err == nil {
t.Error("expected a validation error for a bad address")
}
// A secondary carrying a gateway is silently dropped by netsh, so it is
// rejected rather than saved as a lie.
withGW := sample()
withGW.Secondary = []Address{{Address: "10.0.0.5", Prefix: 24, Gateway: "10.0.0.1"}}
if _, err := s.Put(withGW); err == nil {
t.Error("expected a validation error for a secondary with a gateway")
}
}
func TestDecodeRejectsWrongVersion(t *testing.T) {
_, err := Decode([]byte(`{"version": 99, "presets": []}`))
if err == nil || !strings.Contains(err.Error(), "version") {
t.Errorf("expected a version error, got %v", err)
}
}
func TestDecodeRejectsDuplicateIDs(t *testing.T) {
doc := `{"version":1,"presets":[
{"id":"a","name":"one","adapter":"Ethernet","mode":"dhcp","dns":{"mode":"dhcp"}},
{"id":"a","name":"two","adapter":"Ethernet","mode":"dhcp","dns":{"mode":"dhcp"}}]}`
if _, err := Decode([]byte(doc)); err == nil {
t.Error("expected a duplicate-id error")
}
}
func TestImportMergeKeepsBothOnIDCollision(t *testing.T) {
s := newTestStore(t)
saved, err := s.Put(sample())
if err != nil {
t.Fatal(err)
}
pack, err := s.Export()
if err != nil {
t.Fatal(err)
}
res, err := s.Import(pack, ImportMerge)
if err != nil {
t.Fatal(err)
}
if res.Added != 1 || len(res.Renamed) != 1 {
t.Fatalf("expected 1 added and 1 renamed, got %+v", res)
}
if len(s.All()) != 2 {
t.Fatalf("merge should keep both, got %d presets", len(s.All()))
}
// The original must be untouched: a colleague's pack silently overwriting
// a working preset is the failure this guards against.
orig, ok := s.Get(saved.ID)
if !ok || orig.Name != saved.Name {
t.Errorf("original preset was modified: %+v", orig)
}
var found bool
for _, p := range s.All() {
if strings.HasSuffix(p.Name, "(imported)") {
found = true
}
}
if !found {
t.Error("the colliding import should have been suffixed with (imported)")
}
}
func TestImportReplaceDiscardsExisting(t *testing.T) {
s := newTestStore(t)
if _, err := s.Put(sample()); err != nil {
t.Fatal(err)
}
doc := `{"version":1,"presets":[{"id":"z","name":"only","adapter":"Ethernet","mode":"dhcp","dns":{"mode":"dhcp"}}]}`
if _, err := s.Import([]byte(doc), ImportReplace); err != nil {
t.Fatal(err)
}
all := s.All()
if len(all) != 1 || all[0].ID != "z" {
t.Errorf("replace should leave exactly the incoming presets, got %+v", all)
}
}
// A rejected import must change nothing: partial application would leave the
// user with a half-imported pack and no way to tell what landed.
func TestImportRejectsWithoutMutating(t *testing.T) {
s := newTestStore(t)
if _, err := s.Put(sample()); err != nil {
t.Fatal(err)
}
before := len(s.All())
bad := `{"version":1,"presets":[{"id":"x","name":"broken","adapter":"Ethernet","mode":"static","dns":{"mode":"dhcp"}}]}`
if _, err := s.Import([]byte(bad), ImportMerge); err == nil {
t.Fatal("expected the import to be rejected")
}
if len(s.All()) != before {
t.Errorf("a rejected import changed the store: %d -> %d", before, len(s.All()))
}
}
func TestExportFiltersByGroup(t *testing.T) {
s := newTestStore(t)
if _, err := s.Put(sample()); err != nil {
t.Fatal(err)
}
other := sample()
other.Name = "Management"
other.Group = "Herespace"
if _, err := s.Put(other); err != nil {
t.Fatal(err)
}
b, err := s.Export("Riedel")
if err != nil {
t.Fatal(err)
}
f, err := Decode(b)
if err != nil {
t.Fatalf("a filtered export must still be a valid pack: %v", err)
}
if len(f.Presets) != 1 || f.Presets[0].Group != "Riedel" {
t.Errorf("group filter returned %+v", f.Presets)
}
}
func TestUngroupedSortsLast(t *testing.T) {
s := newTestStore(t)
loose := sample()
loose.Name = "Loose"
loose.Group = ""
if _, err := s.Put(loose); err != nil {
t.Fatal(err)
}
if _, err := s.Put(sample()); err != nil {
t.Fatal(err)
}
groups := s.Groups()
if len(groups) != 2 || groups[len(groups)-1] != "Ungrouped" {
t.Errorf("Ungrouped should sort last, got %v", groups)
}
}
+439
View File
@@ -0,0 +1,439 @@
// Package server is the "slow path": a local HTTP server hosting the preset
// editor, opened in the user's default browser.
//
// It exists because the fast path must not involve a browser and the editor
// must not involve a MessageBox. Switching a preset is two clicks in the tray;
// managing fifty of them needs a real UI, and an embedded web app is the only
// way to get one without linking a GUI toolkit and giving up cross-compilation.
//
// The server is not running most of the time. The tray starts it on demand and
// it shuts itself down once the browser stops sending heartbeats.
package server
import (
"context"
"crypto/rand"
"crypto/subtle"
"embed"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"io/fs"
"log"
"net"
"net/http"
"strings"
"sync"
"time"
"gitea.apointless.space/bsncubed/ipswap/internal/config"
"gitea.apointless.space/bsncubed/ipswap/internal/netcfg"
"gitea.apointless.space/bsncubed/ipswap/internal/preset"
)
//go:embed web
var webFS embed.FS
const (
// idleTimeout is how long the server survives without a heartbeat. The
// browser beats every 30s, so this tolerates a few missed beats before
// concluding the tab is gone.
idleTimeout = 5 * time.Minute
// tokenHeader carries the session token on mutating requests.
tokenHeader = "X-Ipswap-Token"
)
// Server hosts the editor.
type Server struct {
store *preset.Store
mgr netcfg.Manager
paths config.Paths
// onSettingsChange lets the tray react to a settings save, e.g. to
// rewrite the Run key when start-with-Windows is toggled.
onSettingsChange func(config.Settings)
// onPresetsChange asks the tray to rebuild its menu.
onPresetsChange func()
mu sync.Mutex
settings config.Settings
token string
srv *http.Server
ln net.Listener
lastBeat time.Time
stop chan struct{}
}
// New builds a server. It does not listen until Start is called.
func New(store *preset.Store, mgr netcfg.Manager, paths config.Paths, settings config.Settings) *Server {
return &Server{store: store, mgr: mgr, paths: paths, settings: settings}
}
// OnSettingsChange registers a callback fired after settings are saved.
func (s *Server) OnSettingsChange(f func(config.Settings)) { s.onSettingsChange = f }
// OnPresetsChange registers a callback fired after any preset mutation.
func (s *Server) OnPresetsChange(f func()) { s.onPresetsChange = f }
// URL returns the address to open, including the session token. It is empty
// when the server is not running.
func (s *Server) URL() string {
s.mu.Lock()
defer s.mu.Unlock()
if s.ln == nil {
return ""
}
return fmt.Sprintf("http://127.0.0.1:%d/?t=%s", s.ln.Addr().(*net.TCPAddr).Port, s.token)
}
// Start binds a random loopback port and serves until Stop or an idle timeout.
// Calling it while already running just returns the existing URL, so clicking
// "Manage presets…" twice reuses the one session.
func (s *Server) Start() (string, error) {
s.mu.Lock()
if s.ln != nil {
url := fmt.Sprintf("http://127.0.0.1:%d/?t=%s", s.ln.Addr().(*net.TCPAddr).Port, s.token)
s.lastBeat = time.Now()
s.mu.Unlock()
return url, nil
}
// Loopback only. This binds no external interface at any point, which
// matters given the app spends its life on customer networks.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
s.mu.Unlock()
return "", fmt.Errorf("binding a local port: %w", err)
}
token, err := newToken()
if err != nil {
ln.Close()
s.mu.Unlock()
return "", err
}
s.ln = ln
s.token = token
s.lastBeat = time.Now()
s.stop = make(chan struct{})
s.srv = &http.Server{
Handler: s.routes(),
ReadHeaderTimeout: 10 * time.Second,
}
stop := s.stop
srv := s.srv
port := ln.Addr().(*net.TCPAddr).Port
s.mu.Unlock()
go func() {
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
log.Printf("editor server stopped: %v", err)
}
}()
go s.watchIdle(stop)
log.Printf("editor server listening on 127.0.0.1:%d", port)
return fmt.Sprintf("http://127.0.0.1:%d/?t=%s", port, token), nil
}
// Stop shuts the server down. It is safe to call when not running.
func (s *Server) Stop() {
s.mu.Lock()
srv, stop := s.srv, s.stop
s.srv, s.ln, s.stop, s.token = nil, nil, nil, ""
s.mu.Unlock()
if stop != nil {
close(stop)
}
if srv == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)
log.Printf("editor server shut down")
}
func (s *Server) watchIdle(stop <-chan struct{}) {
t := time.NewTicker(30 * time.Second)
defer t.Stop()
for {
select {
case <-stop:
return
case <-t.C:
s.mu.Lock()
idle := time.Since(s.lastBeat)
s.mu.Unlock()
if idle > idleTimeout {
log.Printf("editor server idle for %s, shutting down", idle.Round(time.Second))
s.Stop()
return
}
}
}
}
func newToken() (string, error) {
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
return "", fmt.Errorf("generating a session token: %w", err)
}
return hex.EncodeToString(b[:]), nil
}
// Settings returns the current settings.
func (s *Server) Settings() config.Settings {
s.mu.Lock()
defer s.mu.Unlock()
return s.settings
}
// SetSettings replaces the settings the server serves, for when something
// outside the editor changes them.
func (s *Server) SetSettings(c config.Settings) {
s.mu.Lock()
s.settings = c
s.mu.Unlock()
}
func (s *Server) beat() {
s.mu.Lock()
s.lastBeat = time.Now()
s.mu.Unlock()
}
// --- routing ---
func (s *Server) routes() http.Handler {
mux := http.NewServeMux()
assets, err := fs.Sub(webFS, "web")
if err != nil {
// Only reachable if the embed directive and the directory disagree,
// which is a build-time mistake, not a runtime condition.
panic(err)
}
mux.Handle("GET /", http.FileServer(http.FS(assets)))
mux.HandleFunc("POST /api/heartbeat", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
})
mux.HandleFunc("GET /api/presets", s.handleListPresets)
mux.HandleFunc("POST /api/presets", s.handlePutPreset)
mux.HandleFunc("PUT /api/presets/{id}", s.handlePutPreset)
mux.HandleFunc("DELETE /api/presets/{id}", s.handleDeletePreset)
mux.HandleFunc("GET /api/adapters", s.handleAdapters)
mux.HandleFunc("GET /api/settings", s.handleGetSettings)
mux.HandleFunc("PUT /api/settings", s.handlePutSettings)
mux.HandleFunc("GET /api/export", s.handleExport)
mux.HandleFunc("POST /api/import", s.handleImport)
return s.withAuth(mux)
}
// withAuth gates every request on the session token and refreshes the idle
// timer.
//
// The token arrives once in the URL and is then stored in a SameSite=Strict
// cookie. Mutating requests additionally require the token in a header, which
// a cross-site page cannot set: a cookie alone would let any website in the
// browser POST to this port and rewrite the user's presets.
func (s *Server) withAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
token := s.token
s.mu.Unlock()
if token == "" {
http.Error(w, "server is shutting down", http.StatusServiceUnavailable)
return
}
if q := r.URL.Query().Get("t"); q != "" && tokenEqual(q, token) {
http.SetCookie(w, &http.Cookie{
Name: "ipswap_session",
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
})
s.beat()
next.ServeHTTP(w, r)
return
}
c, err := r.Cookie("ipswap_session")
if err != nil || !tokenEqual(c.Value, token) {
http.Error(w, "unauthorised: reopen the editor from the tray menu", http.StatusUnauthorized)
return
}
if r.Method != http.MethodGet && r.Method != http.MethodHead {
if !tokenEqual(r.Header.Get(tokenHeader), token) {
http.Error(w, "missing session header", http.StatusForbidden)
return
}
}
s.beat()
next.ServeHTTP(w, r)
})
}
func tokenEqual(got, want string) bool {
return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1
}
// --- handlers ---
func (s *Server) handleListPresets(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"presets": s.store.All(),
"groups": s.store.Groups(),
})
}
func (s *Server) handlePutPreset(w http.ResponseWriter, r *http.Request) {
var p preset.Preset
if err := readJSON(r, &p); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
if id := r.PathValue("id"); id != "" {
p.ID = id
}
saved, err := s.store.Put(p)
if err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
s.notifyPresets()
writeJSON(w, http.StatusOK, saved)
}
func (s *Server) handleDeletePreset(w http.ResponseWriter, r *http.Request) {
if err := s.store.Delete(r.PathValue("id")); err != nil {
writeErr(w, http.StatusNotFound, err)
return
}
s.notifyPresets()
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) handleAdapters(w http.ResponseWriter, r *http.Request) {
adapters, err := s.mgr.Adapters()
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
type row struct {
netcfg.Adapter
Current string `json:"current"`
}
out := make([]row, 0, len(adapters))
for _, a := range adapters {
r := row{Adapter: a}
if live, err := s.mgr.Current(a.Name); err == nil {
r.Current = live.Summary()
}
out = append(out, r)
}
writeJSON(w, http.StatusOK, map[string]any{"adapters": out})
}
func (s *Server) handleGetSettings(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.Settings())
}
func (s *Server) handlePutSettings(w http.ResponseWriter, r *http.Request) {
c := s.Settings()
if err := readJSON(r, &c); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
if err := c.Save(s.paths.Config); err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
s.SetSettings(c)
if s.onSettingsChange != nil {
s.onSettingsChange(c)
}
writeJSON(w, http.StatusOK, c)
}
func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
var groups []string
if g := strings.TrimSpace(r.URL.Query().Get("groups")); g != "" {
groups = strings.Split(g, ",")
}
b, err := s.store.Export(groups...)
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Disposition", `attachment; filename="ipswap-presets.json"`)
w.Write(b)
}
func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) {
mode := preset.ImportMode(r.URL.Query().Get("mode"))
if mode == "" {
mode = preset.ImportMerge
}
body, err := io.ReadAll(io.LimitReader(r.Body, 8<<20))
if err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
res, err := s.store.Import(body, mode)
if err != nil {
// Import validates the whole file first, so a rejection here means
// nothing on disk changed.
writeErr(w, http.StatusBadRequest, err)
return
}
s.notifyPresets()
writeJSON(w, http.StatusOK, res)
}
func (s *Server) notifyPresets() {
if s.onPresetsChange != nil {
s.onPresetsChange()
}
}
// --- helpers ---
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
log.Printf("writing response: %v", err)
}
}
func writeErr(w http.ResponseWriter, status int, err error) {
writeJSON(w, status, map[string]string{"error": err.Error()})
}
func readJSON(r *http.Request, v any) error {
dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
if err := dec.Decode(v); err != nil {
return fmt.Errorf("invalid request body: %w", err)
}
return nil
}
+214
View File
@@ -0,0 +1,214 @@
package server
import (
"encoding/json"
"io"
"net/http"
"net/url"
"path/filepath"
"strings"
"testing"
"gitea.apointless.space/bsncubed/ipswap/internal/config"
"gitea.apointless.space/bsncubed/ipswap/internal/netcfg"
"gitea.apointless.space/bsncubed/ipswap/internal/preset"
)
func newTestServer(t *testing.T) (*Server, string, string) {
t.Helper()
dir := t.TempDir()
store, err := preset.NewStore(filepath.Join(dir, "presets.json"))
if err != nil {
t.Fatal(err)
}
paths := config.Paths{Dir: dir, Presets: filepath.Join(dir, "presets.json"), Config: filepath.Join(dir, "config.json")}
s := New(store, netcfg.New(), paths, config.Default())
raw, err := s.Start()
if err != nil {
t.Fatal(err)
}
t.Cleanup(s.Stop)
u, err := url.Parse(raw)
if err != nil {
t.Fatal(err)
}
return s, "http://" + u.Host, u.Query().Get("t")
}
// do issues a request with no cookie jar, so each call is judged purely on
// what it carries.
func do(t *testing.T, method, url, token, header, body string) *http.Response {
t.Helper()
var r io.Reader
if body != "" {
r = strings.NewReader(body)
}
req, err := http.NewRequest(method, url, r)
if err != nil {
t.Fatal(err)
}
if token != "" {
req.AddCookie(&http.Cookie{Name: "ipswap_session", Value: token})
}
if header != "" {
req.Header.Set(tokenHeader, header)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
return resp
}
func TestServesOnLoopbackOnly(t *testing.T) {
_, base, _ := newTestServer(t)
if !strings.HasPrefix(base, "http://127.0.0.1:") {
t.Errorf("editor must bind loopback only, got %s", base)
}
}
func TestRejectsMissingToken(t *testing.T) {
_, base, _ := newTestServer(t)
resp := do(t, "GET", base+"/api/presets", "", "", "")
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("no token should be 401, got %d", resp.StatusCode)
}
}
func TestRejectsWrongToken(t *testing.T) {
_, base, _ := newTestServer(t)
resp := do(t, "GET", base+"/api/presets", strings.Repeat("a", 64), "", "")
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("a wrong token should be 401, got %d", resp.StatusCode)
}
}
func TestQueryTokenSetsCookie(t *testing.T) {
_, base, token := newTestServer(t)
resp := do(t, "GET", base+"/api/presets?t="+token, "", "", "")
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("query token should authenticate, got %d", resp.StatusCode)
}
var found bool
for _, c := range resp.Cookies() {
if c.Name == "ipswap_session" {
found = true
if c.SameSite != http.SameSiteStrictMode {
t.Error("session cookie must be SameSite=Strict")
}
if !c.HttpOnly {
t.Error("session cookie must be HttpOnly")
}
}
}
if !found {
t.Error("expected a session cookie to be set")
}
}
// A cookie alone must not be enough to mutate: any page in the browser can
// make the browser send this cookie to 127.0.0.1, but it cannot set a header.
func TestMutationRequiresHeaderNotJustCookie(t *testing.T) {
_, base, token := newTestServer(t)
body := `{"name":"x","adapter":"Ethernet","mode":"dhcp","dns":{"mode":"dhcp"}}`
resp := do(t, "POST", base+"/api/presets", token, "", body)
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("cookie-only mutation should be 403, got %d", resp.StatusCode)
}
resp2 := do(t, "POST", base+"/api/presets", token, token, body)
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp2.Body)
t.Errorf("cookie plus header should succeed, got %d: %s", resp2.StatusCode, b)
}
}
func TestPresetCRUD(t *testing.T) {
_, base, token := newTestServer(t)
body := `{"name":"Control","group":"Riedel","adapter":"Ethernet","mode":"static",
"primary":{"address":"192.168.42.100","prefix":24,"gateway":"192.168.42.1"},
"dns":{"mode":"static","servers":["192.168.42.1"]}}`
resp := do(t, "POST", base+"/api/presets", token, token, body)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("create failed: %d %s", resp.StatusCode, b)
}
var created preset.Preset
if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
t.Fatal(err)
}
if created.ID == "" {
t.Fatal("server should assign an id")
}
list := do(t, "GET", base+"/api/presets", token, "", "")
defer list.Body.Close()
var got struct {
Presets []preset.Preset `json:"presets"`
Groups []string `json:"groups"`
}
if err := json.NewDecoder(list.Body).Decode(&got); err != nil {
t.Fatal(err)
}
if len(got.Presets) != 1 || got.Presets[0].Name != "Control" {
t.Fatalf("list returned %+v", got.Presets)
}
del := do(t, "DELETE", base+"/api/presets/"+created.ID, token, token, "")
defer del.Body.Close()
if del.StatusCode != http.StatusOK {
t.Errorf("delete failed: %d", del.StatusCode)
}
}
func TestInvalidPresetIsRejected(t *testing.T) {
_, base, token := newTestServer(t)
body := `{"name":"bad","adapter":"Ethernet","mode":"static","dns":{"mode":"dhcp"}}`
resp := do(t, "POST", base+"/api/presets", token, token, body)
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("a static preset with no primary should be 400, got %d", resp.StatusCode)
}
}
func TestStopRejectsSubsequentRequests(t *testing.T) {
s, base, token := newTestServer(t)
s.Stop()
if _, err := http.Get(base + "/api/presets?t=" + token); err == nil {
t.Error("expected the listener to be closed after Stop")
}
}
func TestIndexIsServed(t *testing.T) {
_, base, token := newTestServer(t)
resp := do(t, "GET", base+"/?t="+token, "", "", "")
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("index should be served, got %d", resp.StatusCode)
}
b, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(b), "ipswap") {
t.Error("index.html does not look like the editor")
}
}
+333
View File
@@ -0,0 +1,333 @@
/*
* apointless.css — reconstructed from the description in claude.md.
*
* If you have the canonical apointless.css, drop it in over this file: the
* markup below only uses the components the spec lists (cards, stat cards,
* badges, pills, buttons, inputs, tables, alerts, code blocks, spinners), so a
* real copy should be a straight swap.
*
* Fonts are declared as stacks with system fallbacks rather than pulled from a
* CDN: ipswap runs on laptops sitting on customer networks with no route to
* the internet, and a webfont that 404s would leave the editor unstyled.
*/
:root {
--bg: #0b0d11;
--surface-1: #12151b;
--surface-2: #1a1e28;
--surface-3: #232838;
--text: #e6e9ef;
--text-muted: #9aa3b2;
--text-dim: #6b7382;
--accent: #3b82f6;
--accent-hover: #60a5fa;
--accent-dim: rgba(59, 130, 246, 0.15);
--ok: #22c55e;
--warn: #f59e0b;
--danger: #ef4444;
--info: #38bdf8;
--border: #232838;
--border-strong: #2f3648;
--mono: "JetBrains Mono", ui-monospace, "Cascadia Mono", "Consolas", monospace;
--sans: "DM Sans", ui-sans-serif, system-ui, "Segoe UI", sans-serif;
--radius: 10px;
--radius-sm: 6px;
--gap: 16px;
}
html.light {
--bg: #f6f7f9;
--surface-1: #ffffff;
--surface-2: #f0f2f5;
--surface-3: #e4e7ec;
--text: #12151b;
--text-muted: #4b5563;
--text-dim: #6b7382;
--accent-dim: rgba(59, 130, 246, 0.12);
--border: #dfe3e9;
--border-strong: #c8ced8;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--sans);
font-size: 15px;
line-height: 1.55;
min-height: 100vh;
position: relative;
}
/* Subtle blue grid behind everything. */
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
background-image:
linear-gradient(to right, rgba(59, 130, 246, 0.05) 1px, transparent 1px),
linear-gradient(to bottom, rgba(59, 130, 246, 0.05) 1px, transparent 1px);
background-size: 40px 40px;
}
.wrap {
position: relative;
z-index: 1;
max-width: 1100px;
margin: 0 auto;
padding: 28px 20px 64px;
}
/* --- typography --- */
h1, h2, h3 { font-weight: 600; line-height: 1.25; margin: 0 0 8px; }
h1 { font-size: 24px; letter-spacing: -0.02em; }
h2 { font-size: 18px; }
h3 { font-size: 15px; color: var(--text-muted); }
.mono { font-family: var(--mono); }
.muted { color: var(--text-muted); }
.dim { color: var(--text-dim); font-size: 13px; }
header.top {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--gap);
margin-bottom: 24px;
flex-wrap: wrap;
}
/* --- cards --- */
.card {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px;
margin-bottom: var(--gap);
}
.card > h2 { margin-bottom: 12px; }
.stat-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
margin-bottom: var(--gap);
}
.stat-card {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 16px;
}
.stat-card .label {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim);
}
.stat-card .value {
font-family: var(--mono);
font-size: 18px;
margin-top: 4px;
word-break: break-all;
}
/* --- badges and pills --- */
.badge, .pill {
display: inline-block;
font-size: 12px;
font-family: var(--mono);
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--border-strong);
color: var(--text-muted);
white-space: nowrap;
}
.badge.accent { background: var(--accent-dim); border-color: var(--accent); color: var(--accent-hover); }
.badge.ok { background: rgba(34, 197, 94, 0.12); border-color: var(--ok); color: var(--ok); }
.badge.warn { background: rgba(245, 158, 11, 0.12); border-color: var(--warn); color: var(--warn); }
.badge.danger { background: rgba(239, 68, 68, 0.12); border-color: var(--danger); color: var(--danger); }
.pill { background: var(--surface-2); }
/* --- buttons --- */
button, .btn {
font-family: var(--sans);
font-size: 14px;
font-weight: 500;
padding: 7px 14px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-strong);
background: var(--surface-2);
color: var(--text);
cursor: pointer;
transition: background 0.12s ease, border-color 0.12s ease;
}
button:hover, .btn:hover { background: var(--surface-3); border-color: var(--accent); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
button:disabled:hover { background: var(--surface-2); border-color: var(--border-strong); }
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
button.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
button.danger { border-color: var(--danger); color: var(--danger); }
button.danger:hover { background: rgba(239, 68, 68, 0.12); }
button.ghost { background: transparent; border-color: transparent; color: var(--text-muted); }
button.ghost:hover { background: var(--surface-2); border-color: var(--border); }
button.small { font-size: 12px; padding: 4px 9px; }
.btn-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
/* --- inputs --- */
label { display: block; font-size: 13px; color: var(--text-muted); margin-bottom: 4px; }
input, select, textarea {
width: 100%;
font-family: var(--mono);
font-size: 14px;
padding: 7px 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-strong);
background: var(--surface-2);
color: var(--text);
}
input:focus, select:focus, textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-dim);
}
input[type="checkbox"] { width: auto; accent-color: var(--accent); }
.field { margin-bottom: 12px; }
.field-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
}
.check { display: flex; align-items: center; gap: 8px; }
.check label { margin: 0; }
/* --- tables --- */
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th {
text-align: left;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-dim);
font-weight: 500;
padding: 8px 10px;
border-bottom: 1px solid var(--border-strong);
}
td { padding: 9px 10px; border-bottom: 1px solid var(--border); vertical-align: middle; }
tbody tr:hover { background: var(--surface-2); }
td.mono, .table-scroll td.addr { font-family: var(--mono); font-size: 13px; }
.table-scroll { overflow-x: auto; }
/* --- alerts --- */
.alert {
border-radius: var(--radius-sm);
border: 1px solid var(--border-strong);
background: var(--surface-2);
padding: 10px 14px;
margin-bottom: 12px;
font-size: 14px;
}
.alert.ok { border-color: var(--ok); background: rgba(34, 197, 94, 0.10); }
.alert.warn { border-color: var(--warn); background: rgba(245, 158, 11, 0.10); }
.alert.danger { border-color: var(--danger); background: rgba(239, 68, 68, 0.10); }
.alert.info { border-color: var(--info); background: rgba(56, 189, 248, 0.10); }
/* --- code --- */
pre, code {
font-family: var(--mono);
font-size: 13px;
}
pre {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 12px;
overflow-x: auto;
}
/* --- spinner --- */
.spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid var(--border-strong);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
vertical-align: -2px;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* --- layout odds and ends --- */
.group-head {
display: flex;
align-items: center;
gap: 10px;
margin: 20px 0 8px;
}
.group-head h2 { margin: 0; }
.empty {
text-align: center;
color: var(--text-dim);
padding: 32px 16px;
}
dialog {
background: var(--surface-1);
color: var(--text);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
padding: 20px;
width: min(680px, 92vw);
}
dialog::backdrop { background: rgba(0, 0, 0, 0.6); }
.sec-list { display: flex; flex-direction: column; gap: 8px; }
.sec-row { display: flex; gap: 8px; align-items: center; }
.sec-row input { flex: 1; }
.hidden { display: none !important; }
+435
View File
@@ -0,0 +1,435 @@
/*
* ipswap preset editor.
*
* Plain ES modules-free JavaScript on purpose: the whole app is served from an
* embed.FS inside a single .exe, and a build step for the front end would mean
* a Node toolchain in CI for a few hundred lines of DOM code.
*/
// The token arrives once in the query string. The server also sets it as a
// SameSite=Strict cookie, but mutations additionally require it as a header —
// a cookie alone would let any page in the browser POST to this port.
const TOKEN = new URLSearchParams(location.search).get("t") || "";
const state = {
presets: [],
groups: [],
adapters: [],
settings: null,
editing: null,
};
// --- theme ---
const themeToggle = document.getElementById("theme-toggle");
function applyTheme(light) {
document.documentElement.classList.toggle("light", light);
themeToggle.textContent = light ? "Dark" : "Light";
localStorage.setItem("ipswap-theme", light ? "light" : "dark");
}
applyTheme(localStorage.getItem("ipswap-theme") === "light");
themeToggle.addEventListener("click", () =>
applyTheme(!document.documentElement.classList.contains("light"))
);
// --- api ---
async function api(path, opts = {}) {
const headers = Object.assign({}, opts.headers);
if (opts.method && opts.method !== "GET") {
headers["X-Ipswap-Token"] = TOKEN;
if (opts.body && !headers["Content-Type"]) {
headers["Content-Type"] = "application/json";
}
}
const res = await fetch(path, Object.assign({}, opts, { headers }));
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
// Non-JSON bodies come from the auth middleware, which writes plain text.
if (!res.ok) throw new Error(text || res.statusText);
}
if (!res.ok) throw new Error((data && data.error) || text || res.statusText);
return data;
}
// The server shuts down after five minutes without one of these.
setInterval(() => {
api("/api/heartbeat", { method: "POST" }).catch(() => {});
}, 30_000);
// --- alerts ---
function alertBox(kind, message, container = "alerts") {
const host = document.getElementById(container);
host.innerHTML = `<div class="alert ${kind}">${escapeHTML(message)}</div>`;
if (kind === "ok") setTimeout(() => (host.innerHTML = ""), 4000);
}
function escapeHTML(s) {
return String(s).replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]
);
}
// --- rendering ---
function maskOf(prefix) {
if (state.settings && state.settings.mask_style === "dotted") {
let m = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
return [m >>> 24, (m >>> 16) & 255, (m >>> 8) & 255, m & 255].join(".");
}
return "/" + prefix;
}
function addrText(a) {
return a.address + maskOf(a.prefix).replace(/^(?!\/)/, " ");
}
function renderStats() {
const adapters = new Set(state.presets.map((p) => p.adapter));
document.getElementById("stats").innerHTML = `
<div class="stat-card"><div class="label">Presets</div><div class="value">${state.presets.length}</div></div>
<div class="stat-card"><div class="label">Groups</div><div class="value">${state.groups.length}</div></div>
<div class="stat-card"><div class="label">Adapters in use</div><div class="value">${adapters.size}</div></div>
`;
}
function renderPresets() {
const host = document.getElementById("groups");
if (!state.presets.length) {
host.innerHTML = `<div class="card"><div class="empty">No presets yet. Create one, or import a pack from a colleague.</div></div>`;
return;
}
const byGroup = new Map();
for (const p of state.presets) {
const g = p.group && p.group.trim() ? p.group : "Ungrouped";
if (!byGroup.has(g)) byGroup.set(g, []);
byGroup.get(g).push(p);
}
let html = "";
for (const [group, presets] of byGroup) {
html += `
<div class="group-head">
<h2>${escapeHTML(group)}</h2>
<span class="pill">${presets.length}</span>
</div>
<div class="card" style="padding:0">
<div class="table-scroll">
<table>
<thead><tr>
<th>Name</th><th>Adapter</th><th>Address</th><th>Gateway</th><th>DNS</th><th></th>
</tr></thead>
<tbody>`;
for (const p of presets) {
const isDHCP = p.mode === "dhcp";
const addr = isDHCP
? `<span class="badge accent">DHCP</span>`
: escapeHTML(addrText(p.primary)) +
(p.secondary && p.secondary.length
? ` <span class="pill">+${p.secondary.length}</span>`
: "");
const dns =
p.dns.mode === "dhcp"
? `<span class="badge">from DHCP</span>`
: escapeHTML((p.dns.servers || []).join(", "));
html += `<tr>
<td>${escapeHTML(p.name)}${p.notes ? `<div class="dim">${escapeHTML(p.notes)}</div>` : ""}</td>
<td class="mono">${escapeHTML(p.adapter)}</td>
<td class="addr">${addr}</td>
<td class="addr">${isDHCP ? "" : escapeHTML(p.primary.gateway || "—")}</td>
<td class="addr">${dns}</td>
<td style="text-align:right; white-space:nowrap">
<button class="small" data-edit="${escapeHTML(p.id)}">Edit</button>
<button class="small danger" data-del="${escapeHTML(p.id)}">Delete</button>
</td>
</tr>`;
}
html += `</tbody></table></div></div>`;
}
host.innerHTML = html;
host.querySelectorAll("[data-edit]").forEach((b) =>
b.addEventListener("click", () => openEditor(b.dataset.edit))
);
host.querySelectorAll("[data-del]").forEach((b) =>
b.addEventListener("click", () => deletePreset(b.dataset.del))
);
}
function renderAdapters() {
const body = document.getElementById("adapters");
if (!state.adapters.length) {
body.innerHTML = `<tr><td colspan="4" class="dim">No adapters found.</td></tr>`;
return;
}
body.innerHTML = state.adapters
.map(
(a) => `<tr>
<td class="mono">${escapeHTML(a.name)}</td>
<td class="dim">${escapeHTML(a.description || "")}</td>
<td class="addr">${escapeHTML(a.current || "—")}</td>
<td>${a.up ? '<span class="badge ok">up</span>' : '<span class="badge">down</span>'}</td>
</tr>`
)
.join("");
}
// --- loading ---
async function loadAll() {
const [presets, adapters, settings] = await Promise.all([
api("/api/presets"),
api("/api/adapters"),
api("/api/settings"),
]);
state.presets = presets.presets || [];
state.groups = presets.groups || [];
state.adapters = adapters.adapters || [];
state.settings = settings;
renderStats();
renderPresets();
renderAdapters();
document.getElementById("group-list").innerHTML = state.groups
.map((g) => `<option value="${escapeHTML(g)}">`)
.join("");
document.getElementById("f-adapter").innerHTML = state.adapters
.map((a) => `<option value="${escapeHTML(a.name)}">${escapeHTML(a.name)}</option>`)
.join("");
}
// --- editor ---
const editor = document.getElementById("editor");
function secondaryRow(addr = "", prefix = "") {
const div = document.createElement("div");
div.className = "sec-row";
div.innerHTML = `
<input class="sec-addr" placeholder="10.0.10.50" value="${escapeHTML(addr)}">
<input class="sec-prefix" placeholder="/24" value="${escapeHTML(prefix)}" style="max-width:150px">
<button type="button" class="small ghost sec-del">✕</button>`;
div.querySelector(".sec-del").addEventListener("click", () => div.remove());
return div;
}
function openEditor(id) {
const p = id ? state.presets.find((x) => x.id === id) : null;
state.editing = p ? p.id : null;
document.getElementById("editor-title").textContent = p ? "Edit preset" : "New preset";
document.getElementById("editor-error").innerHTML = "";
document.getElementById("f-name").value = p ? p.name : "";
document.getElementById("f-group").value = p ? p.group || "" : "";
document.getElementById("f-adapter").value = p ? p.adapter : (state.adapters[0] || {}).name || "";
document.getElementById("f-mode").value = p ? p.mode : "static";
document.getElementById("f-notes").value = p ? p.notes || "" : "";
const prim = (p && p.primary) || {};
document.getElementById("f-address").value = prim.address || "";
document.getElementById("f-prefix").value = prim.prefix !== undefined ? maskOf(prim.prefix) : "";
document.getElementById("f-gateway").value = prim.gateway || "";
document.getElementById("f-metric").value = prim.gateway_metric || 0;
const secs = document.getElementById("secondaries");
secs.innerHTML = "";
for (const s of (p && p.secondary) || []) secs.appendChild(secondaryRow(s.address, maskOf(s.prefix)));
document.getElementById("f-dns-mode").value = p ? p.dns.mode : "static";
document.getElementById("f-dns").value = p ? (p.dns.servers || []).join(", ") : "";
syncModeVisibility();
editor.showModal();
}
function syncModeVisibility() {
const isDHCP = document.getElementById("f-mode").value === "dhcp";
document.getElementById("static-fields").classList.toggle("hidden", isDHCP);
const dnsStatic = document.getElementById("f-dns-mode").value === "static";
document.getElementById("dns-servers-field").classList.toggle("hidden", !dnsStatic);
}
document.getElementById("f-mode").addEventListener("change", syncModeVisibility);
document.getElementById("f-dns-mode").addEventListener("change", syncModeVisibility);
document.getElementById("btn-add-sec").addEventListener("click", () =>
document.getElementById("secondaries").appendChild(secondaryRow())
);
document.getElementById("btn-new").addEventListener("click", () => openEditor(null));
document.getElementById("btn-cancel").addEventListener("click", () => editor.close());
// Masks are parsed here so the editor accepts both spellings, matching the Go
// side's ParsePrefix. Anything malformed is reported before the request goes
// out rather than coming back as a server-side validation error.
function parsePrefix(s) {
s = (s || "").trim().replace(/^\//, "");
if (!s) throw new Error("mask is required");
if (s.includes(".")) {
const parts = s.split(".").map(Number);
if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) {
throw new Error(`"${s}" is not a dotted-quad netmask`);
}
const m = ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
let ones = 0;
while (ones < 32 && m & (1 << (31 - ones))) ones++;
if (ones < 32 && (m << ones) >>> 0) throw new Error(`"${s}" is not a contiguous netmask`);
return ones;
}
const n = Number(s);
if (!Number.isInteger(n) || n < 0 || n > 32) throw new Error(`"${s}" is not a prefix length`);
return n;
}
async function savePreset() {
const mode = document.getElementById("f-mode").value;
const dnsMode = document.getElementById("f-dns-mode").value;
let body;
try {
body = {
id: state.editing || "",
name: document.getElementById("f-name").value.trim(),
group: document.getElementById("f-group").value.trim(),
adapter: document.getElementById("f-adapter").value,
mode,
notes: document.getElementById("f-notes").value.trim(),
dns: {
mode: dnsMode,
servers:
dnsMode === "static"
? document.getElementById("f-dns").value.split(",").map((s) => s.trim()).filter(Boolean)
: undefined,
},
};
if (mode === "static") {
body.primary = {
address: document.getElementById("f-address").value.trim(),
prefix: parsePrefix(document.getElementById("f-prefix").value),
gateway: document.getElementById("f-gateway").value.trim() || undefined,
gateway_metric: Number(document.getElementById("f-metric").value) || undefined,
};
const secs = [];
for (const row of document.querySelectorAll("#secondaries .sec-row")) {
const a = row.querySelector(".sec-addr").value.trim();
if (!a) continue;
secs.push({ address: a, prefix: parsePrefix(row.querySelector(".sec-prefix").value) });
}
if (secs.length) body.secondary = secs;
}
} catch (e) {
alertBox("danger", e.message, "editor-error");
return;
}
try {
const path = state.editing ? `/api/presets/${encodeURIComponent(state.editing)}` : "/api/presets";
await api(path, { method: state.editing ? "PUT" : "POST", body: JSON.stringify(body) });
editor.close();
await loadAll();
alertBox("ok", "Preset saved.");
} catch (e) {
alertBox("danger", e.message, "editor-error");
}
}
document.getElementById("btn-save").addEventListener("click", savePreset);
async function deletePreset(id) {
const p = state.presets.find((x) => x.id === id);
if (!confirm(`Delete preset "${p ? p.name : id}"?`)) return;
try {
await api(`/api/presets/${encodeURIComponent(id)}`, { method: "DELETE" });
await loadAll();
alertBox("ok", "Preset deleted.");
} catch (e) {
alertBox("danger", e.message);
}
}
// --- settings ---
const settingsDlg = document.getElementById("settings");
document.getElementById("btn-settings").addEventListener("click", () => {
const s = state.settings || {};
document.getElementById("s-mask").value = s.mask_style || "prefix";
document.getElementById("s-startup").checked = !!s.start_with_windows;
document.getElementById("s-updates").checked = !!s.check_updates;
document.getElementById("s-repo").value = s.update_repo || "";
settingsDlg.showModal();
});
document.getElementById("btn-settings-cancel").addEventListener("click", () => settingsDlg.close());
document.getElementById("btn-settings-save").addEventListener("click", async () => {
try {
await api("/api/settings", {
method: "PUT",
body: JSON.stringify({
mask_style: document.getElementById("s-mask").value,
start_with_windows: document.getElementById("s-startup").checked,
check_updates: document.getElementById("s-updates").checked,
update_repo: document.getElementById("s-repo").value.trim(),
}),
});
settingsDlg.close();
await loadAll();
alertBox("ok", "Settings saved.");
} catch (e) {
alertBox("danger", e.message);
}
});
// --- import / export ---
document.getElementById("btn-export").addEventListener("click", () => {
// A plain navigation, so the browser's own download UI picks the location.
location.href = "/api/export";
});
const importDlg = document.getElementById("import");
document.getElementById("btn-import").addEventListener("click", () => {
document.getElementById("import-error").innerHTML = "";
importDlg.showModal();
});
document.getElementById("btn-import-cancel").addEventListener("click", () => importDlg.close());
document.getElementById("btn-import-go").addEventListener("click", async () => {
const file = document.getElementById("i-file").files[0];
if (!file) {
alertBox("danger", "Choose a file first.", "import-error");
return;
}
const mode = document.getElementById("i-mode").value;
if (mode === "replace" && !confirm("Replace discards every preset you currently have. Continue?")) {
return;
}
try {
const res = await api(`/api/import?mode=${mode}`, { method: "POST", body: await file.text() });
importDlg.close();
await loadAll();
let msg = `Imported ${res.added} preset${res.added === 1 ? "" : "s"}.`;
if (res.renamed && res.renamed.length) {
msg += ` ${res.renamed.length} had an id collision and were kept alongside the originals.`;
}
alertBox("ok", msg);
} catch (e) {
alertBox("danger", e.message, "import-error");
}
});
// --- go ---
loadAll().catch((e) => alertBox("danger", `Could not load: ${e.message}`));
+193
View File
@@ -0,0 +1,193 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ipswap — presets</title>
<link rel="stylesheet" href="/app.css">
</head>
<body>
<div class="wrap">
<header class="top">
<div>
<h1>ipswap</h1>
<div class="dim">Preset editor · this page closes itself when you stop using it</div>
</div>
<div class="btn-row">
<button id="theme-toggle" class="ghost small">Light</button>
<button id="btn-settings" class="ghost small">Settings</button>
</div>
</header>
<div id="alerts"></div>
<div class="stat-row" id="stats"></div>
<div class="card">
<div class="btn-row" style="justify-content: space-between;">
<h2 style="margin:0">Presets</h2>
<div class="btn-row">
<button id="btn-import">Import…</button>
<button id="btn-export">Export</button>
<button id="btn-new" class="primary">New preset</button>
</div>
</div>
</div>
<div id="groups"></div>
<div class="card">
<h2>Adapters on this machine</h2>
<div class="table-scroll">
<table>
<thead>
<tr><th>Name</th><th>Description</th><th>Current IPv4</th><th>State</th></tr>
</thead>
<tbody id="adapters"><tr><td colspan="4"><span class="spinner"></span></td></tr></tbody>
</table>
</div>
</div>
</div>
<!-- preset editor -->
<dialog id="editor">
<form method="dialog" id="editor-form">
<h2 id="editor-title">New preset</h2>
<div id="editor-error"></div>
<div class="field-row">
<div class="field">
<label for="f-name">Name</label>
<input id="f-name" required placeholder="Artist frame — control">
</div>
<div class="field">
<label for="f-group">Group</label>
<input id="f-group" list="group-list" placeholder="Riedel">
<datalist id="group-list"></datalist>
</div>
</div>
<div class="field-row">
<div class="field">
<label for="f-adapter">Adapter</label>
<select id="f-adapter"></select>
</div>
<div class="field">
<label for="f-mode">Mode</label>
<select id="f-mode">
<option value="static">Static</option>
<option value="dhcp">DHCP</option>
</select>
</div>
</div>
<div id="static-fields">
<div class="field-row">
<div class="field">
<label for="f-address">Address</label>
<input id="f-address" placeholder="192.168.42.100">
</div>
<div class="field">
<label for="f-prefix">Mask <span class="dim">(/24 or 255.255.255.0)</span></label>
<input id="f-prefix" placeholder="/24">
</div>
</div>
<div class="field-row">
<div class="field">
<label for="f-gateway">Gateway <span class="dim">(optional)</span></label>
<input id="f-gateway" placeholder="192.168.42.1">
</div>
<div class="field">
<label for="f-metric">Gateway metric <span class="dim">(0 = automatic)</span></label>
<input id="f-metric" type="number" min="0" value="0">
</div>
</div>
<div class="field">
<label>Secondary addresses</label>
<div class="sec-list" id="secondaries"></div>
<button type="button" id="btn-add-sec" class="small" style="margin-top:8px">Add secondary</button>
</div>
</div>
<div class="field">
<label for="f-dns-mode">DNS</label>
<select id="f-dns-mode">
<option value="static">Static</option>
<option value="dhcp">From DHCP</option>
</select>
</div>
<div class="field" id="dns-servers-field">
<label for="f-dns">DNS servers <span class="dim">(comma separated, order matters)</span></label>
<input id="f-dns" placeholder="192.168.42.1, 1.1.1.1">
</div>
<div class="field">
<label for="f-notes">Notes</label>
<input id="f-notes" placeholder="Frame A, rack 3">
</div>
<div class="btn-row" style="justify-content: flex-end; margin-top: 16px;">
<button type="button" id="btn-cancel" class="ghost">Cancel</button>
<button type="button" id="btn-save" class="primary">Save</button>
</div>
</form>
</dialog>
<!-- settings -->
<dialog id="settings">
<h2>Settings</h2>
<div class="field">
<label for="s-mask">Mask display</label>
<select id="s-mask">
<option value="prefix">Prefix (/24)</option>
<option value="dotted">Dotted (255.255.255.0)</option>
</select>
</div>
<div class="field check">
<input type="checkbox" id="s-startup">
<label for="s-startup">Start ipswap when Windows starts</label>
</div>
<div class="field check">
<input type="checkbox" id="s-updates">
<label for="s-updates">Check for updates on startup</label>
</div>
<div class="field">
<label for="s-repo">Update repository <span class="dim">(https://host/owner/repo — blank disables)</span></label>
<input id="s-repo" placeholder="https://gitea.apointless.space/bsncubed/ipswap">
</div>
<div class="btn-row" style="justify-content: flex-end; margin-top: 16px;">
<button type="button" id="btn-settings-cancel" class="ghost">Cancel</button>
<button type="button" id="btn-settings-save" class="primary">Save</button>
</div>
</dialog>
<!-- import -->
<dialog id="import">
<h2>Import presets</h2>
<div class="alert info">
The file is validated in full before anything is written. If it is rejected, nothing changes.
</div>
<div class="field">
<label for="i-file">Preset pack (.json)</label>
<input type="file" id="i-file" accept="application/json,.json">
</div>
<div class="field">
<label for="i-mode">Mode</label>
<select id="i-mode">
<option value="merge">Merge — keep what I have, add these</option>
<option value="replace">Replace — discard my existing presets</option>
</select>
</div>
<div id="import-error"></div>
<div class="btn-row" style="justify-content: flex-end; margin-top: 16px;">
<button type="button" id="btn-import-cancel" class="ghost">Cancel</button>
<button type="button" id="btn-import-go" class="primary">Import</button>
</div>
</dialog>
<script src="/app.js"></script>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
package tray
import (
"errors"
"os"
"path/filepath"
"gitea.apointless.space/bsncubed/ipswap/internal/config"
"gitea.apointless.space/bsncubed/ipswap/internal/netcfg"
)
// isElevationErr reports whether a failed apply was refused for lack of
// administrator rights, which is the one failure that has a useful next step.
func isElevationErr(err error) bool {
return errors.Is(err, netcfg.ErrElevationRequired)
}
// tempDir is where update downloads land.
func tempDir() string {
return filepath.Join(os.TempDir(), config.AppName)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

+430
View File
@@ -0,0 +1,430 @@
// Package tray is the fast path: the tray icon, its grouped preset menu, and
// the confirm-then-apply flow.
//
// Everything here is driven by systray's callbacks, which run on the UI
// goroutine. Nothing slow may happen inline — a netsh apply takes one to three
// seconds and would freeze the menu — so every click handler hands off to a
// goroutine immediately.
package tray
import (
"context"
_ "embed"
"fmt"
"log"
"sync"
"time"
"fyne.io/systray"
"gitea.apointless.space/bsncubed/ipswap/internal/config"
"gitea.apointless.space/bsncubed/ipswap/internal/desktop"
"gitea.apointless.space/bsncubed/ipswap/internal/dialog"
"gitea.apointless.space/bsncubed/ipswap/internal/elevate"
"gitea.apointless.space/bsncubed/ipswap/internal/netcfg"
"gitea.apointless.space/bsncubed/ipswap/internal/preset"
"gitea.apointless.space/bsncubed/ipswap/internal/server"
"gitea.apointless.space/bsncubed/ipswap/internal/updater"
)
//go:embed icon.ico
var iconICO []byte
// App owns the tray and everything it talks to.
type App struct {
Store *preset.Store
Manager netcfg.Manager
Server *server.Server
Paths config.Paths
Version string
mu sync.Mutex
settings config.Settings
// activeID is the preset currently matching the live adapter state, empty
// when nothing matches.
activeID string
// pendingUpdate is set once the update check finds a newer release.
pendingUpdate *updater.Update
// menuGen is closed and replaced on every menu rebuild, so the click
// listeners attached to the previous set of items exit instead of leaking
// a goroutine per rebuild.
menuGen chan struct{}
titleItem *systray.MenuItem
updateItem *systray.MenuItem
// presetItems maps preset id to its menu entry, for the check marks.
presetItems map[string]*systray.MenuItem
elevated bool
}
// New builds the tray application.
func New(store *preset.Store, mgr netcfg.Manager, srv *server.Server, paths config.Paths, settings config.Settings, version string) *App {
return &App{
Store: store,
Manager: mgr,
Server: srv,
Paths: paths,
Version: version,
settings: settings,
elevated: elevate.IsElevated(),
}
}
// Run blocks until the user exits. It must be called from the main goroutine.
func (a *App) Run() {
a.Server.OnPresetsChange(func() {
// The editor changed something; rebuild on the UI goroutine.
go a.rebuild()
})
a.Server.OnSettingsChange(a.applySettings)
systray.Run(a.onReady, a.onExit)
}
func (a *App) onReady() {
systray.SetIcon(iconICO)
systray.SetTitle(config.AppName)
systray.SetTooltip(config.AppName)
a.build()
go a.pollActive()
go a.checkUpdatesOnStartup()
}
func (a *App) onExit() {
a.Server.Stop()
log.Printf("%s exiting", config.AppName)
}
// --- menu construction ---
func (a *App) rebuild() {
systray.ResetMenu()
a.build()
a.refreshActive()
}
func (a *App) build() {
a.mu.Lock()
if a.menuGen != nil {
close(a.menuGen) // retire the previous generation's listeners
}
gen := make(chan struct{})
a.menuGen = gen
pending := a.pendingUpdate
a.mu.Unlock()
// Update offer sits at the very top when there is one.
updateItem := systray.AddMenuItem("", "A newer release is available")
updateItem.Hide()
if pending != nil {
updateItem.SetTitle(fmt.Sprintf("Update available — v%s", pending.Version))
updateItem.Show()
}
a.onClick(gen, updateItem, a.doUpdate)
titleItem := systray.AddMenuItem(config.AppName, "The preset currently applied")
titleItem.Disable()
systray.AddSeparator()
// Groups become submenus. A flat list of fifty presets is unusable, so
// this is not optional even when there are only a handful.
presets := a.Store.All()
if len(presets) == 0 {
empty := systray.AddMenuItem("No presets yet — use Manage presets…", "")
empty.Disable()
}
items := make(map[string]*systray.MenuItem, len(presets))
var currentGroup string
var sub *systray.MenuItem
for _, p := range presets {
g := p.GroupOrUngrouped()
if g != currentGroup || sub == nil {
currentGroup = g
sub = systray.AddMenuItem(g, "")
}
item := sub.AddSubMenuItemCheckbox(p.Name, p.Notes, false)
items[p.ID] = item
id := p.ID
a.onClick(gen, item, func() { a.applyPreset(id) })
}
// The menu is built on the UI goroutine but read by the active-preset
// poller, so every handle it touches is published under the lock.
a.mu.Lock()
a.presetItems = items
a.titleItem = titleItem
a.updateItem = updateItem
a.mu.Unlock()
systray.AddSeparator()
manage := systray.AddMenuItem("Manage presets…", "Open the preset editor in your browser")
a.onClick(gen, manage, a.openEditor)
check := systray.AddMenuItem("Check for updates", "")
a.onClick(gen, check, a.checkUpdatesNow)
if !a.elevated {
systray.AddSeparator()
relaunch := systray.AddMenuItem("Relaunch as administrator", "Applying a preset needs an elevated process")
a.onClick(gen, relaunch, a.relaunch)
}
systray.AddSeparator()
quit := systray.AddMenuItem("Exit", "")
a.onClick(gen, quit, func() { systray.Quit() })
}
// onClick runs fn on its own goroutine for every click, until this menu
// generation is retired.
func (a *App) onClick(gen <-chan struct{}, item *systray.MenuItem, fn func()) {
go func() {
for {
select {
case <-gen:
return
case _, ok := <-item.ClickedCh:
if !ok {
return
}
go fn()
}
}
}()
}
// --- actions ---
// applyPreset is the whole fast path: read the live config, show the diff,
// and on a Yes run the netsh plan.
func (a *App) applyPreset(id string) {
p, ok := a.Store.Get(id)
if !ok {
dialog.Error(config.AppName, "That preset no longer exists.")
return
}
// Read live rather than reusing the poll's cached view: this text is the
// only safety net before a destructive change and it must not be stale.
live, err := a.Manager.Current(p.Adapter)
if err != nil {
dialog.Error(config.AppName, fmt.Sprintf(
"Could not read the current configuration of adapter %q.\n\n%v", p.Adapter, err))
return
}
if !dialog.Confirm(config.AppName, netcfg.ConfirmText(p, live)) {
log.Printf("apply of %q cancelled at the prompt", p.Name)
return
}
log.Printf("applying preset %q to %q", p.Name, p.Adapter)
plan := netcfg.Plan(p, live)
if err := a.Manager.Apply(plan); err != nil {
log.Printf("apply of %q failed: %v", p.Name, err)
if isElevationErr(err) && !a.elevated {
if dialog.ErrorWithRetryAsAdmin(config.AppName,
fmt.Sprintf("Applying %q needs administrator rights.", p.Name)) {
a.relaunch()
}
return
}
dialog.Error(config.AppName, fmt.Sprintf("Applying %q failed.\n\n%v", p.Name, err))
return
}
log.Printf("applied preset %q", p.Name)
a.refreshActive()
}
func (a *App) openEditor() {
url, err := a.Server.Start()
if err != nil {
dialog.Error(config.AppName, fmt.Sprintf("Could not start the preset editor.\n\n%v", err))
return
}
if err := desktop.OpenURL(url); err != nil {
// The server is up, so give the user the address rather than just failing.
dialog.Error(config.AppName, fmt.Sprintf(
"Could not open your browser. Paste this address into it:\n\n%s\n\n%v", url, err))
}
}
func (a *App) relaunch() {
if err := elevate.RelaunchAsAdmin(); err != nil {
log.Printf("relaunch as admin failed: %v", err)
return
}
systray.Quit()
}
func (a *App) applySettings(c config.Settings) {
a.mu.Lock()
a.settings = c
a.mu.Unlock()
if err := desktop.SetRunAtLogin(c.StartWithWindows); err != nil {
log.Printf("updating the start-with-Windows setting: %v", err)
}
}
func (a *App) currentSettings() config.Settings {
a.mu.Lock()
defer a.mu.Unlock()
return a.settings
}
// --- active preset detection ---
// pollActive re-reads the live adapter state on a timer so the check mark and
// tooltip stay right when something outside ipswap changes the network.
func (a *App) pollActive() {
a.refreshActive()
interval := time.Duration(a.currentSettings().ActivePollSeconds) * time.Second
t := time.NewTicker(interval)
defer t.Stop()
for range t.C {
a.refreshActive()
}
}
func (a *App) refreshActive() {
presets := a.Store.All()
// One read per distinct adapter rather than one per preset: fifty presets
// across three adapters is three reads, not fifty.
live := map[string]netcfg.LiveConfig{}
for _, p := range presets {
if _, done := live[p.Adapter]; done {
continue
}
cfg, err := a.Manager.Current(p.Adapter)
if err != nil {
continue
}
live[p.Adapter] = cfg
}
activeID, activeName := "", ""
for _, p := range presets {
cfg, ok := live[p.Adapter]
if ok && netcfg.Matches(p, cfg) {
activeID, activeName = p.ID, p.Name
break
}
}
a.mu.Lock()
a.activeID = activeID
items := a.presetItems
title := a.titleItem
a.mu.Unlock()
for id, item := range items {
if id == activeID {
item.Check()
} else {
item.Uncheck()
}
}
label := config.AppName + " — unmatched"
if activeName != "" {
label = config.AppName + " — " + activeName
}
systray.SetTooltip(label)
if title != nil {
title.SetTitle(label)
}
}
// --- updates ---
func (a *App) checkUpdatesOnStartup() {
if !a.currentSettings().CheckUpdates {
return
}
// Never block startup on the network, and never surface a failure: a
// laptop on a customer site usually cannot reach the Gitea host.
if _, err := a.checkUpdates(); err != nil {
log.Printf("startup update check failed (ignored): %v", err)
}
}
// checkUpdatesNow is the menu-driven check, which does report its result.
func (a *App) checkUpdatesNow() {
up, err := a.checkUpdates()
switch {
case err != nil:
dialog.Error(config.AppName, fmt.Sprintf("Could not check for updates.\n\n%v", err))
case up == nil:
dialog.Info(config.AppName, fmt.Sprintf("%s v%s is up to date.", config.AppName, a.Version))
default:
dialog.Info(config.AppName, fmt.Sprintf(
"%s v%s is available.\n\nChoose \"Update available\" in the tray menu to install it.", config.AppName, up.Version))
}
}
func (a *App) checkUpdates() (*updater.Update, error) {
s := a.currentSettings()
if s.UpdateRepo == "" {
return nil, fmt.Errorf("no update repository is configured (set one in Settings)")
}
c := &updater.Checker{Repo: s.UpdateRepo, Current: a.Version}
up, err := c.Check(context.Background())
if err != nil {
return nil, err
}
a.mu.Lock()
a.pendingUpdate = up
item := a.updateItem
a.mu.Unlock()
if up != nil && item != nil {
item.SetTitle(fmt.Sprintf("Update available — v%s", up.Version))
item.Show()
}
return up, nil
}
// doUpdate downloads, verifies and installs. Nothing here happens without the
// user having clicked the update item first.
func (a *App) doUpdate() {
a.mu.Lock()
up := a.pendingUpdate
repo := a.settings.UpdateRepo
a.mu.Unlock()
if up == nil {
return
}
if !dialog.Confirm(config.AppName, fmt.Sprintf(
"Download and install %s v%s?\n\n%s will close and reopen once the update is in place.",
config.AppName, up.Version, config.AppName)) {
return
}
c := &updater.Checker{Repo: repo, Current: a.Version}
path, err := c.Download(context.Background(), up, tempDir())
if err != nil {
dialog.Error(config.AppName, fmt.Sprintf("The update could not be downloaded.\n\n%v", err))
return
}
if err := updater.InstallAndRestart(path); err != nil {
dialog.Error(config.AppName, fmt.Sprintf("The update could not be installed.\n\n%v", err))
return
}
systray.Quit()
}
+11
View File
@@ -0,0 +1,11 @@
//go:build !windows
package updater
import "errors"
// InstallAndRestart is Windows-only: the swap helper is a batch file and the
// thing being replaced is an .exe.
func InstallAndRestart(downloadedExe string) error {
return errors.New("in-place update is only supported on Windows")
}
+95
View File
@@ -0,0 +1,95 @@
//go:build windows
package updater
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"syscall"
)
// swapScript waits for ipswap to exit, replaces the binary, restarts it and
// deletes itself.
//
// A running .exe on Windows is locked against overwriting, so the swap cannot
// be done by the process being replaced — it has to outlive it. A tiny batch
// file is the least machinery that does this without shipping a second binary.
//
// %1 is the PID to wait for, %2 the freshly downloaded exe, %3 the exe to
// replace. The tasklist/find pair is the standard "is this PID still alive"
// idiom; `timeout` rather than `ping -n` keeps it readable.
const swapScript = `@echo off
setlocal
set PID=%~1
set NEWEXE=%~2
set TARGET=%~3
rem Give up after ~30s rather than spinning forever if the old process hangs.
set /a TRIES=0
:wait
tasklist /FI "PID eq %PID%" 2>nul | find "%PID%" >nul
if errorlevel 1 goto swap
set /a TRIES+=1
if %TRIES% GEQ 30 goto giveup
timeout /t 1 /nobreak >nul
goto wait
:swap
copy /y "%NEWEXE%" "%TARGET%" >nul
if errorlevel 1 goto giveup
del /q "%NEWEXE%" >nul 2>&1
start "" "%TARGET%"
goto done
:giveup
rem Leave the download in place so the user can swap it in by hand.
exit /b 1
:done
endlocal
rem Delete this script last; cmd tolerates a batch file removing itself.
del /q "%~f0" >nul 2>&1
`
// InstallAndRestart writes the swap helper, launches it detached, and returns.
// The caller must exit promptly afterwards — the helper is already waiting on
// this process to go away.
//
// This is only ever reached from an explicit click on "Update available": the
// updater never installs on its own.
func InstallAndRestart(downloadedExe string) error {
target, err := os.Executable()
if err != nil {
return fmt.Errorf("locating the running executable: %w", err)
}
target, err = filepath.Abs(target)
if err != nil {
return err
}
script := filepath.Join(os.TempDir(), "ipswap-update.cmd")
if err := os.WriteFile(script, []byte(swapScript), 0o755); err != nil {
return fmt.Errorf("writing the update helper: %w", err)
}
cmd := exec.Command("cmd.exe", "/c", script,
strconv.Itoa(os.Getpid()), downloadedExe, target)
// Detach: the helper has to survive this process exiting, and it must not
// flash a console window while it waits.
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: windowsCreateNoWindow | windowsDetachedProcess,
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("starting the update helper: %w", err)
}
return cmd.Process.Release()
}
const (
windowsDetachedProcess = 0x00000008
windowsCreateNoWindow = 0x08000000
)
+102
View File
@@ -0,0 +1,102 @@
package updater
import (
"fmt"
"strconv"
"strings"
)
// Version is a parsed semantic version. Only what a release-tag comparison
// needs is modelled: major.minor.patch plus an optional pre-release, which
// sorts before the same version without one.
type Version struct {
Major, Minor, Patch int
Pre string
}
// ParseVersion accepts "v1.2.0", "1.2.0", "1.2", "1" and "1.2.0-rc1". Build
// metadata after "+" is ignored, as semver says it must be for ordering.
func ParseVersion(s string) (Version, error) {
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "v")
if s == "" {
return Version{}, fmt.Errorf("empty version")
}
if i := strings.IndexByte(s, '+'); i >= 0 {
s = s[:i]
}
var v Version
if i := strings.IndexByte(s, '-'); i >= 0 {
v.Pre = s[i+1:]
s = s[:i]
}
parts := strings.Split(s, ".")
if len(parts) > 3 {
return Version{}, fmt.Errorf("%q has too many components", s)
}
dst := []*int{&v.Major, &v.Minor, &v.Patch}
for i, p := range parts {
n, err := strconv.Atoi(p)
if err != nil || n < 0 {
return Version{}, fmt.Errorf("%q is not a version number", s)
}
*dst[i] = n
}
return v, nil
}
// Compare returns -1, 0 or 1 as v sorts before, equal to, or after other.
func (v Version) Compare(other Version) int {
for _, pair := range [][2]int{
{v.Major, other.Major},
{v.Minor, other.Minor},
{v.Patch, other.Patch},
} {
if pair[0] != pair[1] {
if pair[0] < pair[1] {
return -1
}
return 1
}
}
// A pre-release sorts before the release it leads up to.
switch {
case v.Pre == "" && other.Pre == "":
return 0
case v.Pre == "":
return 1
case other.Pre == "":
return -1
case v.Pre < other.Pre:
return -1
case v.Pre > other.Pre:
return 1
}
return 0
}
func (v Version) String() string {
s := fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch)
if v.Pre != "" {
s += "-" + v.Pre
}
return s
}
// IsNewer reports whether candidate is a strictly later version than current.
// An unparseable version on either side means "no update", because offering a
// download on the strength of a tag we could not read is worse than silence.
func IsNewer(candidate, current string) bool {
c, err := ParseVersion(candidate)
if err != nil {
return false
}
cur, err := ParseVersion(current)
if err != nil {
return false
}
return c.Compare(cur) > 0
}
+63
View File
@@ -0,0 +1,63 @@
package updater
import "testing"
func TestParseVersion(t *testing.T) {
cases := []struct {
in string
want Version
}{
{"v1.2.0", Version{1, 2, 0, ""}},
{"1.2.0", Version{1, 2, 0, ""}},
{"1.2", Version{1, 2, 0, ""}},
{"1", Version{1, 0, 0, ""}},
{"v1.2.0-rc1", Version{1, 2, 0, "rc1"}},
{"1.2.0+build7", Version{1, 2, 0, ""}},
{" v0.1.0 ", Version{0, 1, 0, ""}},
}
for _, tc := range cases {
got, err := ParseVersion(tc.in)
if err != nil {
t.Errorf("ParseVersion(%q) errored: %v", tc.in, err)
continue
}
if got != tc.want {
t.Errorf("ParseVersion(%q) = %+v, want %+v", tc.in, got, tc.want)
}
}
for _, in := range []string{"", "v", "abc", "1.2.3.4", "1.x", "-1.0.0"} {
if got, err := ParseVersion(in); err == nil {
t.Errorf("ParseVersion(%q) = %+v, want error", in, got)
}
}
}
func TestIsNewer(t *testing.T) {
yes := [][2]string{
{"v1.2.1", "v1.2.0"},
{"v1.3.0", "v1.2.9"},
{"v2.0.0", "v1.99.99"},
{"v1.2.0", "v1.2.0-rc1"},
}
for _, tc := range yes {
if !IsNewer(tc[0], tc[1]) {
t.Errorf("IsNewer(%q, %q) = false, want true", tc[0], tc[1])
}
}
no := [][2]string{
{"v1.2.0", "v1.2.0"},
{"v1.2.0", "v1.2.1"},
{"v1.2.0-rc1", "v1.2.0"},
// An unreadable tag must never trigger an update offer.
{"garbage", "v1.2.0"},
{"v1.2.0", "garbage"},
{"", "v1.0.0"},
}
for _, tc := range no {
if IsNewer(tc[0], tc[1]) {
t.Errorf("IsNewer(%q, %q) = true, want false", tc[0], tc[1])
}
}
}
+266
View File
@@ -0,0 +1,266 @@
// Package updater checks a Gitea repository for a newer release and, on
// request, downloads and verifies the new binary.
//
// Two rules shape the whole package: it never installs on its own, and it never
// blocks startup. The check runs in a goroutine behind a short timeout and
// fails silently into the log, because a laptop on a customer site frequently
// has no route to the Gitea host and that must not be a visible error.
package updater
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"time"
)
// checkTimeout bounds the startup release check.
const checkTimeout = 8 * time.Second
// maxAssetBytes caps a download. The binary is a few MB; anything near this is
// a wrong URL or a captive portal serving a login page.
const maxAssetBytes = 128 << 20
// Release is the subset of Gitea's release JSON that matters here.
type Release struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
Body string `json:"body"`
Assets []Asset `json:"assets"`
HTMLURL string `json:"html_url"`
}
// Asset is one file attached to a release.
type Asset struct {
Name string `json:"name"`
Size int64 `json:"size"`
BrowserDownloadURL string `json:"browser_download_url"`
}
// Checker polls one repository.
type Checker struct {
// Repo is the repository as "https://gitea.example.com/owner/repo".
Repo string
// Current is the compiled-in version, from -X main.version.
Current string
HTTP *http.Client
}
// Update describes an available newer release.
type Update struct {
Version string
Release Release
Asset Asset
// SHA256 is the expected checksum, empty if the release published none.
SHA256 string
}
// apiURL turns a repo browse URL into its releases/latest API endpoint.
func apiURL(repo string) (string, error) {
repo = strings.TrimSuffix(strings.TrimSpace(repo), "/")
if repo == "" {
return "", fmt.Errorf("no update repository configured")
}
u, err := url.Parse(repo)
if err != nil {
return "", fmt.Errorf("update repo %q is not a URL: %w", repo, err)
}
if u.Scheme != "https" && u.Scheme != "http" {
return "", fmt.Errorf("update repo %q must be an http(s) URL", repo)
}
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", fmt.Errorf("update repo %q should look like https://host/owner/repo", repo)
}
u.Path = path.Join("/api/v1/repos", parts[0], parts[1], "releases/latest")
return u.String(), nil
}
func (c *Checker) client() *http.Client {
if c.HTTP != nil {
return c.HTTP
}
return &http.Client{Timeout: checkTimeout}
}
// Check asks for the latest release and returns an Update if it is newer than
// the running build. A nil Update with a nil error means "up to date".
func (c *Checker) Check(ctx context.Context) (*Update, error) {
endpoint, err := apiURL(c.Repo)
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(ctx, checkTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
resp, err := c.client().Do(req)
if err != nil {
return nil, fmt.Errorf("contacting %s: %w", endpoint, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s returned %s", endpoint, resp.Status)
}
var rel Release
if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&rel); err != nil {
return nil, fmt.Errorf("parsing the release response: %w", err)
}
if !IsNewer(rel.TagName, c.Current) {
return nil, nil
}
asset, ok := pickExe(rel.Assets)
if !ok {
return nil, fmt.Errorf("release %s has no .exe asset", rel.TagName)
}
return &Update{
Version: strings.TrimPrefix(rel.TagName, "v"),
Release: rel,
Asset: asset,
SHA256: findChecksum(rel, asset.Name),
}, nil
}
func pickExe(assets []Asset) (Asset, bool) {
for _, a := range assets {
if strings.HasSuffix(strings.ToLower(a.Name), ".exe") {
return a, true
}
}
return Asset{}, false
}
var sha256Re = regexp.MustCompile(`\b([a-fA-F0-9]{64})\b`)
// findChecksum digs the expected SHA256 out of the release body. The body is
// scanned for a line naming the asset; a bare 64-hex string anywhere is
// accepted as a fallback for a release that publishes only the one checksum.
//
// A SHA256SUMS sibling asset is handled by the caller, which can fetch it — it
// is not available from the release JSON alone.
func findChecksum(rel Release, assetName string) string {
for _, line := range strings.Split(rel.Body, "\n") {
if strings.Contains(line, assetName) {
if m := sha256Re.FindStringSubmatch(line); m != nil {
return strings.ToLower(m[1])
}
}
}
if m := sha256Re.FindStringSubmatch(rel.Body); m != nil {
return strings.ToLower(m[1])
}
return ""
}
// SumsAsset finds a SHA256SUMS-style asset in the release, if there is one.
func (u *Update) SumsAsset() (Asset, bool) {
for _, a := range u.Release.Assets {
n := strings.ToUpper(a.Name)
if strings.Contains(n, "SHA256") {
return a, true
}
}
return Asset{}, false
}
// Download fetches the update's .exe into dir and verifies its SHA256 before
// returning the path. A release with no published checksum is refused: an
// unverified binary that we are about to swap in and run is not worth the
// convenience.
func (c *Checker) Download(ctx context.Context, u *Update, dir string) (string, error) {
want := u.SHA256
if want == "" {
// Try the sibling SHA256SUMS asset before giving up.
if sums, ok := u.SumsAsset(); ok {
body, err := c.fetch(ctx, sums.BrowserDownloadURL, 1<<20)
if err != nil {
return "", fmt.Errorf("fetching %s: %w", sums.Name, err)
}
want = checksumFor(string(body), u.Asset.Name)
}
}
if want == "" {
return "", fmt.Errorf("release %s publishes no SHA256 for %s; refusing to install an unverified binary", u.Release.TagName, u.Asset.Name)
}
body, err := c.fetch(ctx, u.Asset.BrowserDownloadURL, maxAssetBytes)
if err != nil {
return "", fmt.Errorf("downloading %s: %w", u.Asset.Name, err)
}
sum := sha256.Sum256(body)
got := hex.EncodeToString(sum[:])
if !strings.EqualFold(got, want) {
return "", fmt.Errorf("checksum mismatch for %s:\n expected %s\n got %s", u.Asset.Name, want, got)
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
dest := filepath.Join(dir, u.Asset.Name)
if err := os.WriteFile(dest, body, 0o755); err != nil {
return "", fmt.Errorf("writing %s: %w", dest, err)
}
return dest, nil
}
func (c *Checker) fetch(ctx context.Context, rawURL string, limit int64) ([]byte, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
resp, err := c.client().Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("server returned %s", resp.Status)
}
return io.ReadAll(io.LimitReader(resp.Body, limit))
}
// checksumFor pulls one file's hash out of a `sha256sum` style listing.
func checksumFor(sums, name string) string {
for _, line := range strings.Split(sums, "\n") {
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
// The name may carry sha256sum's binary-mode "*" prefix.
if strings.TrimPrefix(fields[len(fields)-1], "*") == name {
if sha256Re.MatchString(fields[0]) {
return strings.ToLower(fields[0])
}
}
}
return ""
}
+94
View File
@@ -0,0 +1,94 @@
package updater
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestAPIURL(t *testing.T) {
got, err := apiURL("https://gitea.apointless.space/bsncubed/ipswap")
if err != nil {
t.Fatal(err)
}
want := "https://gitea.apointless.space/api/v1/repos/bsncubed/ipswap/releases/latest"
if got != want {
t.Errorf("apiURL = %q, want %q", got, want)
}
if _, err := apiURL("https://gitea.apointless.space/bsncubed/ipswap/"); err != nil {
t.Errorf("a trailing slash should be tolerated: %v", err)
}
for _, bad := range []string{"", "not a url", "https://host", "https://host/a/b/c"} {
if _, err := apiURL(bad); err == nil {
t.Errorf("apiURL(%q) should have failed", bad)
}
}
}
func TestCheckFindsNewerRelease(t *testing.T) {
rel := Release{
TagName: "v1.2.0",
Body: "ipswap.exe e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n",
Assets: []Asset{
{Name: "SHA256SUMS", BrowserDownloadURL: "http://x/sums"},
{Name: "ipswap.exe", BrowserDownloadURL: "http://x/ipswap.exe"},
},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(rel)
}))
defer srv.Close()
c := &Checker{Repo: srv.URL + "/owner/repo", Current: "v1.1.0"}
up, err := c.Check(context.Background())
if err != nil {
t.Fatal(err)
}
if up == nil {
t.Fatal("expected an update, got nil")
}
if up.Version != "1.2.0" {
t.Errorf("version = %q, want 1.2.0", up.Version)
}
if up.Asset.Name != "ipswap.exe" {
t.Errorf("picked asset %q, want ipswap.exe", up.Asset.Name)
}
if up.SHA256 != "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" {
t.Errorf("checksum = %q", up.SHA256)
}
}
func TestCheckIgnoresOlderRelease(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(Release{TagName: "v1.0.0"})
}))
defer srv.Close()
c := &Checker{Repo: srv.URL + "/owner/repo", Current: "v1.2.0"}
up, err := c.Check(context.Background())
if err != nil {
t.Fatal(err)
}
if up != nil {
t.Errorf("expected no update, got %+v", up)
}
}
func TestChecksumFor(t *testing.T) {
sums := "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ipswap.exe\n" +
"aaaabbbbccccddddeeeeffff00001111222233334444555566667777888899990 other.zip\n"
got := checksumFor(sums, "ipswap.exe")
if got != "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" {
t.Errorf("checksumFor = %q", got)
}
if checksumFor(sums, "missing.exe") != "" {
t.Error("expected empty checksum for an absent file")
}
// sha256sum's binary-mode marker must not defeat the lookup.
if checksumFor("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 *ipswap.exe", "ipswap.exe") == "" {
t.Error("binary-mode '*' prefix should be tolerated")
}
}