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