23dcfb393f
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>
235 lines
6.3 KiB
Go
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{}
|