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