Files
ipswap/internal/netcfg/netcfg_windows.go
T
bsncubed 23dcfb393f 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>
2026-08-20 13:29:35 +10:00

235 lines
6.3 KiB
Go

//go:build windows
package netcfg
import (
"errors"
"fmt"
"log"
"net"
"os/exec"
"strings"
"syscall"
"time"
"unsafe"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
"gitea.apointless.space/bsncubed/ipswap/internal/preset"
)
// ErrElevationRequired is returned when netsh refuses because the process token
// is not elevated. The caller turns this into the "Relaunch as administrator"
// offer rather than a bare failure message.
var ErrElevationRequired = errors.New("this operation requires an elevated process")
// ipAdapterDHCPEnabled is IP_ADAPTER_DHCP_ENABLED from iptypes.h.
const ipAdapterDHCPEnabled = 0x00000004
// Interface types we never want in the adapter picker.
const (
ifTypeSoftwareLoopback = 24
ifTypeTunnel = 131
)
// applyTimeout bounds a whole plan. A single netsh call is normally well under
// a second; anything past this is a hung call, not a slow one.
const applyTimeout = 30 * time.Second
// Windows is the real Manager.
type Windows struct{}
// New returns the platform Manager.
func New() Manager { return Windows{} }
// Adapters lists IPv4-capable, non-loopback, non-tunnel adapters.
func (Windows) Adapters() ([]Adapter, error) {
rows, err := adapterRows()
if err != nil {
return nil, err
}
var out []Adapter
for _, a := range rows {
if a.IfType == ifTypeSoftwareLoopback || a.IfType == ifTypeTunnel {
continue
}
out = append(out, Adapter{
Name: windows.UTF16PtrToString(a.FriendlyName),
Description: windows.UTF16PtrToString(a.Description),
GUID: windows.BytePtrToString(a.AdapterName),
Up: a.OperStatus == windows.IfOperStatusUp,
})
}
return out, nil
}
// Current reads one adapter's live IPv4 configuration.
func (Windows) Current(adapter string) (LiveConfig, error) {
rows, err := adapterRows()
if err != nil {
return LiveConfig{}, err
}
for _, a := range rows {
if !strings.EqualFold(windows.UTF16PtrToString(a.FriendlyName), adapter) {
continue
}
cfg := LiveConfig{
Adapter: adapter,
DHCP: a.Flags&ipAdapterDHCPEnabled != 0,
}
for ua := a.FirstUnicastAddress; ua != nil; ua = ua.Next {
ip := ua.Address.IP()
if ip == nil || ip.To4() == nil {
continue // v1 is IPv4 only
}
cfg.Addresses = append(cfg.Addresses, preset.Address{
Address: ip.String(),
Prefix: int(ua.OnLinkPrefixLength),
})
}
for ga := a.FirstGatewayAddress; ga != nil; ga = ga.Next {
if ip := ga.Address.IP(); ip != nil && ip.To4() != nil {
cfg.Gateways = append(cfg.Gateways, ip.String())
}
}
for da := a.FirstDnsServerAddress; da != nil; da = da.Next {
if ip := da.Address.IP(); ip != nil && ip.To4() != nil {
cfg.DNS = append(cfg.DNS, ip.String())
}
}
// GetAdaptersAddresses reports the resolvers in use but not whether
// they were configured or leased, and that distinction decides whether
// a DNS-from-DHCP preset matches. The registry holds it: a non-empty
// NameServer value means statically configured.
cfg.DNSFromDHCP = dnsIsFromDHCP(windows.BytePtrToString(a.AdapterName))
return cfg, nil
}
return LiveConfig{}, fmt.Errorf("adapter %q not found", adapter)
}
// Apply runs each command in order, stopping at the first failure.
func (Windows) Apply(plan []Command) error {
for _, c := range plan {
log.Printf("netsh: %s (%s)", c, c.Desc)
out, err := runNetsh(c)
if out != "" {
log.Printf("netsh output: %s", out)
}
if err != nil {
if isElevationError(out) {
return fmt.Errorf("%w\n\ncommand: %s\n%s", ErrElevationRequired, c, out)
}
return fmt.Errorf("%s failed: %w\n\n%s", c.Desc, err, out)
}
}
return nil
}
func runNetsh(c Command) (string, error) {
cmd := exec.Command("netsh", c.Args...)
// The binary is linked with -H windowsgui and has no console of its own, so
// without this every netsh call flashes a console window on screen.
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
done := make(chan struct{})
var out []byte
var err error
go func() {
out, err = cmd.CombinedOutput()
close(done)
}()
select {
case <-done:
case <-time.After(applyTimeout):
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
<-done
return string(out), fmt.Errorf("timed out after %s", applyTimeout)
}
return strings.TrimSpace(string(out)), err
}
// isElevationError sniffs netsh's refusal. netsh exits non-zero with a message
// rather than a distinguishable code, and the message is localised, so this
// also accepts the English text as the common case and falls back to the
// Win32 error number that appears in several translations.
func isElevationError(out string) bool {
l := strings.ToLower(out)
return strings.Contains(l, "requires elevation") ||
strings.Contains(l, "requested operation requires") ||
strings.Contains(l, "access is denied") ||
strings.Contains(l, "740")
}
func dnsIsFromDHCP(guid string) bool {
if guid == "" {
return true
}
key, err := registry.OpenKey(
registry.LOCAL_MACHINE,
`SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces\`+guid,
registry.QUERY_VALUE,
)
if err != nil {
// Not readable without admin on some machines; assume DHCP rather
// than falsely reporting a static configuration.
return true
}
defer key.Close()
ns, _, err := key.GetStringValue("NameServer")
if err != nil {
return true
}
return strings.TrimSpace(ns) == ""
}
// adapterRows walks the GetAdaptersAddresses linked list into a slice.
func adapterRows() ([]*windows.IpAdapterAddresses, error) {
flags := uint32(windows.GAA_FLAG_INCLUDE_GATEWAYS |
windows.GAA_FLAG_SKIP_ANYCAST |
windows.GAA_FLAG_SKIP_MULTICAST)
size := uint32(15 * 1024)
var buf []byte
for attempt := 0; attempt < 4; attempt++ {
buf = make([]byte, size)
err := windows.GetAdaptersAddresses(
windows.AF_INET, flags, 0,
(*windows.IpAdapterAddresses)(unsafe.Pointer(&buf[0])),
&size,
)
if err == nil {
break
}
// The call reports the required size in `size`; retry with it.
if err == windows.ERROR_BUFFER_OVERFLOW {
continue
}
return nil, fmt.Errorf("GetAdaptersAddresses: %w", err)
}
var out []*windows.IpAdapterAddresses
for a := (*windows.IpAdapterAddresses)(unsafe.Pointer(&buf[0])); a != nil; a = a.Next {
out = append(out, a)
}
return out, nil
}
// compile-time assertion that net is used even if the address helpers change.
var _ = net.IP{}