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:
2026-08-20 13:29:35 +10:00
commit 23dcfb393f
46 changed files with 5782 additions and 0 deletions
+92
View File
@@ -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
}
+251
View File
@@ -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
}
+48
View File
@@ -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))
}
+234
View File
@@ -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{}
+188
View File
@@ -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)
}
}
}