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,92 @@
|
||||
package netcfg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.apointless.space/bsncubed/ipswap/internal/preset"
|
||||
)
|
||||
|
||||
// ConfirmText renders the before/after body of the confirmation MessageBox.
|
||||
//
|
||||
// The live side must be read at prompt time rather than taken from the cached
|
||||
// active-preset poll: the prompt is the only safety net before a destructive
|
||||
// change, and a 30-second-stale "CURRENT" block would undermine it.
|
||||
func ConfirmText(p preset.Preset, live LiveConfig) string {
|
||||
var b strings.Builder
|
||||
|
||||
fmt.Fprintf(&b, "Apply preset %q to adapter %q?\n\n", p.Name, p.Adapter)
|
||||
|
||||
b.WriteString("CURRENT\n")
|
||||
writeLive(&b, live)
|
||||
|
||||
b.WriteString("\nNEW\n")
|
||||
writePreset(&b, p)
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func writeLive(b *strings.Builder, live LiveConfig) {
|
||||
if len(live.Addresses) == 0 {
|
||||
b.WriteString(" (no IPv4 address)\n")
|
||||
}
|
||||
for i, a := range live.Addresses {
|
||||
suffix := ""
|
||||
if live.DHCP {
|
||||
suffix = " (DHCP)"
|
||||
}
|
||||
prefix := " "
|
||||
if i > 0 {
|
||||
prefix = " + "
|
||||
}
|
||||
fmt.Fprintf(b, "%s%s%s\n", prefix, a.String(), suffix)
|
||||
}
|
||||
if len(live.Gateways) > 0 {
|
||||
fmt.Fprintf(b, " Gateway: %s\n", strings.Join(live.Gateways, ", "))
|
||||
}
|
||||
if len(live.DNS) > 0 {
|
||||
suffix := ""
|
||||
if live.DNSFromDHCP {
|
||||
suffix = " (DHCP)"
|
||||
}
|
||||
fmt.Fprintf(b, " DNS: %s%s\n", strings.Join(live.DNS, ", "), suffix)
|
||||
}
|
||||
}
|
||||
|
||||
func writePreset(b *strings.Builder, p preset.Preset) {
|
||||
if p.Mode == preset.ModeDHCP {
|
||||
b.WriteString(" DHCP\n")
|
||||
} else if p.Primary != nil {
|
||||
fmt.Fprintf(b, " %s\n", p.Primary.String())
|
||||
for _, s := range p.Secondary {
|
||||
fmt.Fprintf(b, " + %s\n", s.String())
|
||||
}
|
||||
if p.Primary.Gateway != "" {
|
||||
fmt.Fprintf(b, " Gateway: %s\n", p.Primary.Gateway)
|
||||
}
|
||||
}
|
||||
|
||||
switch p.DNS.Mode {
|
||||
case preset.ModeDHCP:
|
||||
b.WriteString(" DNS: from DHCP\n")
|
||||
default:
|
||||
fmt.Fprintf(b, " DNS: %s\n", strings.Join(p.DNS.Servers, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
// Summary is the one-line description of a live config, used for the editor's
|
||||
// adapter picker.
|
||||
func (l LiveConfig) Summary() string {
|
||||
if len(l.Addresses) == 0 {
|
||||
return "no IPv4 address"
|
||||
}
|
||||
parts := make([]string, 0, len(l.Addresses))
|
||||
for _, a := range l.Addresses {
|
||||
parts = append(parts, a.String())
|
||||
}
|
||||
s := strings.Join(parts, ", ")
|
||||
if l.DHCP {
|
||||
s += " (DHCP)"
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//go:build !windows
|
||||
|
||||
package netcfg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gitea.apointless.space/bsncubed/ipswap/internal/preset"
|
||||
)
|
||||
|
||||
// ErrElevationRequired keeps the non-Windows build type-compatible so the rest
|
||||
// of the tree compiles and `go vet ./...` is useful during development on
|
||||
// Linux. Nothing here touches a real adapter.
|
||||
var ErrElevationRequired = errors.New("this operation requires an elevated process")
|
||||
|
||||
// ErrUnsupported is returned by every write path off Windows.
|
||||
var ErrUnsupported = errors.New("adapter configuration is only supported on Windows")
|
||||
|
||||
// Stub is a Manager that reports a plausible fixture instead of touching the
|
||||
// host. It exists so the tray, the editor server and the confirmation text can
|
||||
// be exercised on the development machine.
|
||||
type Stub struct{}
|
||||
|
||||
// New returns the platform Manager.
|
||||
func New() Manager { return Stub{} }
|
||||
|
||||
func (Stub) Adapters() ([]Adapter, error) {
|
||||
return []Adapter{
|
||||
{Name: "Ethernet", Description: "Stub adapter (non-Windows build)", Up: true},
|
||||
{Name: "Wi-Fi", Description: "Stub adapter (non-Windows build)", Up: false},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (Stub) Current(adapter string) (LiveConfig, error) {
|
||||
return LiveConfig{
|
||||
Adapter: adapter,
|
||||
DHCP: true,
|
||||
Addresses: []preset.Address{{Address: "192.168.1.87", Prefix: 24}},
|
||||
Gateways: []string{"192.168.1.1"},
|
||||
DNS: []string{"192.168.1.1"},
|
||||
DNSFromDHCP: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (Stub) Apply(plan []Command) error {
|
||||
return fmt.Errorf("%w (plan had %d commands)", ErrUnsupported, len(plan))
|
||||
}
|
||||
@@ -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{}
|
||||
@@ -0,0 +1,188 @@
|
||||
package netcfg
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.apointless.space/bsncubed/ipswap/internal/preset"
|
||||
)
|
||||
|
||||
func staticPreset() preset.Preset {
|
||||
return preset.Preset{
|
||||
ID: "test",
|
||||
Name: "Artist frame — control",
|
||||
Adapter: "Ethernet",
|
||||
Mode: preset.ModeStatic,
|
||||
Primary: &preset.Address{
|
||||
Address: "192.168.42.100",
|
||||
Prefix: 24,
|
||||
Gateway: "192.168.42.1",
|
||||
},
|
||||
Secondary: []preset.Address{{Address: "10.0.10.50", Prefix: 24}},
|
||||
DNS: preset.DNS{Mode: preset.ModeStatic, Servers: []string{"192.168.42.1", "1.1.1.1"}},
|
||||
}
|
||||
}
|
||||
|
||||
func staticLive() LiveConfig {
|
||||
return LiveConfig{
|
||||
Adapter: "Ethernet",
|
||||
Addresses: []preset.Address{{Address: "192.168.1.87", Prefix: 24}, {Address: "172.16.0.5", Prefix: 16}},
|
||||
Gateways: []string{"192.168.1.1"},
|
||||
DNS: []string{"192.168.1.1"},
|
||||
}
|
||||
}
|
||||
|
||||
// The leak this guards against is the reason Plan exists: without a delete for
|
||||
// every live address, secondaries from the previous preset stay attached.
|
||||
func TestPlanDeletesEveryExistingAddress(t *testing.T) {
|
||||
cmds := Plan(staticPreset(), staticLive())
|
||||
|
||||
var deletes []string
|
||||
for _, c := range cmds {
|
||||
if len(c.Args) > 3 && c.Args[2] == "delete" && c.Args[3] == "address" {
|
||||
deletes = append(deletes, strings.Join(c.Args, " "))
|
||||
}
|
||||
}
|
||||
if len(deletes) != 2 {
|
||||
t.Fatalf("expected 2 delete commands, got %d: %v", len(deletes), deletes)
|
||||
}
|
||||
for _, want := range []string{"addr=192.168.1.87", "addr=172.16.0.5"} {
|
||||
found := false
|
||||
for _, d := range deletes {
|
||||
if strings.Contains(d, want) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("no delete command for %s: %v", want, deletes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanOrdering(t *testing.T) {
|
||||
cmds := Plan(staticPreset(), staticLive())
|
||||
|
||||
// deletes, then set primary, then add secondary, then DNS.
|
||||
var kinds []string
|
||||
for _, c := range cmds {
|
||||
switch {
|
||||
case c.Args[2] == "delete":
|
||||
kinds = append(kinds, "delete")
|
||||
case c.Args[2] == "set" && c.Args[3] == "address":
|
||||
kinds = append(kinds, "set-addr")
|
||||
case c.Args[2] == "add" && c.Args[3] == "address":
|
||||
kinds = append(kinds, "add-addr")
|
||||
case c.Args[3] == "dnsservers":
|
||||
kinds = append(kinds, "dns")
|
||||
}
|
||||
}
|
||||
want := []string{"delete", "delete", "set-addr", "add-addr", "dns", "dns"}
|
||||
if strings.Join(kinds, ",") != strings.Join(want, ",") {
|
||||
t.Errorf("plan order = %v, want %v", kinds, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanUsesDottedMaskNotPrefix(t *testing.T) {
|
||||
// netsh rejects "/24" outright.
|
||||
cmds := Plan(staticPreset(), staticLive())
|
||||
for _, c := range cmds {
|
||||
for _, a := range c.Args {
|
||||
if strings.HasPrefix(a, "/") {
|
||||
t.Errorf("command passes a prefix-notation mask: %v", c.Args)
|
||||
}
|
||||
}
|
||||
}
|
||||
joined := strings.Join(cmds[2].Args, " ")
|
||||
if !strings.Contains(joined, "255.255.255.0") {
|
||||
t.Errorf("set address command lacks a dotted mask: %s", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanDHCPSkipsDeletes(t *testing.T) {
|
||||
p := preset.Preset{
|
||||
ID: "d", Name: "DHCP", Adapter: "Ethernet",
|
||||
Mode: preset.ModeDHCP,
|
||||
DNS: preset.DNS{Mode: preset.ModeDHCP},
|
||||
}
|
||||
cmds := Plan(p, staticLive())
|
||||
if len(cmds) != 2 {
|
||||
t.Fatalf("expected 2 commands for a DHCP preset, got %d: %v", len(cmds), cmds)
|
||||
}
|
||||
for _, c := range cmds {
|
||||
if c.Args[2] == "delete" {
|
||||
t.Errorf("DHCP plan should not delete addresses: %v", c.Args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A DHCP lease is not deletable with `netsh delete address`, and the static set
|
||||
// replaces it anyway, so a live-DHCP adapter gets no delete commands.
|
||||
func TestPlanSkipsDeletesWhenLiveIsDHCP(t *testing.T) {
|
||||
live := staticLive()
|
||||
live.DHCP = true
|
||||
for _, c := range Plan(staticPreset(), live) {
|
||||
if c.Args[2] == "delete" {
|
||||
t.Errorf("should not delete DHCP-assigned addresses: %v", c.Args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanAdapterNameIsNotShellQuoted(t *testing.T) {
|
||||
p := staticPreset()
|
||||
p.Adapter = "Ethernet 3"
|
||||
for _, c := range Plan(p, LiveConfig{Adapter: "Ethernet 3"}) {
|
||||
for _, a := range c.Args {
|
||||
if strings.HasPrefix(a, "name=") && strings.Contains(a, `"`) {
|
||||
t.Errorf("adapter name should not carry quotes through os/exec: %q", a)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatches(t *testing.T) {
|
||||
p := staticPreset()
|
||||
live := LiveConfig{
|
||||
Adapter: "Ethernet",
|
||||
Addresses: []preset.Address{{Address: "10.0.10.50", Prefix: 24}, {Address: "192.168.42.100", Prefix: 24}},
|
||||
Gateways: []string{"192.168.42.1"},
|
||||
DNS: []string{"192.168.42.1", "1.1.1.1"},
|
||||
}
|
||||
if !Matches(p, live) {
|
||||
t.Error("preset should match an adapter carrying exactly its addresses")
|
||||
}
|
||||
|
||||
extra := live
|
||||
extra.Addresses = append(append([]preset.Address{}, live.Addresses...), preset.Address{Address: "10.9.9.9", Prefix: 24})
|
||||
if Matches(p, extra) {
|
||||
t.Error("a preset that is a subset of the live config is not the active preset")
|
||||
}
|
||||
|
||||
reordered := live
|
||||
reordered.DNS = []string{"1.1.1.1", "192.168.42.1"}
|
||||
if Matches(p, reordered) {
|
||||
t.Error("DNS order is significant and should not match")
|
||||
}
|
||||
|
||||
dhcp := live
|
||||
dhcp.DNSFromDHCP = true
|
||||
if Matches(p, dhcp) {
|
||||
t.Error("static-DNS preset should not match a DHCP-DNS adapter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmTextShapes(t *testing.T) {
|
||||
got := ConfirmText(staticPreset(), staticLive())
|
||||
for _, want := range []string{
|
||||
`Apply preset "Artist frame — control" to adapter "Ethernet"?`,
|
||||
"CURRENT", "NEW",
|
||||
"192.168.1.87/24",
|
||||
"192.168.42.100/24",
|
||||
"+ 10.0.10.50/24",
|
||||
"Gateway: 192.168.42.1",
|
||||
"DNS: 192.168.42.1, 1.1.1.1",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("confirm text missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user