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