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