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