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
+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
}