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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user