//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{}