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