commit 50a535bb7ffcbc4676f37855ffae32b30f95cc44 Author: James Coleman Date: Wed Jul 1 15:17:11 2026 -0500 first commit diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..83d4a90 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2026 Mr. Gecko's Media (James Coleman). http://mrgeckosmedia.com/ + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cc8d408 --- /dev/null +++ b/README.md @@ -0,0 +1,244 @@ +# go-network-configurator + +[![Go Reference](https://pkg.go.dev/badge/github.com/grmrgecko/go-network-configurator.svg)](https://pkg.go.dev/github.com/grmrgecko/go-network-configurator) + +A Go library for inspecting and changing a host's IP addresses and static +routes at runtime **and** persisting those changes to whatever on-disk network +configuration backend the system actually uses — so the change survives a +reboot. + +It is designed for hosting environments where an automated system needs to add, +swap, or remove IPs on a server without knowing in advance whether that server +is running netplan, systemd-networkd, NetworkManager, RHEL network-scripts, +ifupdown, or cloud-init — and without leaving a control panel (cPanel, Plesk, +InterWorx) out of sync. + +```go +import "github.com/grmrgecko/go-network-configurator" +``` + +## How it works + +A single `Configurator` applies every change in two layers: + +1. **Runtime** — the live kernel state is changed immediately (via netlink on + Linux, the IP Helper API on Windows). Address and gateway changes are + verified against an internet-reachability test and **rolled back + automatically** if connectivity is lost. +2. **Persistence** — the same change is written to every detected configuration + backend so it survives a reboot, and registered control panels are told to + re-read the system's IPs. + +Backends are auto-detected at construction time. A host running both netplan and +cloud-init, for example, has both files kept in sync; a failure writing one +backend is logged but does not abort the others. + +## Supported backends + +**Network configuration (persistence)** + +| Backend | Detection | +| ------------------- | ------------------------------------------- | +| netplan | `netplan` binary on `PATH` | +| cloud-init | cloud-init network config present | +| NetworkManager | `NetworkManager.service` active | +| systemd-networkd | `systemd-networkd.service` active | +| RHEL network-scripts| `network.service` active (config dir on legacy hosts) | +| ifupdown | `/etc/network/interfaces` | + +**Control panels** + +| Panel | Detection | +| --------- | ---------------------------------- | +| cPanel | `/usr/local/cpanel/bin/whmapi1` | +| Plesk | `/usr/sbin/plesk` | +| InterWorx | `/usr/bin/nodeworx` | + +Detection of the service-managed backends (NetworkManager, systemd-networkd, +and RHEL network-scripts) uses systemd over D-Bus, with a fallback for legacy +systems that lack a usable systemd D-Bus interface (Ubuntu 14.04 Upstart, +CentOS 5/6). On that fallback path network-scripts is detected by the presence +of its `/etc/sysconfig/network-scripts` directory. + +## Platforms + +- **Linux** — full support (all backends and panels above). +- **Windows** — runtime changes via the IP Helper API, persisted to cloud-init, + with Plesk panel support. + +## API + +```go +type Configurator interface { + // Enumerate interfaces with their addresses, gateways, static routes, + // DNS servers, and search domains. + GetInterfaces(ctx context.Context) ([]*Interface, error) + + // Add an IP address (optionally setting/replacing the default gateway). + AddAddress(ctx context.Context, iface string, addr *net.IPNet, gateway net.IP) error + + // Promote an address already on the interface to be the primary of its family. + SetPrimaryAddress(ctx context.Context, iface string, addr *net.IPNet) error + + // Remove an IP address. + RemoveAddress(ctx context.Context, iface string, addr *net.IPNet) error + + // Add or remove a static route. + AddRoute(ctx context.Context, iface string, dst *net.IPNet, gateway net.IP, metric int) error + RemoveRoute(ctx context.Context, iface string, dst *net.IPNet, gateway net.IP) error + + // Set the DNS servers and search domains for an interface. + SetDNS(ctx context.Context, iface string, servers []net.IP, searchDomains []string) error +} + +// Construct the configurator for the current OS, auto-detecting backends. +// Behaviour is tuned with Option values (see Options below). +func NewConfigurator(opts ...Option) (Configurator, error) + +// Resolve an interface by name, or by the special "public-internet" / +// "public-internet-6" selectors. +func FindInterfaceByName(name string, ifaces []*Interface) *Interface +``` + +Every operation takes a `context.Context`. The slow steps — the ICMP probe of a +new gateway and the internet-reachability test — honour cancellation and +deadlines, so a caller can bound how long a change may block. + +### DNS + +`SetDNS` applies an interface's resolvers and search domains to the live +resolver so they take effect immediately, and persists them through whichever +network management backends are detected on the host so they survive a reboot. + +### Logging + +The package uses a single, package-wide logger for non-fatal diagnostics from +backends and control panels. It defaults to the logrus standard logger. Replace +it with `SetLogger` before constructing any `Configurator`: + +```go +netconfig.SetLogger(myLogger) // anything with Printf/Println, e.g. *log.Logger +``` + +### Options + +`NewConfigurator` accepts functional options: + +```go +c, err := netconfig.NewConfigurator( + netconfig.WithTestAddress("http://my-canary/health"), // connectivity-test URL + netconfig.WithConnectivityCheck(false), // skip the post-change probe + rollback + netconfig.WithSkipConnectivityCheck(), // same as WithConnectivityCheck(false) + netconfig.WithConnectivityTimeout(5 * time.Second), // HTTP reachability timeout + netconfig.WithPingCount(3), // ICMP probes per ping test + netconfig.WithPingTimeout(10 * time.Second), // overall ping-run timeout + netconfig.WithBackupRetention(10), // .bak.* copies kept per config file (0 = keep all) + netconfig.WithAllowPrimaryRemoval(true), // let RemoveAddress remove the primary IP + netconfig.WithSkipPanels(true), // skip all panel interaction (add/primary/remove) +) +``` + +`WithConnectivityCheck(false)` (or the convenience `WithSkipConnectivityCheck()`) is useful on hosts with no outbound internet +access, where the default reachability test would always fail and roll changes +back. `WithConnectivityTimeout`, `WithPingCount`, and `WithPingTimeout` tune the +probes used after gateway changes. `WithBackupRetention` limits how many +`.bak.` copies each backend keeps per original config file; the +oldest backups beyond this number are pruned on every save. + +`WithAllowPrimaryRemoval(true)` overrides the default refusal to remove an +address that is the primary of its family. The intended safe flow is to call +`SetPrimaryAddress` on another address first and then remove the old one; enable +the override only when you are deliberately tearing down the current primary. + +`WithSkipPanels(true)` skips all control-panel interaction: `AddAddress`, +`SetPrimaryAddress`, and `RemoveAddress` do not tell cPanel, Plesk, or +InterWorx to reload, set the main IP, or release an IP. Only the running system +and the network-manager configuration files are changed. + +`FindInterfaceByName` accepts two well-known selectors in addition to a literal +interface name: + +- `"public-internet"` — the interface that carries the IPv4 default gateway. +- `"public-internet-6"` — the interface that carries the IPv6 default gateway. + +## Usage + +```go +package main + +import ( + "context" + "log" + "net" + + netconfig "github.com/grmrgecko/go-network-configurator" +) + +func main() { + ctx := context.Background() + + c, err := netconfig.NewConfigurator() + if err != nil { + log.Fatal(err) + } + + ifaces, err := c.GetInterfaces(ctx) + if err != nil { + log.Fatal(err) + } + iface := netconfig.FindInterfaceByName(netconfig.Public, ifaces) + if iface == nil { + log.Fatal("no public interface found") + } + + // Swap the primary IP without dropping connectivity: + // add a new address, switch the system over to it, then remove the old one. + _, newIP, _ := net.ParseCIDR("203.0.113.20/24") + _, oldIP, _ := net.ParseCIDR("203.0.113.10/24") + + if err := c.AddAddress(ctx, iface.Name, newIP, nil); err != nil { + log.Fatal(err) + } + if err := c.SetPrimaryAddress(ctx, iface.Name, newIP); err != nil { + log.Fatal(err) + } + if err := c.RemoveAddress(ctx, iface.Name, oldIP); err != nil { + log.Fatal(err) + } +} +``` + +The primary address is the first address listed on an interface. +`SetPrimaryAddress` reorders an interface's addresses so the chosen IP is +first within its family, both in the running kernel and in the written +configuration. The address must already be present on the interface +(compose it with `AddAddress`); IPv4 and IPv6 each have their own primary. + +## Safety + +Operations that can break connectivity are guarded: + +- Adding an IP that already answers ping is refused. +- After an address/gateway change, an internet-reachability test runs; if it + fails, the previous runtime state (addresses and default route) is restored + and the operation returns an error. +- Removing an address that is the only route to the default gateway is refused. +- Removing the primary address of its family is refused by default (use + `WithAllowPrimaryRemoval(true)` to override). The safe flow is to promote + another address with `SetPrimaryAddress` first, then remove the old one. + +Most operations require elevated privileges (root on Linux, Administrator on +Windows) to modify network state. + +## Development + +```sh +go test ./... +``` + +Some tests exercise real kernel networking inside a throwaway network namespace +and are skipped unless run as root: + +```sh +sudo go test ./... +``` diff --git a/cloudinit.go b/cloudinit.go new file mode 100644 index 0000000..3d48739 --- /dev/null +++ b/cloudinit.go @@ -0,0 +1,1255 @@ +package netconfig + +import ( + "context" + "encoding/json" + "fmt" + "net" + "os" + "path" + "sort" + "strings" + "time" + + "dario.cat/mergo" + "github.com/vishvananda/netlink" + "gopkg.in/yaml.v3" +) + +const ( + cloudInitDefault = "/etc/cloud/cloud.cfg" + cloudInitDir = "/etc/cloud/cloud.cfg.d" + cloudBaseInit = "/curtin/network.json" +) + +// Name server cvonfigurations. +type ciNameservers struct { + Search []string `yaml:"search,omitempty,flow" json:"search,omitempty"` + Addresses []string `yaml:"addresses,omitempty,flow" json:"addresses,omitempty"` +} + +// Common route configs. +type ciRoute struct { + To string `yaml:"to,omitempty" json:"to,omitempty"` + Via string `yaml:"via,omitempty" json:"via,omitempty"` + Metric *int `yaml:"metric,omitempty" json:"metric,omitempty"` +} + +// Match options. +type ciMatch struct { + Name string `yaml:"name,omitempty" json:"name,omitempty"` + MACAddress string `yaml:"macaddress,omitempty" json:"macaddress,omitempty"` + Driver string `yaml:"driver,omitempty" json:"driver,omitempty"` +} + +// Common configurations on physical interfaces. +type ciPhysical struct { + Match ciMatch `yaml:"match,omitempty" json:"match,omitempty"` + SetName string `yaml:"set-name,omitempty" json:"set-name,omitempty"` + Wakeonlan bool `yaml:"wakeonlan,omitempty" json:"wakeonlan,omitempty"` +} + +// Common cloudinit interface configurations. +type ciInterface struct { + Addresses []string `yaml:"addresses,omitempty" json:"addresses,omitempty"` + DHCP4 *bool `yaml:"dhcp4,omitempty" json:"dhcp4,omitempty"` + DHCP6 *bool `yaml:"dhcp6,omitempty" json:"dhcp6,omitempty"` + DHCP4Overrides map[string]any `yaml:"dhcp4-overrides,omitempty" json:"dhcp4-overrides,omitempty"` + DHCP6Overrides map[string]any `yaml:"dhcp6-overrides,omitempty" json:"dhcp6-overrides,omitempty"` + Gateway4 string `yaml:"gateway4,omitempty" json:"gateway4,omitempty"` + Gateway6 string `yaml:"gateway6,omitempty" json:"gateway6,omitempty"` + Nameservers ciNameservers `yaml:"nameservers,omitempty" json:"nameservers,omitempty"` + MTU int `yaml:"mtu,omitempty" json:"mtu,omitempty"` + Renderer string `yaml:"renderer,omitempty" json:"renderer,omitempty"` + Routes []ciRoute `yaml:"routes,omitempty" json:"routes,omitempty"` + Optional bool `yaml:"optional,omitempty" json:"optional,omitempty"` +} + +// Physical ethernet interface. +type ciEthernet struct { + ciPhysical `yaml:",inline" json:",inline"` + ciInterface `yaml:",inline" json:",inline"` +} + +// Bridge interface. +type ciBridgeParameters struct { + AgeingTime *int `yaml:"ageing-time,omitempty" json:"ageing-time,omitempty"` + ForwardDelay intString `yaml:"forward-delay,omitempty" json:"forward-delay,omitempty"` + HelloTime *int `yaml:"hello-time,omitempty" json:"hello-time,omitempty"` + MaxAge *int `yaml:"max-age,omitempty" json:"max-age,omitempty"` + PathCost *int `yaml:"path-cost,omitempty" json:"path-cost,omitempty"` + Priority *int `yaml:"priority,omitempty" json:"priority,omitempty"` + STP *bool `yaml:"stp,omitempty" json:"stp,omitempty"` +} +type ciBridge struct { + Interfaces []string `yaml:"interfaces,omitempty,flow" json:"interfaces,omitempty"` + ciInterface `yaml:",inline" json:",inline"` + Parameters ciBridgeParameters `yaml:"parameters,omitempty" json:"parameters,omitempty"` +} + +// Bond interfaces. +type ciBondParameters struct { + Mode intString `yaml:"mode,omitempty" json:"mode,omitempty"` + LACPRate intString `yaml:"lacp-rate,omitempty" json:"lacp-rate,omitempty"` + MIIMonitorInterval intString `yaml:"mii-monitor-interval,omitempty" json:"mii-monitor-interval,omitempty"` + MinLinks *int `yaml:"min-links,omitempty" json:"min-links,omitempty"` + TransmitHashPolicy string `yaml:"transmit-hash-policy,omitempty" json:"transmit-hash-policy,omitempty"` + ADSelect intString `yaml:"ad-select,omitempty" json:"ad-select,omitempty"` + AllSlavesActive *bool `yaml:"all-slaves-active,omitempty" json:"all-slaves-active,omitempty"` + ARPInterval *int `yaml:"arp-interval,omitempty" json:"arp-interval,omitempty"` + ARPIPTargets []string `yaml:"arp-ip-targets,omitempty" json:"arp-ip-targets,omitempty"` + ARPValidate intString `yaml:"arp-validate,omitempty" json:"arp-validate,omitempty"` + ARPAllTargets intString `yaml:"arp-all-targets,omitempty" json:"arp-all-targets,omitempty"` + UpDelay intString `yaml:"up-delay,omitempty" json:"up-delay,omitempty"` + DownDelay intString `yaml:"down-delay,omitempty" json:"down-delay,omitempty"` + FailOverMACPolicy intString `yaml:"fail-over-mac-policy,omitempty" json:"fail-over-mac-policy,omitempty"` + GratuitousARP *int `yaml:"gratuitous-arp,omitempty" json:"gratuitous-arp,omitempty"` + PacketsPerSlave *int `yaml:"packets-per-slave,omitempty" json:"packets-per-slave,omitempty"` + PrimaryReselectPolicy intString `yaml:"primary-reselect-policy,omitempty" json:"primary-reselect-policy,omitempty"` + LearnPacketInterval *int `yaml:"learn-packet-interval,omitempty" json:"learn-packet-interval,omitempty"` +} +type ciBond struct { + Interfaces []string `yaml:"interfaces,omitempty,flow" json:"interfaces,omitempty"` + ciInterface `yaml:",inline" json:",inline"` + Parameters ciBondParameters `yaml:"parameters,omitempty" json:"parameters,omitempty"` +} + +// VLAN interface. +type ciVLAN struct { + Id *int `yaml:"id,omitempty" json:"id,omitempty"` + Link string `yaml:"link,omitempty" json:"link,omitempty"` + ciInterface `yaml:",inline" json:",inline"` +} + +// Cloud-init network version 1 config. +type ciSubnetRoute struct { + Destination string `yaml:"destination,omitempty" json:"destination,omitempty"` + Netmask string `yaml:"netmask,omitempty" json:"netmask,omitempty"` + Gateway string `yaml:"gateway,omitempty" json:"gateway,omitempty"` + Metric int `yaml:"metric,omitempty" json:"metric,omitempty"` +} +type ciSubnet struct { + Type string `yaml:"type" json:"type"` + Control intString `yaml:"control,omitempty" json:"control,omitempty"` + Address string `yaml:"address,omitempty" json:"address,omitempty"` + Netmask string `yaml:"netmask,omitempty" json:"netmask,omitempty"` + Broadcast string `yaml:"broadcast,omitempty" json:"broadcast,omitempty"` + Gateway string `yaml:"gateway,omitempty" json:"gateway,omitempty"` + DNSNameservers []string `yaml:"dns_nameservers,omitempty" json:"dns_nameservers,omitempty"` + DNSSearch []string `yaml:"dns_search,omitempty" json:"dns_search,omitempty"` + Routes []ciSubnetRoute `yaml:"routes,omitempty" json:"routes,omitempty"` +} +type ciConfig struct { + // Common options. + Type string `yaml:"type" json:"type"` + Name string `yaml:"name,omitempty" json:"name,omitempty"` + MACAddress string `yaml:"mac_address,omitempty" json:"mac_address,omitempty"` + MTU int `yaml:"mtu,omitempty" json:"mtu,omitempty"` + AcceptRA *bool `yaml:"accept-ra,omitempty" json:"accept-ra,omitempty"` + Subnets []ciSubnet `yaml:"subnets,omitempty" json:"subnets,omitempty"` + + // Bridge and bond options. + BondInterfaces []string `yaml:"bond_interfaces,omitempty,flow" json:"bond_interfaces,omitempty"` + BridgeInterfaces []string `yaml:"bridge_interfaces,omitempty,flow" json:"bridge_interfaces,omitempty"` + Params map[string]string `yaml:"params,omitempty" json:"params,omitempty"` + + // VLAN options. + VLANLink string `yaml:"vlan_link,omitempty" json:"vlan_link,omitempty"` + VLANID int `yaml:"vlan_id,omitempty" json:"vlan_id,omitempty"` + + // Nameserver options. + Interface string `yaml:"interface,omitempty" json:"interface,omitempty"` + Addresses []string `yaml:"address,omitempty" json:"address,omitempty"` + Search []string `yaml:"search,omitempty" json:"search,omitempty"` + + // Route options. + Destination string `yaml:"destination,omitempty" json:"destination,omitempty"` + Netmask string `yaml:"netmask,omitempty" json:"netmask,omitempty"` + Gateway string `yaml:"gateway,omitempty" json:"gateway,omitempty"` + Metric int `yaml:"metric,omitempty" json:"metric,omitempty"` +} + +// Common network structure for both v1 and v2. +type ciNetwork struct { + Version int `yaml:"version" json:"version"` + Renderer string `yaml:"renderer,omitempty" json:"renderer,omitempty"` + Ethernets map[string]ciEthernet `yaml:"ethernets,omitempty" json:"ethernets,omitempty"` + Bridges map[string]ciBridge `yaml:"bridges,omitempty" json:"bridges,omitempty"` + Bonds map[string]ciBond `yaml:"bonds,omitempty" json:"bonds,omitempty"` + VLANs map[string]ciVLAN `yaml:"vlans,omitempty" json:"vlans,omitempty"` + Configs []ciConfig `yaml:"config,omitempty" json:"config,omitempty"` +} + +// The default netplan configuration structure and interface to configuring netplan. +type ciFile struct { + Path string + OnlyNetwork bool + NetworkEncap bool +} +type cloudInit struct { + Network ciNetwork `yaml:"network" json:"network"` + configFiles []ciFile `yaml:"-" json:"-"` + backupRetention int `yaml:"-" json:"-"` +} + +// Find cloud-init and cloudbase-init network configurations. +func newCloudInit(backupRetention int) (ci *cloudInit, err error) { + ci, err = newCloudInitWith(cloudInitDefault, cloudBaseInit, cloudInitDir) + if ci != nil { + ci.backupRetention = backupRetention + } + return +} +func newCloudInitWith(cloudinitFile, cloudbaseFile, cloudinitDir string) (ci *cloudInit, err error) { + // Read data. + var mergedConfig map[string]any + var configFiles []ciFile + var files []string + + // Check which base file exists. + if _, serr := os.Stat(cloudinitFile); serr == nil { + files = append(files, cloudinitFile) + } else if _, serr := os.Stat(cloudbaseFile); serr == nil { + files = append(files, cloudbaseFile) + } + + // No files, end. + if len(files) == 0 { + return nil, fmt.Errorf("unable to find cloudinit config") + } + + // Try to read the directory files. + unsortedEntries, _ := os.ReadDir(cloudinitDir) + entries := sortableDirEntries(unsortedEntries) + sort.Sort(entries) + + // Add found files to list. + for _, entry := range entries { + // If not an cfg file, skip. + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".cfg") { + continue + } + files = append(files, path.Join(cloudinitDir, entry.Name())) + } + + // Parse config files. + for _, configFile := range files { + var data []byte + data, err = os.ReadFile(configFile) + if err != nil { + return + } + + // Setup cloud-init file info. + var file ciFile + file.Path = configFile + + // Parse the yaml to an map. + var config map[string]any + if strings.HasSuffix(configFile, ".json") { + err = json.Unmarshal(data, &config) + configS, ok := config["config"] + if ok { + version, ok := config["version"] + if ok { + network := make(map[string]any) + network["config"] = configS + network["version"] = version + config = make(map[string]any) + config["network"] = network + file.NetworkEncap = true + } + + } + } else { + err = yaml.Unmarshal(data, &config) + } + if err != nil { + return + } + + // Determine if this config only contains network configs. + foundNetwork := false + foundNonNetwork := false + for key, value := range config { + if key == "network" { + // This is a network config, unless the network config + // only contains the config disabled option. + foundNetwork = true + configS, ok := value.(map[string]any) + if ok { + // We found disabled if the config key is a string + // with disabled. + foundDisable := false + configV, ok := configS["config"].(string) + if ok && configV == "disabled" { + foundDisable = true + } + + // If there are other configs that are not + // the config key, there are other configs to + // parse. + for keyV := range configS { + if keyV != "config" { + foundDisable = false + } + } + + // If we only found a disabled config, no network + // config exists here. + if foundDisable { + foundNetwork = false + } + } + } else { + foundNonNetwork = true + } + } + + // If there are no network configs in this file, skip it. + if !foundNetwork { + continue + } + + // If there are network configs, determine if its only network + // and add to list of config files. + file.OnlyNetwork = !foundNonNetwork + configFiles = append(configFiles, file) + + // If this is the first config file read, don't merge. + if mergedConfig == nil { + mergedConfig = config + continue + } + + // Merge the configurations. + err = mergo.Merge(&mergedConfig, config) + if err != nil { + return + } + } + + // Convert merged configs back into yaml so we can re-parse into a struct. + mergedData, err := yaml.Marshal(mergedConfig) + if err != nil { + return + } + + // Parse the cloud-init config. + ci = new(cloudInit) + err = yaml.Unmarshal(mergedData, ci) + if err != nil { + return nil, err + } + + // If there are no ethernet configurations or configs in cloud-init, we consider the cloud-init configurations to not exist. + if len(ci.Network.Ethernets) == 0 && len(ci.Network.Configs) == 0 { + return nil, fmt.Errorf("no ethernet configurations in cloud-init") + } + + // Successfully parsed, we should return. + ci.configFiles = configFiles + return +} + +// Convert cloud-init interface to a netconfig Interface. +func (*cloudInit) ConvertInterface(name string, MAC string, config ciInterface, links []netlink.Link) *Interface { + // Find the MAC address, starting with the list of interfaces + // on the machine. + var mac net.HardwareAddr + var foundLink netlink.Link + for _, link := range links { + if link.Attrs().Name == name { + mac = link.Attrs().HardwareAddr + foundLink = link + } + } + + // If the MAC was not found, try parsing the config. + if mac == nil && MAC != "" { + mac, _ = net.ParseMAC(MAC) + } + + // Setup interface with the name and MAC. + i := new(Interface) + i.Name = name + i.MAC = mac + i.Link = foundLink + + // Parse addresses. + for _, addr := range config.Addresses { + ip, ipnet, err := net.ParseCIDR(addr) + if err != nil { + continue + } + ipnet.IP = ip + i.Addresses = append(i.Addresses, ipnet) + } + + // Add gateways. + if config.Gateway4 != "" { + i.Gateway4 = net.ParseIP(config.Gateway4) + } + if config.Gateway6 != "" { + i.Gateway6 = net.ParseIP(config.Gateway6) + } + + // Parse nameservers. + for _, addr := range config.Nameservers.Addresses { + if ip := net.ParseIP(addr); ip != nil { + i.DNS = append(i.DNS, ip) + } + } + i.SearchDomains = config.Nameservers.Search + + // Parse routes. + for _, route := range config.Routes { + dstIP, dst, err := net.ParseCIDR(route.To) + if err != nil { + continue + } + dst.IP = dstIP + ip := net.ParseIP(route.Via) + r := new(Route) + r.Destination = dst + r.Gateway = ip + if route.Metric != nil { + r.Metric = *route.Metric + } else { + r.Metric = 256 + } + i.Routes = append(i.Routes, r) + } + return i +} + +// Get interfaces configured. +func (ci *cloudInit) GetInterfaces() (interfaces []*Interface, err error) { + // Connect to netlink. + var h *netlink.Handle + h, err = netlink.NewHandle() + if err != nil { + return + } + defer h.Close() + + // Get list of interfaces to match with MAC address. + var links []netlink.Link + links, err = h.LinkList() + if err != nil { + return + } + + // Add configs. + for _, config := range ci.Network.Configs { + // Skip types we cannot parse. + switch config.Type { + case "physical", "bond", "bridge", "vlan": + default: + continue + } + + // Find the MAC address, starting with the list of interfaces + // on the machine. + var mac net.HardwareAddr + var foundLink netlink.Link + for _, link := range links { + if link.Attrs().Name == config.Name { + mac = link.Attrs().HardwareAddr + foundLink = link + } + } + + // If we did not find the MAC address, but one is defined. Parse it. + if mac == nil && config.MACAddress != "" { + mac, _ = net.ParseMAC(config.MACAddress) + } + + // Setup interface with the name and MAC. + i := new(Interface) + i.Name = config.Name + i.MAC = mac + i.Link = foundLink + + // Parse subnet configs. + for _, subnet := range config.Subnets { + // Add DNS servers and search domains. + for _, dns := range subnet.DNSNameservers { + if ip := net.ParseIP(dns); ip != nil { + i.DNS = append(i.DNS, ip) + } + } + i.SearchDomains = append(i.SearchDomains, subnet.DNSSearch...) + + // Add routes. + for _, route := range subnet.Routes { + r := new(Route) + + // Try and parse the destination. + var ipnet *net.IPNet + var ip net.IP + if strings.Contains(route.Destination, "/") { + ip, ipnet, err = net.ParseCIDR(route.Destination) + if err != nil { + continue + } + ipnet.IP = ip + } else { + ipnet = &net.IPNet{ + IP: net.ParseIP(route.Destination), + } + if ipnet.IP.To4() == nil { + ipnet.Mask = net.CIDRMask(128, 128) + } else { + ipnet.Mask = net.CIDRMask(32, 32) + } + } + + // If the IP wasn't parsed, skip this route. + if ipnet.IP == nil { + continue + } + + // If the netmask exists, parse and add to the destination. + if route.Netmask != "" { + ipMask := net.ParseIP(route.Netmask).To4() + if ipMask == nil { + continue + } + ipnet.Mask = net.IPMask(ipMask) + } + r.Destination = ipnet + + // Parse the gateway, requiring it. + gateway := net.ParseIP(route.Gateway) + if gateway == nil { + continue + } + r.Gateway = gateway + + // Add the route. + r.Metric = route.Metric + i.Routes = append(i.Routes, r) + } + + // Only add subnet as address if static type. + if subnet.Type != "static" { + continue + } + + // Parse the address field. + var ipnet *net.IPNet + var ip net.IP + if strings.Contains(subnet.Address, "/") { + ip, ipnet, err = net.ParseCIDR(subnet.Address) + if err != nil { + continue + } + ipnet.IP = ip + } else { + ipnet = &net.IPNet{ + IP: net.ParseIP(subnet.Address), + } + if ipnet.IP.To4() == nil { + ipnet.Mask = net.CIDRMask(128, 128) + } else { + ipnet.Mask = net.CIDRMask(32, 32) + } + } + + // If IP not parsed, skip. + if ipnet.IP == nil { + continue + } + + // If subnet assigned, parse and add to address. + if subnet.Netmask != "" { + ipMask := net.ParseIP(subnet.Netmask).To4() + if ipMask == nil { + continue + } + ipnet.Mask = net.IPMask(ipMask) + } + + // Add address. + i.Addresses = append(i.Addresses, ipnet) + + // Parse the gateway. + gateway := net.ParseIP(subnet.Gateway) + if gateway != nil { + if gateway.To4() == nil { + i.Gateway6 = gateway + } else { + i.Gateway4 = gateway + } + } + } + + // Add interface to list. + interfaces = append(interfaces, i) + } + + // Add ethernet interfaces to the list. + var keys []string + for key := range ci.Network.Ethernets { + keys = append(keys, key) + } + sort.Strings(keys) + for _, name := range keys { + config := ci.Network.Ethernets[name] + // If the name is being changed in the config, use that + // as the name. + if config.SetName != "" { + name = config.SetName + } + + // Get interface. + i := ci.ConvertInterface(name, config.Match.MACAddress, config.ciInterface, links) + + // Add the interface. + interfaces = append(interfaces, i) + } + + // Add Bridge interfaces to the list. + keys = nil + for key := range ci.Network.Bridges { + keys = append(keys, key) + } + sort.Strings(keys) + for _, name := range keys { + config := ci.Network.Bridges[name] + // Get interface. + i := ci.ConvertInterface(name, "", config.ciInterface, links) + + // Add the interface. + interfaces = append(interfaces, i) + } + + // Add Bond interfaces to the list. + keys = nil + for key := range ci.Network.Bonds { + keys = append(keys, key) + } + sort.Strings(keys) + for _, name := range keys { + config := ci.Network.Bonds[name] + // Get interface. + i := ci.ConvertInterface(name, "", config.ciInterface, links) + + // Add the interface. + interfaces = append(interfaces, i) + } + + // Add VLAN interfaces to the list. + keys = nil + for key := range ci.Network.VLANs { + keys = append(keys, key) + } + sort.Strings(keys) + for _, name := range keys { + config := ci.Network.VLANs[name] + // Get interface. + i := ci.ConvertInterface(name, "", config.ciInterface, links) + + // Add the interface. + interfaces = append(interfaces, i) + } + + return +} + +// Save configurations. +func (ci *cloudInit) Save() error { + // First we are to determine which configuration we will store the network + // configurations to. We will call this the main config, and will consider + // the first network only config file as the main config. + mainConfig := "" + for _, file := range ci.configFiles { + if file.OnlyNetwork { + mainConfig = file.Path + break + } + } + + // If there is no network only configs, choose the first config file. + if mainConfig == "" { + mainConfig = ci.configFiles[0].Path + } + + // Update all files with new configurations. + tmpSuffix := fmt.Sprintf(".tmp.%d", time.Now().UnixNano()) + bakSuffix := fmt.Sprintf(".bak.%d", time.Now().UnixNano()) + for _, file := range ci.configFiles { + configPath := file.Path + configDir, configName := path.Split(configPath) + tmpName := configName + tmpSuffix + tmpPath := path.Join(configDir, tmpName) + bakName := configName + bakSuffix + bakPath := path.Join(configDir, bakName) + + // Try to encode/save the config, start by encoding to yaml, + // then decode to a map. + data, err := yaml.Marshal(ci) + if err != nil { + return err + } + var config map[string]any + err = yaml.Unmarshal(data, &config) + if err != nil { + return err + } + + // Decode the current config. + data, err = os.ReadFile(configPath) + if err != nil { + return err + } + var origConfig map[string]any + if strings.HasSuffix(configPath, ".json") { + err = json.Unmarshal(data, &origConfig) + } else { + err = yaml.Unmarshal(data, &origConfig) + } + if err != nil { + return err + } + + // Update the original config with the new config. + // On files which were not determined as the main config, + // we will delete the network config. + if configPath == mainConfig { + if file.NetworkEncap { + network := config["network"].(map[string]any) + origConfig["config"] = network["config"] + } else { + origConfig["network"] = config["network"] + } + } else { + if file.NetworkEncap { + delete(origConfig, "config") + } else { + delete(origConfig, "network") + } + } + + // Save to temporary file. + if strings.HasSuffix(configPath, ".json") { + data, err = json.MarshalIndent(origConfig, "", " ") + data = append(data, '\n') + } else { + data, err = yaml.Marshal(origConfig) + } + if err != nil { + return err + } + err = os.WriteFile(tmpPath, data, 0644) + if err != nil { + return err + } + + // Backup original file. + err = fileMove(configPath, bakPath) + if err != nil { + return err + } + + // Move new config in place. + err = fileMove(tmpPath, configPath) + if err != nil { + return err + } + + // Prune old backups of this config file beyond the retention count. + pruneBackups(configDir, suffixBackupMatcher(configName), ci.backupRetention) + } + + return nil +} + +// Set addresses to interface. +func (ci *cloudInit) SetIfaceAddresses(_ context.Context, iface string, addrs []*net.IPNet, gateway4, gateway6 net.IP) (err error) { + // Apply changes to v1 configs. + for c, config := range ci.Network.Configs { + // Skip types we cannot parse. + switch config.Type { + case "physical", "bond", "bridge", "vlan": + default: + continue + } + + // Skip interfaces that are not the one we're updating. + if config.Name != iface { + continue + } + + // Parse all routes and DNS config so we can re-add as needed. + var routes []Route + var dns []string + var domains []string + for _, subnet := range config.Subnets { + for _, route := range subnet.Routes { + var r Route + // Try and parse the destination. + var ipnet *net.IPNet + var ip net.IP + if strings.Contains(route.Destination, "/") { + ip, ipnet, err = net.ParseCIDR(route.Destination) + if err != nil { + continue + } + ipnet.IP = ip + } else { + ipnet = &net.IPNet{ + IP: net.ParseIP(route.Destination), + } + if ipnet.IP.To4() == nil { + ipnet.Mask = net.CIDRMask(128, 128) + } else { + ipnet.Mask = net.CIDRMask(32, 32) + } + } + + // If the IP wasn't parsed, skip this route. + if ipnet.IP == nil { + continue + } + + // If the netmask exists, parse and add to the destination. + if route.Netmask != "" { + ipMask := net.ParseIP(route.Netmask).To4() + if ipMask == nil { + continue + } + ipnet.Mask = net.IPMask(ipMask) + } + r.Destination = ipnet + + // Parse the gateway, requiring it. + gateway := net.ParseIP(route.Gateway) + if gateway == nil { + continue + } + r.Gateway = gateway + + // Add the route. + r.Metric = route.Metric + routes = append(routes, r) + } + for _, d := range subnet.DNSNameservers { + found := false + for _, d2 := range dns { + if d2 == d { + found = true + break + } + } + if !found { + dns = append(dns, d) + } + } + for _, d := range subnet.DNSSearch { + found := false + for _, d2 := range domains { + if d2 == d { + found = true + break + } + } + if !found { + domains = append(domains, d) + } + } + } + + // Clear existing subnets. + config.Subnets = nil + + // Add subnets. + for _, addr := range addrs { + var subnet ciSubnet + subnet.Type = "static" + subnet.Address = addr.String() + if addr.Contains(gateway4) { + subnet.Gateway = gateway4.String() + } else if addr.Contains(gateway6) { + subnet.Gateway = gateway6.String() + } + + // Add back DNS config. + subnet.DNSNameservers = dns + subnet.DNSSearch = domains + + // Add all pertaining routes. + for { + foundRoute := false + for r, route := range routes { + if addr.Contains(route.Gateway) { + foundRoute = true + subnet.Routes = append(subnet.Routes, ciSubnetRoute{ + Destination: route.Destination.String(), + Gateway: route.Gateway.String(), + Metric: route.Metric, + }) + routes = append(routes[:r], routes[r+1:]...) + break + } + } + if !foundRoute { + break + } + } + + // Add subnet. + config.Subnets = append(config.Subnets, subnet) + } + + // Add stray routes to the first subnet. + if len(routes) != 0 && len(config.Subnets) != 0 { + for _, route := range routes { + config.Subnets[0].Routes = append(config.Subnets[0].Routes, ciSubnetRoute{ + Destination: route.Destination.String(), + Gateway: route.Gateway.String(), + Metric: route.Metric, + }) + } + } + + // Update the config. + ci.Network.Configs[c] = config + } + + // Check ethernet interfaces. + for name, config := range ci.Network.Ethernets { + // If the name is being changed in the config, use that + // as the name. + ifName := name + if config.SetName != "" { + ifName = config.SetName + } + + // If this is not the interface we're updating addresses on. + if ifName != iface { + continue + } + + // Re-configure with specified addresses. + config.Addresses = nil + for _, addr := range addrs { + config.Addresses = append(config.Addresses, addr.String()) + } + if gateway4 == nil { + config.Gateway4 = "" + } else { + config.Gateway4 = gateway4.String() + } + if gateway6 == nil { + config.Gateway6 = "" + } else { + config.Gateway6 = gateway6.String() + } + ci.Network.Ethernets[name] = config + } + + // Check bridges. + { + config, ok := ci.Network.Bridges[iface] + if ok { + // Re-configure with specified addresses. + config.Addresses = nil + for _, addr := range addrs { + config.Addresses = append(config.Addresses, addr.String()) + } + if gateway4 == nil { + config.Gateway4 = "" + } else { + config.Gateway4 = gateway4.String() + } + if gateway6 == nil { + config.Gateway6 = "" + } else { + config.Gateway6 = gateway6.String() + } + ci.Network.Bridges[iface] = config + } + } + + // Check bonds. + { + config, ok := ci.Network.Bonds[iface] + if ok { + // Re-configure with specified addresses. + config.Addresses = nil + for _, addr := range addrs { + config.Addresses = append(config.Addresses, addr.String()) + } + if gateway4 == nil { + config.Gateway4 = "" + } else { + config.Gateway4 = gateway4.String() + } + if gateway6 == nil { + config.Gateway6 = "" + } else { + config.Gateway6 = gateway6.String() + } + ci.Network.Bonds[iface] = config + } + } + + // Check VLAN interfaces. + { + config, ok := ci.Network.VLANs[iface] + if ok { + // Re-configure with specified addresses. + config.Addresses = nil + for _, addr := range addrs { + config.Addresses = append(config.Addresses, addr.String()) + } + if gateway4 == nil { + config.Gateway4 = "" + } else { + config.Gateway4 = gateway4.String() + } + if gateway6 == nil { + config.Gateway6 = "" + } else { + config.Gateway6 = gateway6.String() + } + ci.Network.VLANs[iface] = config + } + } + + // Try to save changes. + err = ci.Save() + return +} + +// Set static routes to interface. +func (ci *cloudInit) SetIfaceRoutes(_ context.Context, iface string, routes []*Route) (err error) { + // Apply changes to v1 configs. + for c, config := range ci.Network.Configs { + // Skip types we cannot parse. + switch config.Type { + case "physical", "bond", "bridge", "vlan": + default: + continue + } + + // Skip interfaces that are not the one we're updating. + if config.Name != iface { + continue + } + + if len(config.Subnets) == 0 { + return fmt.Errorf("no subnets on interface") + } + + // Remove existing routes, and parse destination. + subnetMap := make(map[int]*net.IPNet) + for s, subnet := range config.Subnets { + config.Subnets[s].Routes = nil + + // Only add subnet as address if static type. + if subnet.Type != "static" { + continue + } + + // Parse the address field. + var ipnet *net.IPNet + var ip net.IP + if strings.Contains(subnet.Address, "/") { + ip, ipnet, err = net.ParseCIDR(subnet.Address) + if err != nil { + continue + } + ipnet.IP = ip + } else { + ipnet = &net.IPNet{ + IP: net.ParseIP(subnet.Address), + } + if ipnet.IP.To4() == nil { + ipnet.Mask = net.CIDRMask(128, 128) + } else { + ipnet.Mask = net.CIDRMask(32, 32) + } + } + + // If IP not parsed, skip. + if ipnet.IP == nil { + continue + } + + // If subnet assigned, parse and add to address. + if subnet.Netmask != "" { + ipMask := net.ParseIP(subnet.Netmask).To4() + if ipMask == nil { + continue + } + ipnet.Mask = net.IPMask(ipMask) + } + + // Add to map. + subnetMap[s] = ipnet + } + + // Add each route accordingly. + for _, route := range routes { + // Find subnet to assign route, defaulting to 0. + idx := 0 + for s, ipnet := range subnetMap { + if ipnet.Contains(route.Gateway) { + idx = s + break + } + } + + // Add route. + var r ciSubnetRoute + r.Destination = route.Destination.String() + r.Gateway = route.Gateway.String() + r.Metric = route.Metric + config.Subnets[idx].Routes = append(config.Subnets[idx].Routes, r) + } + + // Update configs. + ci.Network.Configs[c] = config + } + + // Check ethernet interfaces. + for name, config := range ci.Network.Ethernets { + // If the name is being changed in the config, use that + // as the name. + ifName := name + if config.SetName != "" { + ifName = config.SetName + } + + // If this is not the interface we're updating routes on. + if ifName != iface { + continue + } + + // Re-configure with specified routes. + config.Routes = nil + for _, route := range routes { + config.Routes = append(config.Routes, ciRoute{ + To: route.Destination.String(), + Via: route.Gateway.String(), + Metric: &route.Metric, + }) + } + ci.Network.Ethernets[name] = config + } + + // Check bridges. + { + config, ok := ci.Network.Bridges[iface] + if ok { + // Re-configure with specified routes. + config.Routes = nil + for _, route := range routes { + config.Routes = append(config.Routes, ciRoute{ + To: route.Destination.String(), + Via: route.Gateway.String(), + Metric: &route.Metric, + }) + } + ci.Network.Bridges[iface] = config + } + } + + // Check bonds. + { + config, ok := ci.Network.Bonds[iface] + if ok { + // Re-configure with specified routes. + config.Routes = nil + for _, route := range routes { + config.Routes = append(config.Routes, ciRoute{ + To: route.Destination.String(), + Via: route.Gateway.String(), + Metric: &route.Metric, + }) + } + ci.Network.Bonds[iface] = config + } + } + + // Check VLAN interfaces. + { + config, ok := ci.Network.VLANs[iface] + if ok { + // Re-configure with specified routes. + config.Routes = nil + for _, route := range routes { + config.Routes = append(config.Routes, ciRoute{ + To: route.Destination.String(), + Via: route.Gateway.String(), + Metric: &route.Metric, + }) + } + ci.Network.VLANs[iface] = config + } + } + + // Try to save changes. + err = ci.Save() + return +} + +// Set DNS servers and search domains on interface. +func (ci *cloudInit) SetIfaceDNS(_ context.Context, iface string, servers []net.IP, searchDomains []string) (err error) { + dnsStrings := ipStrings(servers) + nameservers := ciNameservers{ + Search: searchDomains, + Addresses: dnsStrings, + } + + // Apply changes to v1 configs, where DNS lives on each subnet. + for c, config := range ci.Network.Configs { + switch config.Type { + case "physical", "bond", "bridge", "vlan": + default: + continue + } + if config.Name != iface { + continue + } + for s := range config.Subnets { + config.Subnets[s].DNSNameservers = dnsStrings + config.Subnets[s].DNSSearch = searchDomains + } + ci.Network.Configs[c] = config + } + + // Apply changes to v2 ethernet interfaces. + for name, config := range ci.Network.Ethernets { + ifName := name + if config.SetName != "" { + ifName = config.SetName + } + if ifName != iface { + continue + } + config.Nameservers = nameservers + ci.Network.Ethernets[name] = config + } + + // Apply changes to v2 bridges, bonds, and VLANs. + if config, ok := ci.Network.Bridges[iface]; ok { + config.Nameservers = nameservers + ci.Network.Bridges[iface] = config + } + if config, ok := ci.Network.Bonds[iface]; ok { + config.Nameservers = nameservers + ci.Network.Bonds[iface] = config + } + if config, ok := ci.Network.VLANs[iface]; ok { + config.Nameservers = nameservers + ci.Network.VLANs[iface] = config + } + + // Try to save changes. + err = ci.Save() + return +} diff --git a/cloudinit_test.go b/cloudinit_test.go new file mode 100644 index 0000000..9821cd0 --- /dev/null +++ b/cloudinit_test.go @@ -0,0 +1,285 @@ +package netconfig + +import ( + "context" + "net" + "os" + "path/filepath" + "testing" +) + +// Validate the cloud-init configuration parser/writer functions. +func TestCloudinit(t *testing.T) { + // Setup test file. + tmpDir, err := os.MkdirTemp("", "") + if err != nil { + t.Fatal(err) + } + configPath := filepath.Join(tmpDir, "network.yaml") + testDir, err := filepath.Abs("./tests/cloudinit") + if err != nil { + t.Fatal(err) + } + resultsDir := filepath.Join(testDir, "results") + err = fileCopy(filepath.Join(testDir, "network.yaml"), configPath) + if err != nil { + t.Fatal(err) + } + + // Setup ifupdown and parse test file. + ci, err := newCloudInitWith(configPath, filepath.Join(tmpDir, "nothing.json"), filepath.Join(tmpDir, "cloud.cfg.d")) + if err != nil { + t.Fatal(err) + } + + // Get the interfaces state. + interfaces, err := ci.GetInterfaces() + if err != nil { + t.Fatal(err) + } + + // Verify interfaces read from file. + err = testVerifyInterfaces(interfaces, resultsDir, 1) + if err != nil { + t.Error(err) + } + + // Test setting the IP addresses on an interface. + err = ci.SetIfaceAddresses(context.Background(), "test_eth0.1556", []*net.IPNet{ + { + IP: net.ParseIP("1.2.3.4"), + Mask: net.CIDRMask(24, 32), + }, + { + IP: net.ParseIP("1.2.3.43"), + Mask: net.CIDRMask(24, 32), + }, + { + IP: net.ParseIP("fc00::2"), + Mask: net.CIDRMask(64, 128), + }, + }, net.ParseIP("1.2.3.1"), net.ParseIP("fc00::1")) + if err != nil { + t.Fatal(err) + } + + // Test setting routes on an interface. + err = ci.SetIfaceRoutes(context.Background(), "test_eth3", []*Route{ + { + Destination: &net.IPNet{ + IP: net.ParseIP("abcd:ef12:3455:10::"), + Mask: net.CIDRMask(64, 128), + }, + Gateway: net.ParseIP("abcd:ef12:3456:10::1"), + Metric: 100, + }, + { + Destination: &net.IPNet{ + IP: net.ParseIP("10.253.2.0"), + Mask: net.CIDRMask(24, 32), + }, + Gateway: net.ParseIP("203.0.113.22"), + Metric: 100, + }, + }) + if err != nil { + t.Fatal(err) + } + + // Get the interfaces state. + interfaces, err = ci.GetInterfaces() + if err != nil { + t.Fatal(err) + } + + // Verify interfaces read from file. + err = testVerifyInterfaces(interfaces, resultsDir, 2) + if err != nil { + t.Error(err) + } + + // Read the current file and expected state. + err = testVerifyResults(resultsDir, tmpDir, 1) + if err != nil { + t.Error(err) + } + + // Test setting the IP addresses on an interface. + err = ci.SetIfaceAddresses(context.Background(), "test_eth0", []*net.IPNet{ + { + IP: net.ParseIP("1.2.10.4"), + Mask: net.CIDRMask(24, 32), + }, + }, net.ParseIP("1.2.10.254"), nil) + if err != nil { + t.Fatal(err) + } + + // Test setting routes on an interface. + err = ci.SetIfaceRoutes(context.Background(), "test_eth0.1556", []*Route{}) + if err != nil { + t.Fatal(err) + } + + // Get the interfaces state. + interfaces, err = ci.GetInterfaces() + if err != nil { + t.Fatal(err) + } + + // Verify interfaces read from file. + err = testVerifyInterfaces(interfaces, resultsDir, 3) + if err != nil { + t.Error(err) + } + + // Read the current file and expected state. + err = testVerifyResults(resultsDir, tmpDir, 2) + if err != nil { + t.Error(err) + } + + // Cleanup. + err = os.RemoveAll(tmpDir) + if err != nil { + t.Fatal(err) + } +} + +// Validate the cloudbase-init configuration parser/writer functions. +func TestCloudbaseinit(t *testing.T) { + // Setup test file. + tmpDir, err := os.MkdirTemp("", "") + if err != nil { + t.Fatal(err) + } + configPath := filepath.Join(tmpDir, "network.json") + testDir, err := filepath.Abs("./tests/cloudinit/cloudbase") + if err != nil { + t.Fatal(err) + } + resultsDir := filepath.Join(testDir, "results") + err = fileCopy(filepath.Join(testDir, "network.json"), configPath) + if err != nil { + t.Fatal(err) + } + + // Setup ifupdown and parse test file. + ci, err := newCloudInitWith(filepath.Join(tmpDir, "nothing.yaml"), configPath, filepath.Join(tmpDir, "cloud.cfg.d")) + if err != nil { + t.Fatal(err) + } + + // Get the interfaces state. + interfaces, err := ci.GetInterfaces() + if err != nil { + t.Fatal(err) + } + + // Verify interfaces read from file. + err = testVerifyInterfaces(interfaces, resultsDir, 1) + if err != nil { + t.Error(err) + } + + // Test setting the IP addresses on an interface. + err = ci.SetIfaceAddresses(context.Background(), "test_eth0.1556", []*net.IPNet{ + { + IP: net.ParseIP("1.2.3.4"), + Mask: net.CIDRMask(24, 32), + }, + { + IP: net.ParseIP("1.2.3.43"), + Mask: net.CIDRMask(24, 32), + }, + { + IP: net.ParseIP("fc00::2"), + Mask: net.CIDRMask(64, 128), + }, + }, net.ParseIP("1.2.3.1"), net.ParseIP("fc00::1")) + if err != nil { + t.Fatal(err) + } + + // Test setting routes on an interface. + err = ci.SetIfaceRoutes(context.Background(), "test_eth3", []*Route{ + { + Destination: &net.IPNet{ + IP: net.ParseIP("abcd:ef12:3455:10::"), + Mask: net.CIDRMask(64, 128), + }, + Gateway: net.ParseIP("abcd:ef12:3456:10::1"), + Metric: 100, + }, + { + Destination: &net.IPNet{ + IP: net.ParseIP("10.253.2.0"), + Mask: net.CIDRMask(24, 32), + }, + Gateway: net.ParseIP("203.0.113.22"), + Metric: 100, + }, + }) + if err != nil { + t.Fatal(err) + } + + // Get the interfaces state. + interfaces, err = ci.GetInterfaces() + if err != nil { + t.Fatal(err) + } + + // Verify interfaces read from file. + err = testVerifyInterfaces(interfaces, resultsDir, 2) + if err != nil { + t.Error(err) + } + + // Read the current file and expected state. + err = testVerifyResults(resultsDir, tmpDir, 1) + if err != nil { + t.Error(err) + } + + // Test setting the IP addresses on an interface. + err = ci.SetIfaceAddresses(context.Background(), "test_eth0", []*net.IPNet{ + { + IP: net.ParseIP("1.2.10.4"), + Mask: net.CIDRMask(24, 32), + }, + }, net.ParseIP("1.2.10.254"), nil) + if err != nil { + t.Fatal(err) + } + + // Test setting routes on an interface. + err = ci.SetIfaceRoutes(context.Background(), "test_eth0.1556", []*Route{}) + if err != nil { + t.Fatal(err) + } + + // Get the interfaces state. + interfaces, err = ci.GetInterfaces() + if err != nil { + t.Fatal(err) + } + + // Verify interfaces read from file. + err = testVerifyInterfaces(interfaces, resultsDir, 3) + if err != nil { + t.Error(err) + } + + // Read the current file and expected state. + err = testVerifyResults(resultsDir, tmpDir, 2) + if err != nil { + t.Error(err) + } + + // Cleanup. + err = os.RemoveAll(tmpDir) + if err != nil { + t.Fatal(err) + } +} diff --git a/configurator_linux.go b/configurator_linux.go new file mode 100644 index 0000000..eb7965c --- /dev/null +++ b/configurator_linux.go @@ -0,0 +1,1018 @@ +package netconfig + +import ( + "bytes" + "context" + "fmt" + "net" + "os" + "os/exec" + + dbus "github.com/coreos/go-systemd/dbus" + "github.com/vishvananda/netlink" +) + +type linuxConfigurator struct { + ifaceBackends []namedIfaceBackend + panelBackends []namedPanelBackend + // The tunable settings resolved from the Option values. Its fields + // (testAddress, pingCount, skipPanels, ...) are promoted and read directly + // as c.testAddress etc. by the mutating methods. + *configOptions +} + +// Returns the linux network configurator. Behaviour is tuned with Option +// values such as WithTestAddress and WithConnectivityCheck; use SetLogger to +// replace the package-wide logger. +func NewConfigurator(opts ...Option) (configurator Configurator, err error) { + options := newConfigOptions(opts...) + // Connect to dbus for systemd and list active units to determine which + // network managers are running. This fails on systems without a usable + // systemd D-Bus interface, so we fall back to detecting legacy managers: + // - Ubuntu 14.04: Upstart is PID 1 and org.freedesktop.systemd1 is served + // by systemd-shim, which lacks ListUnits ("No such method 'ListUnits'"). + // - CentOS 5/6: no systemd1 on the bus at all, so ListUnits (or the + // connection itself) fails with ServiceUnknown. + activeUnits := make(map[string]bool) + conn, derr := dbus.NewWithContext(context.Background()) + if derr == nil { + defer conn.Close() + var units []dbus.UnitStatus + units, derr = conn.ListUnitsContext(context.Background()) + for _, unit := range units { + if unit.ActiveState == "active" { + activeUnits[unit.Name] = true + } + } + } + if derr != nil { + logger.Printf("unable to list systemd units, falling back to legacy detection: %v", derr) + activeUnits = legacyActiveUnits() + } + + // Determine candidates based on unit status. + c := new(linuxConfigurator) + c.configOptions = options + configurator = c + + // Detect each backend. A nil concrete pointer wrapped in an interface is + // itself non-nil, so backends are collected as concrete pointers here and + // only registered below when non-nil. + var ( + nd *networkd + nm *networkManager + ns *networkScripts + iud *ifUpDown + np *netplan + ci *cloudInit + ) + if activeUnits["systemd-networkd.service"] { + nd, err = newNetworkd(options.backupRetention) + if err != nil { + logger.Println("error parsing networkd config:", err) + } + } + if activeUnits["NetworkManager.service"] { + nm, err = newNetworkManager() + if err != nil { + logger.Println("error parsing network manager config:", err) + } + } + if activeUnits["network.service"] { + ns, err = newNetworkScripts(options.backupRetention) + if err != nil { + logger.Println("error parsing network scripts:", err) + } + } + + // If the ifupdown config exists, add its config. + if _, serr := os.Stat(ifUpDownConfig); serr == nil { + iud, err = newIfUpDown(options.backupRetention) + if err != nil { + logger.Println("error prasing ifupdown:", err) + } + } + + _, err = exec.LookPath("netplan") + if err == nil { + np, err = newNetplan(options.backupRetention) + if err != nil { + logger.Println("error parsing netplan:", err) + } + } + ci, err = newCloudInit(options.backupRetention) + if err != nil { + logger.Println("error parsing cloud-init:", err) + } + + // Register the detected file backends in a stable apply order. Each backend + // received the configured backup retention from its constructor above. + if np != nil { + c.ifaceBackends = append(c.ifaceBackends, namedIfaceBackend{"Netplan", np}) + } + if ci != nil { + c.ifaceBackends = append(c.ifaceBackends, namedIfaceBackend{"cloud-init", ci}) + } + if nm != nil { + c.ifaceBackends = append(c.ifaceBackends, namedIfaceBackend{"NetworkManager", nm}) + } + if nd != nil { + c.ifaceBackends = append(c.ifaceBackends, namedIfaceBackend{"Networkd", nd}) + } + if ns != nil { + c.ifaceBackends = append(c.ifaceBackends, namedIfaceBackend{"NetworkScripts", ns}) + } + if iud != nil { + c.ifaceBackends = append(c.ifaceBackends, namedIfaceBackend{"IfUpDown", iud}) + } + + // Register the detected control panels in a stable apply order. + if _, serr := os.Stat(cpanelBin); serr == nil { + c.panelBackends = append(c.panelBackends, namedPanelBackend{"cPanel", new(cpanel)}) + } + if _, serr := os.Stat(pleskBin); serr == nil { + c.panelBackends = append(c.panelBackends, namedPanelBackend{"Plesk", new(plesk)}) + } + if _, serr := os.Stat(nodeworxBin); serr == nil { + c.panelBackends = append(c.panelBackends, namedPanelBackend{"Interworx", new(interworx)}) + } + + // Detection failures are logged above; they are not fatal to constructing + // the configurator, so do not propagate them to the caller. + err = nil + return +} + +// legacyActiveUnits detects active network managers on systems that lack a +// usable systemd D-Bus interface, where ListUnits is unavailable: Upstart on +// Ubuntu 14.04 and SysVinit/Upstart on CentOS 5/6. Detection is by config and +// runtime presence rather than by querying any specific init system, so it is +// init-agnostic and spawns no subprocesses. +// +// systemd-networkd cannot run on these systems, and ifupdown is detected +// separately by config-file presence, so only the RHEL network-scripts service +// and NetworkManager are considered here. The returned keys match the systemd +// unit names used by the caller. +func legacyActiveUnits() map[string]bool { + active := make(map[string]bool) + + // RHEL-family network-scripts has no daemon; presence of its config + // directory is the best available signal that it manages the network. + if fi, serr := os.Stat(networkScriptsPath); serr == nil && fi.IsDir() { + active["network.service"] = true + } + + // NetworkManager manages the network via its own D-Bus interface; a + // runtime pid file indicates the daemon is running. + for _, pidFile := range []string{ + "/var/run/NetworkManager/NetworkManager.pid", + "/run/NetworkManager/NetworkManager.pid", + } { + if _, serr := os.Stat(pidFile); serr == nil { + active["NetworkManager.service"] = true + break + } + } + + return active +} + +// Get list of interfaces and their configs. +func (c *linuxConfigurator) GetInterfaces(ctx context.Context) (interfaces []*Interface, err error) { + // Connect to netlink. + var h *netlink.Handle + h, err = netlink.NewHandle() + if err != nil { + return + } + defer h.Close() + + // Get list of interfaces. + var links []netlink.Link + links, err = h.LinkList() + if err != nil { + return + } + + // Add interfaces to list. + for _, link := range links { + // Skip the localhost interface. + if link.Attrs().Name == "lo" { + continue + } + + // Make interface. + i := new(Interface) + i.Name = link.Attrs().Name + i.MAC = link.Attrs().HardwareAddr + i.Link = link + + // Add IP Addresses. + addrs, err := h.AddrList(link, netlink.FAMILY_ALL) + if err != nil { + return nil, err + } + for _, addr := range addrs { + if addr.IP.IsLinkLocalUnicast() { + continue + } + i.Addresses = append(i.Addresses, addr.IPNet) + } + + // Add static routes. + routes, err := h.RouteList(link, netlink.FAMILY_ALL) + if err != nil { + return nil, err + } + routeLoop: + for _, route := range routes { + // Skip nil routes. + if route.Gw == nil { + continue + } + + // The gateway route is 0.0.0.0/0 or ::/0. + ones, _ := route.Dst.Mask.Size() + if ones == 0 { + if route.Gw.To4() == nil { + i.Gateway6 = route.Gw + } else { + i.Gateway4 = route.Gw + } + continue + } + + // Skip route to own networks. + for _, addr := range addrs { + if route.Dst.Contains(addr.IP) && bytes.Equal(route.Dst.Mask, addr.Mask) { + continue routeLoop + } + } + + // Make route. + r := new(Route) + r.Destination = route.Dst + r.Gateway = route.Gw + r.Metric = route.Priority + i.Routes = append(i.Routes, r) + } + + // Add interface. + interfaces = append(interfaces, i) + } + + // DNS servers and search domains are not exposed via netlink, so merge + // them in from the persisted backend configurations. This lets callers + // read back what SetDNS wrote via Interface.DNS and Interface.SearchDomains. + mergeDNSFromBackends(c.ifaceBackends, interfaces) + return +} + +// Add an IP address. +func (c *linuxConfigurator) AddAddress(ctx context.Context, iface string, addr *net.IPNet, gateway net.IP) error { + // Connect to netlink. + h, err := netlink.NewHandle() + if err != nil { + return err + } + defer h.Close() + + // Get link by iface name. + link, err := h.LinkByName(iface) + if err != nil { + return err + } + + // Determine address family so we can confirm we're not removing + // the last address on the default gateway. + family := netlink.FAMILY_V4 + zeroIP := net.IPv4zero + if addr.IP.To4() == nil { + family = netlink.FAMILY_V6 + zeroIP = net.IPv6zero + } + if gateway != nil && !gateway.Equal(zeroIP) { + if !addr.Contains(gateway) && !gateway.IsLinkLocalUnicast() { + return fmt.Errorf("provided gateway is not reachable from network") + } + } + + // Get existing addresses and save the full pre-change state so we can + // restore it if the connectivity test fails. + exists := false + var addresses []*net.IPNet + var origAddresses []*net.IPNet + addrs, err := h.AddrList(link, netlink.FAMILY_ALL) + if err != nil { + return err + } + for _, address := range addrs { + // Save a copy of every non-link-local address before any mutation. + // This must be a distinct *net.IPNet, not address.IPNet itself: when + // the address already exists below we mutate address.IPNet.Mask in + // place, and since AddrList's Addr embeds a *net.IPNet, appending the + // pointer directly here would let that later mutation silently + // corrupt the pre-change snapshot rollback relies on. + if !address.IP.IsLinkLocalUnicast() { + origAddresses = append(origAddresses, &net.IPNet{IP: address.IPNet.IP, Mask: address.IPNet.Mask}) + } + + // If the address already exists, update the netmask. + if address.IPNet.IP.Equal(addr.IP) { + exists = true + address.IPNet.Mask = addr.Mask + err = h.AddrReplace(link, &address) + if err != nil { + return err + } + } + + // Ignore link local addresses. + if address.IP.IsLinkLocalUnicast() { + continue + } + + // Add address to list. + addresses = append(addresses, address.IPNet) + } + + // Add if not already existing. + if !exists { + if pingTest(ctx, addr.IP.String(), c.pingCount, c.pingTimeout) { + return fmt.Errorf("the ip we are adding is responding to ping") + } + err = h.AddrAdd(link, &netlink.Addr{IPNet: addr}) + if err != nil { + return err + } + addresses = append(addresses, addr) + } + + // Find the default gateway, and update or remove based on config. + var origRoute netlink.Route + var gateway4 net.IP + var gateway6 net.IP + routes, err := h.RouteList(link, netlink.FAMILY_ALL) + if err != nil { + return err + } + // A dual-stack host has a default route for each family, and RouteList + // with FAMILY_ALL always returns the IPv4 routes before the IPv6 ones. + // Scan until the first default route of each family is captured rather + // than breaking on the first default route found, otherwise the family + // we're configuring may never be processed. + seen4, seen6 := false, false + for _, route := range routes { + // The gateway route is 0.0.0.0/0 or ::/0, handled once per family. + ones, _ := route.Dst.Mask.Size() + if ones != 0 { + continue + } + if (route.Family == netlink.FAMILY_V4 && seen4) || + (route.Family == netlink.FAMILY_V6 && seen6) { + continue + } + + if route.Family == family { + origRoute = route + // If gateway is nil, we're keeping original gateway. + // If the gateway is the zero IP, we should remove the route. + // If the gateway is regular, try updating the gateway. + if gateway == nil { + gateway = route.Gw + } else if gateway.Equal(zeroIP) { + err = h.RouteDel(&route) + if err != nil { + return err + } + route.Gw = nil + } else { + route.Gw = gateway + err = h.RouteChange(&route) + if err != nil { + return err + } + } + } + // Capture the (possibly mutated) gateway for the config backends. + if route.Family == netlink.FAMILY_V4 { + gateway4 = route.Gw + seen4 = true + } else if route.Family == netlink.FAMILY_V6 { + gateway6 = route.Gw + seen6 = true + } + if seen4 && seen6 { + break + } + } + + // Set gateway as nil on zero IP. + if gateway.Equal(zeroIP) { + gateway = nil + } + + // Test internet to confirm we did not break connection. + if gateway != nil { + // If the gateway doesn't exist, add it. + var addedGateway *netlink.Route + if family == netlink.FAMILY_V4 && gateway4 == nil { + addedGateway = &netlink.Route{ + Family: family, + LinkIndex: link.Attrs().Index, + Dst: &net.IPNet{ + IP: net.IPv4zero, + Mask: net.CIDRMask(0, 32), + }, + Gw: gateway, + } + err = h.RouteAdd(addedGateway) + if err != nil { + return fmt.Errorf("failed to add gateway4 %s: %v", gateway.String(), err) + } + gateway4 = gateway + } else if family == netlink.FAMILY_V6 && gateway6 == nil { + addedGateway = &netlink.Route{ + Family: family, + LinkIndex: link.Attrs().Index, + Dst: &net.IPNet{ + IP: net.IPv6zero, + Mask: net.CIDRMask(0, 128), + }, + Gw: gateway, + } + err = h.RouteAdd(addedGateway) + if err != nil { + return fmt.Errorf("failed to add gateway6 %s: %v", gateway.String(), err) + } + gateway6 = gateway + } + + // First ping gateway to ensure ARP works, then confirm we did not + // break connectivity. The check (and its rollback) is skipped when + // connectivity verification is disabled. + if !c.skipConnectivityCheck { + pingTest(ctx, gateway.String(), c.pingCount, c.pingTimeout) + + // If the connection broke, restore the full pre-change address + // list and default route. + if !testInternet(ctx, c.testAddress, c.connectivityTimeout) { + // Remove the address we added so we can rebuild the original + // address list without it. + if !exists { + if derr := h.AddrDel(link, &netlink.Addr{IPNet: addr}); derr != nil { + return derr + } + } + + // Re-apply every original non-link-local address. + for _, orig := range origAddresses { + if err = h.AddrReplace(link, &netlink.Addr{IPNet: orig}); err != nil { + return err + } + } + + // Restore the original default route for this family. + if origRoute.Gw != nil { + err = h.RouteReplace(&origRoute) + if err != nil { + return err + } + } else if addedGateway != nil { + err = h.RouteDel(addedGateway) + if err != nil { + return err + } + } + return fmt.Errorf("aborted operation due to loss of internet") + } + } + } + + // Update configuration files. Skip control panels when skipPanels is set. + applyIfaceAddresses(ctx, c.ifaceBackends, iface, addresses, gateway4, gateway6) + if !c.skipPanels { + reloadPanels(ctx, c.panelBackends) + } + + return nil +} + +// Promote an existing address to be the primary address of its family. The +// address must already be present on the interface. At runtime the kernel +// treats the first address inserted in a subnet as primary, so the family's +// addresses are removed and re-added with addr first; the persisted backends +// likewise encode primary as list position. Connectivity is verified and rolled +// back on failure, mirroring AddAddress. +func (c *linuxConfigurator) SetPrimaryAddress(ctx context.Context, iface string, addr *net.IPNet) error { + // Connect to netlink. + h, err := netlink.NewHandle() + if err != nil { + return err + } + defer h.Close() + + // Get link by iface name. + link, err := h.LinkByName(iface) + if err != nil { + return err + } + + // Determine address family of the address being promoted. + family := netlink.FAMILY_V4 + if addr.IP.To4() == nil { + family = netlink.FAMILY_V6 + } + + // Get existing addresses. Build the full non-link-local address list (for + // the config backends) and the ordered same-family sublist (for the runtime + // reorder), and confirm the target address is actually present. + exists := false + var addresses []*net.IPNet + var familyAddrs []netlink.Addr + addrs, err := h.AddrList(link, netlink.FAMILY_ALL) + if err != nil { + return err + } + for _, address := range addrs { + if address.IPNet.IP.Equal(addr.IP) { + exists = true + } + + // Ignore link local addresses. + if address.IP.IsLinkLocalUnicast() { + continue + } + + addresses = append(addresses, address.IPNet) + + // Collect this family's addresses in kernel order for the reorder. + isV4 := address.IP.To4() != nil + if (family == netlink.FAMILY_V4) == isV4 { + familyAddrs = append(familyAddrs, address) + } + } + + // The address must already exist on the interface. + if !exists { + return fmt.Errorf("address not found on interface") + } + + // Find the default gateway for this family so the config backends keep the + // gateway and so we can restore the route if reordering drops it. + var gateway4 net.IP + var gateway6 net.IP + var familyRoute netlink.Route + haveFamilyRoute := false + routes, err := h.RouteList(link, netlink.FAMILY_ALL) + if err != nil { + return err + } + // As in AddAddress, a dual-stack host has a default route per family and + // RouteList returns IPv4 before IPv6, so capture the first default of each + // family rather than breaking on the first one found. + seen4, seen6 := false, false + for _, route := range routes { + ones, _ := route.Dst.Mask.Size() + if ones != 0 { + continue + } + if route.Family == netlink.FAMILY_V4 && !seen4 { + gateway4 = route.Gw + seen4 = true + } else if route.Family == netlink.FAMILY_V6 && !seen6 { + gateway6 = route.Gw + seen6 = true + } else { + continue + } + if route.Family == family { + familyRoute = route + haveFamilyRoute = true + } + if seen4 && seen6 { + break + } + } + + // If the address is already primary, there is nothing to reorder at runtime; + // fall through to re-apply the configuration so it matches the running state. + if len(familyAddrs) != 0 && familyAddrs[0].IPNet.IP.Equal(addr.IP) { + addresses, _ = reorderPrimaryAddress(addresses, addr) + applyIfaceAddresses(ctx, c.ifaceBackends, iface, addresses, gateway4, gateway6) + if !c.skipPanels { + reloadPanels(ctx, c.panelBackends) + setMainIPOnPanels(ctx, c.panelBackends, addr.IP) + } + return nil + } + + // Build the desired runtime order with the target address first. + desired := append([]netlink.Addr{}, familyAddrs...) + for i, a := range desired { + if a.IPNet.IP.Equal(addr.IP) { + desired = append(desired[:i], desired[i+1:]...) + break + } + } + target := netlink.Addr{} + for _, a := range familyAddrs { + if a.IPNet.IP.Equal(addr.IP) { + target = a + break + } + } + desired = append([]netlink.Addr{target}, desired...) + + // reapply removes the family's addresses and re-adds them in order, then + // restores the family's default route if it was dropped along with its + // connected address. + reapply := func(order []netlink.Addr) error { + for i := range familyAddrs { + if derr := h.AddrDel(link, &familyAddrs[i]); derr != nil { + return derr + } + } + for i := range order { + a := order[i] + if aerr := h.AddrReplace(link, &a); aerr != nil { + return aerr + } + } + if !haveFamilyRoute { + return nil + } + // Re-add the default route if reordering removed it. + present := false + cur, rerr := h.RouteList(link, family) + if rerr != nil { + return rerr + } + for _, route := range cur { + ones, _ := route.Dst.Mask.Size() + if ones == 0 && route.Gw.Equal(familyRoute.Gw) { + present = true + break + } + } + if !present { + r := familyRoute + if rerr := h.RouteReplace(&r); rerr != nil { + return rerr + } + } + return nil + } + + // Apply the new order. + if err = reapply(desired); err != nil { + return err + } + + // Confirm we did not break connectivity, restoring the original order on + // failure. Skipped when connectivity verification is disabled. + if !c.skipConnectivityCheck && haveFamilyRoute && familyRoute.Gw != nil { + pingTest(ctx, familyRoute.Gw.String(), c.pingCount, c.pingTimeout) + if !testInternet(ctx, c.testAddress, c.connectivityTimeout) { + if rerr := reapply(familyAddrs); rerr != nil { + return rerr + } + return fmt.Errorf("aborted operation due to loss of internet") + } + } + + // Update configuration files with the reordered address list. Skip control + // panels when skipPanels is set. + addresses, _ = reorderPrimaryAddress(addresses, addr) + applyIfaceAddresses(ctx, c.ifaceBackends, iface, addresses, gateway4, gateway6) + if !c.skipPanels { + reloadPanels(ctx, c.panelBackends) + setMainIPOnPanels(ctx, c.panelBackends, addr.IP) + } + + return nil +} + +// Remove an IP address. +func (c *linuxConfigurator) RemoveAddress(ctx context.Context, iface string, addr *net.IPNet) error { + // Connect to netlink. + h, err := netlink.NewHandle() + if err != nil { + return err + } + defer h.Close() + + // Get link by iface name. + link, err := h.LinkByName(iface) + if err != nil { + return err + } + + // Determine address family so we can confirm we're not removing + // the last address on the default gateway. + family := netlink.FAMILY_V4 + if addr.IP.To4() == nil { + family = netlink.FAMILY_V6 + } + + // Find the default gateway. + var gw net.IP + var gateway4 net.IP + var gateway6 net.IP + routes, err := h.RouteList(link, netlink.FAMILY_ALL) + if err != nil { + return err + } + // As in AddAddress, a dual-stack host has a default route per family and + // RouteList returns IPv4 before IPv6, so capture the first default of + // each family rather than breaking on the first one found. + seen4, seen6 := false, false + for _, route := range routes { + // The gateway route is 0.0.0.0/0 or ::/0, handled once per family. + ones, _ := route.Dst.Mask.Size() + if ones != 0 { + continue + } + if route.Family == netlink.FAMILY_V4 && !seen4 { + gateway4 = route.Gw + seen4 = true + } else if route.Family == netlink.FAMILY_V6 && !seen6 { + gateway6 = route.Gw + seen6 = true + } else { + continue + } + if route.Family == family { + gw = route.Gw + } + if seen4 && seen6 { + break + } + } + + // Get existing addresses. + var nlAddr *netlink.Addr + routeToGateway := false + var addresses []*net.IPNet + addrs, err := h.AddrList(link, netlink.FAMILY_ALL) + if err != nil { + return err + } + // Determine the current primary address of the same family: the first + // non-link-local address of the family in kernel order. Removing the + // primary changes the system's source address and can leave a control + // panel without a main IP, so it is refused unless explicitly allowed. + isV4 := family == netlink.FAMILY_V4 + var primaryOfFamily net.IP + for _, address := range addrs { + if address.IP.IsLinkLocalUnicast() { + continue + } + if (address.IP.To4() != nil) != isV4 { + continue + } + primaryOfFamily = address.IP + break + } + for _, address := range addrs { + // If the address already exists, set the nlAddr and skip. + if address.IPNet.IP.Equal(addr.IP) { + nlAddr = &address + continue + } + + // If the gateway can be reached via this IP, note that. + if gw != nil && (address.IPNet.Contains(gw) || gw.IsLinkLocalUnicast()) { + routeToGateway = true + } + + // Ignore link local addresses. + if address.IP.IsLinkLocalUnicast() { + continue + } + + // Add the address. + addresses = append(addresses, address.IPNet) + } + + // Refuse to remove the primary address unless explicitly allowed. The + // caller is expected to promote another address first; WithAllowPrimaryRemoval + // overrides this for deliberate teardowns. + if primaryOfFamily != nil && primaryOfFamily.Equal(addr.IP) && !c.allowPrimaryRemoval { + return fmt.Errorf("refusing to remove primary address %s; promote another address first or enable WithAllowPrimaryRemoval", addr.IP.String()) + } + + // If we cannot reach the gateway after removing the address, + // we should error out. + if gw != nil && !routeToGateway { + return fmt.Errorf("unable to reach gateway after removing address.") + } + + // We should be able to remove the address if it exists. + if nlAddr != nil { + err = h.AddrDel(link, nlAddr) + if err != nil { + return err + } + + // Confirm removing the address did not break connectivity, the same + // way AddAddress and SetPrimaryAddress do; if it did, restore the + // address and abort before persisting the change or notifying any + // panel. The check (and its rollback) is skipped when connectivity + // verification is disabled. + if !c.skipConnectivityCheck { + if gw != nil { + pingTest(ctx, gw.String(), c.pingCount, c.pingTimeout) + } + if !testInternet(ctx, c.testAddress, c.connectivityTimeout) { + if rerr := h.AddrReplace(link, nlAddr); rerr != nil { + return rerr + } + return fmt.Errorf("removing address %s broke internet connectivity; address restored", addr.IP.String()) + } + } + } + + // Update configuration files. When skipPanels is set, control panels are + // left untouched and only the network-manager files are rewritten. + if !c.skipPanels { + removeIPFromPanels(ctx, c.panelBackends, addr.IP) + } + applyIfaceAddresses(ctx, c.ifaceBackends, iface, addresses, gateway4, gateway6) + + return nil +} + +// Add a static route. +func (c *linuxConfigurator) AddRoute(ctx context.Context, iface string, dst *net.IPNet, gateway net.IP, metric int) error { + // Connect to netlink. + h, err := netlink.NewHandle() + if err != nil { + return err + } + defer h.Close() + + // Get link by iface name. + link, err := h.LinkByName(iface) + if err != nil { + return err + } + + // Determine the family. + family := netlink.FAMILY_V4 + if dst.IP.To4() == nil { + family = netlink.FAMILY_V6 + } + + // Verify the addresses on the interface are not in the destination. This + // only guards against a redundant route to the interface's own connected + // subnet; a default route (0.0.0.0/0 or ::/0) always "contains" every + // address and is exempted, otherwise a default route could never be + // added through this API at all. + addrs, err := h.AddrList(link, netlink.FAMILY_ALL) + if err != nil { + return err + } + if ones, _ := dst.Mask.Size(); ones != 0 { + for _, address := range addrs { + if dst.Contains(address.IP) { + return fmt.Errorf("cannot add routes that contains an address on the interface") + } + } + } + + // Add static route. + staticRoute := &netlink.Route{ + Family: family, + LinkIndex: link.Attrs().Index, + Dst: dst, + Gw: gateway, + Priority: metric, + } + err = h.RouteAdd(staticRoute) + if err != nil { + return err + } + + // Get all static routes. + var staticRoutes []*Route + routes, err := h.RouteList(link, netlink.FAMILY_ALL) + if err != nil { + return err + } +routeLoop: + for _, route := range routes { + // The gateway route is 0.0.0.0/0 or ::/0. + ones, _ := route.Dst.Mask.Size() + if ones == 0 { + continue + } + + // Skip route to own networks. + for _, addr := range addrs { + if route.Dst.Contains(addr.IP) && bytes.Equal(route.Dst.Mask, addr.Mask) { + continue routeLoop + } + } + + // Make route. + r := new(Route) + r.Destination = route.Dst + r.Gateway = route.Gw + r.Metric = route.Priority + staticRoutes = append(staticRoutes, r) + } + + // Update configuration files. + applyIfaceRoutes(ctx, c.ifaceBackends, iface, staticRoutes) + + return nil +} + +// Remove a static route. +func (c *linuxConfigurator) RemoveRoute(ctx context.Context, iface string, dst *net.IPNet, gateway net.IP) error { + // Connect to netlink. + h, err := netlink.NewHandle() + if err != nil { + return err + } + defer h.Close() + + // Get link by iface name. + link, err := h.LinkByName(iface) + if err != nil { + return err + } + + // Verify the addresses on the interface are not in the destination. This + // only guards against a redundant route to the interface's own connected + // subnet; a default route (0.0.0.0/0 or ::/0) always "contains" every + // address and is exempted, otherwise a default route could never be + // removed through this API at all. + addrs, err := h.AddrList(link, netlink.FAMILY_ALL) + if err != nil { + return err + } + if ones, _ := dst.Mask.Size(); ones != 0 { + for _, address := range addrs { + if dst.Contains(address.IP) { + return fmt.Errorf("cannot remove routes that contains an address on the interface") + } + } + } + + // Get all static routes. + var staticRoutes []*Route + routes, err := h.RouteList(link, netlink.FAMILY_ALL) + if err != nil { + return err + } +routeLoop: + for _, route := range routes { + // The gateway route is 0.0.0.0/0 or ::/0. + ones, _ := route.Dst.Mask.Size() + if ones == 0 { + continue + } + + // Skip route to own networks. + for _, addr := range addrs { + if route.Dst.Contains(addr.IP) && bytes.Equal(route.Dst.Mask, addr.Mask) { + continue routeLoop + } + } + + // If this is the route we're removing, remove it. + if route.Dst.Contains(dst.IP) && bytes.Equal(route.Dst.Mask, dst.Mask) && + route.Gw.Equal(gateway) { + err = h.RouteDel(&route) + if err != nil { + return err + } + continue + } + + // Make route. + r := new(Route) + r.Destination = route.Dst + r.Gateway = route.Gw + r.Metric = route.Priority + staticRoutes = append(staticRoutes, r) + } + + // Update configuration files. + applyIfaceRoutes(ctx, c.ifaceBackends, iface, staticRoutes) + + return nil +} + +// Set the DNS servers and search domains for an interface. The change is +// written to every detected configuration backend so it survives a reboot, and +// also applied to the live resolver (systemd-resolved via resolvectl, or +// /etc/resolv.conf where resolvectl is unavailable) so it takes effect +// immediately rather than only on the next reload. +func (c *linuxConfigurator) SetDNS(ctx context.Context, iface string, servers []net.IP, searchDomains []string) error { + applyIfaceDNS(ctx, c.ifaceBackends, iface, servers, searchDomains) + applyLiveDNS(ctx, iface, servers, searchDomains) + return nil +} diff --git a/configurator_linux_test.go b/configurator_linux_test.go new file mode 100644 index 0000000..ba50bd1 --- /dev/null +++ b/configurator_linux_test.go @@ -0,0 +1,568 @@ +package netconfig + +import ( + "context" + "net" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/vishvananda/netlink" + "github.com/vishvananda/netns" + "golang.org/x/sys/unix" +) + +// Function to setup a test namespace for test network interfaces. +func setupNetlinkTest(t testing.TB) func() { + t.Helper() + + if os.Getuid() != 0 { + t.Skip("Test requires root privileges.") + } + + runtime.LockOSThread() + ns, err := netns.New() + if err != nil { + t.Fatal("Failed to create new netns", err) + } + + link, err := netlink.LinkByName("lo") + if err != nil { + t.Fatalf("Failed to find \"lo\" in new netns: %v", err) + } + err = netlink.LinkSetUp(link) + if err != nil { + t.Fatalf("Failed to bring up \"lo\" in new netns: %v", err) + } + + return func() { + ns.Close() + runtime.UnlockOSThread() + } +} + +// isPrimary reports whether the given IP is currently the primary address of +// its subnet on the link, i.e. the kernel has not flagged it IFA_F_SECONDARY. +// It also confirms the address is present at all. +func isPrimary(t testing.TB, h *netlink.Handle, link netlink.Link, ip net.IP) (present, primary bool) { + t.Helper() + addrs, err := h.AddrList(link, netlink.FAMILY_ALL) + if err != nil { + t.Fatalf("AddrList: %v", err) + } + for _, a := range addrs { + if a.IPNet.IP.Equal(ip) { + return true, a.Flags&unix.IFA_F_SECONDARY == 0 + } + } + return false, false +} + +// TestLinuxSetPrimaryAddress exercises the netlink reordering performed by +// SetPrimaryAddress: promoting a secondary same-subnet address to primary, +// idempotency when already primary, and the error when the address is absent. +func TestLinuxSetPrimaryAddress(t *testing.T) { + tearDown := setupNetlinkTest(t) + defer tearDown() + + h, err := netlink.NewHandle() + if err != nil { + t.Fatal(err) + } + defer h.Close() + + // Setup test link. + name := "test_enp1s0" + link := netlink.Link(&netlink.Dummy{LinkAttrs: netlink.LinkAttrs{ + Name: name, + HardwareAddr: net.HardwareAddr{0x52, 0x54, 0x00, 0x8b, 0x0d, 0x93}, + }}) + if err = h.LinkAdd(link); err != nil { + t.Fatal(err) + } + if err = h.LinkSetUp(link); err != nil { + t.Fatal(err) + } + + // Add two addresses in the same subnet. The first added is the primary. + primaryIP := &net.IPNet{IP: net.ParseIP("1.2.3.4"), Mask: net.CIDRMask(24, 32)} + secondaryIP := &net.IPNet{IP: net.ParseIP("1.2.3.5"), Mask: net.CIDRMask(24, 32)} + if err = h.AddrAdd(link, &netlink.Addr{IPNet: primaryIP}); err != nil { + t.Fatal(err) + } + if err = h.AddrAdd(link, &netlink.Addr{IPNet: secondaryIP}); err != nil { + t.Fatal(err) + } + + // Add a default route via the subnet so connectivity verification has a + // gateway to work with. + gw := net.ParseIP("1.2.3.1") + if err = h.RouteAdd(&netlink.Route{ + Family: netlink.FAMILY_V4, + LinkIndex: link.Attrs().Index, + Dst: &net.IPNet{IP: net.IPv4zero, Mask: net.CIDRMask(0, 32)}, + Gw: gw, + }); err != nil { + t.Fatal(err) + } + + // Sanity check the initial primary/secondary assignment. + if _, primary := isPrimary(t, h, link, primaryIP.IP); !primary { + t.Fatal("expected 1.2.3.4 to start as primary") + } + if _, primary := isPrimary(t, h, link, secondaryIP.IP); primary { + t.Fatal("expected 1.2.3.5 to start as secondary") + } + + // Configurator with the success sentinel and a capturing backend. + backend := &fakeIfaceBackend{} + c := &linuxConfigurator{configOptions: &configOptions{testAddress: "test_success"}} + c.ifaceBackends = append(c.ifaceBackends, namedIfaceBackend{"fake", backend}) + + // Promote the secondary to primary. + if err = c.SetPrimaryAddress(context.Background(), name, secondaryIP); err != nil { + t.Fatalf("SetPrimaryAddress: %v", err) + } + + // The kernel should now treat 1.2.3.5 as primary and 1.2.3.4 as secondary, + // with both addresses still present and the default route intact. + if present, primary := isPrimary(t, h, link, secondaryIP.IP); !present || !primary { + t.Errorf("after promotion 1.2.3.5: present=%v primary=%v, want both true", present, primary) + } + if present, primary := isPrimary(t, h, link, primaryIP.IP); !present || primary { + t.Errorf("after promotion 1.2.3.4: present=%v primary=%v, want present true primary false", present, primary) + } + routes, err := h.RouteList(link, netlink.FAMILY_V4) + if err != nil { + t.Fatal(err) + } + haveDefault := false + for _, r := range routes { + if ones, _ := r.Dst.Mask.Size(); ones == 0 && r.Gw.Equal(gw) { + haveDefault = true + } + } + if !haveDefault { + t.Error("default route missing after promotion") + } + + // The persisted config must receive the reordered list, primary first. + if len(backend.lastAddrs) == 0 || !backend.lastAddrs[0].IP.Equal(secondaryIP.IP) { + t.Errorf("backend primary = %v, want 1.2.3.5 first", backend.lastAddrs) + } + + // Promoting the already-primary address is a no-op that still succeeds. + if err = c.SetPrimaryAddress(context.Background(), name, secondaryIP); err != nil { + t.Errorf("SetPrimaryAddress (idempotent): %v", err) + } + if _, primary := isPrimary(t, h, link, secondaryIP.IP); !primary { + t.Error("1.2.3.5 should remain primary after idempotent call") + } + + // Promoting an address not on the interface must error before any change. + absent := &net.IPNet{IP: net.ParseIP("1.2.3.99"), Mask: net.CIDRMask(24, 32)} + err = c.SetPrimaryAddress(context.Background(), name, absent) + if err == nil || err.Error() != "address not found on interface" { + t.Errorf("absent address error = %v, want \"address not found on interface\"", err) + } +} + +// TestLinuxRemovePrimaryRefused verifies that RemoveAddress refuses to remove +// the primary address of its family by default, and that WithAllowPrimaryRemoval +// overrides the refusal. +func TestLinuxRemovePrimaryRefused(t *testing.T) { + tearDown := setupNetlinkTest(t) + defer tearDown() + + h, err := netlink.NewHandle() + if err != nil { + t.Fatal(err) + } + defer h.Close() + + name := "test_enp1s0" + link := netlink.Link(&netlink.Dummy{LinkAttrs: netlink.LinkAttrs{Name: name}}) + if err = h.LinkAdd(link); err != nil { + t.Fatal(err) + } + if err = h.LinkSetUp(link); err != nil { + t.Fatal(err) + } + + primaryIP := &net.IPNet{IP: net.ParseIP("1.2.3.4"), Mask: net.CIDRMask(24, 32)} + if err = h.AddrAdd(link, &netlink.Addr{IPNet: primaryIP}); err != nil { + t.Fatal(err) + } + + c := &linuxConfigurator{configOptions: &configOptions{testAddress: "test_success"}} + c.ifaceBackends = append(c.ifaceBackends, namedIfaceBackend{"fake", &fakeIfaceBackend{}}) + + // Default: removing the primary is refused. + err = c.RemoveAddress(context.Background(), name, primaryIP) + if err == nil || !contains(err.Error(), "refusing to remove primary address") { + t.Errorf("RemoveAddress primary = %v, want refusal error", err) + } + + // With the override enabled, removal succeeds. + c.allowPrimaryRemoval = true + if err = c.RemoveAddress(context.Background(), name, primaryIP); err != nil { + t.Errorf("RemoveAddress with allowPrimaryRemoval: %v", err) + } +} + +func contains(s, substr string) bool { + return strings.Contains(s, substr) +} + +func TestLinuxConfigurator(t *testing.T) { + tearDown := setupNetlinkTest(t) + defer tearDown() + + // Connect to netlink. + h, err := netlink.NewHandle() + if err != nil { + t.Fatal(err) + } + defer h.Close() + + // Setup test link. + eth0Name := "test_enp1s0" + eth0Link := netlink.Link(&netlink.Dummy{LinkAttrs: netlink.LinkAttrs{ + Name: eth0Name, + HardwareAddr: net.HardwareAddr{0x52, 0x54, 0x00, 0x8b, 0x0d, 0x93}, + }}) + err = h.LinkAdd(eth0Link) + if err != nil { + t.Fatal(err) + } + + // Set test link up. + err = h.LinkSetUp(eth0Link) + if err != nil { + t.Fatal(err) + } + + // Add test IP. + testIP1 := &net.IPNet{ + IP: net.ParseIP("1.2.3.4"), + Mask: net.CIDRMask(24, 32), + } + err = h.AddrAdd(eth0Link, &netlink.Addr{IPNet: testIP1}) + if err != nil { + t.Fatal(err) + } + + // Add default route. + testGW1 := net.ParseIP("1.2.3.1") + defaultRoute := &netlink.Route{ + Family: netlink.FAMILY_V4, + LinkIndex: eth0Link.Attrs().Index, + Dst: &net.IPNet{ + IP: net.IPv4zero, + Mask: net.CIDRMask(0, 32), + }, + Gw: testGW1, + } + err = h.RouteAdd(defaultRoute) + if err != nil { + t.Fatal(err) + } + + // Setup test link. + eth1Name := "test_enp2s0" + eth1Link := &netlink.Dummy{LinkAttrs: netlink.LinkAttrs{ + Name: eth1Name, + HardwareAddr: net.HardwareAddr{0x52, 0x54, 0x00, 0x8b, 0xad, 0x93}, + }} + err = h.LinkAdd(eth1Link) + if err != nil { + t.Fatal(err) + } + + // Set test link up. + err = h.LinkSetUp(eth1Link) + if err != nil { + t.Fatal(err) + } + + // Add test IP. + testIP2 := &net.IPNet{ + IP: net.ParseIP("fc00:5aa8:7160:d9eb:1:0:1:3"), + Mask: net.CIDRMask(64, 128), + } + err = h.AddrAdd(eth1Link, &netlink.Addr{IPNet: testIP2}) + if err != nil { + t.Fatal(err) + } + + // Add default route. + testGW2 := net.ParseIP("fe80::1") + ipv6Dest := &net.IPNet{ + IP: net.ParseIP("fc00:5aa8:7160:d9eb::1"), + Mask: net.CIDRMask(128, 128), + } + ipv6Route := &netlink.Route{ + Family: netlink.FAMILY_V6, + LinkIndex: eth1Link.Attrs().Index, + Dst: ipv6Dest, + Gw: testGW2, + Priority: 200, + } + err = h.RouteAdd(ipv6Route) + if err != nil { + t.Fatal(err) + } + + // Setup configurator with test URL to test server. + c := &linuxConfigurator{configOptions: &configOptions{}} + c.testAddress = "test_success" + // The test intentionally removes addresses that are primary at runtime; + // enable the override so the removal calls succeed. + c.allowPrimaryRemoval = true + + // Setup test results path. + testDir, err := filepath.Abs("./tests/configurator_linux") + if err != nil { + t.Fatal(err) + } + resultsDir := filepath.Join(testDir, "results") + tmpDir, err := os.MkdirTemp("", "") + if err != nil { + t.Fatal(err) + } + configPath := filepath.Join(tmpDir, "50-cloud-init.yaml") + err = fileCopy(filepath.Join(testDir, "50-cloud-init.yaml"), configPath) + if err != nil { + t.Fatal(err) + } + + // Setup ifupdown and parse test file. + ci, err := newCloudInitWith(configPath, filepath.Join(tmpDir, "nothing.json"), filepath.Join(tmpDir, "cloud.cfg.d")) + if err != nil { + t.Fatal(err) + } + c.ifaceBackends = append(c.ifaceBackends, namedIfaceBackend{"cloud-init", ci}) + + // Get list of interfaces. + interfaces, err := c.GetInterfaces(context.Background()) + if err != nil { + t.Fatal(err) + } + + // Verify interfaces read from file. + err = testVerifyInterfaces(interfaces, resultsDir, 1) + if err != nil { + t.Error(err) + } + + // Test add addresses. + testIP3 := &net.IPNet{IP: net.ParseIP("1.2.3.5"), Mask: net.CIDRMask(24, 32)} + err = c.AddAddress(context.Background(), eth0Name, testIP3, nil) + if err != nil { + t.Error(err) + } + testIP4 := &net.IPNet{IP: net.ParseIP("fc00:5aa8:7160:d9eb:1:0:1:5"), Mask: net.CIDRMask(64, 128)} + err = c.AddAddress(context.Background(), eth1Name, testIP4, net.ParseIP("fc00:5aa8:7160:d9eb::")) + if err != nil { + t.Error(err) + } + + // Get list of interfaces. + interfaces, err = c.GetInterfaces(context.Background()) + if err != nil { + t.Fatal(err) + } + + // Verify interfaces read from file. + err = testVerifyInterfaces(interfaces, resultsDir, 2) + if err != nil { + t.Error(err) + } + + // Read the current file and expected state. + err = testVerifyResults(resultsDir, tmpDir, 1) + if err != nil { + t.Error(err) + } + + // Test remove addresses. + err = c.RemoveAddress(context.Background(), eth0Name, testIP1) + if err != nil { + t.Error(err) + } + err = c.RemoveAddress(context.Background(), eth1Name, testIP2) + if err != nil { + t.Error(err) + } + + // Get list of interfaces. + interfaces, err = c.GetInterfaces(context.Background()) + if err != nil { + t.Fatal(err) + } + + // Verify interfaces read from file. + err = testVerifyInterfaces(interfaces, resultsDir, 3) + if err != nil { + t.Error(err) + } + + // Read the current file and expected state. + err = testVerifyResults(resultsDir, tmpDir, 2) + if err != nil { + t.Error(err) + } + + // Test rollback on failures. + c.testAddress = "test_fail" + err = c.AddAddress(context.Background(), eth0Name, testIP1, nil) + if err == nil || err.Error() != "aborted operation due to loss of internet" { + t.Errorf("Failed rollback test: %v", err) + } + + // Get list of interfaces. + interfaces, err = c.GetInterfaces(context.Background()) + if err != nil { + t.Fatal(err) + } + + // Verify interfaces read from file. + err = testVerifyInterfaces(interfaces, resultsDir, 3) + if err != nil { + t.Error(err) + } + + // Read the current file and expected state. + err = testVerifyResults(resultsDir, tmpDir, 2) + if err != nil { + t.Error(err) + } + + // Test remove gateway. + err = c.AddAddress(context.Background(), eth0Name, testIP3, make(net.IP, 4)) + if err != nil { + t.Error(err) + } + + // Get list of interfaces. + interfaces, err = c.GetInterfaces(context.Background()) + if err != nil { + t.Fatal(err) + } + + // Verify interfaces read from file. + err = testVerifyInterfaces(interfaces, resultsDir, 4) + if err != nil { + t.Error(err) + } + + // Read the current file and expected state. + err = testVerifyResults(resultsDir, tmpDir, 3) + if err != nil { + t.Error(err) + } + + // Test add gateway rollback. + err = c.AddAddress(context.Background(), eth0Name, testIP3, testGW1) + if err == nil || err.Error() != "aborted operation due to loss of internet" { + t.Errorf("Failed rollback test: %v", err) + } + + // Get list of interfaces. + interfaces, err = c.GetInterfaces(context.Background()) + if err != nil { + t.Fatal(err) + } + + // Verify interfaces read from file. + err = testVerifyInterfaces(interfaces, resultsDir, 4) + if err != nil { + t.Error(err) + } + + // Read the current file and expected state. + err = testVerifyResults(resultsDir, tmpDir, 3) + if err != nil { + t.Error(err) + } + + // Test remove gateway. + c.testAddress = "test_success" + err = c.AddAddress(context.Background(), eth0Name, testIP3, testGW1) + if err != nil { + t.Error(err) + } + + // Get list of interfaces. + interfaces, err = c.GetInterfaces(context.Background()) + if err != nil { + t.Fatal(err) + } + + // Verify interfaces read from file. + err = testVerifyInterfaces(interfaces, resultsDir, 3) + if err != nil { + t.Error(err) + } + + // Read the current file and expected state. + err = testVerifyResults(resultsDir, tmpDir, 2) + if err != nil { + t.Error(err) + } + + // Test removing route. + err = c.RemoveRoute(context.Background(), eth1Name, ipv6Dest, testGW2) + if err != nil { + t.Error(err) + } + + // Get list of interfaces. + interfaces, err = c.GetInterfaces(context.Background()) + if err != nil { + t.Fatal(err) + } + + // Verify interfaces read from file. + err = testVerifyInterfaces(interfaces, resultsDir, 5) + if err != nil { + t.Error(err) + } + + // Read the current file and expected state. + err = testVerifyResults(resultsDir, tmpDir, 4) + if err != nil { + t.Error(err) + } + + // Test add route. + ipv4Dest := &net.IPNet{ + IP: net.ParseIP("10.0.10.0"), + Mask: net.CIDRMask(24, 32), + } + testGW3 := net.ParseIP("1.2.3.254") + err = c.AddRoute(context.Background(), eth0Name, ipv4Dest, testGW3, 100) + if err != nil { + t.Error(err) + } + + // Get list of interfaces. + interfaces, err = c.GetInterfaces(context.Background()) + if err != nil { + t.Fatal(err) + } + + // Verify interfaces read from file. + err = testVerifyInterfaces(interfaces, resultsDir, 6) + if err != nil { + t.Error(err) + } + + // Read the current file and expected state. + err = testVerifyResults(resultsDir, tmpDir, 5) + if err != nil { + t.Error(err) + } +} diff --git a/configurator_windows.go b/configurator_windows.go new file mode 100644 index 0000000..bdeef59 --- /dev/null +++ b/configurator_windows.go @@ -0,0 +1,1010 @@ +package netconfig + +import ( + "bytes" + "context" + "fmt" + "net" + "net/netip" + "os" + + "golang.org/x/sys/windows" + "golang.zx2c4.com/wireguard/windows/tunnel/winipcfg" +) + +type windowsConfigurator struct { + ifaceBackends []namedIfaceBackend + panelBackends []namedPanelBackend + // The tunable settings resolved from the Option values. Its fields + // (testAddress, pingCount, skipPanels, ...) are promoted and read directly + // as c.testAddress etc. by the mutating methods. + *configOptions +} + +// Returns the windows network configurator. Behaviour is tuned with Option +// values such as WithTestAddress and WithConnectivityCheck; use SetLogger to +// replace the package-wide logger. +func NewConfigurator(opts ...Option) (configurator Configurator, err error) { + options := newConfigOptions(opts...) + c := new(windowsConfigurator) + c.configOptions = options + configurator = c + + var ci *cloudInit + ci, err = newCloudInit(options.backupRetention) + if err != nil { + logger.Println("error parsing cloud-init:", err) + } + if ci != nil { + c.ifaceBackends = append(c.ifaceBackends, namedIfaceBackend{"cloud-init", ci}) + } + if _, serr := os.Stat(pleskBin); serr == nil { + c.panelBackends = append(c.panelBackends, namedPanelBackend{"Plesk", new(plesk)}) + } + + // Detection failures are logged above; they are not fatal to constructing + // the configurator, so do not propagate them to the caller. + err = nil + return +} + +// Get IP addresses from ip adapter. +func ipAdapterAddresses(ipAdapter *winipcfg.IPAdapterAddresses) (addresses []*net.IPNet) { + // Parse IP addresses on the adapter. To do so, + // we need to get the unicast addresses and find + // a matching prefix to get the subnet length. + unicast := ipAdapter.FirstUnicastAddress + for unicast != nil { + addr := unicast.Address.IP() + + // Check prefixes on the adapter to find the prefix for + // this address. + prefix := ipAdapter.FirstPrefix + for prefix != nil { + // Get the prefix size. + pAddr := prefix.Address.IP() + cidrSize := 32 + if pAddr.To4() == nil { + cidrSize = 128 + } + + // If the length is the same as the size, skip. + if int(prefix.PrefixLength) == cidrSize { + prefix = prefix.Next + continue + } + + // Convert prefix to ipnet. + nPrefix := &net.IPNet{ + IP: pAddr, + Mask: net.CIDRMask(int(prefix.PrefixLength), cidrSize), + } + + // If this is the prefix for the address, we found the + // subnet length. + if nPrefix.Contains(addr) { + // Encode address and add. + ipnet := &net.IPNet{ + IP: addr, + Mask: net.CIDRMask(int(prefix.PrefixLength), cidrSize), + } + addresses = append(addresses, ipnet) + + // We found the prefix, so stop searching prefixes. + prefix = nil + continue + } + + // Check the next prefix. + prefix = prefix.Next + } + + // Check the next IP addr. + unicast = unicast.Next + } + + return +} + +// Get list of interfaces and their configs. +func (c *windowsConfigurator) GetInterfaces(ctx context.Context) (interfaces []*Interface, err error) { + // Reference of zero IP addresses. + zeroIP4 := make(net.IP, 4) + zeroIP6 := make(net.IP, 16) + + // Get routes from Windows. + routes, err := winipcfg.GetIPForwardTable2(windows.AF_UNSPEC) + if err != nil { + return nil, err + } + + // Get adaptures from Windows. GAAFlagSkipDNSServer is intentionally omitted + // so the per-adapter DNS server list is populated and can be reported back. + ipAdaters, err := winipcfg.GetAdaptersAddresses(windows.AF_UNSPEC, winipcfg.GAAFlagIncludePrefix|winipcfg.GAAFlagSkipAnycast|winipcfg.GAAFlagSkipMulticast|winipcfg.GAAFlagSkipFriendlyName|winipcfg.GAAFlagSkipDNSInfo) + if err != nil { + return nil, err + } + + // Convert adapters to interfaces. + for _, ipAdapter := range ipAdaters { + // Skip loopback adapters using the interface type rather than a + // locale-dependent friendly name. + if ipAdapter.IfType == winipcfg.IfType(windows.IF_TYPE_SOFTWARE_LOOPBACK) { + continue + } + + i := new(Interface) + i.Name = ipAdapter.FriendlyName() + i.MAC = ipAdapter.PhysicalAddress() + i.Link = ipAdapter.LUID + for _, addr := range ipAdapterAddresses(ipAdapter) { + if !addr.IP.IsLinkLocalUnicast() { + i.Addresses = append(i.Addresses, addr) + } + } + + // Add DNS servers configured on this adapter. The live resolver list + // is the source of truth on Windows (analogous to addresses/routes). + for dns := ipAdapter.FirstDNSServerAddress; dns != nil; dns = dns.Next { + if ip := dns.Address.IP(); ip != nil { + i.DNS = append(i.DNS, ip) + } + } + + // Add routes. + routeLoop: + for _, route := range routes { + // Only routes on this interface. + if route.InterfaceLUID != ipAdapter.LUID { + continue + } + + // Setup new route to translate. + r := new(Route) + + // Get the prefix, destination IP and gateway. + prefix := route.DestinationPrefix.Prefix() + r.Destination = &net.IPNet{ + IP: prefix.Addr().AsSlice(), + } + gateway := net.IP(route.NextHop.Addr().AsSlice()) + r.Gateway = gateway + + // If the gateway is the zero IP, ignore. + if gateway.Equal(zeroIP4) || gateway.Equal(zeroIP6) { + continue + } + + // If this is the gatway route, assign gateway. + if prefix.Bits() == 0 { + if gateway.To4() != nil { + i.Gateway4 = gateway + } else { + i.Gateway6 = gateway + } + continue + } + + // Apply prefix to destination. + if r.Destination.IP.To4() != nil { + r.Destination.Mask = net.CIDRMask(prefix.Bits(), 32) + } else { + r.Destination.Mask = net.CIDRMask(prefix.Bits(), 128) + } + + // Skip route to own networks. + for _, addr := range i.Addresses { + if r.Destination.Contains(addr.IP) && bytes.Equal(r.Destination.Mask, addr.Mask) { + continue routeLoop + } + } + + // Add route to list. + r.Metric = int(route.Metric) + i.Routes = append(i.Routes, r) + } + + // Add interface. + interfaces = append(interfaces, i) + } + return +} + +// Add an IP address. +func (c *windowsConfigurator) AddAddress(ctx context.Context, iface string, addr *net.IPNet, gateway net.IP) error { + // Get routes from Windows. + routes, err := winipcfg.GetIPForwardTable2(windows.AF_UNSPEC) + if err != nil { + return err + } + + // Get adaptures from Windows. + ipAdaters, err := winipcfg.GetAdaptersAddresses(windows.AF_UNSPEC, winipcfg.GAAFlagIncludePrefix|winipcfg.GAAFlagSkipAnycast|winipcfg.GAAFlagSkipMulticast|winipcfg.GAAFlagSkipDNSServer|winipcfg.GAAFlagSkipFriendlyName|winipcfg.GAAFlagSkipDNSInfo) + if err != nil { + return err + } + + // Find the ip adapter. + var ipAdapter *winipcfg.IPAdapterAddresses + for _, adap := range ipAdaters { + if adap.FriendlyName() == iface { + ipAdapter = adap + } + } + + // If not found, return err. + if ipAdapter == nil { + return fmt.Errorf("no adapter found with name: %s", iface) + } + + // Determine address family so we can confirm we're not removing + // the last address on the default gateway. + family := windows.AF_INET + zeroIP := make(net.IP, 4) + if addr.IP.To4() == nil { + family = windows.AF_INET6 + zeroIP = make(net.IP, 16) + } + if gateway != nil && !gateway.Equal(zeroIP) { + if !addr.Contains(gateway) && !gateway.IsLinkLocalUnicast() { + return fmt.Errorf("provided gateway is not reachable from network") + } + } + + // Get existing addresses. + exists := false + var origAddr *net.IPNet + addresses := ipAdapterAddresses(ipAdapter) + var prefixes []netip.Prefix + for a, address := range addresses { + // If the address already exists, update the netmask. + if address.IP.Equal(addr.IP) { + origAddr = address + exists = true + address = addr + addresses[a] = addr + } + + // Add to prefix list. + ones, _ := address.Mask.Size() + var ip netip.Addr + if address.IP.To4() != nil { + ip = netip.AddrFrom4([4]byte(address.IP.To4())) + } else { + ip = netip.AddrFrom16([16]byte(address.IP.To16())) + } + prefixes = append(prefixes, netip.PrefixFrom(ip, ones)) + } + + // Add if not already existing. + if !exists { + if pingTest(ctx, addr.IP.String(), c.pingCount, c.pingTimeout) { + return fmt.Errorf("the ip we are adding is responding to ping") + } + addresses = append(addresses, addr) + + // Add to prefix list. + ones, _ := addr.Mask.Size() + var ip netip.Addr + if addr.IP.To4() != nil { + ip = netip.AddrFrom4([4]byte(addr.IP.To4())) + } else { + ip = netip.AddrFrom16([16]byte(addr.IP.To16())) + } + prefixes = append(prefixes, netip.PrefixFrom(ip, ones)) + } + + // Set the IP addresses on the interface. + err = ipAdapter.LUID.SetIPAddresses(prefixes) + if err != nil { + return err + } + + // Find the default gateway, and update or remove based on config. + var origGateway netip.Addr + var gateway4 net.IP + var gateway6 net.IP + for _, route := range routes { + if route.InterfaceLUID != ipAdapter.LUID { + continue + } + prefix := route.DestinationPrefix.Prefix() + if prefix.Bits() == 0 { + if route.NextHop.Family == winipcfg.AddressFamily(family) { + origGateway = route.NextHop.Addr() + // If gateway is nil, we're keeping original gateway. + // If the gateway is the zero IP, we should remove the route. + // If the gateway is regular, try updating the gateway. + if gateway == nil { + gateway = net.IP(route.NextHop.Addr().AsSlice()) + } else if gateway.Equal(zeroIP) { + err = route.Delete() + if err != nil { + return err + } + continue + } else { + var ip netip.Addr + if gateway.To4() != nil { + ip = netip.AddrFrom4([4]byte(gateway.To4())) + } else { + ip = netip.AddrFrom16([16]byte(gateway.To16())) + } + route.NextHop.SetAddr(ip) + err = route.Set() + if err != nil { + return err + } + } + } + if route.NextHop.Addr().Is4() { + gateway4 = net.IP(route.NextHop.Addr().AsSlice()) + } else { + gateway6 = net.IP(route.NextHop.Addr().AsSlice()) + } + continue + } + } + + // Set gateway as nil on zero IP. + if gateway.Equal(zeroIP) { + gateway = nil + } + + // Test internet to confirm we did not break connection. + if gateway != nil { + // If the gateway doesn't exist, add it using the adapter's configured + // metric for the family instead of a hard-coded value. + addedGateway := false + if family == windows.AF_INET && gateway4 == nil { + ip := netip.AddrFrom4([4]byte(gateway.To4())) + defaultIP := netip.AddrFrom4([4]byte(zeroIP.To4())) + err = ipAdapter.LUID.AddRoute(netip.PrefixFrom(defaultIP, 0), ip, ipAdapter.Ipv4Metric) + if err != nil { + return err + } + gateway4 = gateway + addedGateway = true + } else if family == windows.AF_INET6 && gateway6 == nil { + ip := netip.AddrFrom16([16]byte(gateway.To16())) + defaultIP := netip.AddrFrom16([16]byte(zeroIP.To16())) + err = ipAdapter.LUID.AddRoute(netip.PrefixFrom(defaultIP, 0), ip, ipAdapter.Ipv6Metric) + if err != nil { + return err + } + gateway6 = gateway + addedGateway = true + } + + // First ping gateway to ensure ARP works. + if !c.skipConnectivityCheck { + pingTest(ctx, gateway.String(), c.pingCount, c.pingTimeout) + } + + // If the connection broke, then we should restore configurations. + // The check (and rollback) is skipped when connectivity verification + // is disabled. + if !c.skipConnectivityCheck && !testInternet(ctx, c.testAddress, c.connectivityTimeout) { + // Attempt to restore the original IP list. Rebuild the prefix + // list in place (rather than shadowing the outer prefixes) from + // the restored addresses, dropping the newly added address. + prefixes = nil + for a, address := range addresses { + if address.IP.Equal(addr.IP) { + // If the address did not exist before the change, drop it + // on rollback rather than restoring a stale value. + if !exists { + continue + } + address = origAddr + addresses[a] = origAddr + } + + // Add to prefix list. + ones, _ := address.Mask.Size() + var ip netip.Addr + if address.IP.To4() != nil { + ip = netip.AddrFrom4([4]byte(address.IP.To4())) + } else { + ip = netip.AddrFrom16([16]byte(address.IP.To16())) + } + prefixes = append(prefixes, netip.PrefixFrom(ip, ones)) + } + + // Restore original addresses. + err = ipAdapter.LUID.SetIPAddresses(prefixes) + if err != nil { + return err + } + + // Try and restore the original gateway. + if origGateway.IsValid() { + for _, route := range routes { + if route.InterfaceLUID != ipAdapter.LUID { + continue + } + prefix := route.DestinationPrefix.Prefix() + if prefix.Bits() == 0 { + if route.NextHop.Family == winipcfg.AddressFamily(family) { + route.NextHop.SetAddr(origGateway) + err = route.Set() + if err != nil { + return err + } + } + if route.NextHop.Addr().Is4() { + gateway4 = net.IP(route.NextHop.Addr().AsSlice()) + } else { + gateway6 = net.IP(route.NextHop.Addr().AsSlice()) + } + continue + } + } + } else if addedGateway { + if family == windows.AF_INET { + ip := netip.AddrFrom4([4]byte(gateway.To4())) + defaultIP := netip.AddrFrom4([4]byte(zeroIP.To4())) + err = ipAdapter.LUID.DeleteRoute(netip.PrefixFrom(defaultIP, 0), ip) + } else if family == windows.AF_INET6 { + ip := netip.AddrFrom16([16]byte(gateway.To16())) + defaultIP := netip.AddrFrom16([16]byte(zeroIP.To16())) + err = ipAdapter.LUID.DeleteRoute(netip.PrefixFrom(defaultIP, 0), ip) + } + if err != nil { + return err + } + } + + return fmt.Errorf("aborted operation due to loss of internet") + } + } + + // Update configuration files. Skip control panels when skipPanels is set. + applyIfaceAddresses(ctx, c.ifaceBackends, iface, addresses, gateway4, gateway6) + if !c.skipPanels { + reloadPanels(ctx, c.panelBackends) + } + + return nil +} + +// prefixesFromAddresses converts a list of addresses to the netip.Prefix list +// expected by winipcfg's SetIPAddresses, preserving order. +func prefixesFromAddresses(addresses []*net.IPNet) []netip.Prefix { + var prefixes []netip.Prefix + for _, address := range addresses { + ones, _ := address.Mask.Size() + var ip netip.Addr + if address.IP.To4() != nil { + ip = netip.AddrFrom4([4]byte(address.IP.To4())) + } else { + ip = netip.AddrFrom16([16]byte(address.IP.To16())) + } + prefixes = append(prefixes, netip.PrefixFrom(ip, ones)) + } + return prefixes +} + +// Promote an existing address to be the primary address of its family. The +// address must already be present on the adapter. winipcfg's SetIPAddresses +// takes an ordered list, so the addresses are re-applied with addr first; the +// persisted backends likewise encode primary as list position. Connectivity is +// verified and rolled back on failure, mirroring AddAddress. +func (c *windowsConfigurator) SetPrimaryAddress(ctx context.Context, iface string, addr *net.IPNet) error { + // Get routes from Windows. + routes, err := winipcfg.GetIPForwardTable2(windows.AF_UNSPEC) + if err != nil { + return err + } + + // Get adaptures from Windows. + ipAdaters, err := winipcfg.GetAdaptersAddresses(windows.AF_UNSPEC, winipcfg.GAAFlagIncludePrefix|winipcfg.GAAFlagSkipAnycast|winipcfg.GAAFlagSkipMulticast|winipcfg.GAAFlagSkipDNSServer|winipcfg.GAAFlagSkipFriendlyName|winipcfg.GAAFlagSkipDNSInfo) + if err != nil { + return err + } + + // Find the ip adapter. + var ipAdapter *winipcfg.IPAdapterAddresses + for _, adap := range ipAdaters { + if adap.FriendlyName() == iface { + ipAdapter = adap + } + } + + // If not found, return err. + if ipAdapter == nil { + return fmt.Errorf("no adapter found with name: %s", iface) + } + + // Determine address family of the address being promoted. + family := windows.AF_INET + if addr.IP.To4() == nil { + family = windows.AF_INET6 + } + + // Get existing addresses and confirm the target is present. + original := ipAdapterAddresses(ipAdapter) + exists := false + for _, address := range original { + if address.IP.Equal(addr.IP) { + exists = true + break + } + } + if !exists { + return fmt.Errorf("address not found on adapter") + } + + // Find the default gateway for each family so the config backends keep them. + var gateway4 net.IP + var gateway6 net.IP + for _, route := range routes { + if route.InterfaceLUID != ipAdapter.LUID { + continue + } + if route.DestinationPrefix.Prefix().Bits() != 0 { + continue + } + if route.NextHop.Addr().Is4() { + gateway4 = net.IP(route.NextHop.Addr().AsSlice()) + } else { + gateway6 = net.IP(route.NextHop.Addr().AsSlice()) + } + } + + // Reorder so the target address is first in its family. + reordered, _ := reorderPrimaryAddress(original, addr) + + // If already primary within its family, just re-apply the configuration. + alreadyPrimary := false + for _, address := range original { + isV4 := address.IP.To4() != nil + if (family == windows.AF_INET) == isV4 { + alreadyPrimary = address.IP.Equal(addr.IP) + break + } + } + if !alreadyPrimary { + // Set the reordered IP addresses on the interface. + if err = ipAdapter.LUID.SetIPAddresses(prefixesFromAddresses(reordered)); err != nil { + return err + } + + // Confirm we did not break connectivity, restoring the original order + // on failure. + var gw net.IP + if family == windows.AF_INET { + gw = gateway4 + } else { + gw = gateway6 + } + if !c.skipConnectivityCheck && gw != nil { + pingTest(ctx, gw.String(), c.pingCount, c.pingTimeout) + if !testInternet(ctx, c.testAddress, c.connectivityTimeout) { + if rerr := ipAdapter.LUID.SetIPAddresses(prefixesFromAddresses(original)); rerr != nil { + return rerr + } + return fmt.Errorf("aborted operation due to loss of internet") + } + } + } + + // Update configuration files with the reordered address list. Skip control + // panels when skipPanels is set. + applyIfaceAddresses(ctx, c.ifaceBackends, iface, reordered, gateway4, gateway6) + if !c.skipPanels { + reloadPanels(ctx, c.panelBackends) + setMainIPOnPanels(ctx, c.panelBackends, addr.IP) + } + + return nil +} + +// Remove an IP address. +func (c *windowsConfigurator) RemoveAddress(ctx context.Context, iface string, addr *net.IPNet) error { + // Get routes from Windows. + routes, err := winipcfg.GetIPForwardTable2(windows.AF_UNSPEC) + if err != nil { + return err + } + + // Get adaptures from Windows. + ipAdaters, err := winipcfg.GetAdaptersAddresses(windows.AF_UNSPEC, winipcfg.GAAFlagIncludePrefix|winipcfg.GAAFlagSkipAnycast|winipcfg.GAAFlagSkipMulticast|winipcfg.GAAFlagSkipDNSServer|winipcfg.GAAFlagSkipFriendlyName|winipcfg.GAAFlagSkipDNSInfo) + if err != nil { + return err + } + + // Find the ip adapter. + var ipAdapter *winipcfg.IPAdapterAddresses + for _, adap := range ipAdaters { + if adap.FriendlyName() == iface { + ipAdapter = adap + } + } + + // If not found, return err. + if ipAdapter == nil { + return fmt.Errorf("no adapter found with name: %s", iface) + } + + // Determine address family so we can confirm we're not removing + // the last address on the default gateway. + family := windows.AF_INET + if addr.IP.To4() == nil { + family = windows.AF_INET6 + } + + // Find the default gateway. + var gw net.IP + var gateway4 net.IP + var gateway6 net.IP + for _, route := range routes { + if route.InterfaceLUID != ipAdapter.LUID { + continue + } + prefix := route.DestinationPrefix.Prefix() + if prefix.Bits() == 0 { + if route.NextHop.Family == winipcfg.AddressFamily(family) { + gw = net.IP(route.NextHop.Addr().AsSlice()) + } + if route.NextHop.Addr().Is4() { + gateway4 = net.IP(route.NextHop.Addr().AsSlice()) + } else { + gateway6 = net.IP(route.NextHop.Addr().AsSlice()) + } + continue + } + } + + // Get existing addresses, building the list without the address being + // removed so both the interface and the config backends drop it. + // Determine the current primary of the same family (the first address of + // the family in adapter order); removing it is refused unless explicitly + // allowed, since it changes the system's source address and can leave a + // control panel without a main IP. + isV4 := family == windows.AF_INET + var primaryOfFamily net.IP + routeToGateway := false + var addresses []*net.IPNet + origAddresses := ipAdapterAddresses(ipAdapter) + for _, address := range origAddresses { + if primaryOfFamily == nil { + if (address.IP.To4() != nil) == isV4 { + primaryOfFamily = address.IP + } + } + // If the address is the one we're removing, skip it. + if address.IP.Equal(addr.IP) { + continue + } + + // If the gateway can be reached via this IP, note that. + if gw != nil && (address.Contains(gw) || gw.IsLinkLocalUnicast()) { + routeToGateway = true + } + + // Keep the address. + addresses = append(addresses, address) + } + + // Refuse to remove the primary address unless explicitly allowed. The + // caller is expected to promote another address first; WithAllowPrimaryRemoval + // overrides this for deliberate teardowns. + if primaryOfFamily != nil && primaryOfFamily.Equal(addr.IP) && !c.allowPrimaryRemoval { + return fmt.Errorf("refusing to remove primary address %s; promote another address first or enable WithAllowPrimaryRemoval", addr.IP.String()) + } + + // If we cannot reach the gateway after removing the address, + // we should error out. + if gw != nil && !routeToGateway { + return fmt.Errorf("unable to reach gateway after removing address.") + } + + // Set the IP addresses on the interface. + err = ipAdapter.LUID.SetIPAddresses(prefixesFromAddresses(addresses)) + if err != nil { + return err + } + + // Confirm removing the address did not break connectivity, the same way + // AddAddress and SetPrimaryAddress do; if it did, restore the original + // address list and abort before persisting the change or notifying any + // panel. The check (and its rollback) is skipped when connectivity + // verification is disabled. + if !c.skipConnectivityCheck { + if gw != nil { + pingTest(ctx, gw.String(), c.pingCount, c.pingTimeout) + } + if !testInternet(ctx, c.testAddress, c.connectivityTimeout) { + if rerr := ipAdapter.LUID.SetIPAddresses(prefixesFromAddresses(origAddresses)); rerr != nil { + return rerr + } + return fmt.Errorf("removing address %s broke internet connectivity; address restored", addr.IP.String()) + } + } + + // Update configuration files. When skipPanels is set, control panels are + // left untouched and only the network-manager files are rewritten. + if !c.skipPanels { + removeIPFromPanels(ctx, c.panelBackends, addr.IP) + } + applyIfaceAddresses(ctx, c.ifaceBackends, iface, addresses, gateway4, gateway6) + + return nil +} + +// Add a static route. +func (c *windowsConfigurator) AddRoute(ctx context.Context, iface string, dst *net.IPNet, gateway net.IP, metric int) error { + // Reference of zero IP addresses. + zeroIP4 := make(net.IP, 4) + zeroIP6 := make(net.IP, 16) + + // Get adaptures from Windows. + ipAdaters, err := winipcfg.GetAdaptersAddresses(windows.AF_UNSPEC, winipcfg.GAAFlagIncludePrefix|winipcfg.GAAFlagSkipAnycast|winipcfg.GAAFlagSkipMulticast|winipcfg.GAAFlagSkipDNSServer|winipcfg.GAAFlagSkipFriendlyName|winipcfg.GAAFlagSkipDNSInfo) + if err != nil { + return err + } + + // Find the ip adapter. + var ipAdapter *winipcfg.IPAdapterAddresses + for _, adap := range ipAdaters { + if adap.FriendlyName() == iface { + ipAdapter = adap + } + } + + // If not found, return err. + if ipAdapter == nil { + return fmt.Errorf("no adapter found with name: %s", iface) + } + + // Determine the family. + family := windows.AF_INET + if dst.IP.To4() == nil { + family = windows.AF_INET6 + } + + // Verify the addresses on the interface are not in the destination. This + // only guards against a redundant route to the interface's own connected + // subnet; a default route (0.0.0.0/0 or ::/0) always "contains" every + // address and is exempted, otherwise a default route could never be + // added through this API at all. + addresses := ipAdapterAddresses(ipAdapter) + prefix, _ := dst.Mask.Size() + if prefix != 0 { + for _, address := range addresses { + if dst.Contains(address.IP) { + return fmt.Errorf("cannot add routes that contains an address on the interface") + } + } + } + + // Add static route. A nil gateway means an on-link route with no next + // hop; converting a nil net.IP via To4()/To16() yields a 0-length slice, + // which panics when forced into a [4]byte/[16]byte array, so it is + // represented as the unspecified address instead, matching how a + // zero-gateway route is already treated elsewhere in this file as "no + // gateway" when reading routes back. + var dstIP netip.Addr + var gatewayIP netip.Addr + if family == windows.AF_INET { + dstIP = netip.AddrFrom4([4]byte(dst.IP.To4())) + if gateway != nil { + gatewayIP = netip.AddrFrom4([4]byte(gateway.To4())) + } else { + gatewayIP = netip.IPv4Unspecified() + } + } else { + dstIP = netip.AddrFrom16([16]byte(dst.IP.To16())) + if gateway != nil { + gatewayIP = netip.AddrFrom16([16]byte(gateway.To16())) + } else { + gatewayIP = netip.IPv6Unspecified() + } + } + err = ipAdapter.LUID.AddRoute(netip.PrefixFrom(dstIP, prefix), gatewayIP, uint32(metric)) + if err != nil { + return err + } + + // Get routes from Windows. + var staticRoutes []*Route + routes, err := winipcfg.GetIPForwardTable2(windows.AF_UNSPEC) + if err != nil { + return err + } +routeLoop: + for _, route := range routes { + // Only routes on this interface. + if route.InterfaceLUID != ipAdapter.LUID { + continue + } + + // Setup new route to translate. + r := new(Route) + + // Get the prefix, destination IP and gateway. + prefix := route.DestinationPrefix.Prefix() + r.Destination = &net.IPNet{ + IP: prefix.Addr().AsSlice(), + } + gateway := net.IP(route.NextHop.Addr().AsSlice()) + r.Gateway = gateway + + // If the gateway is the zero IP, ignore. + if gateway.Equal(zeroIP4) || gateway.Equal(zeroIP6) { + continue + } + + // If this is the gatway route, assign gateway. + if prefix.Bits() == 0 { + continue + } + + // Apply prefix to destination. + if r.Destination.IP.To4() != nil { + r.Destination.Mask = net.CIDRMask(prefix.Bits(), 32) + } else { + r.Destination.Mask = net.CIDRMask(prefix.Bits(), 128) + } + + // Skip route to own networks. + for _, addr := range addresses { + if r.Destination.Contains(addr.IP) && bytes.Equal(r.Destination.Mask, addr.Mask) { + continue routeLoop + } + } + + // Add route to list. + r.Metric = int(route.Metric) + staticRoutes = append(staticRoutes, r) + } + + // Update configuration files. + applyIfaceRoutes(ctx, c.ifaceBackends, iface, staticRoutes) + + return nil +} + +// Remove a static route. +func (c *windowsConfigurator) RemoveRoute(ctx context.Context, iface string, dst *net.IPNet, gateway net.IP) error { + // Reference of zero IP addresses. + zeroIP4 := make(net.IP, 4) + zeroIP6 := make(net.IP, 16) + + // Get adaptures from Windows. + ipAdaters, err := winipcfg.GetAdaptersAddresses(windows.AF_UNSPEC, winipcfg.GAAFlagIncludePrefix|winipcfg.GAAFlagSkipAnycast|winipcfg.GAAFlagSkipMulticast|winipcfg.GAAFlagSkipDNSServer|winipcfg.GAAFlagSkipFriendlyName|winipcfg.GAAFlagSkipDNSInfo) + if err != nil { + return err + } + + // Find the ip adapter. + var ipAdapter *winipcfg.IPAdapterAddresses + for _, adap := range ipAdaters { + if adap.FriendlyName() == iface { + ipAdapter = adap + } + } + + // If not found, return err. + if ipAdapter == nil { + return fmt.Errorf("no adapter found with name: %s", iface) + } + + // Determine the family. + family := windows.AF_INET + if dst.IP.To4() == nil { + family = windows.AF_INET6 + } + + // Verify the addresses on the interface are not in the destination. This + // only guards against a redundant route to the interface's own connected + // subnet; a default route (0.0.0.0/0 or ::/0) always "contains" every + // address and is exempted, otherwise a default route could never be + // removed through this API at all. + addresses := ipAdapterAddresses(ipAdapter) + prefix, _ := dst.Mask.Size() + if prefix != 0 { + for _, address := range addresses { + if dst.Contains(address.IP) { + return fmt.Errorf("cannot remove routes that contains an address on the interface") + } + } + } + + // Remove route. A nil gateway means an on-link route with no next hop; + // converting a nil net.IP via To4()/To16() yields a 0-length slice, which + // panics when forced into a [4]byte/[16]byte array, so it is represented + // as the unspecified address instead, matching how a zero-gateway route + // is already treated elsewhere in this file as "no gateway" when reading + // routes back. + var dstIP netip.Addr + var gatewayIP netip.Addr + if family == windows.AF_INET { + dstIP = netip.AddrFrom4([4]byte(dst.IP.To4())) + if gateway != nil { + gatewayIP = netip.AddrFrom4([4]byte(gateway.To4())) + } else { + gatewayIP = netip.IPv4Unspecified() + } + } else { + dstIP = netip.AddrFrom16([16]byte(dst.IP.To16())) + if gateway != nil { + gatewayIP = netip.AddrFrom16([16]byte(gateway.To16())) + } else { + gatewayIP = netip.IPv6Unspecified() + } + } + err = ipAdapter.LUID.DeleteRoute(netip.PrefixFrom(dstIP, prefix), gatewayIP) + if err != nil { + return err + } + + // Get routes from Windows. + var staticRoutes []*Route + routes, err := winipcfg.GetIPForwardTable2(windows.AF_UNSPEC) + if err != nil { + return err + } +routeLoop: + for _, route := range routes { + // Only routes on this interface. + if route.InterfaceLUID != ipAdapter.LUID { + continue + } + + // Setup new route to translate. + r := new(Route) + + // Get the prefix, destination IP and gateway. + prefix := route.DestinationPrefix.Prefix() + r.Destination = &net.IPNet{ + IP: prefix.Addr().AsSlice(), + } + gateway := net.IP(route.NextHop.Addr().AsSlice()) + r.Gateway = gateway + + // If the gateway is the zero IP, ignore. + if gateway.Equal(zeroIP4) || gateway.Equal(zeroIP6) { + continue + } + + // If this is the gatway route, assign gateway. + if prefix.Bits() == 0 { + continue + } + + // Apply prefix to destination. + if r.Destination.IP.To4() != nil { + r.Destination.Mask = net.CIDRMask(prefix.Bits(), 32) + } else { + r.Destination.Mask = net.CIDRMask(prefix.Bits(), 128) + } + + // Skip route to own networks. + for _, addr := range addresses { + if r.Destination.Contains(addr.IP) && bytes.Equal(r.Destination.Mask, addr.Mask) { + continue routeLoop + } + } + + // Add route to list. + r.Metric = int(route.Metric) + staticRoutes = append(staticRoutes, r) + } + + // Update configuration files. + applyIfaceRoutes(ctx, c.ifaceBackends, iface, staticRoutes) + + return nil +} + +// Set the DNS servers and search domains for an interface. The change is +// written to the detected configuration backends (cloud-init) so it survives a +// reboot, and also applied to the live adapter via winipcfg so it takes effect +// immediately. +func (c *windowsConfigurator) SetDNS(ctx context.Context, iface string, servers []net.IP, searchDomains []string) error { + applyIfaceDNS(ctx, c.ifaceBackends, iface, servers, searchDomains) + applyLiveDNSWindows(ctx, iface, servers, searchDomains) + return nil +} diff --git a/cpanel.go b/cpanel.go new file mode 100644 index 0000000..e5ccfd0 --- /dev/null +++ b/cpanel.go @@ -0,0 +1,410 @@ +package netconfig + +import ( + "context" + "encoding/json" + "fmt" + "net" + "os" + "strings" +) + +const ( + cpanelBin = "/usr/local/cpanel/bin/whmapi1" + cpanelJSON = "--output=json" + + // Paths and scripts that define cPanel's main shared IPv4: mainip is the + // stored main IP, ADDR in wwwacct.conf is the shared IP, and + // mainipcheck/fixetchosts sync the stored main IP and /etc/hosts. IPv6 is + // handled as ranges (see removeIP). + cpanelWWWAcctConf = "/etc/wwwacct.conf" + cpanelMainIP = "/var/cpanel/mainip" + cpanelMainIPCheck = "/scripts/mainipcheck" + cpanelFixEtcHosts = "/scripts/fixetchosts" +) + +type cpanelMetadata struct { + Command string `json:"command"` + Reason string `json:"reason"` + Result int `json:"result"` +} +type cpanelBase struct { + Metadata cpanelMetadata `json:"metadata"` +} + +type cpanelIPInfo struct { + Active int `json:"active"` + Dedicated int `json:"dedicated"` + IP string `json:"ip"` + MainIP int `json:"mainaddr"` + PublicIP string `json:"public_ip"` + Removable int `json:"removable"` + Used int `json:"used"` +} +type cpanelListIPs struct { + Data struct { + IPs []cpanelIPInfo `json:"ip"` + } `json:"data"` + Metadata cpanelMetadata `json:"metadata"` +} + +type cpanelAccountInfo struct { + User string `json:"user"` + Domain string `json:"domain"` + IP string `json:"ip"` + IPv6 []string `json:"ipv6"` +} +type cpanelListAccounts struct { + Data struct { + Accounts []cpanelAccountInfo `json:"acct"` + } `json:"data"` + Metadata cpanelMetadata `json:"metadata"` +} + +type cpanel struct { +} + +// Parse cpanel API json output. +func (*cpanel) parseJSON(out []string, v any) error { + if len(out) != 1 { + return fmt.Errorf("no json output.") + } + + return json.Unmarshal([]byte(out[0]), v) +} + +// Move sites off the old IP to the new IP. +func (p *cpanel) moveSites(ctx context.Context, oldAddr, newAddr net.IP) error { + // Get list of accounts in cPanel using the old IP. + out, err := runCommand(ctx, cpanelBin, cpanelJSON, "listaccts", fmt.Sprintf("search=%s", oldAddr.String()), "searchtype=ip") + if err != nil { + return err + } + var data cpanelListAccounts + err = p.parseJSON(out, &data) + if err != nil { + return err + } + + // On error, log. + if data.Metadata.Result != 1 { + return fmt.Errorf("%s", data.Metadata.Reason) + } + + // Move the sites to new IP. + for _, account := range data.Data.Accounts { + // If the IP is not the one we're removing, skip.' + ip := net.ParseIP(account.IP) + if !ip.Equal(oldAddr) { + continue + } + + // Update the IP address for the account. + out, err := runCommand(ctx, cpanelBin, cpanelJSON, "setsiteip", fmt.Sprintf("ip=%s", newAddr.String()), fmt.Sprintf("user=%s", account.User)) + if err != nil { + return err + } + var data cpanelBase + err = p.parseJSON(out, &data) + if err != nil { + return err + } + logger.Println("cPanel site move response:", data.Metadata.Reason) + } + + return nil +} + +// Remove an IP address from cpanel. IPv4 and IPv6 are handled separately: the +// IPv4 main-IP pool (listips) is IPv4-only, so it must not be consulted when +// removing an IPv6 address. +func (p *cpanel) removeIP(ctx context.Context, addr net.IP) error { + if addr.To4() == nil { + return p.removeIPv6(ctx, addr) + } + return p.removeIPv4(ctx, addr) +} + +// removeIPv4 moves accounts off addr onto cPanel's main shared IPv4, then +// releases addr. +func (p *cpanel) removeIPv4(ctx context.Context, addr net.IP) error { + // Get list of accounts in cPanel using the old IP. + out, err := runCommand(ctx, cpanelBin, cpanelJSON, "listaccts", fmt.Sprintf("search=%s", addr.String()), "searchtype=ip") + if err != nil { + return err + } + var accounts cpanelListAccounts + err = p.parseJSON(out, &accounts) + if err != nil { + return err + } + if accounts.Metadata.Result != 1 { + return fmt.Errorf("%s", accounts.Metadata.Reason) + } + + // Get list of IP addresses in cPanel. + out, err = runCommand(ctx, cpanelBin, cpanelJSON, "listips") + if err != nil { + return err + } + var data cpanelListIPs + err = p.parseJSON(out, &data) + if err != nil { + return err + } + + // On error, log. + if data.Metadata.Result != 1 { + return fmt.Errorf("%s", data.Metadata.Reason) + } + + // Information to extract from IP list. + var mainIP net.IP + isMainIP := false + var publicIPs []net.IP + var privateIPs []net.IP + + // Parse IP list info. + for _, ipInfo := range data.Data.IPs { + ip := net.ParseIP(ipInfo.IP) + + // Depending on the ip, if the IP is one being removed we capture if its the main IP. + // If the IP is the main IP, but no the one being removed, capture that fact. + // If its an public IP, associate with public IP list, and if its private add as needed. + if ip.Equal(addr) { + isMainIP = ipInfo.MainIP == 1 && mainIP == nil + } else if ipInfo.MainIP == 1 { + mainIP = ip + isMainIP = false + } else if !ip.IsPrivate() { + publicIPs = append(publicIPs, ip) + } else { + pubIP := net.ParseIP(ipInfo.PublicIP) + if pubIP != nil && !pubIP.IsPrivate() { + publicIPs = append(publicIPs, ip) + } else { + privateIPs = append(privateIPs, ip) + } + } + } + + // If no main IP found, we should find a new main IP. + noMainIP := mainIP == nil + if noMainIP { + if len(publicIPs) != 0 { + mainIP = publicIPs[0] + } else if len(privateIPs) != 0 { + mainIP = privateIPs[0] + } else { + return fmt.Errorf("unable to find the main IP to move ip addresses to") + } + } + + // Move sites to the main IP from the old IP. + err = p.moveSites(ctx, addr, mainIP) + if err != nil { + return err + } + + // Remove the IP, incase there is an IP alias for it. We don't really care about the output for this, + // it may fail if cPanel already picked up that the IP was removed from the system interface. + runCommand(ctx, cpanelBin, cpanelJSON, "delip", fmt.Sprintf("ip=%s", addr.String())) + + // Rebuild the IP pool. + err = p.reload(ctx) + if err != nil { + return err + } + + // If the main IP did not exist, or if the IP being removed was the main IP, tell cPanel to check. + if isMainIP || noMainIP { + out, err := runCommand(ctx, cpanelMainIPCheck) + if err != nil { + return err + } + logger.Println("cPanel main IP output:", strings.Join(out, "\n")) + } + + return nil +} + +// removeIPv6 moves accounts off addr onto a replacement IPv6 address, then +// releases addr. cPanel's listips pool is IPv4-only, so the replacement is +// drawn from the other IPv6 addresses the affected accounts already hold; it is +// never an IPv4 address. When no other IPv6 is available the address is dropped +// from each account rather than substituted. +func (p *cpanel) removeIPv6(ctx context.Context, addr net.IP) error { + // cPanel tracks IPv6 as ranges; search accounts by the IPv6 address. + out, err := runCommand(ctx, cpanelBin, cpanelJSON, "listaccts", fmt.Sprintf("search=%s", addr.String()), "searchtype=ipv6") + if err != nil { + return err + } + var accounts cpanelListAccounts + err = p.parseJSON(out, &accounts) + if err != nil { + return err + } + if accounts.Metadata.Result != 1 { + return fmt.Errorf("%s", accounts.Metadata.Reason) + } + + // Choose a replacement IPv6 from the addresses the affected accounts already + // use, excluding the one being removed. Never fall back to an IPv4 address. + var replacement net.IP + for _, account := range accounts.Data.Accounts { + for _, ipStr := range account.IPv6 { + ip := net.ParseIP(ipStr) + if ip == nil || ip.To4() != nil || ip.Equal(addr) { + continue + } + replacement = ip + break + } + if replacement != nil { + break + } + } + + // Move the sites off the address (onto the replacement, or drop it when + // there is no other IPv6 to move to). + err = p.moveIPv6Sites(ctx, accounts, addr, replacement) + if err != nil { + return err + } + + // Remove the IP, incase there is an IP alias for it. We don't really care about the output for this, + // it may fail if cPanel already picked up that the IP was removed from the system interface. + runCommand(ctx, cpanelBin, cpanelJSON, "delip", fmt.Sprintf("ip=%s", addr.String()), "ipv6=1") + + // Rebuild the IP pool. + return p.reload(ctx) +} + +// moveIPv6Sites moves any accounts using oldAddr as an IPv6 address over to +// newAddr, or drops oldAddr from the account when newAddr is nil. cPanel +// exposes account IPv6 via listaccts, so we use that rather than the IPv4 +// listips path. +func (p *cpanel) moveIPv6Sites(ctx context.Context, accounts cpanelListAccounts, oldAddr, newAddr net.IP) error { + for _, account := range accounts.Data.Accounts { + // Confirm the account actually has the address being removed. + found := false + for _, ip := range account.IPv6 { + if net.ParseIP(ip).Equal(oldAddr) { + found = true + break + } + } + if !found { + continue + } + + // Build the new IPv6 list, replacing oldAddr with newAddr. When there is + // no replacement (newAddr is nil), drop oldAddr from the list entirely + // rather than substituting an address of the wrong family. + var newIPv6 []string + for _, ip := range account.IPv6 { + if net.ParseIP(ip).Equal(oldAddr) { + if newAddr != nil { + newIPv6 = append(newIPv6, newAddr.String()) + } + } else { + newIPv6 = append(newIPv6, ip) + } + } + + out, err := runCommand(ctx, cpanelBin, cpanelJSON, "setsiteip", fmt.Sprintf("ip=%s", account.IP), fmt.Sprintf("user=%s", account.User), fmt.Sprintf("ipv6=%s", strings.Join(newIPv6, ","))) + if err != nil { + return err + } + var data cpanelBase + err = p.parseJSON(out, &data) + if err != nil { + return err + } + logger.Println("cPanel IPv6 site move response:", data.Metadata.Reason) + } + return nil +} + +// setWWWAcctAddr rewrites an address directive in a wwwacct.conf-style file to +// ip in place, preserving every other line and the file's permissions. If the +// directive is absent it is appended. directive is "ADDR" for the shared IPv4 or +// "ADDR6" for the shared IPv6; the two are distinct, so setting one leaves the +// other untouched. +func setWWWAcctAddr(path, directive, ip string) error { + info, err := os.Stat(path) + if err != nil { + return err + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + + lines := strings.Split(string(data), "\n") + replaced := false + for i, line := range lines { + fields := strings.Fields(line) + if len(fields) >= 1 && fields[0] == directive { + lines[i] = directive + " " + ip + replaced = true + } + } + if !replaced { + // Insert before a trailing empty element (from a final newline) so the + // file keeps a single trailing newline rather than gaining a blank line. + if n := len(lines); n > 0 && lines[n-1] == "" { + lines = append(lines[:n-1], directive+" "+ip, "") + } else { + lines = append(lines, directive+" "+ip) + } + } + + return os.WriteFile(path, []byte(strings.Join(lines, "\n")), info.Mode().Perm()) +} + +// setMainIP repoints cPanel's main shared IP to addr: set the shared-IP +// directive in wwwacct.conf (ADDR for IPv4, ADDR6 for IPv6), then run +// mainipcheck and fixetchosts to sync the stored main IP and /etc/hosts. Only +// IPv4 has a stored-main-IP file (/var/cpanel/mainip); IPv6 is tracked as ranges +// and has no such file. The IP pool (/etc/ipaddrpool) is not written here +// because reload() runs /scripts/rebuildippool, which rebuilds the pool from the +// system's bound IPs. +func (p *cpanel) setMainIP(ctx context.Context, addr net.IP) error { + ip := addr.String() + + // Point the shared IP at the new main address (ADDR6 for IPv6). + directive := "ADDR" + if addr.To4() == nil { + directive = "ADDR6" + } + if err := setWWWAcctAddr(cpanelWWWAcctConf, directive, ip); err != nil { + return err + } + + // Only IPv4 has a stored main IP file; record it without a trailing newline. + if addr.To4() != nil { + if err := os.WriteFile(cpanelMainIP, []byte(ip), 0644); err != nil { + return err + } + } + + // Sync cPanel's stored main IP (/var/cpanel/mainip) and /etc/hosts. + out, err := runCommand(ctx, cpanelMainIPCheck) + if err != nil { + return err + } + logger.Println("cPanel main IP output:", strings.Join(out, "\n")) + runCommand(ctx, cpanelFixEtcHosts) + return nil +} + +// Tell cpanel to reload the available IP addresses. +func (*cpanel) reload(ctx context.Context) error { + out, err := runCommand(ctx, "/scripts/rebuildippool") + if err != nil { + return err + } + logger.Println("cPanel IP pool output:", strings.Join(out, "\n")) + return nil +} diff --git a/cpanel_test.go b/cpanel_test.go new file mode 100644 index 0000000..2385c44 --- /dev/null +++ b/cpanel_test.go @@ -0,0 +1,143 @@ +package netconfig + +import ( + "os" + "path/filepath" + "testing" +) + +// parseJSON unmarshals a single line of whmapi1 output; verify its guard on +// the expected single-line shape and that valid/invalid JSON behave correctly. +func TestCpanelParseJSON(t *testing.T) { + c := &cpanel{} + + t.Run("valid single line", func(t *testing.T) { + var v cpanelBase + err := c.parseJSON([]string{`{"metadata":{"command":"listips","result":1}}`}, &v) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if v.Metadata.Command != "listips" || v.Metadata.Result != 1 { + t.Errorf("decoded = %+v, want command=listips result=1", v.Metadata) + } + }) + + t.Run("no output errors", func(t *testing.T) { + var v cpanelBase + if err := c.parseJSON(nil, &v); err == nil { + t.Error("expected error for empty output") + } + }) + + t.Run("multiple lines errors", func(t *testing.T) { + var v cpanelBase + if err := c.parseJSON([]string{"{}", "{}"}, &v); err == nil { + t.Error("expected error for multi-line output") + } + }) + + t.Run("invalid json errors", func(t *testing.T) { + var v cpanelBase + if err := c.parseJSON([]string{"not json"}, &v); err == nil { + t.Error("expected error for invalid json") + } + }) +} + +// setWWWAcctAddr repoints an address directive (ADDR / ADDR6) in wwwacct.conf; +// verify it replaces the value in place, appends when absent, and that ADDR and +// ADDR6 are set independently without disturbing each other. +func TestSetWWWAcctAddr(t *testing.T) { + t.Run("replaces existing ADDR and preserves other lines", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "wwwacct.conf") + content := "HOST server.example.com\nADDR 203.0.113.10\nNS ns1.example.com\n" + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + if err := setWWWAcctAddr(path, "ADDR", "203.0.113.20"); err != nil { + t.Fatalf("setWWWAcctAddr: %v", err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + want := "HOST server.example.com\nADDR 203.0.113.20\nNS ns1.example.com\n" + if string(got) != want { + t.Errorf("got:\n%s\nwant:\n%s", got, want) + } + }) + + t.Run("appends ADDR when absent", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "wwwacct.conf") + if err := os.WriteFile(path, []byte("HOST server.example.com\n"), 0644); err != nil { + t.Fatal(err) + } + if err := setWWWAcctAddr(path, "ADDR", "203.0.113.20"); err != nil { + t.Fatalf("setWWWAcctAddr: %v", err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + want := "HOST server.example.com\nADDR 203.0.113.20\n" + if string(got) != want { + t.Errorf("got:\n%q\nwant:\n%q", got, want) + } + }) + + t.Run("setting ADDR does not touch ADDR6", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "wwwacct.conf") + content := "ADDR 203.0.113.10\nADDR6 2001:db8::1\n" + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + if err := setWWWAcctAddr(path, "ADDR", "203.0.113.20"); err != nil { + t.Fatalf("setWWWAcctAddr: %v", err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + want := "ADDR 203.0.113.20\nADDR6 2001:db8::1\n" + if string(got) != want { + t.Errorf("got:\n%s\nwant:\n%s", got, want) + } + }) + + t.Run("setting ADDR6 replaces it and leaves ADDR alone", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "wwwacct.conf") + content := "ADDR 203.0.113.10\nADDR6 2001:db8::1\n" + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + if err := setWWWAcctAddr(path, "ADDR6", "2001:db8::20"); err != nil { + t.Fatalf("setWWWAcctAddr: %v", err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + want := "ADDR 203.0.113.10\nADDR6 2001:db8::20\n" + if string(got) != want { + t.Errorf("got:\n%s\nwant:\n%s", got, want) + } + }) + + t.Run("appends ADDR6 when absent", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "wwwacct.conf") + if err := os.WriteFile(path, []byte("ADDR 203.0.113.10\n"), 0644); err != nil { + t.Fatal(err) + } + if err := setWWWAcctAddr(path, "ADDR6", "2001:db8::20"); err != nil { + t.Fatalf("setWWWAcctAddr: %v", err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + want := "ADDR 203.0.113.10\nADDR6 2001:db8::20\n" + if string(got) != want { + t.Errorf("got:\n%s\nwant:\n%s", got, want) + } + }) +} diff --git a/dns_backends_test.go b/dns_backends_test.go new file mode 100644 index 0000000..11236d9 --- /dev/null +++ b/dns_backends_test.go @@ -0,0 +1,145 @@ +package netconfig + +import ( + "context" + "net" + "os" + "path/filepath" + "testing" +) + +// readIfcfg parses a written ifcfg- file back into a key/value map so +// tests can assert on individual directives (PEERDNS, DNS1, ...). +func readIfcfg(t *testing.T, dir, iface string) map[string]string { + t.Helper() + data, err := os.ReadFile(filepath.Join(dir, "ifcfg-"+iface)) + if err != nil { + t.Fatalf("read ifcfg-%s: %v", iface, err) + } + out := make(map[string]string) + for _, line := range splitLines(string(data)) { + k, v, ok := cutKV(line) + if ok { + out[k] = v + } + } + return out +} + +func splitLines(s string) []string { + var lines []string + cur := "" + for _, r := range s { + if r == '\n' { + lines = append(lines, cur) + cur = "" + continue + } + cur += string(r) + } + if cur != "" { + lines = append(lines, cur) + } + return lines +} + +func cutKV(line string) (string, string, bool) { + for i := 0; i < len(line); i++ { + if line[i] == '=' { + v := line[i+1:] + // Strip surrounding quotes written for values with whitespace. + if len(v) >= 2 && v[0] == '"' && v[len(v)-1] == '"' { + v = v[1 : len(v)-1] + } + return line[:i], v, true + } + } + return "", "", false +} + +// TestNetworkScriptsDNSPeerDNS verifies that setting DNS on a network-scripts +// interface writes PEERDNS=no (so dhclient does not override the static +// resolvers) and that clearing DNS removes PEERDNS again. +func TestNetworkScriptsDNSPeerDNS(t *testing.T) { + dir := t.TempDir() + ns := &networkScripts{ConfigDir: dir} + + // Set DNS servers. + err := ns.SetIfaceDNS(context.Background(), "eth0", + []net.IP{net.ParseIP("1.1.1.1"), net.ParseIP("8.8.8.8")}, + []string{"example.com"}) + if err != nil { + t.Fatalf("SetIfaceDNS: %v", err) + } + cfg := readIfcfg(t, dir, "eth0") + if cfg["PEERDNS"] != "no" { + t.Errorf("PEERDNS = %q, want \"no\"", cfg["PEERDNS"]) + } + if cfg["DNS1"] != "1.1.1.1" || cfg["DNS2"] != "8.8.8.8" { + t.Errorf("DNS1/DNS2 = %q/%q, want 1.1.1.1/8.8.8.8", cfg["DNS1"], cfg["DNS2"]) + } + if cfg["DOMAIN"] != "example.com" { + t.Errorf("DOMAIN = %q, want example.com", cfg["DOMAIN"]) + } + + // Clearing DNS should drop PEERDNS so DHCP-provided resolvers resume. + err = ns.SetIfaceDNS(context.Background(), "eth0", nil, nil) + if err != nil { + t.Fatalf("SetIfaceDNS clear: %v", err) + } + cfg = readIfcfg(t, dir, "eth0") + if _, ok := cfg["PEERDNS"]; ok { + t.Errorf("PEERDNS still present after clear: %q", cfg["PEERDNS"]) + } + if _, ok := cfg["DNS1"]; ok { + t.Errorf("DNS1 still present after clear: %q", cfg["DNS1"]) + } +} + +// TestIfUpDownDNSEnsuresInterface verifies that SetIfaceDNS creates the +// interface block when it has not been seen before, rather than silently +// no-opping and reporting success with nothing persisted. +func TestIfUpDownDNSEnsuresInterface(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "interfaces") + if err := os.WriteFile(cfgPath, []byte("auto lo\niface lo inet loopback\n"), 0644); err != nil { + t.Fatal(err) + } + i := &ifUpDown{BaseConfig: cfgPath, BackupDir: cfgPath + ".backup"} + if err := i.ReadFile(cfgPath); err != nil { + t.Fatal(err) + } + + // eth0 is not present in the file; SetIfaceDNS must create it. + err := i.SetIfaceDNS(context.Background(), "eth0", + []net.IP{net.ParseIP("1.1.1.1")}, []string{"example.com"}) + if err != nil { + t.Fatalf("SetIfaceDNS: %v", err) + } + + // Re-read from disk and confirm the stanzas were persisted. + i.Interfaces = nil + if err := i.ReadFile(cfgPath); err != nil { + t.Fatal(err) + } + interfaces, err := i.GetInterfaces() + if err != nil { + t.Fatal(err) + } + var found *Interface + for _, iface := range interfaces { + if iface.Name == "eth0" { + found = iface + break + } + } + if found == nil { + t.Fatal("eth0 was not persisted; SetIfaceDNS silently no-opped") + } + if len(found.DNS) != 1 || !found.DNS[0].Equal(net.ParseIP("1.1.1.1")) { + t.Errorf("DNS = %v, want [1.1.1.1]", found.DNS) + } + if len(found.SearchDomains) != 1 || found.SearchDomains[0] != "example.com" { + t.Errorf("SearchDomains = %v, want [example.com]", found.SearchDomains) + } +} diff --git a/dns_linux.go b/dns_linux.go new file mode 100644 index 0000000..bc75390 --- /dev/null +++ b/dns_linux.go @@ -0,0 +1,154 @@ +package netconfig + +import ( + "bufio" + "context" + "fmt" + "net" + "os" + "os/exec" + "strings" +) + +const ( + resolvectlBin = "resolvectl" + resolvConf = "/etc/resolv.conf" +) + +// applyLiveDNS applies the DNS servers and search domains to the running +// resolver in addition to whatever the configuration backends persist. It is +// best-effort: errors are logged but never returned, because a failure to +// update the live resolver does not undo the (already-written) persistent +// configuration. +// +// Two mechanisms are used, in order of preference: +// 1. systemd-resolved via resolvectl, which keeps DNS per-interface and is the +// correct mechanism when resolved manages /etc/resolv.conf. +// 2. A direct rewrite of /etc/resolv.conf, used only when resolvectl is absent +// and the file is a regular file (not a symlink to the resolved stub). This +// is a global resolver change rather than a per-interface one. +func applyLiveDNS(ctx context.Context, iface string, servers []net.IP, searchDomains []string) { + serverStrs := ipStrings(servers) + if _, err := exec.LookPath(resolvectlBin); err == nil { + if applyResolvectlDNS(ctx, iface, serverStrs, searchDomains) { + return + } + logger.Println("live DNS: resolvectl failed, falling back to /etc/resolv.conf") + } + // The /etc/resolv.conf fallback is a global change with no per-interface + // concept, so an empty server list would strip every nameserver and take + // down all resolution on the host. Clearing DNS is only meaningful + // per-interface (handled above by resolvectl), so skip the file rewrite + // rather than wipe the global resolver. + if len(serverStrs) == 0 { + return + } + if err := writeResolvConf(serverStrs, searchDomains); err != nil { + logger.Printf("live DNS: %v", err) + } +} + +// applyResolvectlDNS sets per-interface DNS servers and search domains via +// resolvectl. An empty server/domain list clears that field for the interface. +// It returns true if the resolvectl calls succeeded. +func applyResolvectlDNS(ctx context.Context, iface string, servers, searchDomains []string) bool { + // When both lists are empty the intent is to drop this interface's DNS + // entirely; "resolvectl revert LINK" is the documented reset and avoids + // relying on the version-sensitive behaviour of passing an empty string. + if len(servers) == 0 && len(searchDomains) == 0 { + if _, err := runCommand(ctx, resolvectlBin, "revert", iface); err != nil { + logger.Printf("live DNS: resolvectl revert: %v", err) + return false + } + return true + } + + ok := true + // resolvectl dns LINK [SERVER...]; passing a single empty string clears the + // per-link server list. + dnsArgs := []string{"dns", iface} + if len(servers) > 0 { + dnsArgs = append(dnsArgs, servers...) + } else { + dnsArgs = append(dnsArgs, "") + } + if _, err := runCommand(ctx, resolvectlBin, dnsArgs...); err != nil { + logger.Printf("live DNS: resolvectl dns: %v", err) + ok = false + } + + // resolvectl domain LINK [DOMAIN...]; passing a single empty string clears + // the per-link search list. + domainArgs := []string{"domain", iface} + if len(searchDomains) > 0 { + domainArgs = append(domainArgs, searchDomains...) + } else { + domainArgs = append(domainArgs, "") + } + if _, err := runCommand(ctx, resolvectlBin, domainArgs...); err != nil { + logger.Printf("live DNS: resolvectl domain: %v", err) + ok = false + } + return ok +} + +// writeResolvConf rewrites /etc/resolv.conf with the given servers and search +// domains, preserving any non-nameserver/non-search lines (comments, options, +// etc.). It only writes a regular file: when /etc/resolv.conf is a symlink (as +// it is when managed by systemd-resolved or resolvconf), it is left untouched +// so the manager keeps ownership. +func writeResolvConf(servers, searchDomains []string) error { + fi, err := os.Lstat(resolvConf) + if err != nil { + return fmt.Errorf("stat %s: %w", resolvConf, err) + } + if fi.Mode()&os.ModeSymlink != 0 { + // resolv.conf is a symlink to a manager-owned target; do not clobber it. + return nil + } + perm := fi.Mode().Perm() + + data, err := os.ReadFile(resolvConf) + if err != nil { + return fmt.Errorf("read %s: %w", resolvConf, err) + } + + var kept []string + scanner := bufio.NewScanner(strings.NewReader(string(data))) + for scanner.Scan() { + field := strings.Fields(scanner.Text()) + if len(field) > 0 && (field[0] == "nameserver" || field[0] == "search") { + continue + } + kept = append(kept, scanner.Text()) + } + if err := scanner.Err(); err != nil { + return fmt.Errorf("scan %s: %w", resolvConf, err) + } + + var b strings.Builder + for _, line := range kept { + b.WriteString(line) + b.WriteByte('\n') + } + if len(searchDomains) > 0 { + b.WriteString("search ") + b.WriteString(strings.Join(searchDomains, " ")) + b.WriteByte('\n') + } + for _, s := range servers { + b.WriteString("nameserver ") + b.WriteString(s) + b.WriteByte('\n') + } + + tmp := resolvConf + ".tmp" + if err := os.WriteFile(tmp, []byte(b.String()), perm); err != nil { + return fmt.Errorf("write %s: %w", tmp, err) + } + if err := fileMove(tmp, resolvConf); err != nil { + os.Remove(tmp) + return fmt.Errorf("commit %s: %w", resolvConf, err) + } + return nil +} diff --git a/dns_windows.go b/dns_windows.go new file mode 100644 index 0000000..395a7f7 --- /dev/null +++ b/dns_windows.go @@ -0,0 +1,58 @@ +package netconfig + +import ( + "context" + "net" + "net/netip" + + "golang.org/x/sys/windows" + "golang.zx2c4.com/wireguard/windows/tunnel/winipcfg" +) + +// applyLiveDNSWindows applies the DNS servers and search domains to the live +// adapter via winipcfg, in addition to whatever the configuration backends +// persist. SetDNS on the adapter is per-family, so it is called for both IPv4 +// and IPv6; winipcfg filters the server list to the matching family. Errors +// are logged but never returned, since a failure to update the live resolver +// does not undo the already-written persistent configuration. +func applyLiveDNSWindows(_ context.Context, iface string, servers []net.IP, searchDomains []string) { + ipAdapters, err := winipcfg.GetAdaptersAddresses(windows.AF_UNSPEC, winipcfg.GAAFlagIncludePrefix|winipcfg.GAAFlagSkipAnycast|winipcfg.GAAFlagSkipMulticast|winipcfg.GAAFlagSkipDNSServer|winipcfg.GAAFlagSkipFriendlyName|winipcfg.GAAFlagSkipDNSInfo) + if err != nil { + logger.Printf("live DNS: list adapters: %v", err) + return + } + var ipAdapter *winipcfg.IPAdapterAddresses + for _, adap := range ipAdapters { + if adap.FriendlyName() == iface { + ipAdapter = adap + break + } + } + if ipAdapter == nil { + logger.Printf("live DNS: no adapter found with name: %s", iface) + return + } + + var serverAddrs []netip.Addr + for _, ip := range servers { + if ip == nil { + continue + } + if v4 := ip.To4(); v4 != nil { + serverAddrs = append(serverAddrs, netip.AddrFrom4([4]byte(v4))) + } else if v16 := ip.To16(); v16 != nil { + serverAddrs = append(serverAddrs, netip.AddrFrom16([16]byte(v16))) + } + // A net.IP that is neither a valid IPv4 nor IPv6 address is skipped + // rather than converted, since [16]byte() on a short slice panics. + } + + // SetDNS is per-family and filters the server list to the matching family, + // so pass the full list to both calls. An empty list clears that family. + if err := ipAdapter.LUID.SetDNS(winipcfg.AddressFamily(windows.AF_INET), serverAddrs, searchDomains); err != nil { + logger.Printf("live DNS: set IPv4: %v", err) + } + if err := ipAdapter.LUID.SetDNS(winipcfg.AddressFamily(windows.AF_INET6), serverAddrs, searchDomains); err != nil { + logger.Printf("live DNS: set IPv6: %v", err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f422a9b --- /dev/null +++ b/go.mod @@ -0,0 +1,28 @@ +module github.com/grmrgecko/go-network-configurator + +go 1.24.2 + +replace github.com/coreos/go-systemd => github.com/coreos/go-systemd/v22 v22.5.0 + +require ( + dario.cat/mergo v1.0.2 + github.com/Wifx/gonetworkmanager/v3 v3.2.0 + github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf + github.com/godbus/dbus/v5 v5.1.0 + github.com/kylelemons/godebug v1.1.0 + github.com/prometheus-community/pro-bing v0.7.0 + github.com/sirupsen/logrus v1.9.3 + github.com/vishvananda/netlink v1.3.1 + github.com/vishvananda/netns v0.0.5 + golang.org/x/sys v0.37.0 + golang.zx2c4.com/wireguard/windows v0.5.3 + gopkg.in/ini.v1 v1.67.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/google/uuid v1.6.0 // indirect + github.com/stretchr/testify v1.10.0 // indirect + golang.org/x/net v0.46.0 // indirect + golang.org/x/sync v0.17.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4befecc --- /dev/null +++ b/go.sum @@ -0,0 +1,48 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/Wifx/gonetworkmanager/v3 v3.2.0 h1:qYBNHTSCRg+cwXRFlAYrlAGyPid9MPy9vdfeQNSMs8U= +github.com/Wifx/gonetworkmanager/v3 v3.2.0/go.mod h1:JPAftRDTjp5o37GNOYytCzerFU8ZjudV/jC/g41kvHE= +github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus-community/pro-bing v0.7.0 h1:KFYFbxC2f2Fp6c+TyxbCOEarf7rbnzr9Gw8eIb0RfZA= +github.com/prometheus-community/pro-bing v0.7.0/go.mod h1:Moob9dvlY50Bfq6i88xIwfyw7xLFHH69LUgx9n5zqCE= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= +github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4= +github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= +github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= +golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/ifupdown.go b/ifupdown.go new file mode 100644 index 0000000..7f622ce --- /dev/null +++ b/ifupdown.go @@ -0,0 +1,1095 @@ +package netconfig + +import ( + "bufio" + "context" + "fmt" + "net" + "os" + "os/exec" + "path" + "sort" + "strconv" + "strings" + "time" + + "github.com/vishvananda/netlink" +) + +const ifUpDownConfig = "/etc/network/interfaces" + +type ifInterface struct { + Name string + Mode string + isDHCP bool + isPPP bool + Files []string + Addresses []*net.IPNet + Gateway4 net.IP + Gateway6 net.IP + Routes []*Route + Config map[string][]string +} + +type ifUpDown struct { + Interfaces []*ifInterface + BaseConfig string + BackupDir string + backupRetention int +} + +// Either retreives an existing interface, or makes a new one. +func (i *ifUpDown) EnsureInterface(name string) *ifInterface { + // Find existing interface and return it. + for _, iface := range i.Interfaces { + if iface.Name == name { + return iface + } + } + + // Make a new interface as one wasn't found. Seed it with the base config + // file so Save (which iterates iface.Files) actually persists the new + // block; without a file the interface would be held only in memory and + // silently dropped on write. + iface := &ifInterface{ + Name: name, + Files: []string{i.BaseConfig}, + Config: make(map[string][]string), + } + i.Interfaces = append(i.Interfaces, iface) + return iface +} + +// Parses an ifupdown interface file. +func (i *ifUpDown) ReadFile(configFile string) error { + // Open the config file. + fd, err := os.Open(configFile) + if err != nil { + return err + } + defer fd.Close() + + // Parse the file. + var iface *ifInterface + var address *net.IPNet + scanner := bufio.NewScanner(fd) + for scanner.Scan() { + line := scanner.Text() + + // Get index of comments and remove them. + idx := strings.IndexByte(line, '#') + if idx != -1 { + line = line[:idx] + } + + // Trim whitespace. + line = strings.TrimSpace(line) + + // Ignore blank lines. + if len(line) == 0 { + continue + } + + // Parse fields. + fields := strings.Fields(line) + switch fields[0] { + // For the global configs, we just skip as we are not reconstructing + // the entire file when saving changes. + case "auto", "allow-hotplug": + continue + + // On source directory, read all files in the specified directory. + case "source-directory": + // If no directory, return error. + if len(fields) < 2 { + return fmt.Errorf("invalid source directory syntax") + } + + // Try to read the directory files. + unsortedEntries, err := os.ReadDir(fields[1]) + if err != nil { + return err + } + + // Sort by name. + entries := sortableDirEntries(unsortedEntries) + sort.Sort(entries) + + // Read each file. + for _, entry := range entries { + // Skip directories. + if entry.IsDir() { + continue + } + + filePath := path.Join(fields[1], entry.Name()) + err := i.ReadFile(filePath) + if err != nil { + return err + } + } + + // On source, read file specified. + case "source": + // If no file specified, error. + if len(fields) < 2 { + return fmt.Errorf("invalid source file syntax") + } + + // Read the file specified. + err := i.ReadFile(fields[1]) + if err != nil { + return err + } + + // For interface configs, set current interface and parse type. + case "iface", "interface": + // We require at least the interface name. + if len(fields) < 2 { + return fmt.Errorf("invalid interface syntax") + } + + // Finalize any addresses. + if address != nil { + iface.Addresses = append(iface.Addresses, address) + address = nil + } + + // Ensure interface is created. + ifname := fields[1] + idx := strings.IndexByte(ifname, ':') + if idx != -1 { + ifname = ifname[:idx] + } + iface = i.EnsureInterface(ifname) + + // Track the file the interface has configurations in. A + // dual-stack interface has separate "inet"/"inet6" stanzas that + // commonly live in the same file, so only record a file once per + // interface: Save processes iface.Files by index using tmp/backup + // names computed a single time up front, and reprocessing the + // same path a second time would clobber the backup of the true + // original with the already-rewritten intermediate content. + if !stringInSlice(iface.Files, configFile) { + iface.Files = append(iface.Files, configFile) + } + + // Determine type. + for _, field := range fields[2:] { + if field == "dhcp" { + iface.isDHCP = true + } else if field == "ppp" { + iface.isPPP = true + } + } + + // Save mode for cases where we need to write. + iface.Mode = strings.Join(fields[2:], " ") + + // Parse assigned addresses. + case "address": + // We require an address field. + if len(fields) < 2 { + return fmt.Errorf("invalid address syntax") + } + + // If no interface, fail. + if iface == nil { + return fmt.Errorf("no interface to assign address to") + } + + // If an address to be parsed is there, add to the list of addresses. + if address != nil { + iface.Addresses = append(iface.Addresses, address) + address = nil + } + + // If not cider, parse IP. + if !strings.Contains(fields[1], "/") { + address = &net.IPNet{ + IP: net.ParseIP(fields[1]), + } + if address.IP == nil { + return fmt.Errorf("invalid IP provided: %s", fields[1]) + } + if address.IP.To4() == nil { + address.Mask = net.CIDRMask(128, 128) + } else { + address.Mask = net.CIDRMask(32, 32) + } + } else { + var ip net.IP + ip, address, err = net.ParseCIDR(fields[1]) + if err != nil { + return err + } + address.IP = ip + } + + // If an netmask is set, assign to the last parsed address. + case "netmask": + // We require an netmask field. + if len(fields) < 2 { + return fmt.Errorf("invalid netmask syntax") + } + + // Require an address. + if address == nil { + return fmt.Errorf("no address to apply netmask") + } + + // If the netmask is an ipmask, parse that. + if strings.Contains(fields[1], ".") { + ipMask := net.ParseIP(fields[1]).To4() + if ipMask == nil { + return fmt.Errorf("invalid netmask: %s", fields[1]) + } + address.Mask = net.IPMask(ipMask) + } else { + // This is just the prefix. + prefix, err := strconv.Atoi(fields[1]) + if err != nil { + return err + } + if address.IP.To4() == nil { + address.Mask = net.CIDRMask(prefix, 128) + } else { + address.Mask = net.CIDRMask(prefix, 32) + } + } + + // If an gateway is defined, assign according to family. + case "gateway": + // We require an gateway field. + if len(fields) < 2 { + return fmt.Errorf("invalid gateway syntax") + } + + // Parse the gateway. + gateway := net.ParseIP(fields[1]) + if gateway == nil { + return fmt.Errorf("failed to parse gateway: %s", fields[1]) + } + + // Add the gateway by family. + if gateway.To4() == nil { + iface.Gateway6 = gateway + } else { + iface.Gateway4 = gateway + } + + // For other configurations, save to the interface key value config. + default: + // All field sshould have key and value. + if len(fields) < 2 { + return fmt.Errorf("invalid syntax") + } + + // If no interface, skip. + if iface == nil { + continue + } + + // If up script, try parsing fields. + if fields[0] == "up" && len(fields) >= 4 { + // If the route command is called, see if we can parse + if fields[1] == "route" { + var dst *net.IPNet + var gateway net.IP + metric := int(256) + isAdd := false + fieldLen := len(fields) + for f := 2; f < fieldLen; f++ { + field := fields[f] + if f+1 >= fieldLen { + continue + } + switch field { + case "add": + isAdd = true + case "-net", "-host": + continue + case "netmask": + f++ + // A netmask before any destination is malformed; skip + // it rather than dereferencing a nil dst. + if dst == nil { + continue + } + ipMask := net.ParseIP(fields[f]).To4() + if ipMask == nil { + continue + } + dst.Mask = net.IPMask(ipMask) + case "gw": + f++ + gateway = net.ParseIP(fields[f]) + case "metric": + f++ + metric, err = strconv.Atoi(fields[f]) + if err != nil { + metric = 256 + } + default: + // If no destination parsed, check if we can parse one here. + if dst != nil { + continue + } + // The destination is the current field (e.g. the token + // after "-net"/"-host"), not the next one. + host := fields[f] + if strings.Contains(host, "/") { + ip, ipnet, err := net.ParseCIDR(host) + if err != nil { + continue + } + ipnet.IP = ip + dst = ipnet + } else { + dst = &net.IPNet{ + IP: net.ParseIP(host), + } + if dst.IP == nil { + dst = nil + continue + } + if dst.IP.To4() == nil { + dst.Mask = net.CIDRMask(128, 128) + } else { + dst.Mask = net.CIDRMask(32, 32) + } + } + } + } + + // If we found that we're adding and all needed + // fields are parsed. Add route and skip adding + // key value config. + if isAdd && dst != nil && gateway != nil { + r := new(Route) + r.Destination = dst + r.Gateway = gateway + r.Metric = metric + iface.Routes = append(iface.Routes, r) + continue + } + + // On the ip command, this can either be an route + // or an address. + } else if fields[1] == "ip" { + isRoute := false + isAddr := false + isAdd := false + thisDev := false + var address *net.IPNet + var dst *net.IPNet + var gateway net.IP + metric := int(256) + fieldLen := len(fields) + for f := 2; f < fieldLen; f++ { + field := fields[f] + if f+1 >= fieldLen { + continue + } + if field == "addr" || field == "address" { + isAddr = true + } else if field == "route" { + isRoute = true + } else if field == "add" { + // typically the address field for both + // routes and addresses is right after + // the add command. + isAdd = true + f++ + ip, network, err := net.ParseCIDR(fields[f]) + if err != nil { + continue + } + network.IP = ip + if isAddr { + address = network + } else if isRoute { + dst = network + } + } else if field == "dev" { + f++ + devName := fields[f] + if devName == "$IFACE" { + thisDev = true + } else if devName == iface.Name { + thisDev = true + } + } else if field == "via" { + f++ + gateway = net.ParseIP(fields[f]) + } else if field == "metric" { + f++ + metric, err = strconv.Atoi(fields[f]) + if err != nil || metric == 0 { + metric = 256 + } + } + } + + // If we parsed an add command, add parsed info. + if isAdd { + // If valid route or address, add and skip + // adding the key/value config. + if isRoute && dst != nil && gateway != nil { + r := new(Route) + r.Destination = dst + r.Gateway = gateway + r.Metric = metric + iface.Routes = append(iface.Routes, r) + continue + } else if isAddr && address != nil && thisDev { + iface.Addresses = append(iface.Addresses, address) + continue + } + } + } + } + + // Check if the down script can be skipped due to route or address. + if fields[0] == "down" && len(fields) >= 4 { + // Parse route del commands to see if its removing a route + // that was added. + if fields[1] == "route" { + var dst *net.IPNet + isDel := false + fieldLen := len(fields) + for f := 2; f < fieldLen; f++ { + field := fields[f] + if f+1 >= fieldLen { + continue + } + switch field { + case "del": + isDel = true + case "-net", "-host": + continue + case "netmask": + f++ + // A netmask before any destination is malformed; skip + // it rather than dereferencing a nil dst. + if dst == nil { + continue + } + ipMask := net.ParseIP(fields[f]).To4() + if ipMask == nil { + continue + } + dst.Mask = net.IPMask(ipMask) + default: + // If no destination parsed, check if we can parse one here. + if dst != nil { + continue + } + // The destination is the current field (e.g. the token + // after "-net"/"-host"), not the next one. + host := fields[f] + if strings.Contains(host, "/") { + ip, ipnet, err := net.ParseCIDR(host) + if err != nil { + continue + } + ipnet.IP = ip + dst = ipnet + } else { + dst = &net.IPNet{ + IP: net.ParseIP(host), + } + if dst.IP == nil { + dst = nil + continue + } + if dst.IP.To4() == nil { + dst.Mask = net.CIDRMask(128, 128) + } else { + dst.Mask = net.CIDRMask(32, 32) + } + } + } + } + + // If route del and we parsed the desitnation, + // confirm the destination is being added and + // ignore this key/value add if it is. + if isDel && dst != nil { + exists := false + for _, route := range iface.Routes { + if route.Destination.Contains(dst.IP) { + exists = true + break + } + } + + if exists { + continue + } + } + + // On IP del command, parse and determine if we should + // skip adding key/value. + } else if fields[1] == "ip" { + isRoute := false + isAddr := false + isDel := false + thisDev := false + var address *net.IPNet + var dst *net.IPNet + fieldLen := len(fields) + for f := 2; f < fieldLen; f++ { + field := fields[f] + if f+1 >= fieldLen { + continue + } + if field == "addr" || field == "address" { + isAddr = true + } else if field == "route" { + isRoute = true + } else if field == "del" || field == "delete" { + isDel = true + f++ + ip, network, err := net.ParseCIDR(fields[f]) + if err != nil { + continue + } + network.IP = ip + if isAddr { + address = network + } else if isRoute { + dst = network + } + } else if field == "dev" { + f++ + devName := fields[f] + if devName == "$IFACE" { + thisDev = true + } else if devName == iface.Name { + thisDev = true + } + } + } + + // If this is an del and the parsed information exists + // in the appropiate bucket. Skip adding key/value. + if isDel { + if isRoute && dst != nil { + continue + } else if isAddr && address != nil && thisDev { + continue + } + } + } + } + + // Get the name and value. + name := fields[0] + value := strings.Join(fields[1:], " ") + + // Add config. + config := iface.Config[name] + config = append(config, value) + iface.Config[name] = config + } + } + + // Finalize any addresses. + if address != nil { + iface.Addresses = append(iface.Addresses, address) + address = nil + } + + return nil +} + +// Setup an ifupdown config tool. +func newIfUpDown(backupRetention int) (i *ifUpDown, err error) { + i = new(ifUpDown) + i.backupRetention = backupRetention + i.BaseConfig = ifUpDownConfig + i.BackupDir = ifUpDownConfig + ".backup" + err = i.ReadFile(i.BaseConfig) + if err != nil { + return nil, err + } + return +} + +// Get interfaces configured. +func (i *ifUpDown) GetInterfaces() (interfaces []*Interface, err error) { + // Connect to netlink. + var h *netlink.Handle + h, err = netlink.NewHandle() + if err != nil { + return + } + defer h.Close() + + // Get list of interfaces to match with MAC address. + var links []netlink.Link + links, err = h.LinkList() + if err != nil { + return + } + + for _, iface := range i.Interfaces { + i := new(Interface) + i.Name = iface.Name + + // Discover the interface name and mac address. + for _, link := range links { + if link.Attrs().Name == iface.Name { + i.MAC = link.Attrs().HardwareAddr + i.Link = link + } + } + + // Add addresses. + for _, address := range iface.Addresses { + i.Addresses = append(i.Addresses, address) + } + + // Add gateways. + i.Gateway4 = iface.Gateway4 + i.Gateway6 = iface.Gateway6 + + // Add DNS servers and search domains from the resolvconf stanzas. + for _, val := range iface.Config["dns-nameservers"] { + for _, field := range strings.Fields(val) { + if ip := net.ParseIP(field); ip != nil { + i.DNS = append(i.DNS, ip) + } + } + } + for _, val := range iface.Config["dns-search"] { + i.SearchDomains = append(i.SearchDomains, strings.Fields(val)...) + } + + // Add static routes. + i.Routes = iface.Routes + + // Add interface. + interfaces = append(interfaces, i) + } + return +} + +// Write our interface config to the specified file. +func (iface *ifInterface) Write(fd *os.File) error { + // Write the interface config. + if iface.Mode != "" { + fmt.Fprintf(fd, "iface %s %s\n", iface.Name, iface.Mode) + } else { + fmt.Fprintf(fd, "iface %s\n", iface.Name) + } + + // Add first address of each family. + first4 := true + first6 := true + for _, addr := range iface.Addresses { + if addr.IP.To4() == nil { + if !first6 { + continue + } + first6 = false + fmt.Fprintf(fd, "\taddress %s\n", addr.String()) + } else { + if !first4 { + continue + } + first4 = false + fmt.Fprintf(fd, "\taddress %s\n", addr.String()) + } + } + + // Add gateways. + if iface.Gateway4 != nil { + fmt.Fprintf(fd, "\tgateway %s\n", iface.Gateway4.String()) + } + if iface.Gateway6 != nil { + fmt.Fprintf(fd, "\tgateway %s\n", iface.Gateway6.String()) + } + + // Add key/value configs. + var keys []string + hasUp := false + hasDown := false + for key := range iface.Config { + if key == "up" { + hasUp = true + continue + } + if key == "down" { + hasDown = true + continue + } + keys = append(keys, key) + } + sort.Strings(keys) + if hasUp { + keys = append(keys, "up") + } + if hasDown { + keys = append(keys, "down") + } + for _, key := range keys { + values := iface.Config[key] + for _, value := range values { + fmt.Fprintf(fd, "\t%s %s\n", key, value) + } + } + + // Add any additional IP addresses via the ip command. + first4 = false + first6 = false + for _, addr := range iface.Addresses { + if addr.IP.To4() == nil { + if !first6 { + first6 = true + continue + } + fmt.Fprintf(fd, "\tup ip -6 addr add %s dev $IFACE\n", addr.String()) + fmt.Fprintf(fd, "\tdown ip -6 addr del %s dev $IFACE\n", addr.String()) + } else { + if !first4 { + first4 = true + continue + } + fmt.Fprintf(fd, "\tup ip addr add %s dev $IFACE\n", addr.String()) + fmt.Fprintf(fd, "\tdown ip addr del %s dev $IFACE\n", addr.String()) + } + } + + // Add static routes. + for _, route := range iface.Routes { + if route.Destination.IP.To4() == nil { + fmt.Fprintf(fd, "\tup ip -6 route add %s via %s metric %d dev $IFACE\n", route.Destination.String(), route.Gateway.String(), route.Metric) + fmt.Fprintf(fd, "\tdown ip -6 route del %s via %s metric %d dev $IFACE\n", route.Destination.String(), route.Gateway.String(), route.Metric) + } else { + fmt.Fprintf(fd, "\tup ip route add %s via %s metric %d dev $IFACE\n", route.Destination.String(), route.Gateway.String(), route.Metric) + fmt.Fprintf(fd, "\tdown ip route del %s via %s metric %d dev $IFACE\n", route.Destination.String(), route.Gateway.String(), route.Metric) + } + } + fmt.Fprintln(fd) + + return nil +} + +// Save the interface config to the files read. +func (iface *ifInterface) Save(backupDir string, backupRetention int) error { + // Confirm the backup dir is created. + if _, serr := os.Stat(backupDir); os.IsNotExist(serr) { + err := os.Mkdir(backupDir, 0755) + if err != nil { + return err + } + } + + // Setup prefix and for each file the interface was found in, + // update the files. + prefixTmp := fmt.Sprintf(".tmp.%d.", time.Now().UnixNano()) + prefixBak := fmt.Sprintf(".bak.%d.", time.Now().UnixNano()) + for i, filePath := range iface.Files { + // Get temporary file path. + file := path.Base(filePath) + tmp := prefixTmp + file + tmpPath := path.Join(backupDir, tmp) + bak := prefixBak + file + bakPath := path.Join(backupDir, bak) + + // Open tmp file for writing. + fdw, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + return err + } + + // Check if the config file exists. + fileExists := true + if _, serr := os.Stat(filePath); os.IsNotExist(serr) { + fileExists = false + } + + // If the file doesn't exist, then we're not trying to replace + // the interface config in a file of potential other interface configs. + if !fileExists { + // Only write our configs on the first file. + if i == 0 { + fmt.Fprintf(fdw, "auto %s\n", iface.Name) + fmt.Fprintf(fdw, "allow-hotplug %s\n\n", iface.Name) + iface.Write(fdw) + } + } else { + // The file does exist, so we should read the original file + // and try to pull out the original interface config ignoring + // other interface configs. + + // Open file for reading. + fd, err := os.Open(filePath) + if err != nil { + return err + } + + // Scan original file. + scanner := bufio.NewScanner(fd) + foundIface := false + wroteIface := false + blankLineCount := 0 + for scanner.Scan() { + line := scanner.Text() + origLine := line + + // Get index of comments and remove them. + idx := strings.IndexByte(line, '#') + comment := false + if idx != -1 { + comment = true + line = line[:idx] + } + + // Trim whitespace. + line = strings.TrimSpace(line) + + // Ignore blank lines, writing them back to the file. + if len(line) == 0 { + // If the blank line is actually a comment line, + // we do not want to apply the blank line limit. + if comment { + fmt.Fprintln(fdw, origLine) + blankLineCount = 0 + continue + } + + // Increment the count of blank lines, allowing 1 + // spacing apart. + blankLineCount++ + if blankLineCount <= 1 { + fmt.Fprintln(fdw, origLine) + } + continue + } else { + // Reset the count as this isn't a blank line. + blankLineCount = 0 + } + + // Parse fields. + fields := strings.Fields(line) + switch fields[0] { + // On the global configs, check if the current interface + // is in its list of interfaces. If it is not, we should + // ensure we write the config and continue to the next + // interface. + case "auto", "allow-hotplug": + containsIface := false + for _, ifname := range fields[1:] { + idx := strings.IndexByte(ifname, ':') + if idx != -1 { + ifname = ifname[:idx] + } + if iface.Name == ifname { + containsIface = true + } + } + if containsIface && foundIface && !wroteIface { + wroteIface = true + foundIface = false + if i == 0 { + err = iface.Write(fdw) + if err != nil { + return err + } + } + } + fmt.Fprintln(fdw, origLine) + + // On the interface config, check if its this interface + // so we can determine when to write our configs. + // If its not this interface, we will just write the config + // line for line. + case "iface", "interface": + // We require at least the interface name. + if len(fields) < 2 { + fmt.Fprintln(fdw, origLine) + continue + } + + // Check if this is the interface. + ifname := fields[1] + idx := strings.IndexByte(ifname, ':') + if idx != -1 { + ifname = ifname[:idx] + } + if iface.Name == ifname { + foundIface = true + } else if foundIface && !wroteIface { + wroteIface = true + foundIface = false + if i == 0 { + err = iface.Write(fdw) + if err != nil { + return err + } + } + } + if !foundIface { + fmt.Fprintln(fdw, origLine) + } + + // On default, its a regular config which we will skip + // when we're on our interface, but write line by line for + // other interfaces. + default: + if foundIface { + continue + } + fmt.Fprintln(fdw, origLine) + } + } + + // if we did not write the interface, write it. + if !wroteIface { + err = iface.Write(fdw) + if err != nil { + return err + } + } + + // Close the open file. + fd.Close() + } + + // We're done writing, so let's close files. + fdw.Close() + + // Backup existing config. + if fileExists { + err = fileMove(filePath, bakPath) + if err != nil { + return err + } + } + + // Move new config in place. + err = fileMove(tmpPath, filePath) + if err != nil { + return err + } + + // Prune old backups of this config file beyond the retention count. + pruneBackups(backupDir, prefixBackupMatcher(file), backupRetention) + } + + return nil +} + +// Set addresses to interface. +func (i *ifUpDown) SetIfaceAddresses(_ context.Context, iface string, addrs []*net.IPNet, gateway4, gateway6 net.IP) (err error) { + // Find the interface. + for _, ifc := range i.Interfaces { + if ifc.Name != iface { + continue + } + + // Ensure we can adjust addresses. + if ifc.isDHCP { + return fmt.Errorf("cannot change addresses on DHCP interface") + } else if ifc.isPPP { + return fmt.Errorf("cannot change addresses on PPP interface") + } + + // Set provided addresses. + ifc.Addresses = addrs + ifc.Gateway4 = gateway4 + ifc.Gateway6 = gateway6 + + // Save the interface config. + err = ifc.Save(i.BackupDir, i.backupRetention) + if err != nil { + return err + } + } + + return nil +} + +// Set static routes to interface. +func (i *ifUpDown) SetIfaceRoutes(_ context.Context, iface string, routes []*Route) (err error) { + // Find the interface. + for _, ifc := range i.Interfaces { + if ifc.Name != iface { + continue + } + + // Ensure we can adjust static routes. + if ifc.isDHCP { + return fmt.Errorf("cannot change static routes on DHCP interface") + } else if ifc.isPPP { + return fmt.Errorf("cannot change static routes on PPP interface") + } + + // Set provided static routes. + ifc.Routes = routes + + // Save the interface config. + err = ifc.Save(i.BackupDir, i.backupRetention) + if err != nil { + return err + } + } + + return nil +} + +// Set DNS servers and search domains on interface. ifupdown relies on the +// resolvconf integration, which reads the dns-nameservers and dns-search +// stanzas from the interface block. +func (i *ifUpDown) SetIfaceDNS(_ context.Context, iface string, servers []net.IP, searchDomains []string) (err error) { + // Find or create the interface. A backend must not silently no-op just + // because it has not seen this interface before (e.g. a freshly attached + // NIC), otherwise the caller gets a success with no persisted change. + ifc := i.EnsureInterface(iface) + + // Ensure we can adjust configuration. + if ifc.isDHCP { + return fmt.Errorf("cannot change DNS on DHCP interface") + } else if ifc.isPPP { + return fmt.Errorf("cannot change DNS on PPP interface") + } + + // Replace the DNS stanzas. + delete(ifc.Config, "dns-nameservers") + delete(ifc.Config, "dns-search") + dns := ipStrings(servers) + if len(dns) != 0 { + ifc.Config["dns-nameservers"] = []string{strings.Join(dns, " ")} + } + if len(searchDomains) != 0 { + ifc.Config["dns-search"] = []string{strings.Join(searchDomains, " ")} + } + + // The dns-nameservers/dns-search stanzas are not understood by ifup + // itself; they are consumed by the resolvconf integration hook. On a host + // without the resolvconf package installed (common on stock Ubuntu 14.04 + // servers) these stanzas are inert and the persisted DNS never reaches + // /etc/resolv.conf on boot. Warn so the operator knows the write alone is + // not sufficient. + if (len(dns) != 0 || len(searchDomains) != 0) && !resolvconfInstalled() { + logger.Printf("IfUpDown: resolvconf not found; dns-nameservers/dns-search in %s will not take effect on reboot without the resolvconf package", ifUpDownConfig) + } + + // Save the interface config. + err = ifc.Save(i.BackupDir, i.backupRetention) + if err != nil { + return err + } + + return nil +} + +// resolvconfInstalled reports whether the resolvconf binary is available on +// PATH, which the ifupdown dns-* stanzas depend on to update the live resolver. +func resolvconfInstalled() bool { + _, err := exec.LookPath("resolvconf") + return err == nil +} diff --git a/ifupdown_test.go b/ifupdown_test.go new file mode 100644 index 0000000..ded374e --- /dev/null +++ b/ifupdown_test.go @@ -0,0 +1,164 @@ +package netconfig + +import ( + "context" + "net" + "os" + "path/filepath" + "testing" +) + +// Validate the netplan configuration parser/writer functions. +func TestIfUpDown(t *testing.T) { + // Setup test file. + tmpDir, err := os.MkdirTemp("", "") + if err != nil { + t.Fatal(err) + } + configPath := filepath.Join(tmpDir, "interfaces") + testDir, err := filepath.Abs("./tests/ifupdown") + if err != nil { + t.Fatal(err) + } + resultsDir := filepath.Join(testDir, "results") + err = fileCopy(filepath.Join(testDir, "interfaces"), configPath) + if err != nil { + t.Fatal(err) + } + + // Setup ifupdown and parse test file. + i := new(ifUpDown) + i.BaseConfig = configPath + i.BackupDir = configPath + ".backup" + err = i.ReadFile(i.BaseConfig) + if err != nil { + t.Fatal(err) + } + + // Get the interfaces state. + interfaces, err := i.GetInterfaces() + if err != nil { + t.Fatal(err) + } + + // Verify interfaces read from file. + err = testVerifyInterfaces(interfaces, resultsDir, 1) + if err != nil { + t.Error(err) + } + + // Test setting the IP addresses on an interface. + err = i.SetIfaceAddresses(context.Background(), "test_eth0", []*net.IPNet{ + { + IP: net.ParseIP("203.0.113.2"), + Mask: net.CIDRMask(24, 32), + }, + { + IP: net.ParseIP("203.0.113.3"), + Mask: net.CIDRMask(24, 32), + }, + { + IP: net.ParseIP("fc00::2"), + Mask: net.CIDRMask(64, 128), + }, + }, net.ParseIP("203.0.113.6"), net.ParseIP("fc00::1")) + if err != nil { + t.Fatal(err) + } + + // Test setting routes on an interface. + err = i.SetIfaceRoutes(context.Background(), "test_eth1", []*Route{ + { + Destination: &net.IPNet{ + IP: net.ParseIP("abcd:ef12:3455:3::"), + Mask: net.CIDRMask(64, 128), + }, + Gateway: net.ParseIP("abcd:ef12:3456:3::1"), + Metric: 100, + }, + { + Destination: &net.IPNet{ + IP: net.ParseIP("10.253.2.0"), + Mask: net.CIDRMask(24, 32), + }, + Gateway: net.ParseIP("1.2.3.1"), + Metric: 100, + }, + }) + if err != nil { + t.Fatal(err) + } + + // Verify we can read the modified files. + i.Interfaces = nil + err = i.ReadFile(i.BaseConfig) + if err != nil { + t.Fatal(err) + } + + // Get the interfaces state. + interfaces, err = i.GetInterfaces() + if err != nil { + t.Fatal(err) + } + + // Verify interfaces read from file. + err = testVerifyInterfaces(interfaces, resultsDir, 2) + if err != nil { + t.Error(err) + } + + // Read the current file and expected state. + err = testVerifyResults(resultsDir, tmpDir, 1) + if err != nil { + t.Error(err) + } + + // Test setting the IP addresses on an interface. + err = i.SetIfaceAddresses(context.Background(), "test_backend", []*net.IPNet{ + { + IP: net.ParseIP("1.2.10.4"), + Mask: net.CIDRMask(24, 32), + }, + }, net.ParseIP("1.2.10.254"), nil) + if err != nil { + t.Fatal(err) + } + + // Test setting routes on an interface. + err = i.SetIfaceRoutes(context.Background(), "test_eth0", []*Route{}) + if err != nil { + t.Fatal(err) + } + + // Verify we can read the modified files. + i.Interfaces = nil + err = i.ReadFile(i.BaseConfig) + if err != nil { + t.Fatal(err) + } + + // Get the interfaces state. + interfaces, err = i.GetInterfaces() + if err != nil { + t.Fatal(err) + } + + // Verify interfaces read from file. + err = testVerifyInterfaces(interfaces, resultsDir, 3) + if err != nil { + t.Error(err) + } + + // Read the current file and expected state. + err = testVerifyResults(resultsDir, tmpDir, 2) + if err != nil { + t.Error(err) + } + + // Cleanup. + err = os.RemoveAll(tmpDir) + if err != nil { + t.Fatal(err) + } +} diff --git a/interworx.go b/interworx.go new file mode 100644 index 0000000..a426bd7 --- /dev/null +++ b/interworx.go @@ -0,0 +1,220 @@ +package netconfig + +import ( + "bufio" + "context" + "fmt" + "net" + "os/exec" + "regexp" + "strings" + "sync" + "time" +) + +const nodeworxBin = "/usr/bin/nodeworx" + +type interworx struct { +} + +// Move sites off the old IP to a new IP. +func (*interworx) moveSites(ctx context.Context, oldAddr, newAddr net.IP) error { + cmd := exec.CommandContext(ctx, "/usr/local/interworx/bin/php", "/usr/local/interworx/bin/migrate-ip.php") + + // Get output pipes. + stdout, err := cmd.StdoutPipe() + if err != nil { + return err + } + stderr, err := cmd.StderrPipe() + if err != nil { + return err + } + stdin, err := cmd.StdinPipe() + if err != nil { + return err + } + + // Send the IP addresses. + + // Start the command. + err = cmd.Start() + if err != nil { + return err + } + + // Setup wait group and buffer. + var wg sync.WaitGroup + wg.Add(2) + + // Setup expected line matching. + oldMatch := regexp.MustCompile(`(?i)OLD IP Address`) + newMatch := regexp.MustCompile(`(?i)NEW IP Address`) + revokeMatch := regexp.MustCompile(`(?i)\[y/N\]`) + + // Read stdout, and provide answers to expected lines. + var line strings.Builder + go func() { + for { + // Read one byte to avoid lock up when the migrator asks for info. + buf := make([]byte, 1) + n, err := stdout.Read(buf) + if err != nil || n != 1 { + break + } + + // If this is a new line, write the log and reset the line. + if buf[0] == '\n' || buf[0] == '\r' { + logger.Printf("Interworx migrate : %s\n", line.String()) + line.Reset() + continue + } + + // Write the read byte to the buffer. + line.Write(buf) + + // If the byte matches expected questions, answer them. + if buf[0] == ']' { + if revokeMatch.MatchString(line.String()) { + fmt.Fprintln(stdin, "y") + } + } else if buf[0] == ':' { + if oldMatch.MatchString(line.String()) { + fmt.Fprintln(stdin, oldAddr.String()) + } else if newMatch.MatchString(line.String()) { + fmt.Fprintln(stdin, newAddr.String()) + } + } + } + wg.Done() + }() + + // Read stderr. + stderrScanner := bufio.NewScanner(stderr) + go func() { + for stderrScanner.Scan() { + line := stderrScanner.Text() + logger.Printf("Interworx migrate : %s\n", line) + } + wg.Done() + }() + + // Wait for file reads to finish before. + wg.Wait() + + // Wait for the command to finish. + err = cmd.Wait() + if err != nil { + return err + } + return nil +} + +// Remove an IP address from interworx. +func (p *interworx) removeIP(ctx context.Context, addr net.IP) error { + // We need the IP type. + isIPv6 := addr.To4() == nil + + // Get list of IP addresses in nodeworx. + out, err := runCommand(ctx, nodeworxBin, "-u", "-n", "-c", "Ip", "-a", "listIpAddresses") + if err != nil { + return err + } + + // Data to capture from IP list. + var sharedIP net.IP + var publicIPs []net.IP + var privateIPs []net.IP + + // Parse the IP list. + for _, line := range out { + fields := strings.Fields(line) + if len(fields) < 4 { + continue + } + + // Parse the IP field. + ip := net.ParseIP(fields[0]) + + // Skip IP addresses of a different type. + if ip.To4() == nil && !isIPv6 { + continue + } else if ip.To4() != nil && isIPv6 { + continue + } + + // Depending on IP match, apply to fields. When we find the address we are removing, + // we should determine how may sites are hosted by the IP. If the type is an shared iP, + // set the shared IP to it. Otherwise, associate other IP types to public/private IP + // lists to allow us to select an IP where a shared IP doesn't exist. + if ip.Equal(addr) { + continue + } else if fields[3] == "shared" && !ip.IsPrivate() { + sharedIP = ip + } else if !ip.IsPrivate() { + publicIPs = append(publicIPs, ip) + } else { + pubIP := net.ParseIP(fields[1]) + if pubIP != nil && !pubIP.IsPrivate() { + publicIPs = append(publicIPs, ip) + } else { + privateIPs = append(privateIPs, ip) + } + } + } + + // If no shared IP, try public IP and private IP association instead. + if sharedIP == nil { + if len(publicIPs) != 0 { + sharedIP = publicIPs[0] + } else if len(privateIPs) != 0 { + sharedIP = privateIPs[0] + } else { + return fmt.Errorf("unable to find a shared IP to move ip addresses to") + } + } + + // Migrate sites to new shared IP. + err = p.moveSites(ctx, addr, sharedIP) + if err != nil { + return err + } + + // Wait briefly for the migration to propagate before removing the address. + // The wait honours ctx cancellation so the caller can abort a stuck removal. + select { + case <-time.After(30 * time.Second): + case <-ctx.Done(): + return ctx.Err() + } + + // Tell nodeworx to remove the IP address. + out, err = runCommand(ctx, nodeworxBin, "-u", "-n", "-c", "Ip", "-a", "delete", "--confirm_action", "all", "--ip", addr.String()) + if err != nil { + return err + } + if len(out) != 0 { + logger.Println("Interworx output:", strings.Join(out, "\n")) + } + return nil +} + +// setMainIP is a no-op for InterWorx. InterWorx changes the primary IP with the +// migrate-ip tool, which moves sites between IPs rather than repointing a stored +// main IP. That migration is performed by removeIP (via moveSites) when the old +// primary is removed, so promoting an address needs no separate action here. +func (*interworx) setMainIP(ctx context.Context, addr net.IP) error { + return nil +} + +// Tell interworx to reload the available IP addresses. +func (*interworx) reload(ctx context.Context) error { + out, err := runCommand(ctx, nodeworxBin, "-u", "-n", "-c", "Ip", "-a", "import", "--ip", "all") + if err != nil { + return err + } + if len(out) != 0 { + logger.Println("Interworx output:", strings.Join(out, "\n")) + } + return nil +} diff --git a/netplan.go b/netplan.go new file mode 100644 index 0000000..f3448a5 --- /dev/null +++ b/netplan.go @@ -0,0 +1,999 @@ +package netconfig + +import ( + "context" + "fmt" + "net" + "os" + "path" + "sort" + "strings" + "time" + + "dario.cat/mergo" + "github.com/vishvananda/netlink" + "gopkg.in/yaml.v3" +) + +// Name server cvonfigurations. +type npNameservers struct { + Search []string `yaml:"search,omitempty,flow"` + Addresses []string `yaml:"addresses,omitempty,flow"` +} + +// Route configurations. +type npRoute struct { + From string `yaml:"from,omitempty"` + OnLink *bool `yaml:"on-link,omitempty"` + Scope string `yaml:"scope,omitempty"` + Table *int `yaml:"table,omitempty"` + To string `yaml:"to,omitempty"` + Type string `yaml:"type,omitempty"` + Via string `yaml:"via,omitempty"` + Metric *int `yaml:"metric,omitempty"` +} +type npRoutePolicy struct { + From string `yaml:"from,omitempty"` + Mark *int `yaml:"mark,omitempty"` + Priority *int `yaml:"priority,omitempty"` + Table *int `yaml:"table,omitempty"` + To string `yaml:"to,omitempty"` + TypeOfService *int `yaml:"type-of-service,omitempty"` +} + +// Match options. +type npMatch struct { + Name string `yaml:"name,omitempty"` + MACAddress string `yaml:"macaddress,omitempty"` + Driver string `yaml:"driver,omitempty"` +} + +// Common configurations on physical interfaces. +type npPhysical struct { + Match npMatch `yaml:"match,omitempty"` + SetName string `yaml:"set-name,omitempty"` + Wakeonlan bool `yaml:"wakeonlan,omitempty"` +} + +// Common netplan interface configurations. +type npInterface struct { + AcceptRA *bool `yaml:"accept-ra,omitempty"` + Addresses []string `yaml:"addresses,omitempty"` + Critical bool `yaml:"critical,omitempty"` + DHCP4 *bool `yaml:"dhcp4,omitempty"` + DHCP6 *bool `yaml:"dhcp6,omitempty"` + DHCP4Overrides map[string]any `yaml:"dhcp4-overrides,omitempty"` + DHCP6Overrides map[string]any `yaml:"dhcp6-overrides,omitempty"` + DHCPIdentifier string `yaml:"dhcp-identifier,omitempty"` + Gateway4 string `yaml:"gateway4,omitempty"` + Gateway6 string `yaml:"gateway6,omitempty"` + Nameservers npNameservers `yaml:"nameservers,omitempty"` + MACAddress string `yaml:"macaddress,omitempty"` + MTU int `yaml:"mtu,omitempty"` + Renderer string `yaml:"renderer,omitempty"` + Routes []npRoute `yaml:"routes,omitempty"` + RoutingPolicy []npRoutePolicy `yaml:"routing-policy,omitempty"` + Optional bool `yaml:"optional,omitempty"` + + // Configure the link-local addresses to bring up. Valid options are + // "ipv4" and "ipv6". According to the netplan reference, netplan will + // only bring up ipv6 addresses if *no* link-local attribute is + // specified. On the other hand, if an empty link-local attribute is + // specified, this instructs netplan not to bring any ipv4/ipv6 address + // up. + LinkLocal *[]string `yaml:"link-local,omitempty"` + + // According to the netplan examples, this section typically includes + // some OVS-specific configuration bits. However, MAAS may just + // include an empty block to indicate the presence of an OVS-managed + // bridge (LP1942328). As a workaround, we make this an optional map + // so we can tell whether it is present (but empty) vs not being + // present. + // + // See: https://github.com/canonical/netplan/blob/main/examples/openvswitch.yaml + OVSParameters *map[string]interface{} `yaml:"openvswitch,omitempty"` +} + +// Physical ethernet configurations. +type npEthernet struct { + npPhysical `yaml:",inline"` + npInterface `yaml:",inline"` +} + +// Wifi configurations. +type npAccessPoint struct { + Password string `yaml:"password,omitempty"` + Mode string `yaml:"mode,omitempty"` + Channel int `yaml:"channel,omitempty"` +} +type npWifi struct { + npPhysical `yaml:",inline"` + AccessPoints map[string]npAccessPoint `yaml:"access-points,omitempty"` + npInterface `yaml:",inline"` +} + +// Bridge interface configurations. +type npBridgeParameters struct { + AgeingTime *int `yaml:"ageing-time,omitempty"` + ForwardDelay intString `yaml:"forward-delay,omitempty"` + HelloTime *int `yaml:"hello-time,omitempty"` + MaxAge *int `yaml:"max-age,omitempty"` + PathCost map[string]int `yaml:"path-cost,omitempty"` + PortPriority map[string]int `yaml:"port-priority,omitempty"` + Priority *int `yaml:"priority,omitempty"` + STP *bool `yaml:"stp,omitempty"` +} +type npBridge struct { + Interfaces []string `yaml:"interfaces,omitempty,flow"` + npInterface `yaml:",inline"` + Parameters npBridgeParameters `yaml:"parameters,omitempty"` +} + +// Bond interface configurations. +type npBondParameters struct { + Mode intString `yaml:"mode,omitempty"` + LACPRate intString `yaml:"lacp-rate,omitempty"` + MIIMonitorInterval intString `yaml:"mii-monitor-interval,omitempty"` + MinLinks *int `yaml:"min-links,omitempty"` + TransmitHashPolicy string `yaml:"transmit-hash-policy,omitempty"` + ADSelect intString `yaml:"ad-select,omitempty"` + AllSlavesActive *bool `yaml:"all-slaves-active,omitempty"` + ARPInterval *int `yaml:"arp-interval,omitempty"` + ARPIPTargets []string `yaml:"arp-ip-targets,omitempty"` + ARPValidate intString `yaml:"arp-validate,omitempty"` + ARPAllTargets intString `yaml:"arp-all-targets,omitempty"` + UpDelay intString `yaml:"up-delay,omitempty"` + DownDelay intString `yaml:"down-delay,omitempty"` + FailOverMACPolicy intString `yaml:"fail-over-mac-policy,omitempty"` + // Netplan misspelled this and retained it along with the corrected spelling. + GratuitousARPLegacy *int `yaml:"gratuitious-arp,omitempty"` // nolint: misspell + GratuitousARP *int `yaml:"gratuitous-arp,omitempty"` + PacketsPerSlave *int `yaml:"packets-per-slave,omitempty"` + PrimaryReselectPolicy intString `yaml:"primary-reselect-policy,omitempty"` + ResendIGMP *int `yaml:"resend-igmp,omitempty"` + // bonding.txt says that this can be a value from 1-0x7fffffff, should we be forcing it to be a hex value? + LearnPacketInterval *int `yaml:"learn-packet-interval,omitempty"` + Primary string `yaml:"primary,omitempty"` +} +type npBond struct { + Interfaces []string `yaml:"interfaces,omitempty,flow"` + npInterface `yaml:",inline"` + Parameters npBondParameters `yaml:"parameters,omitempty"` +} + +// VLAN interface configurations. +type npVLAN struct { + Id *int `yaml:"id,omitempty"` + Link string `yaml:"link,omitempty"` + npInterface `yaml:",inline"` +} + +// Common netplan network configs. +type npNetwork struct { + Version intString `yaml:"version"` + Renderer string `yaml:"renderer,omitempty"` + Ethernets map[string]npEthernet `yaml:"ethernets,omitempty"` + Wifis map[string]npWifi `yaml:"wifis,omitempty"` + Bridges map[string]npBridge `yaml:"bridges,omitempty"` + Bonds map[string]npBond `yaml:"bonds,omitempty"` + VLANs map[string]npVLAN `yaml:"vlans,omitempty"` + Routes []npRoute `yaml:"routes,omitempty"` +} + +// The default netplan configuration structure and interface to configuring netplan. +type npConfig struct { + Network npNetwork `yaml:"network"` + file string `yaml:"-"` + empty bool `yaml:"-"` + // dirty marks that a Set* call actually modified this file's in-memory + // config during the current operation. Save only rewrites/backs up dirty + // files, so files nobody touched (including legitimately empty netplan + // fragments that were never merged away) are left untouched on disk. + dirty bool `yaml:"-"` +} + +type netplan struct { + configs []*npConfig `yaml:"-"` + configDir string `yaml:"-"` + backupRetention int +} + +// Verify netplan exists, and try parsing its configurations. +func newNetplan(backupRetention int) (np *netplan, err error) { + // Calling netplan info will verify netplan is on the machine. + _, err = runCommand(context.Background(), "netplan", "info") + if err != nil { + return + } + + // Try to parse the netplan configurations. /etc/netplan is the durable, + // admin-facing source of truth and is tried first so persisted changes + // actually survive a reboot; /run/netplan is a volatile tmpfs directory + // that netplan also honors (e.g. "netplan try") and is only used as a + // fallback, otherwise a host with any file in /run/netplan would have its + // real /etc/netplan configuration silently ignored. + np, err = readNetplanConfigDirectory("/etc/netplan") + if err != nil { + np, err = readNetplanConfigDirectory("/run/netplan") + if err != nil { + np, err = readNetplanConfigDirectory("/lib/netplan") + } + } + if np != nil { + np.backupRetention = backupRetention + } + return +} + +// Read an netplan configuration directory for netplan configuration files. +func readNetplanConfigDirectory(dirPath string) (np *netplan, err error) { + // Try to read the directory files. + unsortedEntries, err := os.ReadDir(dirPath) + if err != nil { + return + } + + // Sort by name. + entries := sortableDirEntries(unsortedEntries) + sort.Sort(entries) + + // Temporary struct to store unparsed configs. + type npConfigFile struct { + data map[string]any + file string + empty bool + // dirty marks that this file's in-memory data no longer matches what + // is on disk because a duplicate key was merged into (or out of) it + // during parsing, so it must be rewritten on the next Save even if no + // Set* call ends up touching an interface still contained in it. + dirty bool + } + var configs []*npConfigFile + configTypes := []string{"ethernets", "wifis", "bridges", "bonds", "vlans", "routes"} + + // Read each yaml file and merge into a single config. + for _, entry := range entries { + // If not an yaml file, skip. + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") { + continue + } + config := &npConfigFile{ + file: entry.Name(), + empty: false, + } + + // Read the bytes from the file. + configPath := path.Join(dirPath, entry.Name()) + var data []byte + data, err = os.ReadFile(configPath) + if err != nil { + return + } + + // Parse the yaml to an map. + err = yaml.Unmarshal(data, &config.data) + if err != nil { + return + } + + // Check if prior configs have same network configs and merge. + configCount := 0 + network, ok := config.data["network"].(map[string]any) + if !ok { + goto addConfig + } + + // Merge types with prior configs. + for _, t := range configTypes { + // First get the config type. + c, ok := network[t].(map[string]any) + if !ok { + continue + } + + // Loop through all prior parsed configs, + // and find ones that has same type. + for _, thisConfig := range configs { + // If empty, there is no config so skip. + if thisConfig.empty { + continue + } + + // Try and get the config type from this config. + thisC, ok := thisConfig.data["network"].(map[string]any)[t].(map[string]any) + if !ok { + continue + } + + // Find entries in this config which match the current config. + for key, _ := range thisC { + // Find and if not found, skip. + k, ok := c[key].(map[string]any) + if !ok { + continue + } + thisK := thisC[key].(map[string]any) + + // This config contains a key from the current. + // We need to merge. + err = mergo.Merge(&thisK, k) + if err != nil { + return + } + + // Update the previously parsed data with merged data. + thisConfig.data["network"].(map[string]any)[t].(map[string]any)[key] = thisK + thisConfig.dirty = true + + // Delete the config from the current config. + delete(config.data["network"].(map[string]any)[t].(map[string]any), key) + delete(network[t].(map[string]any), key) + config.dirty = true + } + } + } + + // Count number of configs to determine if file is empty. + for _, t := range configTypes { + // First get the config type. + c, ok := network[t].(map[string]any) + if !ok { + continue + } + configCount += len(c) + } + addConfig: + routes, ok := config.data["routes"].([]any) + if ok { + configCount += len(routes) + } + config.empty = configCount == 0 + + // Add config to slice of configs. + configs = append(configs, config) + } + + // No config when no configs. + if len(configs) == 0 { + return nil, fmt.Errorf("no netplan configurations found") + } + err = nil + + // Build the netplan data. + np = new(netplan) + np.configDir = dirPath + for _, config := range configs { + // Convert config data back into yaml so we can re-parse into a struct. + data, err := yaml.Marshal(config.data) + if err != nil { + return nil, err + } + + // Parse the netplan config. + cfg := new(npConfig) + err = yaml.Unmarshal(data, cfg) + if err != nil { + return nil, err + } + + // Successfully parsed, append to the configs slice. + cfg.file = config.file + cfg.dirty = config.dirty + cfg.empty = config.empty + np.configs = append(np.configs, cfg) + } + return +} + +// Convert netplan interface to a netconfig Interface. +func (*netplan) ConvertInterface(name string, config npInterface, links []netlink.Link) *Interface { + // Find the MAC address, starting with the list of interfaces + // on the machine. + var mac net.HardwareAddr + var foundLink netlink.Link + for _, link := range links { + if link.Attrs().Name == name { + mac = link.Attrs().HardwareAddr + foundLink = link + } + } + + // If the MAC was not found, try parsing the config. + if mac == nil && config.MACAddress != "" { + mac, _ = net.ParseMAC(config.MACAddress) + } + + // Setup interface with the name and MAC. + i := new(Interface) + i.Name = name + i.MAC = mac + i.Link = foundLink + + // Parse addresses. + for _, addr := range config.Addresses { + ip, ipnet, err := net.ParseCIDR(addr) + if err != nil { + continue + } + ipnet.IP = ip + i.Addresses = append(i.Addresses, ipnet) + } + + // Add gateways. + if config.Gateway4 != "" { + i.Gateway4 = net.ParseIP(config.Gateway4) + } + if config.Gateway6 != "" { + i.Gateway6 = net.ParseIP(config.Gateway6) + } + + // Parse nameservers. + for _, addr := range config.Nameservers.Addresses { + if ip := net.ParseIP(addr); ip != nil { + i.DNS = append(i.DNS, ip) + } + } + i.SearchDomains = config.Nameservers.Search + + // Parse routes. + for _, route := range config.Routes { + dstIP, dst, err := net.ParseCIDR(route.To) + if err != nil { + continue + } + dst.IP = dstIP + ip := net.ParseIP(route.Via) + r := new(Route) + r.Destination = dst + r.Gateway = ip + if route.Metric != nil { + r.Metric = *route.Metric + } else { + r.Metric = 256 + } + i.Routes = append(i.Routes, r) + } + return i +} + +// Get interfaces configured. +func (np *netplan) GetInterfaces() (interfaces []*Interface, err error) { + // Connect to netlink. + var h *netlink.Handle + h, err = netlink.NewHandle() + if err != nil { + return + } + defer h.Close() + + // Get list of interfaces to match with MAC address. + var links []netlink.Link + links, err = h.LinkList() + if err != nil { + return + } + + // Add each config. + for _, c := range np.configs { + if c.empty { + continue + } + + // Add ethernet interfaces to the list. + var keys []string + for key := range c.Network.Ethernets { + keys = append(keys, key) + } + sort.Strings(keys) + for _, name := range keys { + config := c.Network.Ethernets[name] + // If the name is being changed in the config, use that + // as the name. + if config.SetName != "" { + name = config.SetName + } + + // Update the config MAC to the match MAC. + if config.MACAddress == "" { + config.MACAddress = config.Match.MACAddress + } + + // Get interface. + i := np.ConvertInterface(name, config.npInterface, links) + + // Add the interface. + interfaces = append(interfaces, i) + } + + // Add Wifi interfaces to the list. + keys = nil + for key := range c.Network.Wifis { + keys = append(keys, key) + } + sort.Strings(keys) + for _, name := range keys { + config := c.Network.Wifis[name] + // If the name is being changed in the config, use that + // as the name. + if config.SetName != "" { + name = config.SetName + } + + // Update the config MAC to the match MAC. + if config.MACAddress == "" { + config.MACAddress = config.Match.MACAddress + } + + // Get interface. + i := np.ConvertInterface(name, config.npInterface, links) + + // Add the interface. + interfaces = append(interfaces, i) + } + + // Add Bridge interfaces to the list. + keys = nil + for key := range c.Network.Bridges { + keys = append(keys, key) + } + sort.Strings(keys) + for _, name := range keys { + config := c.Network.Bridges[name] + // Get interface. + i := np.ConvertInterface(name, config.npInterface, links) + + // Add the interface. + interfaces = append(interfaces, i) + } + + // Add Bond interfaces to the list. + keys = nil + for key := range c.Network.Bonds { + keys = append(keys, key) + } + sort.Strings(keys) + for _, name := range keys { + config := c.Network.Bonds[name] + // Get interface. + i := np.ConvertInterface(name, config.npInterface, links) + + // Add the interface. + interfaces = append(interfaces, i) + } + + // Add VLAN interfaces to the list. + keys = nil + for key := range c.Network.VLANs { + keys = append(keys, key) + } + sort.Strings(keys) + for _, name := range keys { + config := c.Network.VLANs[name] + // Get interface. + i := np.ConvertInterface(name, config.npInterface, links) + + // Add the interface. + interfaces = append(interfaces, i) + } + } + + return +} + +// Save configurations. +func (np *netplan) Save() error { + // Update each configuration file that was actually touched by this + // operation. Configs nobody modified are left alone on disk, so an + // unrelated legitimately-empty netplan fragment is never backed up and + // removed as a side effect of changing a different interface. + for _, config := range np.configs { + if !config.dirty { + continue + } + + // Get config paths. + configPath := path.Join(np.configDir, config.file) + tmpName := fmt.Sprintf("%s.tmp.%d", config.file, time.Now().UnixNano()) + tmpPath := path.Join(np.configDir, tmpName) + bakupName := fmt.Sprintf("%s.bak.%d", config.file, time.Now().UnixNano()) + bakupPath := path.Join(np.configDir, bakupName) + + // Only save if not empty. + if !config.empty { + // Try to encode/save the config. + data, err := yaml.Marshal(config) + if err != nil { + return err + } + err = os.WriteFile(tmpPath, data, 0644) + if err != nil { + return err + } + } + + // Backup old config. + err := fileMove(configPath, bakupPath) + if err != nil { + return err + } + + // Move new config in place. + if !config.empty { + // Move new config in place. + err = fileMove(tmpPath, configPath) + if err != nil { + return err + } + } + + // Prune old backups of this config file beyond the retention count. + pruneBackups(np.configDir, suffixBackupMatcher(config.file), np.backupRetention) + + // The file on disk now matches this config's in-memory state, so it + // does not need rewriting again until something else marks it dirty. + // Configs that turned out empty are left dirty so the cleanup pass + // below still notices they were just removed from disk. + if !config.empty { + config.dirty = false + } + } + + // Now that touched configurations were updated, drop the ones we just + // emptied out and removed from disk above. Configs that were already + // empty but untouched this call were left on disk, so they stay tracked. + // Rebuild the slice rather than deleting in place while ranging over it, + // which would shift the backing array under the loop and skip or drop the + // wrong entries when more than one config was emptied. + kept := np.configs[:0] + for _, config := range np.configs { + if config.dirty && config.empty { + continue + } + kept = append(kept, config) + } + np.configs = kept + + return nil +} + +// Set addresses to interface. +func (np *netplan) SetIfaceAddresses(_ context.Context, iface string, addrs []*net.IPNet, gateway4, gateway6 net.IP) (err error) { + // Update each config. + for _, c := range np.configs { + if c.empty { + continue + } + + // Check ethernet interfaces. + for name, config := range c.Network.Ethernets { + // If the name is being changed in the config, use that + // as the name. + ifName := name + if config.SetName != "" { + ifName = config.SetName + } + + // If this is not the interface we're updating addresses on. + if ifName != iface { + continue + } + c.dirty = true + + // Re-configure with specified addresses. + config.Addresses = nil + for _, addr := range addrs { + config.Addresses = append(config.Addresses, addr.String()) + } + if gateway4 == nil { + config.Gateway4 = "" + } else { + config.Gateway4 = gateway4.String() + } + if gateway6 == nil { + config.Gateway6 = "" + } else { + config.Gateway6 = gateway6.String() + } + c.Network.Ethernets[name] = config + } + + // Check Wifi interfaces. + for name, config := range c.Network.Wifis { + // If the name is being changed in the config, use that + // as the name. + ifName := name + if config.SetName != "" { + ifName = config.SetName + } + + // If this is the interface we're updating addresses on. + if ifName != iface { + continue + } + c.dirty = true + + // Re-configure with specified addresses. + config.Addresses = nil + for _, addr := range addrs { + config.Addresses = append(config.Addresses, addr.String()) + } + if gateway4 == nil { + config.Gateway4 = "" + } else { + config.Gateway4 = gateway4.String() + } + if gateway6 == nil { + config.Gateway6 = "" + } else { + config.Gateway6 = gateway6.String() + } + c.Network.Wifis[name] = config + } + + // Check bridges. + { + config, ok := c.Network.Bridges[iface] + if ok { + c.dirty = true + // Re-configure with specified addresses. + config.Addresses = nil + for _, addr := range addrs { + config.Addresses = append(config.Addresses, addr.String()) + } + if gateway4 == nil { + config.Gateway4 = "" + } else { + config.Gateway4 = gateway4.String() + } + if gateway6 == nil { + config.Gateway6 = "" + } else { + config.Gateway6 = gateway6.String() + } + c.Network.Bridges[iface] = config + } + } + + // Check bonds. + { + config, ok := c.Network.Bonds[iface] + if ok { + c.dirty = true + // Re-configure with specified addresses. + config.Addresses = nil + for _, addr := range addrs { + config.Addresses = append(config.Addresses, addr.String()) + } + if gateway4 == nil { + config.Gateway4 = "" + } else { + config.Gateway4 = gateway4.String() + } + if gateway6 == nil { + config.Gateway6 = "" + } else { + config.Gateway6 = gateway6.String() + } + c.Network.Bonds[iface] = config + } + } + + // Check VLAN interfaces. + { + config, ok := c.Network.VLANs[iface] + if ok { + c.dirty = true + // Re-configure with specified addresses. + config.Addresses = nil + for _, addr := range addrs { + config.Addresses = append(config.Addresses, addr.String()) + } + if gateway4 == nil { + config.Gateway4 = "" + } else { + config.Gateway4 = gateway4.String() + } + if gateway6 == nil { + config.Gateway6 = "" + } else { + config.Gateway6 = gateway6.String() + } + c.Network.VLANs[iface] = config + } + } + } + + // Try to save changes. + err = np.Save() + return +} + +// Set static routes to interface. +func (np *netplan) SetIfaceRoutes(_ context.Context, iface string, routes []*Route) (err error) { + // Update each config. + for _, c := range np.configs { + if c.empty { + continue + } + + // Check ethernet interfaces. + for name, config := range c.Network.Ethernets { + // If the name is being changed in the config, use that + // as the name. + ifName := name + if config.SetName != "" { + ifName = config.SetName + } + + // If this is not the interface we're updating routes on. + if ifName != iface { + continue + } + c.dirty = true + + // Re-configure with specified routes. + config.Routes = nil + for _, route := range routes { + config.Routes = append(config.Routes, npRoute{ + To: route.Destination.String(), + Via: route.Gateway.String(), + Metric: &route.Metric, + }) + } + c.Network.Ethernets[name] = config + } + + // Check Wifi interfaces. + for name, config := range c.Network.Wifis { + // If the name is being changed in the config, use that + // as the name. + ifName := name + if config.SetName != "" { + ifName = config.SetName + } + + // If this is the interface we're updating routes on. + if ifName != iface { + continue + } + c.dirty = true + + // Re-configure with specified routes. + config.Routes = nil + for _, route := range routes { + config.Routes = append(config.Routes, npRoute{ + To: route.Destination.String(), + Via: route.Gateway.String(), + Metric: &route.Metric, + }) + } + c.Network.Wifis[name] = config + } + + // Check bridges. + { + config, ok := c.Network.Bridges[iface] + if ok { + c.dirty = true + // Re-configure with specified routes. + config.Routes = nil + for _, route := range routes { + config.Routes = append(config.Routes, npRoute{ + To: route.Destination.String(), + Via: route.Gateway.String(), + Metric: &route.Metric, + }) + } + c.Network.Bridges[iface] = config + } + } + + // Check bonds. + { + config, ok := c.Network.Bonds[iface] + if ok { + c.dirty = true + // Re-configure with specified routes. + config.Routes = nil + for _, route := range routes { + config.Routes = append(config.Routes, npRoute{ + To: route.Destination.String(), + Via: route.Gateway.String(), + Metric: &route.Metric, + }) + } + c.Network.Bonds[iface] = config + } + } + + // Check VLAN interfaces. + { + config, ok := c.Network.VLANs[iface] + if ok { + c.dirty = true + // Re-configure with specified routes. + config.Routes = nil + for _, route := range routes { + config.Routes = append(config.Routes, npRoute{ + To: route.Destination.String(), + Via: route.Gateway.String(), + Metric: &route.Metric, + }) + } + c.Network.VLANs[iface] = config + } + } + } + + // Try to save changes. + err = np.Save() + return +} + +// Set DNS servers and search domains on interface. +func (np *netplan) SetIfaceDNS(_ context.Context, iface string, servers []net.IP, searchDomains []string) (err error) { + nameservers := npNameservers{ + Search: searchDomains, + Addresses: ipStrings(servers), + } + + // Update each config. + for _, c := range np.configs { + if c.empty { + continue + } + + // Check ethernet interfaces. + for name, config := range c.Network.Ethernets { + ifName := name + if config.SetName != "" { + ifName = config.SetName + } + if ifName != iface { + continue + } + c.dirty = true + config.Nameservers = nameservers + c.Network.Ethernets[name] = config + } + + // Check Wifi interfaces. + for name, config := range c.Network.Wifis { + ifName := name + if config.SetName != "" { + ifName = config.SetName + } + if ifName != iface { + continue + } + c.dirty = true + config.Nameservers = nameservers + c.Network.Wifis[name] = config + } + + // Check bridges. + if config, ok := c.Network.Bridges[iface]; ok { + c.dirty = true + config.Nameservers = nameservers + c.Network.Bridges[iface] = config + } + + // Check bonds. + if config, ok := c.Network.Bonds[iface]; ok { + c.dirty = true + config.Nameservers = nameservers + c.Network.Bonds[iface] = config + } + + // Check VLAN interfaces. + if config, ok := c.Network.VLANs[iface]; ok { + c.dirty = true + config.Nameservers = nameservers + c.Network.VLANs[iface] = config + } + } + + // Try to save changes. + err = np.Save() + return +} diff --git a/netplan_test.go b/netplan_test.go new file mode 100644 index 0000000..2aeccf0 --- /dev/null +++ b/netplan_test.go @@ -0,0 +1,151 @@ +package netconfig + +import ( + "context" + "net" + "os" + "path/filepath" + "testing" +) + +// Validate the netplan configuration parser/writer functions. +func TestNetplan(t *testing.T) { + // Setup test file. + tmpDir, err := os.MkdirTemp("", "") + if err != nil { + t.Fatal(err) + } + configPath := filepath.Join(tmpDir, "netplan.yaml") + testDir, err := filepath.Abs("./tests/netplan") + if err != nil { + t.Fatal(err) + } + resultsDir := filepath.Join(testDir, "results") + err = fileCopy(filepath.Join(testDir, "netplan.yaml"), configPath) + if err != nil { + t.Fatal(err) + } + err = fileCopy(filepath.Join(testDir, "netplan2.yaml"), filepath.Join(tmpDir, "netplan2.yaml")) + if err != nil { + t.Fatal(err) + } + + // Setup ifupdown and parse test file. + np, err := readNetplanConfigDirectory(tmpDir) + if err != nil { + t.Fatal(err) + } + + // Get the interfaces state. + interfaces, err := np.GetInterfaces() + if err != nil { + t.Fatal(err) + } + + // Verify interfaces read from file. + err = testVerifyInterfaces(interfaces, resultsDir, 1) + if err != nil { + t.Error(err) + } + + // Test setting the IP addresses on an interface. + err = np.SetIfaceAddresses(context.Background(), "test_eth0.1556", []*net.IPNet{ + { + IP: net.ParseIP("1.2.3.4"), + Mask: net.CIDRMask(24, 32), + }, + { + IP: net.ParseIP("1.2.3.43"), + Mask: net.CIDRMask(24, 32), + }, + { + IP: net.ParseIP("fc00::2"), + Mask: net.CIDRMask(64, 128), + }, + }, net.ParseIP("1.2.3.1"), net.ParseIP("fc00::1")) + if err != nil { + t.Fatal(err) + } + + // Test setting routes on an interface. + err = np.SetIfaceRoutes(context.Background(), "test_eth3", []*Route{ + { + Destination: &net.IPNet{ + IP: net.ParseIP("abcd:ef12:3455:10::"), + Mask: net.CIDRMask(64, 128), + }, + Gateway: net.ParseIP("abcd:ef12:3456:10::1"), + Metric: 100, + }, + { + Destination: &net.IPNet{ + IP: net.ParseIP("10.253.2.0"), + Mask: net.CIDRMask(24, 32), + }, + Gateway: net.ParseIP("203.0.113.22"), + Metric: 100, + }, + }) + if err != nil { + t.Fatal(err) + } + + // Get the interfaces state. + interfaces, err = np.GetInterfaces() + if err != nil { + t.Fatal(err) + } + + // Verify interfaces read from file. + err = testVerifyInterfaces(interfaces, resultsDir, 2) + if err != nil { + t.Error(err) + } + + // Read the current file and expected state. + err = testVerifyResults(resultsDir, tmpDir, 1) + if err != nil { + t.Error(err) + } + + // Test setting the IP addresses on an interface. + err = np.SetIfaceAddresses(context.Background(), "test_eth0", []*net.IPNet{ + { + IP: net.ParseIP("1.2.10.4"), + Mask: net.CIDRMask(24, 32), + }, + }, net.ParseIP("1.2.10.254"), nil) + if err != nil { + t.Fatal(err) + } + + // Test setting routes on an interface. + err = np.SetIfaceRoutes(context.Background(), "test_eth0.1556", []*Route{}) + if err != nil { + t.Fatal(err) + } + + // Get the interfaces state. + interfaces, err = np.GetInterfaces() + if err != nil { + t.Fatal(err) + } + + // Verify interfaces read from file. + err = testVerifyInterfaces(interfaces, resultsDir, 3) + if err != nil { + t.Error(err) + } + + // Read the current file and expected state. + err = testVerifyResults(resultsDir, tmpDir, 2) + if err != nil { + t.Error(err) + } + + // Cleanup. + err = os.RemoveAll(tmpDir) + if err != nil { + t.Fatal(err) + } +} diff --git a/networkConfigurator.go b/networkConfigurator.go new file mode 100644 index 0000000..46625da --- /dev/null +++ b/networkConfigurator.go @@ -0,0 +1,272 @@ +package netconfig + +import ( + "context" + "fmt" + "net" + "strings" +) + +const ( + defaultInternetTestAddress = "http://clients3.google.com/generate_204" + Public = "public-internet" + Public6 = "public-internet-6" +) + +type Route struct { + Destination *net.IPNet + Gateway net.IP + Metric int +} + +func (i *Route) String() string { + return fmt.Sprintf("%s via %s metric %d", i.Destination.String(), i.Gateway.String(), i.Metric) +} + +type Interface struct { + Name string + MAC net.HardwareAddr + Addresses []*net.IPNet + Gateway4 net.IP + Gateway6 net.IP + Routes []*Route + DNS []net.IP + SearchDomains []string + Link any +} + +func (i *Interface) String() string { + var routes []string + for _, route := range i.Routes { + routes = append(routes, route.String()) + } + return fmt.Sprintf( + "Name: %s MAC: %s Addresses: %v Gateway4: %s Gateway6: %s Routes: [%s]", + i.Name, + i.MAC.String(), + i.Addresses, + i.Gateway4.String(), + i.Gateway6.String(), + strings.Join(routes, ", "), + ) +} + +type Configurator interface { + GetInterfaces(ctx context.Context) ([]*Interface, error) + AddAddress(ctx context.Context, iface string, addr *net.IPNet, gateway net.IP) error + SetPrimaryAddress(ctx context.Context, iface string, addr *net.IPNet) error + RemoveAddress(ctx context.Context, iface string, addr *net.IPNet) error + AddRoute(ctx context.Context, iface string, dst *net.IPNet, gateway net.IP, metric int) error + RemoveRoute(ctx context.Context, iface string, dst *net.IPNet, gateway net.IP) error + SetDNS(ctx context.Context, iface string, servers []net.IP, searchDomains []string) error +} + +// ifaceBackend persists interface address, route, and DNS changes to an on-disk +// network configuration backend such as netplan, cloud-init, networkd, +// NetworkManager, RHEL network-scripts, or ifupdown. +type ifaceBackend interface { + SetIfaceAddresses(ctx context.Context, iface string, addrs []*net.IPNet, gateway4, gateway6 net.IP) error + SetIfaceRoutes(ctx context.Context, iface string, routes []*Route) error + SetIfaceDNS(ctx context.Context, iface string, servers []net.IP, searchDomains []string) error +} + +// panelBackend persists IP changes to a hosting control panel such as +// cPanel, Plesk, or InterWorx. +type panelBackend interface { + reload(ctx context.Context) error + setMainIP(ctx context.Context, addr net.IP) error + removeIP(ctx context.Context, addr net.IP) error +} + +// namedIfaceBackend pairs an ifaceBackend with a label used for logging. +type namedIfaceBackend struct { + name string + backend ifaceBackend +} + +// namedPanelBackend pairs a panelBackend with a label used for logging. +type namedPanelBackend struct { + name string + backend panelBackend +} + +// applyIfaceAddresses pushes the interface's address configuration to every +// registered file backend. Individual failures are logged but do not abort +// the others, since each backend writes an independent configuration file. +func applyIfaceAddresses(ctx context.Context, backends []namedIfaceBackend, iface string, addrs []*net.IPNet, gateway4, gateway6 net.IP) { + for _, b := range backends { + if err := b.backend.SetIfaceAddresses(ctx, iface, addrs, gateway4, gateway6); err != nil { + logger.Printf("%s error: %v", b.name, err) + } + } +} + +// applyIfaceRoutes pushes the interface's static routes to every registered +// file backend, logging but not aborting on individual errors. +func applyIfaceRoutes(ctx context.Context, backends []namedIfaceBackend, iface string, routes []*Route) { + for _, b := range backends { + if err := b.backend.SetIfaceRoutes(ctx, iface, routes); err != nil { + logger.Printf("%s error: %v", b.name, err) + } + } +} + +// ifaceDNSReader is an optional capability of a file backend: reading back the +// interfaces — including their DNS servers and search domains — from the +// backend's persisted configuration. Every on-disk backend implements this via +// its existing GetInterfaces method. +type ifaceDNSReader interface { + GetInterfaces() ([]*Interface, error) +} + +// ipIsIn reports whether ip is already present in addrs. +func ipIsIn(addrs []net.IP, ip net.IP) bool { + for _, a := range addrs { + if a.Equal(ip) { + return true + } + } + return false +} + +// stringInSlice reports whether s is already present in list. +func stringInSlice(list []string, s string) bool { + for _, v := range list { + if v == s { + return true + } + } + return false +} + +// mergeDNSFromBackends reads the DNS servers and search domains each backend +// has persisted and merges them into the matching runtime Interface (matched by +// name). The kernel/netlink layer does not track per-interface DNS, so the +// backends are the source of truth for these fields. Entries are de-duplicated +// so a host running several backends (e.g. netplan and cloud-init) does not +// list each resolver more than once. Per-backend errors are logged and do not +// abort the merge. +func mergeDNSFromBackends(backends []namedIfaceBackend, ifaces []*Interface) { + byName := make(map[string]*Interface, len(ifaces)) + for _, i := range ifaces { + byName[i.Name] = i + } + for _, nb := range backends { + reader, ok := nb.backend.(ifaceDNSReader) + if !ok { + continue + } + backendIfaces, err := reader.GetInterfaces() + if err != nil { + logger.Printf("%s: error reading DNS: %v", nb.name, err) + continue + } + for _, bi := range backendIfaces { + target := byName[bi.Name] + if target == nil { + continue + } + for _, ip := range bi.DNS { + if ip == nil || ipIsIn(target.DNS, ip) { + continue + } + target.DNS = append(target.DNS, ip) + } + for _, d := range bi.SearchDomains { + if d == "" || stringInSlice(target.SearchDomains, d) { + continue + } + target.SearchDomains = append(target.SearchDomains, d) + } + } + } +} + +// applyIfaceDNS pushes the interface's DNS servers and search domains to every +// registered file backend, logging but not aborting on individual errors. +func applyIfaceDNS(ctx context.Context, backends []namedIfaceBackend, iface string, servers []net.IP, searchDomains []string) { + for _, b := range backends { + if err := b.backend.SetIfaceDNS(ctx, iface, servers, searchDomains); err != nil { + logger.Printf("%s error: %v", b.name, err) + } + } +} + +// reloadPanels tells every registered control panel to re-read the system's +// IP addresses, logging but not aborting on individual errors. +func reloadPanels(ctx context.Context, backends []namedPanelBackend) { + for _, b := range backends { + if err := b.backend.reload(ctx); err != nil { + logger.Printf("%s error: %v", b.name, err) + } + } +} + +// setMainIPOnPanels tells every registered control panel to repoint its main +// IP to the given address, logging but not aborting on individual errors. +func setMainIPOnPanels(ctx context.Context, backends []namedPanelBackend, ip net.IP) { + for _, b := range backends { + if err := b.backend.setMainIP(ctx, ip); err != nil { + logger.Printf("%s error: %v", b.name, err) + } + } +} + +// removeIPFromPanels tells every registered control panel to release an IP +// address, logging but not aborting on individual errors. +func removeIPFromPanels(ctx context.Context, backends []namedPanelBackend, ip net.IP) { + for _, b := range backends { + if err := b.backend.removeIP(ctx, ip); err != nil { + logger.Printf("%s error: %v", b.name, err) + } + } +} + +// reorderPrimaryAddress returns addrs with target moved to the front, making it +// the first address of its family and therefore the primary in every backend +// (IPADDR0 / IPV6ADDR) and at runtime. The relative order of all other +// addresses is preserved, so the other address family is left untouched. +// Returns false if target is not present in addrs. +func reorderPrimaryAddress(addrs []*net.IPNet, target *net.IPNet) ([]*net.IPNet, bool) { + found := false + reordered := make([]*net.IPNet, 0, len(addrs)) + for _, addr := range addrs { + if addr.IP.Equal(target.IP) { + found = true + continue + } + reordered = append(reordered, addr) + } + if !found { + return addrs, false + } + return append([]*net.IPNet{target}, reordered...), true +} + +// Take a name and a list of interfaces and finds an interface by its name. +func FindInterfaceByName(name string, ifaces []*Interface) *Interface { + switch name { + case Public: + // The interface that carries the IPv4 default gateway. + for _, iface := range ifaces { + if iface.Gateway4 != nil { + return iface + } + } + case Public6: + // The interface that carries the IPv6 default gateway. + for _, iface := range ifaces { + if iface.Gateway6 != nil { + return iface + } + } + default: + // An interface with the specified name. + for _, iface := range ifaces { + if iface.Name == name { + return iface + } + } + } + return nil +} diff --git a/networkConfigurator_test.go b/networkConfigurator_test.go new file mode 100644 index 0000000..5531778 --- /dev/null +++ b/networkConfigurator_test.go @@ -0,0 +1,349 @@ +package netconfig + +import ( + "context" + "errors" + "net" + "testing" +) + +func mustCIDR(t *testing.T, s string) *net.IPNet { + t.Helper() + _, n, err := net.ParseCIDR(s) + if err != nil { + t.Fatalf("ParseCIDR(%q): %v", s, err) + } + return n +} + +func TestRouteString(t *testing.T) { + r := &Route{ + Destination: mustCIDR(t, "10.0.0.0/24"), + Gateway: net.ParseIP("10.0.0.1"), + Metric: 100, + } + if got, want := r.String(), "10.0.0.0/24 via 10.0.0.1 metric 100"; got != want { + t.Errorf("Route.String() = %q, want %q", got, want) + } +} + +func TestInterfaceString(t *testing.T) { + mac, _ := net.ParseMAC("00:11:22:33:44:55") + iface := &Interface{ + Name: "eth0", + MAC: mac, + Addresses: []*net.IPNet{mustCIDR(t, "192.168.1.10/24")}, + Gateway4: net.ParseIP("192.168.1.1"), + Routes: []*Route{ + {Destination: mustCIDR(t, "10.0.0.0/24"), Gateway: net.ParseIP("10.0.0.1"), Metric: 5}, + }, + } + got := iface.String() + want := "Name: eth0 MAC: 00:11:22:33:44:55 Addresses: [192.168.1.0/24] Gateway4: 192.168.1.1 Gateway6: Routes: [10.0.0.0/24 via 10.0.0.1 metric 5]" + if got != want { + t.Errorf("Interface.String()\n got %q\nwant %q", got, want) + } +} + +func TestFindInterfaceByName(t *testing.T) { + eth0 := &Interface{Name: "eth0", Gateway4: net.ParseIP("203.0.113.1")} + eth1 := &Interface{Name: "eth1"} + pub6 := &Interface{Name: "eth2", Gateway6: net.ParseIP("2001:db8::1")} + ifaces := []*Interface{eth1, eth0, pub6} + + t.Run("by name", func(t *testing.T) { + if got := FindInterfaceByName("eth1", ifaces); got != eth1 { + t.Errorf("got %v, want eth1", got) + } + }) + + t.Run("public picks gateway4 interface", func(t *testing.T) { + if got := FindInterfaceByName(Public, ifaces); got != eth0 { + t.Errorf("got %v, want eth0", got) + } + }) + + t.Run("public6 picks gateway6 interface", func(t *testing.T) { + if got := FindInterfaceByName(Public6, ifaces); got != pub6 { + t.Errorf("got %v, want pub6", got) + } + }) + + t.Run("not found", func(t *testing.T) { + if got := FindInterfaceByName("missing", ifaces); got != nil { + t.Errorf("got %v, want nil", got) + } + }) + + t.Run("public with no gateway returns nil", func(t *testing.T) { + if got := FindInterfaceByName(Public, []*Interface{eth1}); got != nil { + t.Errorf("got %v, want nil", got) + } + }) + + t.Run("public6 with no gateway returns nil", func(t *testing.T) { + if got := FindInterfaceByName(Public6, []*Interface{eth1}); got != nil { + t.Errorf("got %v, want nil", got) + } + }) +} + +func TestReorderPrimaryAddress(t *testing.T) { + // addrStrings renders a list of addresses for stable comparison. + addrStrings := func(addrs []*net.IPNet) []string { + out := make([]string, len(addrs)) + for i, a := range addrs { + out[i] = a.IP.String() + } + return out + } + equal := func(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true + } + + // hostIP keeps the host bits so IP.Equal matching is exercised, unlike the + // network address returned by mustCIDR. + hostIP := func(ip, cidr string) *net.IPNet { + n := mustCIDR(t, cidr) + return &net.IPNet{IP: net.ParseIP(ip), Mask: n.Mask} + } + + a := hostIP("192.168.1.10", "192.168.1.10/24") + b := hostIP("192.168.1.11", "192.168.1.11/24") + c := hostIP("192.168.1.12", "192.168.1.12/24") + v6 := hostIP("2001:db8::5", "2001:db8::5/64") + + t.Run("middle moves to front", func(t *testing.T) { + got, ok := reorderPrimaryAddress([]*net.IPNet{a, b, c}, b) + if !ok { + t.Fatal("ok = false, want true") + } + if want := []string{"192.168.1.11", "192.168.1.10", "192.168.1.12"}; !equal(addrStrings(got), want) { + t.Errorf("got %v, want %v", addrStrings(got), want) + } + }) + + t.Run("already first is unchanged", func(t *testing.T) { + got, ok := reorderPrimaryAddress([]*net.IPNet{a, b, c}, a) + if !ok { + t.Fatal("ok = false, want true") + } + if want := []string{"192.168.1.10", "192.168.1.11", "192.168.1.12"}; !equal(addrStrings(got), want) { + t.Errorf("got %v, want %v", addrStrings(got), want) + } + }) + + t.Run("v6 target preserves v4 relative order", func(t *testing.T) { + got, ok := reorderPrimaryAddress([]*net.IPNet{a, b, v6}, v6) + if !ok { + t.Fatal("ok = false, want true") + } + if want := []string{"2001:db8::5", "192.168.1.10", "192.168.1.11"}; !equal(addrStrings(got), want) { + t.Errorf("got %v, want %v", addrStrings(got), want) + } + }) + + t.Run("absent target returns false", func(t *testing.T) { + got, ok := reorderPrimaryAddress([]*net.IPNet{a, b}, c) + if ok { + t.Error("ok = true, want false") + } + // The original slice is returned unchanged. + if want := []string{"192.168.1.10", "192.168.1.11"}; !equal(addrStrings(got), want) { + t.Errorf("got %v, want %v", addrStrings(got), want) + } + }) +} + +// fakeIfaceBackend records the calls made to it and optionally returns an error. +type fakeIfaceBackend struct { + addrCalls int + routeCalls int + dnsCalls int + lastAddrs []*net.IPNet + err error +} + +func (f *fakeIfaceBackend) SetIfaceAddresses(_ context.Context, iface string, addrs []*net.IPNet, gateway4, gateway6 net.IP) error { + f.addrCalls++ + f.lastAddrs = addrs + return f.err +} + +func (f *fakeIfaceBackend) SetIfaceRoutes(_ context.Context, iface string, routes []*Route) error { + f.routeCalls++ + return f.err +} + +func (f *fakeIfaceBackend) SetIfaceDNS(_ context.Context, iface string, servers []net.IP, searchDomains []string) error { + f.dnsCalls++ + return f.err +} + +// fakePanelBackend records the calls made to it and optionally returns an error. +type fakePanelBackend struct { + reloadCalls int + setMainIPCalls int + removeIPCalls int + err error +} + +func (f *fakePanelBackend) reload(_ context.Context) error { + f.reloadCalls++ + return f.err +} + +func (f *fakePanelBackend) setMainIP(_ context.Context, addr net.IP) error { + f.setMainIPCalls++ + return f.err +} + +func (f *fakePanelBackend) removeIP(_ context.Context, addr net.IP) error { + f.removeIPCalls++ + return f.err +} + +// Every registered backend must be invoked even when an earlier backend +// returns an error, since each writes an independent configuration. +func TestApplyIfaceAddresses(t *testing.T) { + failing := &fakeIfaceBackend{err: errors.New("boom")} + ok := &fakeIfaceBackend{} + backends := []namedIfaceBackend{ + {name: "failing", backend: failing}, + {name: "ok", backend: ok}, + } + applyIfaceAddresses(context.Background(), backends, "eth0", nil, nil, nil) + if failing.addrCalls != 1 || ok.addrCalls != 1 { + t.Errorf("addr calls: failing=%d ok=%d, want 1 each", failing.addrCalls, ok.addrCalls) + } +} + +func TestApplyIfaceRoutes(t *testing.T) { + failing := &fakeIfaceBackend{err: errors.New("boom")} + ok := &fakeIfaceBackend{} + backends := []namedIfaceBackend{ + {name: "failing", backend: failing}, + {name: "ok", backend: ok}, + } + applyIfaceRoutes(context.Background(), backends, "eth0", nil) + if failing.routeCalls != 1 || ok.routeCalls != 1 { + t.Errorf("route calls: failing=%d ok=%d, want 1 each", failing.routeCalls, ok.routeCalls) + } +} + +func TestReloadPanels(t *testing.T) { + failing := &fakePanelBackend{err: errors.New("boom")} + ok := &fakePanelBackend{} + backends := []namedPanelBackend{ + {name: "failing", backend: failing}, + {name: "ok", backend: ok}, + } + reloadPanels(context.Background(), backends) + if failing.reloadCalls != 1 || ok.reloadCalls != 1 { + t.Errorf("reload calls: failing=%d ok=%d, want 1 each", failing.reloadCalls, ok.reloadCalls) + } +} + +func TestSetMainIPOnPanels(t *testing.T) { + failing := &fakePanelBackend{err: errors.New("boom")} + ok := &fakePanelBackend{} + backends := []namedPanelBackend{ + {name: "failing", backend: failing}, + {name: "ok", backend: ok}, + } + setMainIPOnPanels(context.Background(), backends, net.ParseIP("192.0.2.5")) + if failing.setMainIPCalls != 1 || ok.setMainIPCalls != 1 { + t.Errorf("setMainIP calls: failing=%d ok=%d, want 1 each", failing.setMainIPCalls, ok.setMainIPCalls) + } +} + +func TestRemoveIPFromPanels(t *testing.T) { + failing := &fakePanelBackend{err: errors.New("boom")} + ok := &fakePanelBackend{} + backends := []namedPanelBackend{ + {name: "failing", backend: failing}, + {name: "ok", backend: ok}, + } + removeIPFromPanels(context.Background(), backends, net.ParseIP("192.0.2.5")) + if failing.removeIPCalls != 1 || ok.removeIPCalls != 1 { + t.Errorf("removeIP calls: failing=%d ok=%d, want 1 each", failing.removeIPCalls, ok.removeIPCalls) + } +} + +// fakeDNSReaderBackend is a file backend that also reports back persisted +// interfaces (including DNS), satisfying both ifaceBackend and ifaceDNSReader. +type fakeDNSReaderBackend struct { + ifaces []*Interface + err error +} + +func (f *fakeDNSReaderBackend) SetIfaceAddresses(context.Context, string, []*net.IPNet, net.IP, net.IP) error { + return nil +} +func (f *fakeDNSReaderBackend) SetIfaceRoutes(context.Context, string, []*Route) error { return nil } +func (f *fakeDNSReaderBackend) SetIfaceDNS(context.Context, string, []net.IP, []string) error { + return nil +} +func (f *fakeDNSReaderBackend) GetInterfaces() ([]*Interface, error) { return f.ifaces, f.err } + +// mergeDNSFromBackends must union DNS from every backend into the matching +// runtime interface (by name), de-duplicate entries so multi-backend hosts do +// not list a resolver twice, skip backends with no matching interface, and keep +// going when one backend errors. +func TestMergeDNSFromBackends(t *testing.T) { + runtime := []*Interface{{Name: "eth0"}, {Name: "eth1"}} + backends := []namedIfaceBackend{ + {name: "broken", backend: &fakeDNSReaderBackend{err: errors.New("boom")}}, + {name: "netplan", backend: &fakeDNSReaderBackend{ifaces: []*Interface{ + {Name: "eth0", DNS: []net.IP{net.ParseIP("8.8.8.8"), net.ParseIP("2001:4860:4860::8888")}, SearchDomains: []string{"example.com"}}, + }}}, + {name: "cloud-init", backend: &fakeDNSReaderBackend{ifaces: []*Interface{ + // Overlaps netplan (must dedupe) and adds one new entry. + {Name: "eth0", DNS: []net.IP{net.ParseIP("8.8.8.8"), net.ParseIP("1.1.1.1")}, SearchDomains: []string{"example.com", "corp.example"}}, + }}}, + {name: "ifupdown", backend: &fakeDNSReaderBackend{ifaces: []*Interface{ + {Name: "eth1", DNS: []net.IP{net.ParseIP("9.9.9.9")}, SearchDomains: []string{"a.test"}}, + // No runtime match; must be ignored. + {Name: "eth9", DNS: []net.IP{net.ParseIP("10.0.0.1")}}, + }}}, + } + mergeDNSFromBackends(backends, runtime) + + eth0 := runtime[0] + wantDNS := []net.IP{net.ParseIP("8.8.8.8"), net.ParseIP("2001:4860:4860::8888"), net.ParseIP("1.1.1.1")} + if !equalIPs(eth0.DNS, wantDNS) { + t.Errorf("eth0 DNS = %v, want %v", eth0.DNS, wantDNS) + } + wantSearch := []string{"example.com", "corp.example"} + if len(eth0.SearchDomains) != len(wantSearch) || eth0.SearchDomains[0] != wantSearch[0] || eth0.SearchDomains[1] != wantSearch[1] { + t.Errorf("eth0 SearchDomains = %v, want %v", eth0.SearchDomains, wantSearch) + } + + eth1 := runtime[1] + if !equalIPs(eth1.DNS, []net.IP{net.ParseIP("9.9.9.9")}) { + t.Errorf("eth1 DNS = %v, want [9.9.9.9]", eth1.DNS) + } + if len(eth1.SearchDomains) != 1 || eth1.SearchDomains[0] != "a.test" { + t.Errorf("eth1 SearchDomains = %v, want [a.test]", eth1.SearchDomains) + } +} + +func equalIPs(a, b []net.IP) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if !a[i].Equal(b[i]) { + return false + } + } + return true +} diff --git a/networkManager.go b/networkManager.go new file mode 100644 index 0000000..574d3cd --- /dev/null +++ b/networkManager.go @@ -0,0 +1,575 @@ +package netconfig + +import ( + "context" + "errors" + "fmt" + "net" + "strings" + + "github.com/Wifx/gonetworkmanager/v3" + "github.com/vishvananda/netlink" +) + +type nmConnection struct { + ID string + Name string + UsingData bool + Addresses4 []*net.IPNet + Addresses6 []*net.IPNet + Gateway4 net.IP + Gateway6 net.IP + Routes4 []*Route + Routes6 []*Route + DNS []net.IP + DNSSearch []string +} + +type networkManager struct { + config gonetworkmanager.Settings +} + +// nmNameservers extracts DNS server IPs from a NetworkManager ipv4/ipv6 +// settings group. It prefers the modern "dns-data" property (array of strings) +// and falls back to the legacy "dns" property, which encodes IPv4 servers as an +// array of uint32 and IPv6 servers as an array of byte arrays. +func nmNameservers(group map[string]any) []net.IP { + var servers []net.IP + if data, ok := group["dns-data"].([]string); ok { + for _, s := range data { + if ip := net.ParseIP(s); ip != nil { + servers = append(servers, ip) + } + } + return servers + } + if data, ok := group["dns"].([]uint32); ok { + for _, u := range data { + if ip := uint2IP(u); len(ip) > 0 { + servers = append(servers, ip) + } + } + return servers + } + if data, ok := group["dns"].([][]byte); ok { + for _, b := range data { + if ip := net.IP(b); ip != nil { + servers = append(servers, ip) + } + } + } + return servers +} + +// nmSearchDomains extracts the DNS search list from a NetworkManager ipv4/ipv6 +// settings group. +func nmSearchDomains(group map[string]any) []string { + if data, ok := group["dns-search"].([]string); ok { + return append([]string(nil), data...) + } + return nil +} + +// Parse a network manager connection settings map to get network configurations. +func (*networkManager) ParseConnection(settings gonetworkmanager.ConnectionSettings) (conn *nmConnection, err error) { + conn = new(nmConnection) + + // Get the interface id. + id, ok := settings["connection"]["id"].(string) + if !ok { + err = fmt.Errorf("failed to get interface id") + return + } + conn.ID = id + + // Get the interface name. + name, ok := settings["connection"]["interface-name"].(string) + if !ok { + err = fmt.Errorf("failed to get interface name") + return + } + conn.Name = name + + // Get the IPv4 address map, and confirm the newer configuration style is used. + addrMap, ok := settings["ipv4"]["address-data"] + if ok { + // Update the information to show the newer configuration is used. + conn.UsingData = true + + // Parse the IPv4 address data into the address list. + if addrMap != nil { + addrSlice := addrMap.([]map[string]any) + for _, addr := range addrSlice { + ip := net.ParseIP(addr["address"].(string)) + prefix := addr["prefix"].(uint32) + conn.Addresses4 = append(conn.Addresses4, &net.IPNet{ + IP: ip, + Mask: net.CIDRMask(int(prefix), 32), + }) + + } + } + + // Parse the IPv4 gateway. + gateway4S, ok := settings["ipv4"]["gateway"].(string) + if ok { + conn.Gateway4 = net.ParseIP(gateway4S) + } + + // Parse the IPv6 addresses. + addr6Map, ok := settings["ipv6"]["address-data"] + if ok && addr6Map != nil { + addrSlice := addr6Map.([]map[string]any) + for _, addr := range addrSlice { + ip := net.ParseIP(addr["address"].(string)) + prefix := addr["prefix"].(uint32) + conn.Addresses6 = append(conn.Addresses6, &net.IPNet{ + IP: ip, + Mask: net.CIDRMask(int(prefix), 128), + }) + + } + } + + // Parse the IPv6 gateway. + gateway6S, ok := settings["ipv6"]["gateway"].(string) + if ok { + conn.Gateway6 = net.ParseIP(gateway6S) + } + + // Parse the IPv4 static route data. + routeMap, ok := settings["ipv4"]["route-data"] + if ok && routeMap != nil { + routeSlice := routeMap.([]map[string]any) + for _, route := range routeSlice { + dstIP := net.ParseIP(route["dest"].(string)) + prefix := route["prefix"].(uint32) + r := new(Route) + r.Destination = &net.IPNet{ + IP: dstIP, + Mask: net.CIDRMask(int(prefix), 32), + } + r.Gateway = net.ParseIP(route["next-hop"].(string)) + r.Metric = int(route["metric"].(uint32)) + conn.Routes4 = append(conn.Routes4, r) + + } + } + + // Parse the IPv6 static route data. + route6Map, ok := settings["ipv6"]["route-data"] + if ok && route6Map != nil { + routeSlice := route6Map.([]map[string]any) + for _, route := range routeSlice { + dstIP := net.ParseIP(route["dest"].(string)) + prefix := route["prefix"].(uint32) + r := new(Route) + r.Destination = &net.IPNet{ + IP: dstIP, + Mask: net.CIDRMask(int(prefix), 128), + } + r.Gateway = net.ParseIP(route["next-hop"].(string)) + r.Metric = int(route["metric"].(uint32)) + conn.Routes6 = append(conn.Routes6, r) + + } + } + } else { + // This is the old style configuration, we do not parse + // these unless the new style is missing. + + // Get the zero IP assignment so we can ignore them for + // gateway addresses. + zeroIP := make(net.IP, 4) + zeroIP6 := make(net.IP, 16) + + // Parse IPv4 address slices. + addrSlice, ok := settings["ipv4"]["addresses"].([][]uint32) + if ok { + for _, addr := range addrSlice { + gateway := uint2IP(addr[2]) + if gateway != nil && !gateway.Equal(zeroIP) { + conn.Gateway4 = gateway + } + conn.Addresses4 = append(conn.Addresses4, &net.IPNet{ + IP: uint2IP(addr[0]), + Mask: net.CIDRMask(int(addr[1]), 32), + }) + } + } + + // Parse IPv6 address slices. + addr6Slice, ok := settings["ipv6"]["addresses"].([][]any) + if ok { + for _, addr := range addr6Slice { + gateway := net.IP(addr[2].([]byte)) + if gateway != nil && !gateway.Equal(zeroIP6) { + conn.Gateway6 = gateway + } + conn.Addresses6 = append(conn.Addresses6, &net.IPNet{ + IP: net.IP(addr[0].([]byte)), + Mask: net.CIDRMask(int(addr[1].(uint32)), 128), + }) + } + } + + // Parse IPv4 static routes. + routeSlice, ok := settings["ipv4"]["routes"].([][]uint32) + if ok { + for _, route := range routeSlice { + r := new(Route) + r.Destination = &net.IPNet{ + IP: uint2IP(route[0]), + Mask: net.CIDRMask(int(route[1]), 32), + } + r.Gateway = uint2IP(route[2]) + r.Metric = int(route[3]) + conn.Routes4 = append(conn.Routes4, r) + } + } + + // Parse IPv6 static routes. + route6Slice, ok := settings["ipv6"]["routes"].([][]any) + if ok { + for _, route := range route6Slice { + r := new(Route) + r.Destination = &net.IPNet{ + IP: net.IP(route[0].([]byte)), + Mask: net.CIDRMask(int(route[1].(uint32)), 128), + } + r.Gateway = net.IP(route[2].([]byte)) + r.Metric = int(route[3].(uint32)) + conn.Routes6 = append(conn.Routes6, r) + } + } + } + + // Parse DNS servers and search domains from both families. DNS is stored + // independently of the address style, so it is read for both new and old + // configurations. + if ipv4, ok := settings["ipv4"]; ok { + conn.DNS = append(conn.DNS, nmNameservers(ipv4)...) + conn.DNSSearch = append(conn.DNSSearch, nmSearchDomains(ipv4)...) + } + if ipv6, ok := settings["ipv6"]; ok { + conn.DNS = append(conn.DNS, nmNameservers(ipv6)...) + conn.DNSSearch = append(conn.DNSSearch, nmSearchDomains(ipv6)...) + } + + return +} + +// Verify netplan exists, and try parsing its configurations. +func newNetworkManager() (nm *networkManager, err error) { + config, err := gonetworkmanager.NewSettings() + if err != nil { + return + } + nm = &networkManager{ + config: config, + } + return +} + +// Get interfaces configured. +func (nm *networkManager) GetInterfaces() (interfaces []*Interface, err error) { + // Connect to netlink. + var h *netlink.Handle + h, err = netlink.NewHandle() + if err != nil { + return + } + defer h.Close() + + // Get list of interfaces to match with MAC address. + var links []netlink.Link + links, err = h.LinkList() + if err != nil { + return + } + + // Get connections from NM. + connections, err := nm.config.ListConnections() + + // Add devices to the list. + for _, c := range connections { + // Get the settings of the connection. + var settings gonetworkmanager.ConnectionSettings + settings, err = c.GetSettings() + if err != nil { + return + } + + // Parse the connection. A connection that fails to parse (for example + // a VPN or bridge-slave profile with no interface-name) is skipped, + // not fatal, so this must not touch the named err return: doing so + // would let the last connection processed decide whether the whole + // call reports an error, discarding an otherwise fully-populated + // interfaces slice depending purely on D-Bus connection ordering. + connection, parseErr := nm.ParseConnection(settings) + if parseErr != nil { + continue + } + + // Find the MAC address and link. + var mac net.HardwareAddr + var foundLink netlink.Link + for _, link := range links { + if link.Attrs().Name == connection.Name { + mac = link.Attrs().HardwareAddr + foundLink = link + } + } + + // Setup new interface. + i := new(Interface) + i.Name = connection.Name + i.MAC = mac + i.Link = foundLink + + // Append addresses. + for _, addr := range connection.Addresses4 { + i.Addresses = append(i.Addresses, addr) + } + for _, addr := range connection.Addresses6 { + i.Addresses = append(i.Addresses, addr) + } + + // Add gateways. + if connection.Gateway4 != nil { + i.Gateway4 = connection.Gateway4 + } + if connection.Gateway6 != nil { + i.Gateway6 = connection.Gateway6 + } + + // Append routes. + for _, route := range connection.Routes4 { + i.Routes = append(i.Routes, route) + } + for _, route := range connection.Routes6 { + i.Routes = append(i.Routes, route) + } + + // Add DNS servers and search domains. + i.DNS = append(i.DNS, connection.DNS...) + i.SearchDomains = append(i.SearchDomains, connection.DNSSearch...) + + // Add the interface. + interfaces = append(interfaces, i) + } + + return +} + +// Set the IP addresses on an interface. +func (nm *networkManager) SetIfaceAddresses(ctx context.Context, iface string, addrs []*net.IPNet, gateway4, gateway6 net.IP) (err error) { + // Separate addresses by addr4 and addr6. + var addrs4 []string + var addrs6 []string + for _, addr := range addrs { + if addr.IP.To4() == nil { + addrs6 = append(addrs6, addr.String()) + } else { + addrs4 = append(addrs4, addr.String()) + } + } + + // Get connections from NM. + connections, err := nm.config.ListConnections() + if err != nil { + return err + } + + var errs []error + + // Find the connections that has the interfaces. + for _, c := range connections { + // Get the settings of the connection. + var settings gonetworkmanager.ConnectionSettings + settings, err = c.GetSettings() + if err != nil { + return err + } + + // Get the interface name. + name, ok := settings["connection"]["interface-name"].(string) + if !ok { + continue + } + if name != iface { + continue + } + + // Get the interface id. + id, ok := settings["connection"]["id"].(string) + if !ok { + err = fmt.Errorf("failed to get interface id") + return + } + + // Update address list for IPv4. + if len(addrs4) == 0 { + _, err = runCommand(ctx, "nmcli", "connection", "modify", id, "ipv4.method", "disabled", "ipv4.addresses", "", "ipv4.gateway", "") + if err != nil { + errs = append(errs, fmt.Errorf("failed to disable ipv4 on %s: %w", id, err)) + } + } else { + gateway4S := "" + if gateway4 != nil { + gateway4S = gateway4.String() + } + _, err = runCommand(ctx, "nmcli", "connection", "modify", id, "ipv4.method", "manual", "ipv4.addresses", strings.Join(addrs4, ","), "ipv4.gateway", gateway4S) + if err != nil { + errs = append(errs, fmt.Errorf("failed to set ipv4.addresses on %s: %w", id, err)) + } + } + + // Update address list for IPv6. + if len(addrs6) == 0 { + _, err = runCommand(ctx, "nmcli", "connection", "modify", id, "ipv6.method", "link-local", "ipv6.addresses", "", "ipv6.gateway", "") + if err != nil { + errs = append(errs, fmt.Errorf("failed to set ipv6 link-local on %s: %w", id, err)) + } + } else { + gateway6S := "" + if gateway6 != nil { + gateway6S = gateway6.String() + } + _, err = runCommand(ctx, "nmcli", "connection", "modify", id, "ipv6.method", "manual", "ipv6.addresses", strings.Join(addrs6, ","), "ipv6.gateway", gateway6S) + if err != nil { + errs = append(errs, fmt.Errorf("failed to set ipv6.addresses on %s: %w", id, err)) + } + } + } + + return errors.Join(errs...) +} + +// Set static routes to interface. +func (nm *networkManager) SetIfaceRoutes(ctx context.Context, iface string, routes []*Route) (err error) { + // Build route slices. + var routes4 []string + var routes6 []string + for _, route := range routes { + r := fmt.Sprintf("%s %s %d", route.Destination.String(), route.Gateway, route.Metric) + if route.Destination.IP.To4() == nil { + routes6 = append(routes6, r) + } else { + routes4 = append(routes4, r) + } + } + + // Get connections from NM. + connections, err := nm.config.ListConnections() + if err != nil { + return err + } + + var errs []error + + // Find the connections that has the interfaces. + for _, c := range connections { + // Get the settings of the connection. + var settings gonetworkmanager.ConnectionSettings + settings, err = c.GetSettings() + if err != nil { + return err + } + + // Get the interface name. + name, ok := settings["connection"]["interface-name"].(string) + if !ok { + continue + } + if name != iface { + continue + } + + // Get the interface id. + id, ok := settings["connection"]["id"].(string) + if !ok { + err = fmt.Errorf("failed to get interface id") + return + } + + // Update routes. + _, err = runCommand(ctx, "nmcli", "connection", "modify", id, "ipv4.routes", strings.Join(routes4, ",")) + if err != nil { + errs = append(errs, fmt.Errorf("failed to set ipv4.routes on %s: %w", id, err)) + } + _, err = runCommand(ctx, "nmcli", "connection", "modify", id, "ipv6.routes", strings.Join(routes6, ",")) + if err != nil { + errs = append(errs, fmt.Errorf("failed to set ipv6.routes on %s: %w", id, err)) + } + } + + return errors.Join(errs...) +} + +// Set DNS servers and search domains on interface. +func (nm *networkManager) SetIfaceDNS(ctx context.Context, iface string, servers []net.IP, searchDomains []string) (err error) { + // Separate DNS servers by family; NetworkManager keeps them under the + // ipv4 and ipv6 settings. + var dns4 []string + var dns6 []string + for _, ip := range servers { + if ip == nil { + continue + } + if ip.To4() == nil { + dns6 = append(dns6, ip.String()) + } else { + dns4 = append(dns4, ip.String()) + } + } + search := strings.Join(searchDomains, ",") + + // Get connections from NM. + connections, err := nm.config.ListConnections() + if err != nil { + return err + } + + var errs []error + + // Find the connections that has the interfaces. + for _, c := range connections { + // Get the settings of the connection. + var settings gonetworkmanager.ConnectionSettings + settings, err = c.GetSettings() + if err != nil { + return err + } + + // Get the interface name. + name, ok := settings["connection"]["interface-name"].(string) + if !ok { + continue + } + if name != iface { + continue + } + + // Get the interface id. + id, ok := settings["connection"]["id"].(string) + if !ok { + err = fmt.Errorf("failed to get interface id") + return + } + + // Update DNS servers and search domains for each family. Also disable + // automatic DNS from DHCP/RA so the static servers are the only + // resolvers used. + _, err = runCommand(ctx, "nmcli", "connection", "modify", id, "ipv4.dns", strings.Join(dns4, ","), "ipv4.dns-search", search, "ipv4.ignore-auto-dns", "yes") + if err != nil { + errs = append(errs, fmt.Errorf("failed to set ipv4.dns on %s: %w", id, err)) + } + _, err = runCommand(ctx, "nmcli", "connection", "modify", id, "ipv6.dns", strings.Join(dns6, ","), "ipv6.dns-search", search, "ipv6.ignore-auto-dns", "yes") + if err != nil { + errs = append(errs, fmt.Errorf("failed to set ipv6.dns on %s: %w", id, err)) + } + } + + return errors.Join(errs...) +} diff --git a/networkManager_test.go b/networkManager_test.go new file mode 100644 index 0000000..14c2a67 --- /dev/null +++ b/networkManager_test.go @@ -0,0 +1,474 @@ +package netconfig + +import ( + "context" + "encoding/json" + "net" + "os" + "path/filepath" + "testing" + + "github.com/Wifx/gonetworkmanager/v3" + "github.com/godbus/dbus/v5" +) + +type mockNMConnection struct { + settings gonetworkmanager.ConnectionSettings +} + +func (c *mockNMConnection) GetPath() dbus.ObjectPath { + return "" +} + +func (c *mockNMConnection) Update(settings gonetworkmanager.ConnectionSettings) error { + return nil +} + +func (c *mockNMConnection) UpdateUnsaved(settings gonetworkmanager.ConnectionSettings) error { + return nil +} + +func (c *mockNMConnection) Delete() error { + return nil +} + +func (c *mockNMConnection) GetSettings() (gonetworkmanager.ConnectionSettings, error) { + return c.settings, nil +} + +func (c *mockNMConnection) GetSecrets(settingName string) (gonetworkmanager.ConnectionSettings, error) { + return nil, nil +} + +func (c *mockNMConnection) ClearSecrets() error { + return nil +} + +func (c *mockNMConnection) Save() error { + return nil +} + +func (c *mockNMConnection) GetPropertyUnsaved() (bool, error) { + return false, nil +} + +func (c *mockNMConnection) GetPropertyFlags() (uint32, error) { + return 0, nil +} + +func (c *mockNMConnection) GetPropertyFilename() (string, error) { + return "", nil +} + +func (c *mockNMConnection) MarshalJSON() ([]byte, error) { + s, _ := c.GetSettings() + return json.Marshal(s) +} + +type mockNMSettings struct { +} + +func (s *mockNMSettings) ListConnections() ([]gonetworkmanager.Connection, error) { + var connections []gonetworkmanager.Connection + + connection := &mockNMConnection{ + settings: gonetworkmanager.ConnectionSettings{ + "ipv4": { + "address-data": []map[string]interface{}(nil), + "dns-search": []string{}, + "method": "auto", + "route-data": []map[string]interface{}(nil), + "routes": [][]uint32{}, + "addresses": [][]uint32{}, + "gateway": "1.2.3.1", + "route-metric": 100, + "dhcp-timeout": 45, + }, + "ipv6": { + "addr-gen-mode": 3, + "address-data": []map[string]interface{}(nil), + "routes": [][]interface{}{}, + "dns-search": []string{}, + "method": "auto", + "route-data": []map[string]interface{}(nil), + "dhcp-timeout": 45, + "route-metric": 100, + "addresses": [][]interface{}{}, + }, + "proxy": {}, + "connection": { + "uuid": "843f71c2-161d-46e1-9533-195bfdc8ae56", + "autoconnect-priority": 1, + "autoconnect-retries": 0, + "id": "test_eth0", + "interface-name": "test_eth0", + "permissions": []string{}, + "timestamp": 1669049774, + "type": "802-3-ethernet", + }, + "802-3-ethernet": { + "auto-negotiate": false, + "mac-address-blacklist": []string{}, + "s390-options": map[string]string{}, + }, + }, + } + connections = append(connections, connection) + + connection = &mockNMConnection{ + settings: gonetworkmanager.ConnectionSettings{ + "ipv4": { + "address-data": []map[string]interface{}{ + { + "address": "1.2.3.4", + "prefix": uint32(24), + }, + }, + "dns-search": []string{}, + "method": "manual", + "route-data": []map[string]interface{}{ + { + "dest": "10.0.0.0", + "prefix": uint32(24), + "next-hop": "1.2.3.5", + "metric": uint32(100), + }, + }, + "routes": [][]uint32{}, + "addresses": [][]uint32{}, + "gateway": "1.2.3.1", + "route-metric": 100, + "dhcp-timeout": 45, + }, + "ipv6": { + "addr-gen-mode": 3, + "address-data": []map[string]interface{}(nil), + "routes": [][]interface{}{}, + "dns-search": []string{}, + "method": "auto", + "route-data": []map[string]interface{}(nil), + "dhcp-timeout": 45, + "route-metric": 100, + "addresses": [][]interface{}{}, + }, + "proxy": {}, + "connection": { + "uuid": "843f71c2-161d-46e1-9533-195bfdc8ae56", + "autoconnect-priority": 1, + "autoconnect-retries": 0, + "id": "main", + "interface-name": "test_eth0.1556", + "permissions": []string{}, + "timestamp": 1669049774, + "type": "802-3-ethernet", + }, + "802-3-ethernet": { + "auto-negotiate": false, + "mac-address-blacklist": []string{}, + "s390-options": map[string]string{}, + }, + }, + } + connections = append(connections, connection) + + connection = &mockNMConnection{ + settings: gonetworkmanager.ConnectionSettings{ + "ipv4": { + "address-data": []map[string]interface{}(nil), + "dns-search": []string{}, + "method": "auto", + "route-data": []map[string]interface{}(nil), + "routes": [][]uint32{}, + "addresses": [][]uint32{}, + "route-metric": 100, + "dhcp-timeout": 45, + }, + "ipv6": { + "addr-gen-mode": 3, + "address-data": []map[string]interface{}{ + { + "address": "fc00:aa8:7160:d9eb:1:0:1:3", + "prefix": uint32(64), + }, + }, + "routes": [][]interface{}{}, + "dns-search": []string{}, + "method": "manual", + "route-data": []map[string]interface{}{ + { + "dest": "fc00:5aa8:7160:d9eb::1", + "prefix": uint32(128), + "next-hop": "fe80::1", + "metric": uint32(200), + }, + }, + "dhcp-timeout": 45, + "route-metric": 100, + "addresses": [][]interface{}{}, + }, + "proxy": {}, + "connection": { + "uuid": "41f45675-787c-491e-a034-0363732908f7", + "autoconnect-priority": 1, + "autoconnect-retries": 0, + "id": "vxlan", + "interface-name": "test_eth1.1557", + "permissions": []string{}, + "timestamp": 1669049774, + "type": "802-3-ethernet", + }, + "802-3-ethernet": { + "auto-negotiate": false, + "mac-address-blacklist": []string{}, + "s390-options": map[string]string{}, + }, + }, + } + connections = append(connections, connection) + + connection = &mockNMConnection{ + settings: gonetworkmanager.ConnectionSettings{ + "ipv4": { + "address-data": []map[string]interface{}{ + { + "address": "203.0.113.2", + "prefix": uint32(24), + }, + { + "address": "203.0.113.3", + "prefix": uint32(24), + }, + }, + "dns-search": []string{}, + "method": "manual", + "gateway": "203.0.113.1", + "route-data": []map[string]interface{}(nil), + "routes": [][]uint32{}, + "addresses": [][]uint32{}, + "route-metric": 100, + "dhcp-timeout": 45, + }, + "ipv6": { + "addr-gen-mode": 3, + "address-data": []map[string]interface{}{ + { + "address": "abcd:ef12:3456:10::4", + "prefix": uint32(64), + }, + }, + "routes": [][]interface{}{}, + "dns-search": []string{}, + "method": "manual", + "gateway": "abcd:ef12:3456:10::1", + "route-data": []map[string]interface{}(nil), + "dhcp-timeout": 45, + "route-metric": 100, + "addresses": [][]interface{}{}, + }, + "proxy": {}, + "connection": { + "uuid": "2f2e2453-4f68-41b1-95c5-817276bbce9f", + "autoconnect-priority": 1, + "autoconnect-retries": 0, + "id": "test", + "interface-name": "test_eth2", + "permissions": []string{}, + "timestamp": 1669049774, + "type": "802-3-ethernet", + }, + "802-3-ethernet": { + "auto-negotiate": false, + "mac-address-blacklist": []string{}, + "s390-options": map[string]string{}, + }, + }, + } + connections = append(connections, connection) + + return connections, nil +} + +func (s *mockNMSettings) ReloadConnections() error { + return nil +} + +func (s *mockNMSettings) GetConnectionByUUID(uuid string) (gonetworkmanager.Connection, error) { + return nil, nil +} + +func (s *mockNMSettings) AddConnection(settings gonetworkmanager.ConnectionSettings) (gonetworkmanager.Connection, error) { + return nil, nil +} + +func (s *mockNMSettings) AddConnectionUnsaved(settings gonetworkmanager.ConnectionSettings) (gonetworkmanager.Connection, error) { + return nil, nil +} + +func (s *mockNMSettings) SaveHostname(hostname string) error { + return nil +} + +func (s *mockNMSettings) GetPropertyHostname() (string, error) { + return "", nil +} + +func (s *mockNMSettings) GetPropertyCanModify() (bool, error) { + return false, nil +} + +// Validate the ifupdown configuration parser/writer functions. +func TestNetworkManager(t *testing.T) { + // Setup test file. + tmpDir := "/tmp/networkManager-netconfig-Test" + testDir, err := filepath.Abs("./tests/networkManager") + if err != nil { + t.Fatal(err) + } + resultsDir := filepath.Join(testDir, "results") + + // Update the PATH environment variable so that our nmcli is used instead of the systems. + pathEnv := os.Getenv("PATH") + os.Setenv("PATH", testDir+":"+pathEnv) + + // Setup ifupdown and parse test file. + n := new(networkManager) + n.config = new(mockNMSettings) + + // Get the interfaces state. + interfaces, err := n.GetInterfaces() + if err != nil { + t.Fatal(err) + } + + // Verify interfaces read from file. + err = testVerifyInterfaces(interfaces, resultsDir, 1) + if err != nil { + t.Error(err) + } + + // Test setting the IP addresses on an interface. + err = n.SetIfaceAddresses(context.Background(), "test_eth0.1556", []*net.IPNet{ + { + IP: net.ParseIP("1.2.3.4"), + Mask: net.CIDRMask(24, 32), + }, + { + IP: net.ParseIP("1.2.3.43"), + Mask: net.CIDRMask(24, 32), + }, + { + IP: net.ParseIP("fc00::2"), + Mask: net.CIDRMask(64, 128), + }, + }, net.ParseIP("1.2.3.1"), net.ParseIP("fc00::1")) + if err != nil { + t.Fatal(err) + } + + // Test setting routes on an interface. + err = n.SetIfaceRoutes(context.Background(), "test_eth2", []*Route{ + { + Destination: &net.IPNet{ + IP: net.ParseIP("abcd:ef12:3455:10::"), + Mask: net.CIDRMask(64, 128), + }, + Gateway: net.ParseIP("abcd:ef12:3456:10::1"), + Metric: 100, + }, + { + Destination: &net.IPNet{ + IP: net.ParseIP("10.253.2.0"), + Mask: net.CIDRMask(24, 32), + }, + Gateway: net.ParseIP("203.0.113.22"), + Metric: 100, + }, + }) + if err != nil { + t.Fatal(err) + } + + // Test setting DNS on an interface; static DNS should disable automatic DNS. + err = n.SetIfaceDNS(context.Background(), "test_eth0", []net.IP{ + net.ParseIP("8.8.8.8"), + net.ParseIP("1.1.1.1"), + net.ParseIP("2001:4860:4860::8888"), + }, []string{"example.com"}) + if err != nil { + t.Fatal(err) + } + + // Read the current file and expected state. + err = testVerifyResults(resultsDir, tmpDir, 1) + if err != nil { + t.Error(err) + } + + // Test setting the IP addresses on an interface. + err = n.SetIfaceAddresses(context.Background(), "test_eth0", []*net.IPNet{ + { + IP: net.ParseIP("1.2.10.4"), + Mask: net.CIDRMask(24, 32), + }, + }, net.ParseIP("1.2.10.254"), nil) + if err != nil { + t.Fatal(err) + } + + // Test setting routes on an interface. + err = n.SetIfaceRoutes(context.Background(), "test_eth0.1556", []*Route{}) + if err != nil { + t.Fatal(err) + } + + // Read the current file and expected state. + err = testVerifyResults(resultsDir, tmpDir, 2) + if err != nil { + t.Error(err) + } + + // Cleanup. + err = os.RemoveAll(tmpDir) + if err != nil { + t.Fatal(err) + } +} + +// nmNameservers must read the modern dns-data (strings) as well as the legacy +// "dns" property (ipv4 uint32 array, ipv6 byte-array array), and nmSearchDomains +// must read dns-search. Empty groups return nothing without error. +func TestNMDNSParsing(t *testing.T) { + // Modern dns-data + dns-search, applies to either family. + group := map[string]any{ + "dns-data": []string{"8.8.8.8", "1.1.1.1"}, + "dns-search": []string{"a.test", "b.test"}, + } + got := nmNameservers(group) + want := []net.IP{net.ParseIP("8.8.8.8"), net.ParseIP("1.1.1.1")} + if !equalIPs(got, want) { + t.Errorf("dns-data = %v, want %v", got, want) + } + if search := nmSearchDomains(group); len(search) != 2 || search[0] != "a.test" || search[1] != "b.test" { + t.Errorf("dns-search = %v, want [a.test b.test]", search) + } + + // Legacy ipv4 "dns" as []uint32 (decoded via uint2IP, matching addresses). + ipv4Legacy := map[string]any{"dns": []uint32{ip2Uint(net.ParseIP("1.2.3.4"))}} + if got := nmNameservers(ipv4Legacy); len(got) != 1 || !got[0].Equal(net.ParseIP("1.2.3.4")) { + t.Errorf("legacy ipv4 dns = %v, want [1.2.3.4]", got) + } + + // Legacy ipv6 "dns" as [][]byte. + ipv6Legacy := map[string]any{"dns": [][]byte{net.ParseIP("2001:4860:4860::8888").To16()}} + if got := nmNameservers(ipv6Legacy); len(got) != 1 || !got[0].Equal(net.ParseIP("2001:4860:4860::8888")) { + t.Errorf("legacy ipv6 dns = %v, want [2001:4860:4860::8888]", got) + } + + // Empty group yields nothing. + if v := nmNameservers(map[string]any{}); len(v) != 0 { + t.Errorf("empty group dns = %v, want empty", v) + } + if v := nmSearchDomains(map[string]any{}); len(v) != 0 { + t.Errorf("empty group dns-search = %v, want empty", v) + } +} diff --git a/networkScripts.go b/networkScripts.go new file mode 100644 index 0000000..3d9dbd0 --- /dev/null +++ b/networkScripts.go @@ -0,0 +1,919 @@ +package netconfig + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "net" + "os" + "path" + "regexp" + "sort" + "strconv" + "strings" + "time" + "unicode" + + "dario.cat/mergo" + "github.com/vishvananda/netlink" +) + +const networkScriptsPath = "/etc/sysconfig/network-scripts" + +type nsFile struct { + Name string + Config map[string]string +} + +type nsRouteFile struct { + Name string + Routes [][]string +} +type nsInterface struct { + Name string + IFFiles []nsFile + Route4File nsRouteFile + Route6File nsRouteFile +} + +type networkScripts struct { + Interfaces []*nsInterface + ConfigDir string + backupRetention int +} + +// Either retreives an existing interface, or makes a new one. +func (ns *networkScripts) EnsureInterface(name string) *nsInterface { + // Find existing interface and return it. + for _, iface := range ns.Interfaces { + if iface.Name == name { + return iface + } + } + + // Make a new interface as one wasn't found. + iface := &nsInterface{ + Name: name, + } + ns.Interfaces = append(ns.Interfaces, iface) + return iface +} + +// Parse a file that should only contain key=value data. +func parseKeyValueFile(fd *os.File) (map[string]string, error) { + // Setup the response. + resp := make(map[string]string) + + var builder strings.Builder + readKey := false + inSingleQuote := false + inDoubleQuote := false + var key string + var value string + + scanner := bufio.NewScanner(fd) +lineLoop: + for scanner.Scan() { + line := scanner.Bytes() + + // Trim whitespace. + line = bytes.TrimFunc(line, unicode.IsSpace) + + // Ignore blank lines. + if len(line) == 0 { + continue + } + + // Ignore comments. + if line[0] == '#' { + continue + } + + lineLen := len(line) + readLoop: + for i := 0; i < lineLen; i++ { + b := line[i] + // Handle single quotes. + if b == '\'' && !inDoubleQuote { + if inSingleQuote { + inSingleQuote = false + } else { + inSingleQuote = true + } + continue + } + + // Handle double quotes. + if b == '"' && !inSingleQuote { + if inDoubleQuote { + inDoubleQuote = false + } else { + inDoubleQuote = true + } + continue + } + + // Handle escapes. + if b == '\\' { + if i+1 >= lineLen { + continue lineLoop + } + if inDoubleQuote && line[i+1] == '"' { + i++ + builder.WriteByte('"') + continue + } + } + + // If equal sign and not in a quote, handle key logic. + if b == '=' && !inDoubleQuote && !inSingleQuote { + if readKey { + return nil, fmt.Errorf("invalid file syntax in %s", fd.Name()) + } + key = builder.String() + builder.Reset() + readKey = true + continue + } + + // Handle spaces outside of quotes. + if unicode.IsSpace(rune(b)) && !inDoubleQuote && !inSingleQuote { + // If there is an space outside of an quote, + // and its not leading to an comment or + // the end of line, it is invalid. + for ; i < lineLen; i++ { + b := line[i] + if unicode.IsSpace(rune(b)) { + continue + } else if b == '#' { + break readLoop + } else { + return nil, fmt.Errorf("invalid file syntax in %s", fd.Name()) + } + } + break + } + + // Write data. + builder.WriteByte(b) + } + + // If we ended reading line in a quote, continue to next line. + if inDoubleQuote || inSingleQuote { + continue lineLoop + } + + // If we didn't read a key, that's invalid. + if !readKey { + return nil, fmt.Errorf("invalid file syntax in %s", fd.Name()) + } + + // At this point, we have fully read the key/value. + value = builder.String() + builder.Reset() + readKey = false + + // Assign key/value. + resp[key] = value + } + return resp, nil +} + +// Read list of IP route formatted fields and return list of routes. +func parseIPRoutes(routes [][]string) (resp []*Route) { + // Loop through each route. +routeLoop: + for _, route := range routes { + // If the length of fields is 0, no route to parse. + routeLen := len(route) + if routeLen == 0 { + continue + } + + // Try and parse the network field. + ip, network, err := net.ParseCIDR(route[0]) + if err != nil { + continue + } + network.IP = ip + + // Read the fields. + var gateway net.IP + metric := int(256) + for i := 1; i < routeLen; i++ { + field := route[i] + if field == "via" && i+1 < routeLen { + i++ + gateway = net.ParseIP(route[i]) + if gateway == nil { + continue routeLoop + } + } + if field == "metric" && i+1 < routeLen { + i++ + metric, err = strconv.Atoi(route[i]) + if err != nil || metric == 0 { + metric = 256 + } + } + } + + // Add the route. + r := new(Route) + r.Destination = network + r.Gateway = gateway + r.Metric = metric + resp = append(resp, r) + } + return +} + +func newNetworkScripts(backupRetention int) (ns *networkScripts, err error) { + ns, err = newNetworkScriptsWithConfig(networkScriptsPath) + if ns != nil { + ns.backupRetention = backupRetention + } + return +} + +func newNetworkScriptsWithConfig(configPath string) (ns *networkScripts, err error) { + // Try to read the directory files. + unsortedEntries, err := os.ReadDir(configPath) + if err != nil { + return + } + + // Sort by name. + entries := sortableDirEntries(unsortedEntries) + sort.Sort(entries) + + // Setup response. + ns = new(networkScripts) + ns.ConfigDir = configPath + + // Setup regex match for route network/netmask style. + routeRe := regexp.MustCompile(`^\s*ADDRESS[0-9]+=`) + + for _, entry := range entries { + // If not an network file, skip. + if entry.IsDir() { + continue + } + + // If the file is a interface config file, read it. + if strings.HasPrefix(entry.Name(), "ifcfg-") { + // Open the file. + configFile := path.Join(configPath, entry.Name()) + fd, err := os.Open(configFile) + if err != nil { + return nil, err + } + + // Parse the file. + resp, err := parseKeyValueFile(fd) + fd.Close() + if err != nil { + return nil, err + } + + // Find the device name. + devName, ok := resp["DEVICE"] + if !ok || devName == "" { + devName = entry.Name()[6:] + } + idx := strings.IndexByte(devName, ':') + if idx != -1 { + devName = devName[:idx] + } + + // In the rare event the device name isn't found with the above, + // try hardware address matching. + if devName == "" { + hwAddr, ok := resp["HWADDR"] + mac, err := net.ParseMAC(hwAddr) + if !ok || err != nil { + return nil, fmt.Errorf("unable to find interface name in %s", configFile) + } + ifaces, err := net.Interfaces() + if err != nil { + return nil, err + } + for _, iface := range ifaces { + if bytes.Equal(iface.HardwareAddr, mac) { + devName = iface.Name + break + } + } + if devName == "" { + return nil, fmt.Errorf("unable to find interface name in %s", configFile) + } + } + + // Setup file. + file := nsFile{ + Name: entry.Name(), + Config: resp, + } + + // Get the interface. + iface := ns.EnsureInterface(devName) + + // Add the file to the config. + iface.IFFiles = append(iface.IFFiles, file) + } + + // If the file is a route file. + if strings.HasPrefix(entry.Name(), "route-") { + // Get the device name for the network/netmask format. + devName := entry.Name()[6:] + if devName == "" { + return nil, fmt.Errorf("invalid route file name") + } + + // Open the file. + configFile := path.Join(configPath, entry.Name()) + fd, err := os.Open(configFile) + if err != nil { + return nil, err + } + + // Confirm file type. + file := nsRouteFile{ + Name: entry.Name(), + } + isNetworkMas := false + scanner := bufio.NewScanner(fd) + for scanner.Scan() { + line := scanner.Text() + if routeRe.MatchString(line) { + isNetworkMas = true + break + } + } + fd.Seek(0, io.SeekStart) + + // Parse depending on type. + if isNetworkMas { + // Parse key value file. + resp, err := parseKeyValueFile(fd) + fd.Close() + if err != nil { + return nil, err + } + + // Parse network/netmask configuration. + for i := 0; ; i++ { + var route []string + addr, ok := resp[fmt.Sprintf("ADDRESS%d", i)] + if !ok { + break + } + netmask, ok := resp[fmt.Sprintf("NETMASK%d", i)] + if !ok { + return nil, fmt.Errorf("invalid file format, missing netmask %s", configFile) + } + gateway, ok := resp[fmt.Sprintf("GATEWAY%d", i)] + if !ok { + return nil, fmt.Errorf("invalid file format, missing gateway %s", configFile) + } + ipMask := net.ParseIP(netmask).To4() + if ipMask == nil { + return nil, fmt.Errorf("invalid file format %s", configFile) + } + mask := net.IPMask(ipMask) + prefix, _ := mask.Size() + route = append(route, fmt.Sprintf("%s/%d", addr, prefix), "via", gateway, "dev", devName) + file.Routes = append(file.Routes, route) + } + } else { + // Read ip command format routes. + scanner := bufio.NewScanner(fd) + for scanner.Scan() { + line := scanner.Text() + line = strings.TrimSpace(line) + + // Ignore blank lines and comments. + if line == "" || line[0] == '#' { + continue + } + + // Parse route. + route := strings.Fields(line) + file.Routes = append(file.Routes, route) + } + fd.Close() + } + + // Get the interface. + iface := ns.EnsureInterface(devName) + + // Add the route to the iface. + iface.Route4File = file + } + + // If the file is a route file. + if strings.HasPrefix(entry.Name(), "route6-") { + // Get the device name for use in assigning to interface. + devName := entry.Name()[7:] + if devName == "" { + return nil, fmt.Errorf("invalid route file name") + } + + // Open the file. + configFile := path.Join(configPath, entry.Name()) + fd, err := os.Open(configFile) + if err != nil { + return nil, err + } + + // Read ip command format routes. + file := nsRouteFile{ + Name: entry.Name(), + } + scanner := bufio.NewScanner(fd) + for scanner.Scan() { + line := scanner.Text() + line = strings.TrimSpace(line) + + // Ignore blank lines and comments. + if line == "" || line[0] == '#' { + continue + } + + // Parse route. + route := strings.Fields(line) + file.Routes = append(file.Routes, route) + } + fd.Close() + + // Get the interface. + iface := ns.EnsureInterface(devName) + + // Add the route to the iface. + iface.Route6File = file + } + } + return +} + +// Reads the environment variables at an IP index, and returns an ipnet. +func (ns *networkScripts) ParseIP(cfg map[string]string, idx string) *net.IPNet { + // Get the IPADDR environment, non-existing means no IP. + addr, ok := cfg["IPADDR"+idx] + if !ok || addr == "" { + return nil + } + + // Get the prefix and netmask to get network size. + prefix := cfg["PREFIX"+idx] + netmask := cfg["NETMASK"+idx] + + // If the prefix isn't found, try parsing it from the netmask. + if prefix == "" { + // Get the prefix from netmask, defaulting to 16. + ipMask := net.ParseIP(netmask).To4() + if ipMask != nil { + mask := net.IPMask(ipMask) + ones, _ := mask.Size() + prefix = strconv.Itoa(ones) + } else { + prefix = "16" + } + } + + // Parse the CIDR. + ip, ipnet, err := net.ParseCIDR(fmt.Sprintf("%s/%s", addr, prefix)) + if err != nil { + return nil + } + ipnet.IP = ip + return ipnet +} + +// Get interfaces configured. +func (ns *networkScripts) GetInterfaces() (interfaces []*Interface, err error) { + // Connect to netlink. + var h *netlink.Handle + h, err = netlink.NewHandle() + if err != nil { + return + } + defer h.Close() + + // Get list of interfaces to match with MAC address. + var links []netlink.Link + links, err = h.LinkList() + if err != nil { + return + } + + for _, iface := range ns.Interfaces { + i := new(Interface) + i.Name = iface.Name + + // Discover the interface name and mac address. + for _, link := range links { + if link.Attrs().Name == iface.Name { + i.MAC = link.Attrs().HardwareAddr + i.Link = link + } + } + + // Parse interface files. + for _, file := range iface.IFFiles { + // Parse IPv4 addresses in the file. + ipAddr := ns.ParseIP(file.Config, "") + if ipAddr != nil { + i.Addresses = append(i.Addresses, ipAddr) + } + for idx := 0; idx <= 255; idx++ { + ipAddr = ns.ParseIP(file.Config, strconv.Itoa(idx)) + if ipAddr != nil { + i.Addresses = append(i.Addresses, ipAddr) + } else if idx >= 2 { + break + } + } + gateway4 := file.Config["GATEWAY"] + if gateway4 != "" { + i.Gateway4 = net.ParseIP(gateway4) + } + gateway6 := file.Config["IPV6_DEFAULTGW"] + if gateway6 != "" { + i.Gateway6 = net.ParseIP(gateway6) + } + + // Parse DNS servers (DNS1, DNS2, ...) and search domains. + for idx := 1; ; idx++ { + dns, ok := file.Config[fmt.Sprintf("DNS%d", idx)] + if !ok { + break + } + if ip := net.ParseIP(dns); ip != nil { + i.DNS = append(i.DNS, ip) + } + } + if domain := file.Config["DOMAIN"]; domain != "" { + i.SearchDomains = append(i.SearchDomains, strings.Fields(domain)...) + } + + // Parse IPv6 addresses in the file. + addr := file.Config["IPV6ADDR"] + if addr != "" { + ip, ipnet, err := net.ParseCIDR(addr) + if err == nil { + ipnet.IP = ip + i.Addresses = append(i.Addresses, ipnet) + } + } + addrs := strings.Fields(file.Config["IPV6ADDR_SECONDARIES"]) + for _, addr := range addrs { + ip, ipnet, err := net.ParseCIDR(addr) + if err == nil { + ipnet.IP = ip + i.Addresses = append(i.Addresses, ipnet) + } + } + } + + // Parse static routes. + i.Routes = parseIPRoutes(iface.Route4File.Routes) + route6 := parseIPRoutes(iface.Route6File.Routes) + i.Routes = append(i.Routes, route6...) + + // Add interface. + interfaces = append(interfaces, i) + } + return +} + +// Save an interface to script. +func (n *nsInterface) Save(config map[string]string, configDir string, backupRetention int) error { + // Get config paths. + configName := fmt.Sprintf("ifcfg-%s", n.Name) + configPath := path.Join(configDir, configName) + tmpName := fmt.Sprintf(".tmp.%d.%s", time.Now().UnixNano(), configName) + tmpPath := path.Join(configDir, tmpName) + + // Try to encode/save the config. + fd, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + return err + } + var keys []string + for key := range config { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + val := config[key] + if strings.ContainsFunc(val, unicode.IsSpace) { + fmt.Fprintf(fd, "%s=\"%s\"\n", key, val) + } else { + fmt.Fprintf(fd, "%s=%s\n", key, val) + } + } + fd.Close() + + // Backup existing configs. + prefix := fmt.Sprintf(".bak.%d.", time.Now().UnixNano()) + for _, file := range n.IFFiles { + newName := prefix + file.Name + oldFile := path.Join(configDir, file.Name) + newFile := path.Join(configDir, newName) + err := fileMove(oldFile, newFile) + if err != nil { + return err + } + } + + // Move new config in place. + err = fileMove(tmpPath, configPath) + if err != nil { + return err + } + + // Update the config in the structure. + n.IFFiles = []nsFile{{ + Name: configName, + Config: config, + }} + + // Prune old backups beyond the retention count. + for _, file := range n.IFFiles { + pruneBackups(configDir, prefixBackupMatcher(file.Name), backupRetention) + } + + return nil +} + +// Save an interface to script. +func (n *nsInterface) SaveRouteFiles(routes4 []string, routes6 []string, configDir string, backupRetention int) error { + // Get config paths for routes IPv4. + config4Name := fmt.Sprintf("route-%s", n.Name) + config4Path := path.Join(configDir, config4Name) + tmp4Name := fmt.Sprintf(".tmp.%d.%s", time.Now().UnixNano(), config4Name) + tmp4Path := path.Join(configDir, tmp4Name) + + // Try to encode/save the config. + if len(routes4) != 0 { + fd, err := os.OpenFile(tmp4Path, os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + return err + } + for _, route := range routes4 { + fmt.Fprintln(fd, route) + } + fd.Close() + } + + // Get config paths for routes IPv6 + config6Name := fmt.Sprintf("route6-%s", n.Name) + config6Path := path.Join(configDir, config6Name) + tmp6Name := fmt.Sprintf(".tmp.%d.%s", time.Now().UnixNano(), config6Name) + tmp6Path := path.Join(configDir, tmp6Name) + + // Try to encode/save the config. + if len(routes6) != 0 { + fd, err := os.OpenFile(tmp6Path, os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + return err + } + for _, route := range routes6 { + fmt.Fprintf(fd, "%s\n", route) + } + fd.Close() + } + + // Backup existing configs. + prefix := fmt.Sprintf(".bak.%d.", time.Now().UnixNano()) + if n.Route4File.Name != "" { + newName := prefix + n.Route4File.Name + oldFile := path.Join(configDir, n.Route4File.Name) + newFile := path.Join(configDir, newName) + err := fileMove(oldFile, newFile) + if err != nil { + return err + } + } + if n.Route6File.Name != "" { + newName := prefix + n.Route6File.Name + oldFile := path.Join(configDir, n.Route6File.Name) + newFile := path.Join(configDir, newName) + err := fileMove(oldFile, newFile) + if err != nil { + return err + } + } + + // Move new config in place. + if len(routes4) != 0 { + err := fileMove(tmp4Path, config4Path) + if err != nil { + return err + } + } + if len(routes6) != 0 { + err := fileMove(tmp6Path, config6Path) + if err != nil { + return err + } + } + + // Update the config in the structure. + n.Route4File = nsRouteFile{} + if len(routes4) != 0 { + n.Route4File.Name = config4Name + for _, route := range routes4 { + n.Route4File.Routes = append(n.Route4File.Routes, strings.Fields(route)) + } + } + n.Route6File = nsRouteFile{} + if len(routes6) != 0 { + n.Route6File.Name = config6Name + for _, route := range routes6 { + n.Route6File.Routes = append(n.Route6File.Routes, strings.Fields(route)) + } + } + + // Prune old backups beyond the retention count. + if n.Route4File.Name != "" { + pruneBackups(configDir, prefixBackupMatcher(n.Route4File.Name), backupRetention) + } + if n.Route6File.Name != "" { + pruneBackups(configDir, prefixBackupMatcher(n.Route6File.Name), backupRetention) + } + + return nil +} + +// Set addresses to interface. +func (ns *networkScripts) SetIfaceAddresses(_ context.Context, iface string, addrs []*net.IPNet, gateway4, gateway6 net.IP) (err error) { + // Separate addresses by addr4 and addr6. + var addrs4 []*net.IPNet + var addrs6 []string + for _, addr := range addrs { + if addr.IP.To4() == nil { + addrs6 = append(addrs6, addr.String()) + } else { + addrs4 = append(addrs4, addr) + } + } + + // Find or create the interface. A backend must not silently no-op just + // because it has not seen this interface before (e.g. a freshly attached + // NIC), otherwise the caller gets a success with no persisted change. + ifc := ns.EnsureInterface(iface) + + // Merge configs. + config := make(map[string]string) + for _, file := range ifc.IFFiles { + err = mergo.Merge(&config, file.Config) + if err != nil { + return err + } + } + + // Delete existing configs. + delete(config, "IPADDR6") + delete(config, "IPV6ADDR") + delete(config, "IPV6ADDR_SECONDARIES") + delete(config, "IPV6_DEFAULTGW") + delete(config, "GATEWAY") + configMap := []string{""} + for i := 0; i <= 255; i++ { + configMap = append(configMap, strconv.Itoa(i)) + } + for _, idx := range configMap { + delete(config, fmt.Sprintf("IPADDR%s", idx)) + delete(config, fmt.Sprintf("PREFIX%s", idx)) + delete(config, fmt.Sprintf("NETMASK%s", idx)) + delete(config, fmt.Sprintf("BROADCAST%s", idx)) + delete(config, fmt.Sprintf("ARPCHECK%s", idx)) + delete(config, fmt.Sprintf("ARPUPDATE%s", idx)) + } + + // Add IP addresses. + for idx, ip := range addrs4 { + config[fmt.Sprintf("IPADDR%d", idx)] = ip.IP.String() + prefix, _ := ip.Mask.Size() + config[fmt.Sprintf("PREFIX%d", idx)] = strconv.Itoa(prefix) + config[fmt.Sprintf("NETMASK%d", idx)] = net.IP(ip.Mask).String() + config[fmt.Sprintf("BROADCAST%d", idx)] = getBroadcast(ip).String() + } + + // Add IPv6 addresses. + if len(addrs6) != 0 { + config["IPV6ADDR"] = addrs6[0] + addrs6 = addrs6[1:] + if len(addrs6) != 0 { + config["IPV6ADDR_SECONDARIES"] = strings.Join(addrs6, " ") + } + } + + // Set gateways. + if gateway4 != nil { + config["GATEWAY"] = gateway4.String() + } + if gateway6 != nil { + config["IPV6_DEFAULTGW"] = gateway6.String() + } + + // Try to save. + err = ifc.Save(config, ns.ConfigDir, ns.backupRetention) + if err != nil { + return err + } + + return +} + +// Set static routes to interface. +func (ns *networkScripts) SetIfaceRoutes(_ context.Context, iface string, routes []*Route) (err error) { + // Build route slices. + var routes4 []string + var routes6 []string + for _, route := range routes { + r := fmt.Sprintf("%s via %s metric %d", route.Destination.String(), route.Gateway, route.Metric) + if route.Destination.IP.To4() == nil { + routes6 = append(routes6, r) + } else { + routes4 = append(routes4, r) + } + } + + // Find or create the interface. A backend must not silently no-op just + // because it has not seen this interface before (e.g. a freshly attached + // NIC), otherwise the caller gets a success with no persisted change. + ifc := ns.EnsureInterface(iface) + + // Save the routes. + err = ifc.SaveRouteFiles(routes4, routes6, ns.ConfigDir, ns.backupRetention) + if err != nil { + return err + } + + return +} + +// Set DNS servers and search domains on interface. +func (ns *networkScripts) SetIfaceDNS(_ context.Context, iface string, servers []net.IP, searchDomains []string) (err error) { + // Find or create the interface. A backend must not silently no-op just + // because it has not seen this interface before (e.g. a freshly attached + // NIC), otherwise the caller gets a success with no persisted change. + ifc := ns.EnsureInterface(iface) + + // Merge configs. + config := make(map[string]string) + for _, file := range ifc.IFFiles { + err = mergo.Merge(&config, file.Config) + if err != nil { + return err + } + } + + // Delete existing DNS and search-domain configs. network-scripts + // stores resolvers as DNS1, DNS2, ... and the search list in DOMAIN. + for key := range config { + if strings.HasPrefix(key, "DNS") { + delete(config, key) + } + } + delete(config, "DOMAIN") + delete(config, "PEERDNS") + + // Add DNS servers, numbered from 1 per the network-scripts format. + idx := 1 + for _, ip := range servers { + if ip == nil { + continue + } + config[fmt.Sprintf("DNS%d", idx)] = ip.String() + idx++ + } + + // On a DHCP interface, dhclient rewrites /etc/resolv.conf from the + // lease on every renewal, which would override the DNS1/DNS2 set above. + // PEERDNS=no tells the initscripts not to accept DHCP-provided resolvers + // so the statically configured servers win. Only set it when we actually + // have servers to enforce; when clearing, leaving PEERDNS unset restores + // the default of honouring DHCP. + if idx > 1 { + config["PEERDNS"] = "no" + } + + // Add search domains. + if len(searchDomains) != 0 { + config["DOMAIN"] = strings.Join(searchDomains, " ") + } + + // Try to save. + err = ifc.Save(config, ns.ConfigDir, ns.backupRetention) + if err != nil { + return err + } + + return +} diff --git a/networkScripts_test.go b/networkScripts_test.go new file mode 100644 index 0000000..d2e8db0 --- /dev/null +++ b/networkScripts_test.go @@ -0,0 +1,173 @@ +package netconfig + +import ( + "context" + "net" + "os" + "path/filepath" + "strings" + "testing" +) + +// Validate the network scripts configuration parser/writer functions. +func TestNetworkScripts(t *testing.T) { + // Setup test file. + tmpDir, err := os.MkdirTemp("", "") + if err != nil { + t.Fatal(err) + } + testDir, err := filepath.Abs("./tests/networkScripts") + if err != nil { + t.Fatal(err) + } + resultsDir := filepath.Join(testDir, "results") + + // Try to read the test directory for files to copy to our temporary directory. + entries, err := os.ReadDir(testDir) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if !strings.HasPrefix(entry.Name(), "ifcfg-") && !strings.HasPrefix(entry.Name(), "route-") && !strings.HasPrefix(entry.Name(), "route6-") { + continue + } + + configPath := filepath.Join(testDir, entry.Name()) + tmpPath := filepath.Join(tmpDir, entry.Name()) + err = fileCopy(configPath, tmpPath) + if err != nil { + t.Fatal(err) + } + } + + // Setup ifupdown and parse test file. + ns, err := newNetworkScriptsWithConfig(tmpDir) + if err != nil { + t.Fatal(err) + } + + // Get the interfaces state. + interfaces, err := ns.GetInterfaces() + if err != nil { + t.Fatal(err) + } + + // Verify interfaces read from file. + err = testVerifyInterfaces(interfaces, resultsDir, 1) + if err != nil { + t.Error(err) + } + + // Test setting the IP addresses on an interface. + err = ns.SetIfaceAddresses(context.Background(), "test_eth0.1556", []*net.IPNet{ + { + IP: net.ParseIP("1.2.3.4"), + Mask: net.CIDRMask(24, 32), + }, + { + IP: net.ParseIP("1.2.3.43"), + Mask: net.CIDRMask(24, 32), + }, + { + IP: net.ParseIP("fc00::2"), + Mask: net.CIDRMask(64, 128), + }, + }, net.ParseIP("1.2.3.1"), net.ParseIP("fc00::1")) + if err != nil { + t.Fatal(err) + } + + // Test setting routes on an interface. + err = ns.SetIfaceRoutes(context.Background(), "test_eth2", []*Route{ + { + Destination: &net.IPNet{ + IP: net.ParseIP("abcd:ef12:3455:10::"), + Mask: net.CIDRMask(64, 128), + }, + Gateway: net.ParseIP("abcd:ef12:3456:10::1"), + Metric: 100, + }, + { + Destination: &net.IPNet{ + IP: net.ParseIP("10.253.2.0"), + Mask: net.CIDRMask(24, 32), + }, + Gateway: net.ParseIP("203.0.113.22"), + Metric: 100, + }, + }) + if err != nil { + t.Fatal(err) + } + + // Verify we can re-read configurations. + ns, err = newNetworkScriptsWithConfig(tmpDir) + if err != nil { + t.Fatal(err) + } + + // Get the interfaces state. + interfaces, err = ns.GetInterfaces() + if err != nil { + t.Fatal(err) + } + + // Verify interfaces read from file. + err = testVerifyInterfaces(interfaces, resultsDir, 2) + if err != nil { + t.Error(err) + } + + // Read the current file and expected state. + err = testVerifyResults(resultsDir, tmpDir, 1) + if err != nil { + t.Error(err) + } + + // Test setting the IP addresses on an interface. + err = ns.SetIfaceAddresses(context.Background(), "test_eth0", []*net.IPNet{ + { + IP: net.ParseIP("1.2.10.4"), + Mask: net.CIDRMask(24, 32), + }, + }, net.ParseIP("1.2.10.254"), nil) + if err != nil { + t.Fatal(err) + } + + // Test setting routes on an interface. + err = ns.SetIfaceRoutes(context.Background(), "test_eth0.1556", []*Route{}) + if err != nil { + t.Fatal(err) + } + + // Verify we can re-read configurations. + ns, err = newNetworkScriptsWithConfig(tmpDir) + if err != nil { + t.Fatal(err) + } + + // Get the interfaces state. + interfaces, err = ns.GetInterfaces() + if err != nil { + t.Fatal(err) + } + + // Verify interfaces read from file. + err = testVerifyInterfaces(interfaces, resultsDir, 3) + if err != nil { + t.Error(err) + } + + // Read the current file and expected state. + err = testVerifyResults(resultsDir, tmpDir, 2) + if err != nil { + t.Error(err) + } + + // Cleanup. + err = os.RemoveAll(tmpDir) + if err != nil { + t.Fatal(err) + } +} diff --git a/networkScripts_unit_test.go b/networkScripts_unit_test.go new file mode 100644 index 0000000..68f3eaa --- /dev/null +++ b/networkScripts_unit_test.go @@ -0,0 +1,135 @@ +package netconfig + +import ( + "net" + "testing" +) + +// parseIPRoutes is a pure transform from raw whitespace-split route fields to +// Route structs; exercise its branches directly rather than via fixtures. +func TestParseIPRoutes(t *testing.T) { + t.Run("full route", func(t *testing.T) { + routes := parseIPRoutes([][]string{ + {"10.0.0.0/24", "via", "10.0.0.1", "metric", "100"}, + }) + if len(routes) != 1 { + t.Fatalf("got %d routes, want 1", len(routes)) + } + r := routes[0] + if got := r.Destination.String(); got != "10.0.0.0/24" { + t.Errorf("destination = %s, want 10.0.0.0/24", got) + } + if !r.Gateway.Equal(net.ParseIP("10.0.0.1")) { + t.Errorf("gateway = %s, want 10.0.0.1", r.Gateway) + } + if r.Metric != 100 { + t.Errorf("metric = %d, want 100", r.Metric) + } + }) + + t.Run("default metric when absent", func(t *testing.T) { + routes := parseIPRoutes([][]string{{"10.0.0.0/24", "via", "10.0.0.1"}}) + if len(routes) != 1 || routes[0].Metric != 256 { + t.Fatalf("expected single route with default metric 256, got %+v", routes) + } + }) + + t.Run("metric zero falls back to default", func(t *testing.T) { + routes := parseIPRoutes([][]string{{"10.0.0.0/24", "metric", "0"}}) + if len(routes) != 1 || routes[0].Metric != 256 { + t.Fatalf("expected default metric 256 for metric 0, got %+v", routes) + } + }) + + t.Run("invalid metric falls back to default", func(t *testing.T) { + routes := parseIPRoutes([][]string{{"10.0.0.0/24", "metric", "notanumber"}}) + if len(routes) != 1 || routes[0].Metric != 256 { + t.Fatalf("expected default metric 256 for bad metric, got %+v", routes) + } + }) + + t.Run("empty and invalid entries are skipped", func(t *testing.T) { + routes := parseIPRoutes([][]string{ + {}, // empty: skipped + {"not-a-cidr"}, // invalid CIDR: skipped + {"10.0.0.0/24", "via", "nope"}, // bad gateway: skipped + {"192.168.0.0/16", "via", "192.168.0.1"}, // valid + }) + if len(routes) != 1 { + t.Fatalf("got %d routes, want 1", len(routes)) + } + if got := routes[0].Destination.String(); got != "192.168.0.0/16" { + t.Errorf("destination = %s, want 192.168.0.0/16", got) + } + }) +} + +// ParseIP derives a *net.IPNet from network-scripts style key/value config. +func TestNetworkScriptsParseIP(t *testing.T) { + ns := &networkScripts{} + + t.Run("missing IPADDR returns nil", func(t *testing.T) { + if got := ns.ParseIP(map[string]string{}, ""); got != nil { + t.Errorf("got %v, want nil", got) + } + }) + + t.Run("empty IPADDR returns nil", func(t *testing.T) { + if got := ns.ParseIP(map[string]string{"IPADDR": ""}, ""); got != nil { + t.Errorf("got %v, want nil", got) + } + }) + + t.Run("explicit prefix", func(t *testing.T) { + got := ns.ParseIP(map[string]string{"IPADDR": "192.168.1.5", "PREFIX": "24"}, "") + if got == nil { + t.Fatal("got nil") + } + if !got.IP.Equal(net.ParseIP("192.168.1.5")) { + t.Errorf("IP = %s, want 192.168.1.5", got.IP) + } + if ones, _ := got.Mask.Size(); ones != 24 { + t.Errorf("prefix = %d, want 24", ones) + } + }) + + t.Run("prefix derived from netmask", func(t *testing.T) { + got := ns.ParseIP(map[string]string{"IPADDR": "10.1.2.3", "NETMASK": "255.255.255.0"}, "") + if got == nil { + t.Fatal("got nil") + } + if ones, _ := got.Mask.Size(); ones != 24 { + t.Errorf("prefix = %d, want 24 (from netmask)", ones) + } + }) + + t.Run("defaults to /16 with no prefix or netmask", func(t *testing.T) { + got := ns.ParseIP(map[string]string{"IPADDR": "10.1.2.3"}, "") + if got == nil { + t.Fatal("got nil") + } + if ones, _ := got.Mask.Size(); ones != 16 { + t.Errorf("prefix = %d, want default 16", ones) + } + }) + + t.Run("indexed keys", func(t *testing.T) { + cfg := map[string]string{"IPADDR0": "172.16.0.9", "PREFIX0": "30"} + got := ns.ParseIP(cfg, "0") + if got == nil { + t.Fatal("got nil") + } + if !got.IP.Equal(net.ParseIP("172.16.0.9")) { + t.Errorf("IP = %s, want 172.16.0.9", got.IP) + } + if ones, _ := got.Mask.Size(); ones != 30 { + t.Errorf("prefix = %d, want 30", ones) + } + }) + + t.Run("invalid address returns nil", func(t *testing.T) { + if got := ns.ParseIP(map[string]string{"IPADDR": "not-an-ip", "PREFIX": "24"}, ""); got != nil { + t.Errorf("got %v, want nil", got) + } + }) +} diff --git a/networkd.go b/networkd.go new file mode 100644 index 0000000..0a38b89 --- /dev/null +++ b/networkd.go @@ -0,0 +1,587 @@ +package netconfig + +import ( + "bytes" + "context" + "fmt" + "net" + "os" + "path" + "sort" + "strconv" + "strings" + "time" + + "github.com/vishvananda/netlink" + "gopkg.in/ini.v1" +) + +type networkdNetwork struct { + Config *ini.File + Name string + MAC net.HardwareAddr + Link netlink.Link + File string + SubFiles []string +} + +type networkd struct { + Networks []*networkdNetwork + configDir string + backupRetention int +} + +// Verify try parsing networkd configurations. +func newNetworkd(backupRetention int) (n *networkd, err error) { + // Try to parse the networkd configurations. /etc/systemd/network is the + // durable, admin-facing source of truth and is tried first so persisted + // changes actually survive a reboot; /run/systemd/network is a volatile + // tmpfs directory that systemd-networkd also honors and is only used as a + // fallback, otherwise a host with any file in /run/systemd/network would + // have its real /etc/systemd/network configuration silently ignored. + n, err = readNetworkdConfigDirectory("/etc/systemd/network") + if err != nil { + n, err = readNetworkdConfigDirectory("/run/systemd/network") + if err != nil { + n, err = readNetworkdConfigDirectory("/lib/systemd/network") + } + } + if n != nil { + n.backupRetention = backupRetention + } + return +} + +// Read an systemd-networkd configuration directory for network configuration files. +func readNetworkdConfigDirectory(dirPath string) (nd *networkd, err error) { + // Try to read the directory files. + unsortedEntries, err := os.ReadDir(dirPath) + if err != nil { + return + } + + // Sort by name. + entries := sortableDirEntries(unsortedEntries) + sort.Sort(entries) + + // Connect to netlink. + var h *netlink.Handle + h, err = netlink.NewHandle() + if err != nil { + return + } + defer h.Close() + + // Get list of interfaces to match with MAC address. + var links []netlink.Link + links, err = h.LinkList() + if err != nil { + return + } + + // Read each network file. + var networks []*networkdNetwork + for _, entry := range entries { + // If not an network file, skip. + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".network") { + continue + } + + // Get the config path and sub config path. + configPath := path.Join(dirPath, entry.Name()) + configSubPath := configPath + ".d" + + // Find any sub configuration files. + var subFiles []string + var subPaths []interface{} + if stat, serr := os.Stat(configSubPath); !os.IsNotExist(serr) && stat.IsDir() { + // Try to read the directory files. + unsortedEntries, err = os.ReadDir(configSubPath) + if err != nil { + return + } + + // Sort by name. + subEntries := sortableDirEntries(unsortedEntries) + sort.Sort(subEntries) + for _, subEnt := range subEntries { + // If not an network file, skip. + if subEnt.IsDir() || !strings.HasSuffix(subEnt.Name(), ".conf") { + continue + } + subFiles = append(subFiles, subEnt.Name()) + subPath := path.Join(configSubPath, subEnt.Name()) + subPaths = append(subPaths, subPath) + } + } + + // Parse the config files found. + var cfg *ini.File + opts := ini.LoadOptions{ + AllowShadows: true, + KeyValueDelimiters: "=", + KeyValueDelimiterOnWrite: "=", + ChildSectionDelimiter: ".", + AllowNonUniqueSections: true, + AllowDuplicateShadowValues: true, + } + cfg, err = ini.LoadSources(opts, configPath, subPaths...) + if err != nil { + return + } + + // The config should have an network and match section. + if !cfg.HasSection("Network") || !cfg.HasSection("Match") { + return nil, fmt.Errorf("the configuration is missing key sections: %s", configPath) + } + + // Discover the interface name and mac address. + var mac net.HardwareAddr + var foundLink netlink.Link + + // Get the match section needed. + match, _ := cfg.GetSection("Match") + + // Find the name in the match section. + var name string + nameKey, _ := match.GetKey("Name") + if nameKey != nil { + name = nameKey.String() + } + + // If the name wasn't in the match section, try finding + // by MAC address.' + if name == "" { + // Get the MAC address from the match section. + var macAddr string + macKey, _ := match.GetKey("MACAddress") + if macKey != nil { + macAddr = macKey.String() + } + + // Try parsing the MAC. + mac, err = net.ParseMAC(macAddr) + if err != nil { + return nil, fmt.Errorf("unable to parse mac address %s: %v", macAddr, err) + } + + // Find the matching link. + for _, link := range links { + if bytes.Equal(link.Attrs().HardwareAddr, mac) { + foundLink = link + name = link.Attrs().Name + } + } + } + + /// If the link wasn't found above, its likely to be found by name. + if foundLink == nil { + for _, link := range links { + if link.Attrs().Name == name { + foundLink = link + } + } + } + + // Add to list of networks. + networks = append(networks, &networkdNetwork{ + Config: cfg, + Name: name, + MAC: mac, + Link: foundLink, + File: entry.Name(), + SubFiles: subFiles, + }) + } + + // If no networks were parsed, this directory has no configurations. + if len(networks) == 0 { + return nil, fmt.Errorf("no configuration files exist") + } + + // Return networkd. + nd = new(networkd) + nd.Networks = networks + nd.configDir = dirPath + return +} + +// Get interfaces configured. +func (nd *networkd) GetInterfaces() (interfaces []*Interface, err error) { + // Add networks to the list. + for _, n := range nd.Networks { + // Setup the new interface. + i := new(Interface) + i.Name = n.Name + i.MAC = n.MAC + i.Link = n.Link + + // Find IP addresses in the network and address sections. + // Also parse any routes. + for _, sec := range n.Config.Sections() { + switch sec.Name() { + // IF this is an network section, try to find addresses. + case "Network": + // Find the addresses in the address key. + addressKey, _ := sec.GetKey("Address") + if addressKey == nil { + continue + } + addresses := addressKey.ValueWithShadows() + + // Add addresses. + for _, addr := range addresses { + // Per the spec, clear on empty. + if addr == "" { + i.Addresses = nil + continue + } + ip, ipnet, err := net.ParseCIDR(addr) + if err != nil { + continue + } + ipnet.IP = ip + i.Addresses = append(i.Addresses, ipnet) + } + + // Find DNS servers and search domains. + if dnsKey, _ := sec.GetKey("DNS"); dnsKey != nil { + for _, d := range dnsKey.ValueWithShadows() { + if ip := net.ParseIP(d); ip != nil { + i.DNS = append(i.DNS, ip) + } + } + } + if domainsKey, _ := sec.GetKey("Domains"); domainsKey != nil { + i.SearchDomains = append(i.SearchDomains, strings.Fields(domainsKey.String())...) + } + + // Find the gateways in the gateway key. + gatewayKey, _ := sec.GetKey("Gateway") + if gatewayKey == nil { + continue + } + gateways := gatewayKey.ValueWithShadows() + + // Add gateways. + for _, gatewayIP := range gateways { + gateway := net.ParseIP(gatewayIP) + if gateway == nil { + continue + } + if gateway.To4() == nil { + i.Gateway6 = gateway + } else { + i.Gateway4 = gateway + } + } + + // IF this is an address section, try to find addresses. + case "Address": + // Find the addresses in the address key. + addressKey, _ := sec.GetKey("Address") + if addressKey == nil { + continue + } + addresses := addressKey.ValueWithShadows() + + // Add addresses. + for _, addr := range addresses { + // Per the spec, clear on empty. + if addr == "" { + i.Addresses = nil + continue + } + ip, ipnet, err := net.ParseCIDR(addr) + if err != nil { + continue + } + ipnet.IP = ip + i.Addresses = append(i.Addresses, ipnet) + } + + // Find DNS servers and search domains. + if dnsKey, _ := sec.GetKey("DNS"); dnsKey != nil { + for _, d := range dnsKey.ValueWithShadows() { + if ip := net.ParseIP(d); ip != nil { + i.DNS = append(i.DNS, ip) + } + } + } + if domainsKey, _ := sec.GetKey("Domains"); domainsKey != nil { + i.SearchDomains = append(i.SearchDomains, strings.Fields(domainsKey.String())...) + } + + // Find the gateways in the gateway key. + gatewayKey, _ := sec.GetKey("Gateway") + if gatewayKey == nil { + continue + } + gateways := gatewayKey.ValueWithShadows() + + // Add gateways. + for _, gatewayIP := range gateways { + gateway := net.ParseIP(gatewayIP) + if gateway == nil { + continue + } + if gateway.To4() == nil { + i.Gateway6 = gateway + } else { + i.Gateway4 = gateway + } + } + + // Add any routes specified. + case "Route": + // Parse the destination. + var destination *net.IPNet + destinationKey, _ := sec.GetKey("Destination") + if destinationKey != nil { + ip, ipnet, err := net.ParseCIDR(destinationKey.String()) + if err == nil { + ipnet.IP = ip + destination = ipnet + } + } + if destination == nil { + continue + } + + // Parse the gateway. + var gateway net.IP + gatewayKey, _ := sec.GetKey("Gateway") + if gatewayKey != nil { + gateway = net.ParseIP(gatewayKey.String()) + } + if gateway == nil { + continue + } + + // Parse the metric. + var metric int = 256 + metricKey, _ := sec.GetKey("Metric") + if metricKey != nil { + metricVal, err := metricKey.Int() + if err == nil { + metric = metricVal + } + } + + // Append the route. + i.Routes = append(i.Routes, &Route{ + Destination: destination, + Gateway: gateway, + Metric: metric, + }) + } + } + + // Add the interface. + interfaces = append(interfaces, i) + } + + return +} + +// Save network config. +func (n *networkdNetwork) Save(configDir string, backupRetention int) error { + // Get config paths. + configPath := path.Join(configDir, n.File) + tmpName := fmt.Sprintf("%s.tmp.%d", n.File, time.Now().UnixNano()) + tmpPath := path.Join(configDir, tmpName) + + // Try to encode/save the config. + fd, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + return err + } + _, err = n.Config.WriteTo(fd) + fd.Close() + if err != nil { + return err + } + + // Track the original file names backed up so we can prune per base. + bases := append([]string{}, n.SubFiles...) + + // Backup existing configs. + suffix := fmt.Sprintf(".bak.%d", time.Now().UnixNano()) + for _, file := range n.SubFiles { + newName := file + suffix + oldFile := path.Join(configDir, file) + newFile := path.Join(configDir, newName) + err := fileMove(oldFile, newFile) + if err != nil { + return err + } + } + bakName := n.File + suffix + bakPath := path.Join(configDir, bakName) + err = fileMove(configPath, bakPath) + if err != nil { + return err + } + + // Move new config in place. + err = fileMove(tmpPath, configPath) + if err != nil { + return err + } + + // We only have one merged file now. + n.SubFiles = nil + + // Prune old backups beyond the retention count, per original file. + bases = append(bases, n.File) + for _, base := range bases { + pruneBackups(configDir, suffixBackupMatcher(base), backupRetention) + } + + return nil +} + +func (nd *networkd) SetIfaceAddresses(_ context.Context, iface string, addrs []*net.IPNet, gateway4, gateway6 net.IP) (err error) { + for _, n := range nd.Networks { + if n.Name != iface { + continue + } + + // Get existing DNS/domain config in-case different configs + // contains them from the main section. + var dns []string + var domains []string + for _, sec := range n.Config.Sections() { + switch sec.Name() { + // IF this is an network section, try to find addresses. + case "Network", "Address": + // Find the DNS. + dnsKey, _ := sec.GetKey("DNS") + if dnsKey != nil { + dnsServers := dnsKey.ValueWithShadows() + + // Get the gateway. + for _, d := range dnsServers { + dns = append(dns, d) + } + } + + // Find the domains. + domainsKey, _ := sec.GetKey("Domains") + if domainsKey != nil { + theseDoamins := strings.Fields(domainsKey.String()) + domains = append(domains, theseDoamins...) + } + } + } + + // Delete the address sections as we'll assign add addresses + // under the network section. + n.Config.DeleteSection("Address") + + // Get the network section to modify it. + sec, err := n.Config.GetSection("Network") + if err != nil { + return err + } + + // Delete existing fields. + sec.DeleteKey("Address") + sec.DeleteKey("Gateway") + sec.DeleteKey("DNS") + sec.DeleteKey("Domains") + + // Update section with addresses and network configs discovered. + for _, addr := range addrs { + sec.NewKey("Address", addr.String()) + } + if gateway4 != nil { + sec.NewKey("Gateway", gateway4.String()) + } + if gateway6 != nil { + sec.NewKey("Gateway", gateway6.String()) + } + for _, d := range dns { + sec.NewKey("DNS", d) + } + sec.NewKey("Domains", strings.Join(domains, " ")) + + // Save the network. + err = n.Save(nd.configDir, nd.backupRetention) + if err != nil { + return err + } + } + + return nil +} + +// Set static routes to interface. +func (nd *networkd) SetIfaceRoutes(_ context.Context, iface string, routes []*Route) (err error) { + for _, n := range nd.Networks { + if n.Name != iface { + continue + } + + // Delete existing routes from the config. + n.Config.DeleteSection("Route") + + // Add routes. + for _, route := range routes { + sec, err := n.Config.NewSection("Route") + if err != nil { + return err + } + + sec.NewKey("Destination", route.Destination.String()) + sec.NewKey("Gateway", route.Gateway.String()) + sec.NewKey("Metric", strconv.Itoa(route.Metric)) + } + + // Save the network. + err = n.Save(nd.configDir, nd.backupRetention) + if err != nil { + return err + } + } + return nil +} + +// Set DNS servers and search domains on interface. +func (nd *networkd) SetIfaceDNS(_ context.Context, iface string, servers []net.IP, searchDomains []string) (err error) { + for _, n := range nd.Networks { + if n.Name != iface { + continue + } + + // DNS may be split across the Network and Address sections; the + // canonical place is the Network section, so clear both and rewrite + // the Network section. + for _, sec := range n.Config.Sections() { + switch sec.Name() { + case "Network", "Address": + sec.DeleteKey("DNS") + sec.DeleteKey("Domains") + } + } + + // Get the network section to modify it. + sec, serr := n.Config.GetSection("Network") + if serr != nil { + return serr + } + for _, d := range ipStrings(servers) { + sec.NewKey("DNS", d) + } + if len(searchDomains) != 0 { + sec.NewKey("Domains", strings.Join(searchDomains, " ")) + } + + // Save the network. + err = n.Save(nd.configDir, nd.backupRetention) + if err != nil { + return err + } + } + return nil +} diff --git a/networkd_test.go b/networkd_test.go new file mode 100644 index 0000000..b3beb32 --- /dev/null +++ b/networkd_test.go @@ -0,0 +1,173 @@ +package netconfig + +import ( + "context" + "net" + "os" + "path/filepath" + "strings" + "testing" +) + +// Validate the networkd configuration parser/writer functions. +func TestNetworkd(t *testing.T) { + // Setup test file. + tmpDir, err := os.MkdirTemp("", "") + if err != nil { + t.Fatal(err) + } + testDir, err := filepath.Abs("./tests/networkd") + if err != nil { + t.Fatal(err) + } + resultsDir := filepath.Join(testDir, "results") + + // Try to read the test directory for files to copy to our temporary directory. + entries, err := os.ReadDir(testDir) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if !strings.HasSuffix(entry.Name(), ".network") { + continue + } + + configPath := filepath.Join(testDir, entry.Name()) + tmpPath := filepath.Join(tmpDir, entry.Name()) + err = fileCopy(configPath, tmpPath) + if err != nil { + t.Fatal(err) + } + } + + // Setup ifupdown and parse test file. + n, err := readNetworkdConfigDirectory(tmpDir) + if err != nil { + t.Fatal(err) + } + + // Get the interfaces state. + interfaces, err := n.GetInterfaces() + if err != nil { + t.Fatal(err) + } + + // Verify interfaces read from file. + err = testVerifyInterfaces(interfaces, resultsDir, 1) + if err != nil { + t.Error(err) + } + + // Test setting the IP addresses on an interface. + err = n.SetIfaceAddresses(context.Background(), "test_eth0.1556", []*net.IPNet{ + { + IP: net.ParseIP("1.2.3.4"), + Mask: net.CIDRMask(24, 32), + }, + { + IP: net.ParseIP("1.2.3.43"), + Mask: net.CIDRMask(24, 32), + }, + { + IP: net.ParseIP("fc00::2"), + Mask: net.CIDRMask(64, 128), + }, + }, net.ParseIP("1.2.3.1"), net.ParseIP("fc00::1")) + if err != nil { + t.Fatal(err) + } + + // Test setting routes on an interface. + err = n.SetIfaceRoutes(context.Background(), "test_eth2", []*Route{ + { + Destination: &net.IPNet{ + IP: net.ParseIP("abcd:ef12:3455:10::"), + Mask: net.CIDRMask(64, 128), + }, + Gateway: net.ParseIP("abcd:ef12:3456:10::1"), + Metric: 100, + }, + { + Destination: &net.IPNet{ + IP: net.ParseIP("10.253.2.0"), + Mask: net.CIDRMask(24, 32), + }, + Gateway: net.ParseIP("203.0.113.22"), + Metric: 100, + }, + }) + if err != nil { + t.Fatal(err) + } + + // Verify we can re-read configurations. + n, err = readNetworkdConfigDirectory(tmpDir) + if err != nil { + t.Fatal(err) + } + + // Get the interfaces state. + interfaces, err = n.GetInterfaces() + if err != nil { + t.Fatal(err) + } + + // Verify interfaces read from file. + err = testVerifyInterfaces(interfaces, resultsDir, 2) + if err != nil { + t.Error(err) + } + + // Read the current file and expected state. + err = testVerifyResults(resultsDir, tmpDir, 1) + if err != nil { + t.Error(err) + } + + // Test setting the IP addresses on an interface. + err = n.SetIfaceAddresses(context.Background(), "test_eth0", []*net.IPNet{ + { + IP: net.ParseIP("1.2.10.4"), + Mask: net.CIDRMask(24, 32), + }, + }, net.ParseIP("1.2.10.254"), nil) + if err != nil { + t.Fatal(err) + } + + // Test setting routes on an interface. + err = n.SetIfaceRoutes(context.Background(), "test_eth0.1556", []*Route{}) + if err != nil { + t.Fatal(err) + } + + // Verify we can re-read configurations. + n, err = readNetworkdConfigDirectory(tmpDir) + if err != nil { + t.Fatal(err) + } + + // Get the interfaces state. + interfaces, err = n.GetInterfaces() + if err != nil { + t.Fatal(err) + } + + // Verify interfaces read from file. + err = testVerifyInterfaces(interfaces, resultsDir, 3) + if err != nil { + t.Error(err) + } + + // Read the current file and expected state. + err = testVerifyResults(resultsDir, tmpDir, 2) + if err != nil { + t.Error(err) + } + + // Cleanup. + err = os.RemoveAll(tmpDir) + if err != nil { + t.Fatal(err) + } +} diff --git a/options.go b/options.go new file mode 100644 index 0000000..8a424f4 --- /dev/null +++ b/options.go @@ -0,0 +1,195 @@ +package netconfig + +import ( + "time" + + log "github.com/sirupsen/logrus" +) + +// Defaults for the tunable settings. They can be overridden with the +// corresponding With* Option when constructing a Configurator. +const ( + // defaultConnectivityTimeout bounds the HTTP reachability test run after an + // address or gateway change so it cannot stall the rollback logic. + defaultConnectivityTimeout = 10 * time.Second + // defaultPingCount is the number of ICMP probes used to confirm a gateway or + // candidate address is reachable. + defaultPingCount = 5 + // defaultPingTimeout bounds a single ping run. + defaultPingTimeout = 20 * time.Second + // defaultBackupRetention is how many .bak.* copies are kept per original + // configuration file. Older backups beyond this number are pruned on save. + defaultBackupRetention = 5 +) + +// Logger is the minimal logging surface used across the package. It is +// satisfied by the standard library *log.Logger, logrus, and most other +// logging libraries, so callers can plug in their own logger via SetLogger. +type Logger interface { + Printf(format string, args ...interface{}) + Println(args ...interface{}) +} + +// logger is the package-wide logger. It defaults to the logrus standard +// logger and can be replaced with SetLogger. It is package scoped because the +// configuration backends and control panels log independently of any single +// Configurator instance. +var logger Logger = log.StandardLogger() + +// SetLogger replaces the package-wide logger used for non-fatal diagnostics. +// It is package scoped (not a per-Configurator option) because the configuration +// backends and control panels log independently of any single Configurator +// instance; the setting therefore applies to every Configurator in the process. +// A nil logger is ignored. +func SetLogger(l Logger) { + if l != nil { + logger = l + } +} + +// configOptions holds the tunable settings applied when constructing a +// Configurator. Use the With* Option helpers to set them. +type configOptions struct { + // testAddress is the URL fetched to confirm connectivity after an + // address or gateway change. + testAddress string + // skipConnectivityCheck disables the post-change internet reachability + // test and its automatic rollback. + skipConnectivityCheck bool + // connectivityTimeout bounds the HTTP reachability test. + connectivityTimeout time.Duration + // pingCount is the number of ICMP probes per ping test. + pingCount int + // pingTimeout bounds a single ping run. + pingTimeout time.Duration + // backupRetention is the number of .bak.* copies kept per original + // configuration file; <= 0 disables pruning (keeps all backups). + backupRetention int + // allowPrimaryRemoval lets RemoveAddress remove an address that is the + // primary of its family, which is otherwise refused. + allowPrimaryRemoval bool + // skipPanels makes RemoveAddress bypass all control-panel backends + // (cPanel/Plesk/InterWorx) and only remove the address from the running + // system and the network-manager configuration files. + skipPanels bool +} + +// defaultConfigOptions returns the options used when no Option is supplied. +func defaultConfigOptions() *configOptions { + return &configOptions{ + testAddress: defaultInternetTestAddress, + connectivityTimeout: defaultConnectivityTimeout, + pingCount: defaultPingCount, + pingTimeout: defaultPingTimeout, + backupRetention: defaultBackupRetention, + } +} + +// newConfigOptions builds a configOptions from the supplied options, starting +// from the package defaults. +func newConfigOptions(opts ...Option) *configOptions { + o := defaultConfigOptions() + for _, opt := range opts { + if opt != nil { + opt(o) + } + } + return o +} + +// Option configures a Configurator constructed with NewConfigurator. +type Option func(*configOptions) + +// WithTestAddress overrides the URL used for the post-change connectivity +// test. The two sentinel values "test_success" and "test_fail" force the test +// to pass or fail without performing a request, which is useful in tests. +func WithTestAddress(addr string) Option { + return func(o *configOptions) { + if addr != "" { + o.testAddress = addr + } + } +} + +// WithConnectivityCheck enables or disables the post-change internet +// reachability test (and the automatic rollback that depends on it). It is +// enabled by default; pass false to skip it, for example on hosts with no +// outbound internet access. +func WithConnectivityCheck(enabled bool) Option { + return func(o *configOptions) { + o.skipConnectivityCheck = !enabled + } +} + +// WithSkipConnectivityCheck is a convenience option that disables the post-change +// internet reachability test and its automatic rollback. It is equivalent to +// WithConnectivityCheck(false). +func WithSkipConnectivityCheck() Option { + return WithConnectivityCheck(false) +} + +// WithConnectivityTimeout sets the timeout for the HTTP reachability test run +// after an address or gateway change. A smaller value fails faster on a +// black-holed route; a larger value tolerates slower links. +func WithConnectivityTimeout(d time.Duration) Option { + return func(o *configOptions) { + if d > 0 { + o.connectivityTimeout = d + } + } +} + +// WithPingCount sets the number of ICMP probes sent when confirming that a +// candidate address or gateway is reachable. More probes are more reliable on +// lossy links but take longer. +func WithPingCount(n int) Option { + return func(o *configOptions) { + if n > 0 { + o.pingCount = n + } + } +} + +// WithPingTimeout sets the overall timeout for a single ping run. +func WithPingTimeout(d time.Duration) Option { + return func(o *configOptions) { + if d > 0 { + o.pingTimeout = d + } + } +} + +// WithBackupRetention sets how many .bak.* copies are kept per original +// configuration file. The oldest backups beyond this number are pruned each +// time a backend saves. Pass 0 (or a negative value) to disable pruning and +// keep every backup. +func WithBackupRetention(n int) Option { + return func(o *configOptions) { + o.backupRetention = n + } +} + +// WithAllowPrimaryRemoval permits RemoveAddress to remove an address that is +// the primary of its family on its interface. By default removing the primary +// is refused so a caller cannot accidentally change the system's source +// address or leave a control panel without a main IP; the intended flow is to +// SetPrimaryAddress on another IP first. Enable this only when you are +// deliberately tearing down the current primary. +func WithAllowPrimaryRemoval(allowed bool) Option { + return func(o *configOptions) { + o.allowPrimaryRemoval = allowed + } +} + +// WithSkipPanels makes the configurator skip all control-panel backends +// (cPanel, Plesk, InterWorx) during mutating operations. When enabled, +// AddAddress, SetPrimaryAddress, and RemoveAddress do not tell the panels to +// reload, set the main IP, or release an IP; only the running system and the +// network-manager configuration files are changed. This is intended for +// maintenance paths where the panel must be left untouched, or where the panel +// is expected to be reconciled separately. +func WithSkipPanels(skip bool) Option { + return func(o *configOptions) { + o.skipPanels = skip + } +} diff --git a/options_test.go b/options_test.go new file mode 100644 index 0000000..cdf912e --- /dev/null +++ b/options_test.go @@ -0,0 +1,225 @@ +package netconfig + +import ( + "context" + "errors" + "net" + "os" + "path/filepath" + "testing" + "time" +) + +// captureLogger records the last formatted message so SetLogger can be +// verified. +type captureLogger struct { + printfCalls int + printlnCalls int +} + +func (c *captureLogger) Printf(format string, args ...interface{}) { c.printfCalls++ } +func (c *captureLogger) Println(args ...interface{}) { c.printlnCalls++ } + +func TestConfigOptionsDefaults(t *testing.T) { + o := newConfigOptions() + if o.testAddress != defaultInternetTestAddress { + t.Errorf("default testAddress = %q, want %q", o.testAddress, defaultInternetTestAddress) + } + if o.skipConnectivityCheck { + t.Error("skipConnectivityCheck = true, want false by default") + } + if o.connectivityTimeout != defaultConnectivityTimeout { + t.Errorf("default connectivityTimeout = %v, want %v", o.connectivityTimeout, defaultConnectivityTimeout) + } + if o.pingCount != defaultPingCount { + t.Errorf("default pingCount = %d, want %d", o.pingCount, defaultPingCount) + } + if o.pingTimeout != defaultPingTimeout { + t.Errorf("default pingTimeout = %v, want %v", o.pingTimeout, defaultPingTimeout) + } + if o.backupRetention != defaultBackupRetention { + t.Errorf("default backupRetention = %d, want %d", o.backupRetention, defaultBackupRetention) + } + if o.allowPrimaryRemoval { + t.Error("allowPrimaryRemoval = true, want false by default") + } + if o.skipPanels { + t.Error("skipPanels = true, want false by default") + } +} + +func TestWithTestAddress(t *testing.T) { + o := newConfigOptions(WithTestAddress("http://canary.example/health")) + if o.testAddress != "http://canary.example/health" { + t.Errorf("testAddress = %q, want the overridden value", o.testAddress) + } + // An empty address must not clobber the default. + o = newConfigOptions(WithTestAddress("")) + if o.testAddress != defaultInternetTestAddress { + t.Errorf("empty WithTestAddress changed default to %q", o.testAddress) + } +} + +func TestWithConnectivityCheck(t *testing.T) { + if o := newConfigOptions(WithConnectivityCheck(false)); !o.skipConnectivityCheck { + t.Error("WithConnectivityCheck(false) did not set skipConnectivityCheck") + } + if o := newConfigOptions(WithConnectivityCheck(true)); o.skipConnectivityCheck { + t.Error("WithConnectivityCheck(true) set skipConnectivityCheck") + } +} + +func TestWithSkipConnectivityCheck(t *testing.T) { + if o := newConfigOptions(WithSkipConnectivityCheck()); !o.skipConnectivityCheck { + t.Error("WithSkipConnectivityCheck did not set skipConnectivityCheck") + } +} + +func TestSetLogger(t *testing.T) { + prev := logger + defer func() { logger = prev }() + + cap := &captureLogger{} + SetLogger(cap) + if logger != cap { + t.Fatal("SetLogger did not replace the package logger") + } + logger.Printf("hi %d", 1) + logger.Println("hi") + if cap.printfCalls != 1 || cap.printlnCalls != 1 { + t.Errorf("logger not used: printf=%d println=%d", cap.printfCalls, cap.printlnCalls) + } + + // A nil logger must be ignored, leaving the current logger in place. + SetLogger(nil) + if logger != cap { + t.Error("SetLogger(nil) replaced the logger") + } +} + +func TestWithConnectivityTimeout(t *testing.T) { + if o := newConfigOptions(WithConnectivityTimeout(5 * time.Second)); o.connectivityTimeout != 5*time.Second { + t.Errorf("connectivityTimeout = %v, want 5s", o.connectivityTimeout) + } + // Non-positive values must not clobber the default. + if o := newConfigOptions(WithConnectivityTimeout(0)); o.connectivityTimeout != defaultConnectivityTimeout { + t.Errorf("zero WithConnectivityTimeout changed default to %v", o.connectivityTimeout) + } +} + +func TestWithPingCount(t *testing.T) { + if o := newConfigOptions(WithPingCount(3)); o.pingCount != 3 { + t.Errorf("pingCount = %d, want 3", o.pingCount) + } + // Non-positive values must not clobber the default. + if o := newConfigOptions(WithPingCount(0)); o.pingCount != defaultPingCount { + t.Errorf("zero WithPingCount changed default to %d", o.pingCount) + } +} + +func TestWithPingTimeout(t *testing.T) { + if o := newConfigOptions(WithPingTimeout(30 * time.Second)); o.pingTimeout != 30*time.Second { + t.Errorf("pingTimeout = %v, want 30s", o.pingTimeout) + } + if o := newConfigOptions(WithPingTimeout(0)); o.pingTimeout != defaultPingTimeout { + t.Errorf("zero WithPingTimeout changed default to %v", o.pingTimeout) + } +} + +func TestWithBackupRetention(t *testing.T) { + if o := newConfigOptions(WithBackupRetention(10)); o.backupRetention != 10 { + t.Errorf("backupRetention = %d, want 10", o.backupRetention) + } + // Zero/negative disables pruning (keeps all backups). + if o := newConfigOptions(WithBackupRetention(0)); o.backupRetention != 0 { + t.Errorf("backupRetention = %d, want 0", o.backupRetention) + } +} + +func TestWithAllowPrimaryRemoval(t *testing.T) { + if o := newConfigOptions(WithAllowPrimaryRemoval(true)); !o.allowPrimaryRemoval { + t.Error("WithAllowPrimaryRemoval(true) did not set allowPrimaryRemoval") + } +} + +func TestWithSkipPanels(t *testing.T) { + if o := newConfigOptions(WithSkipPanels(true)); !o.skipPanels { + t.Error("WithSkipPanels(true) did not set skipPanels") + } + if o := newConfigOptions(WithSkipPanels(false)); o.skipPanels { + t.Error("WithSkipPanels(false) set skipPanels") + } +} + +// applyIfaceDNS must invoke every backend even when an earlier one fails. +func TestApplyIfaceDNS(t *testing.T) { + failing := &fakeIfaceBackend{err: errors.New("boom")} + ok := &fakeIfaceBackend{} + backends := []namedIfaceBackend{ + {name: "failing", backend: failing}, + {name: "ok", backend: ok}, + } + applyIfaceDNS(context.Background(), backends, "eth0", []net.IP{net.ParseIP("8.8.8.8")}, []string{"example.com"}) + if failing.dnsCalls != 1 || ok.dnsCalls != 1 { + t.Errorf("dns calls: failing=%d ok=%d, want 1 each", failing.dnsCalls, ok.dnsCalls) + } +} + +func TestIPStrings(t *testing.T) { + got := ipStrings([]net.IP{net.ParseIP("8.8.8.8"), nil, net.ParseIP("2001:4860:4860::8888")}) + want := []string{"8.8.8.8", "2001:4860:4860::8888"} + if len(got) != len(want) { + t.Fatalf("ipStrings = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("ipStrings[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +// TestNetplanDNS round-trips DNS through the netplan backend: SetIfaceDNS writes +// the nameservers block and GetInterfaces parses it back. +func TestNetplanDNS(t *testing.T) { + tmpDir := t.TempDir() + config := "network:\n version: 2\n ethernets:\n eth0:\n addresses: [192.0.2.10/24]\n" + if err := os.WriteFile(filepath.Join(tmpDir, "01-test.yaml"), []byte(config), 0644); err != nil { + t.Fatal(err) + } + + np, err := readNetplanConfigDirectory(tmpDir) + if err != nil { + t.Fatal(err) + } + + servers := []net.IP{net.ParseIP("8.8.8.8"), net.ParseIP("2001:4860:4860::8888")} + if err := np.SetIfaceDNS(context.Background(), "eth0", servers, []string{"example.com", "corp.example"}); err != nil { + t.Fatal(err) + } + + // Re-read from disk so we exercise the written config, not in-memory state. + np, err = readNetplanConfigDirectory(tmpDir) + if err != nil { + t.Fatal(err) + } + ifaces, err := np.GetInterfaces() + if err != nil { + t.Fatal(err) + } + + var eth0 *Interface + for _, i := range ifaces { + if i.Name == "eth0" { + eth0 = i + } + } + if eth0 == nil { + t.Fatal("eth0 not found after SetIfaceDNS") + } + if len(eth0.DNS) != 2 || !eth0.DNS[0].Equal(servers[0]) || !eth0.DNS[1].Equal(servers[1]) { + t.Errorf("DNS = %v, want %v", eth0.DNS, servers) + } + if len(eth0.SearchDomains) != 2 || eth0.SearchDomains[0] != "example.com" || eth0.SearchDomains[1] != "corp.example" { + t.Errorf("SearchDomains = %v, want [example.com corp.example]", eth0.SearchDomains) + } +} diff --git a/plesk_linux.go b/plesk_linux.go new file mode 100644 index 0000000..439a20c --- /dev/null +++ b/plesk_linux.go @@ -0,0 +1,267 @@ +package netconfig + +import ( + "context" + "fmt" + "net" + "regexp" + "strconv" + "strings" +) + +const pleskBin = "/usr/sbin/plesk" + +type plesk struct { +} + +// parsePleskIPField extracts the IP address from a field produced by +// `plesk bin ipmanage --ip_list`. The field has the form +// ":[/]"; missing components result in a nil IP so the +// line is skipped rather than crashing the process. +func parsePleskIPField(s string) net.IP { + // Drop an optional "/". + if idx := strings.Index(s, "/"); idx != -1 { + s = s[:idx] + } + // The field may be just the IP, or ":". Try the whole + // string first; if it does not parse, drop the interface prefix up to + // the first colon. IPv6 addresses contain colons, so LastIndex would + // truncate the final hextet. + if ip := net.ParseIP(s); ip != nil { + return ip + } + if idx := strings.Index(s, ":"); idx != -1 { + s = s[idx+1:] + // If the IP was compressed with a leading "::", the interface + // prefix consumed one of the leading colons (e.g. "eth0::1"). + if strings.HasPrefix(s, ":") { + s = ":" + s + } + return net.ParseIP(s) + } + return nil +} + +// Move sites off the old IP to a new IP. +func (*plesk) moveSites(ctx context.Context, oldAddr, newAddr net.IP) error { + // We need the IP type. + isIPv6 := oldAddr.To4() == nil + + // Get list of IP addresses from plesk. + domains, err := runCommand(ctx, pleskBin, "bin", "domain", "--list") + if err != nil { + return err + } + + // Domain info parser. + dataParse := regexp.MustCompile(`^([^:]+):\s*([^$]+)$`) + + // Check each domain for the old IP, and update to either remove or move to the new IP. + for _, domain := range domains { + // Get the domain info. + out, err := runCommand(ctx, pleskBin, "bin", "domain", "--info", domain) + if err != nil { + return err + } + + // Parse the domain info IP addresses. + var domainIPv4 []net.IP + var domainIPv6 []net.IP + for _, line := range out { + // Parse and skip lines that do not match. + match := dataParse.FindAllStringSubmatch(line, 1) + if len(match) == 0 { + continue + } + + // Get the key/value from the parsed info. + key := strings.TrimSpace(match[0][1]) + value := strings.TrimSpace(match[0][2]) + + // If this is the IP address configuration, parse the IP addresses assigned. + if key == "IP Address" { + ipAddrs := strings.Split(value, ", ") + for _, ipAddr := range ipAddrs { + ip := net.ParseIP(ipAddr) + if ip.To4() == nil { + domainIPv6 = append(domainIPv6, ip) + } else { + domainIPv4 = append(domainIPv4, ip) + } + } + } + } + + // Look for the old IP according to its type, and remove and or replace with the new IP if there are no other + // IP addresses of its type. + foundOld := false + if isIPv6 { + for i, ip := range domainIPv6 { + if ip.Equal(oldAddr) { + foundOld = true + domainIPv6 = append(domainIPv6[:i], domainIPv6[i+1:]...) + } + } + if foundOld && len(domainIPv6) == 0 { + domainIPv6 = append(domainIPv6, newAddr) + } + } else { + for i, ip := range domainIPv4 { + if ip.Equal(oldAddr) { + foundOld = true + domainIPv4 = append(domainIPv4[:i], domainIPv4[i+1:]...) + } + } + if foundOld && len(domainIPv4) == 0 { + domainIPv4 = append(domainIPv4, newAddr) + } + } + + // If we found the old IP, run the domain update command to update the sites association. + if foundOld { + var domainIPs []string + for _, ip := range domainIPv4 { + domainIPs = append(domainIPs, ip.String()) + } + for _, ip := range domainIPv6 { + domainIPs = append(domainIPs, ip.String()) + } + out, err := runCommand(ctx, pleskBin, "bin", "domain", "--update", domain, "-ip", strings.Join(domainIPs, ",")) + if err != nil { + return err + } + logger.Println("Plesk domain output:", strings.Join(out, "\n")) + } + } + + return nil +} + +// Remove an IP address from Plesk. +func (p *plesk) removeIP(ctx context.Context, addr net.IP) error { + // We need the IP type. + isIPv6 := addr.To4() == nil + + // Get list of IP addresses from plesk. + out, err := runCommand(ctx, pleskBin, "bin", "ipmanage", "--ip_list") + if err != nil { + return err + } + + // We should capture these fields from the list. + ipHosting := 0 + var sharedIP net.IP + var publicIPs []net.IP + var privateIPs []net.IP + + // Parse each line returned from plesk. + for _, line := range out { + fields := strings.Fields(line) + + // Skip blank or malformed lines that lack the fields parsed below. + if len(fields) < 5 { + continue + } + + // Skip the header line. + if fields[2] == "IP" { + continue + } + + // Parse the IP field. + ip := parsePleskIPField(fields[2]) + if ip == nil { + continue + } + + // Skip IP addresses of a different type. + if ip.To4() == nil && !isIPv6 { + continue + } else if ip.To4() != nil && isIPv6 { + continue + } + + // Depending on IP match, apply to fields. When we find the address we are removing, + // we should determine how may sites are hosted by the IP. If the type is an shared iP, + // set the shared IP to it. Otherwise, associate other IP types to public/private IP + // lists to allow us to select an IP where a shared IP doesn't exist. + if ip.Equal(addr) { + ipHosting, _ = strconv.Atoi(fields[4]) + } else if fields[1] == "S" { + sharedIP = ip + } else if !ip.IsPrivate() { + publicIPs = append(publicIPs, ip) + } else { + if len(fields) == 6 { + pubIP := net.ParseIP(fields[5]) + if pubIP != nil { + publicIPs = append(publicIPs, ip) + } else { + privateIPs = append(privateIPs, ip) + } + } else { + privateIPs = append(privateIPs, ip) + } + } + } + + // If no shared IP, try public IP and private IP association instead. + if sharedIP == nil { + if len(publicIPs) != 0 { + sharedIP = publicIPs[0] + } else if len(privateIPs) != 0 { + sharedIP = privateIPs[0] + } else if ipHosting != 0 { + return fmt.Errorf("unable to find a shared IP to move ip addresses to") + } + } + + // If the IP we are removing is hosting sites, move the sites to the shared IP determined above. + if ipHosting != 0 { + err := p.moveSites(ctx, addr, sharedIP) + if err != nil { + return err + } + } + + // Remove the IP address from plesk. + out, err = runCommand(ctx, pleskBin, "bin", "ipmanage", "--remove", addr.String()) + if err != nil { + return err + } + logger.Println("Plesk output:", strings.Join(out, "\n")) + return nil +} + +// setMainIP repoints Plesk's main IP to addr. There is no `plesk bin ipmanage` +// flag for this, so the main flag is set directly in the psa database's +// IP_Addresses table via `plesk db`, which authenticates automatically. The new +// address is promoted first so there is never a window with no main IP, then +// every other address is demoted, leaving exactly one main row. +func (p *plesk) setMainIP(ctx context.Context, addr net.IP) error { + ip := addr.String() + queries := []string{ + fmt.Sprintf("UPDATE IP_Addresses SET main='true' WHERE ip_address='%s'", ip), + fmt.Sprintf("UPDATE IP_Addresses SET main='false' WHERE ip_address<>'%s'", ip), + } + for _, query := range queries { + out, err := runCommand(ctx, pleskBin, "db", query) + if err != nil { + return err + } + if len(out) != 0 { + logger.Println("Plesk db output:", strings.Join(out, "\n")) + } + } + return nil +} + +// Tell plesk to reload the available IP addresses. +func (*plesk) reload(ctx context.Context) error { + out, err := runCommand(ctx, pleskBin, "bin", "ipmanage", "--reread") + if err != nil { + return err + } + logger.Println("Plesk output:", strings.Join(out, "\n")) + return nil +} diff --git a/plesk_windows.go b/plesk_windows.go new file mode 100644 index 0000000..3f05526 --- /dev/null +++ b/plesk_windows.go @@ -0,0 +1,267 @@ +package netconfig + +import ( + "context" + "fmt" + "net" + "regexp" + "strconv" + "strings" +) + +const pleskBin = `C:\Program Files (x86)\Plesk\bin\plesk.exe` + +type plesk struct { +} + +// parsePleskIPField extracts the IP address from a field produced by +// `plesk bin ipmanage --ip_list`. The field has the form +// ":[/]"; missing components result in a nil IP so the +// line is skipped rather than crashing the process. +func parsePleskIPField(s string) net.IP { + // Drop an optional "/". + if idx := strings.Index(s, "/"); idx != -1 { + s = s[:idx] + } + // The field may be just the IP, or ":". Try the whole + // string first; if it does not parse, drop the interface prefix up to + // the first colon. IPv6 addresses contain colons, so LastIndex would + // truncate the final hextet. + if ip := net.ParseIP(s); ip != nil { + return ip + } + if idx := strings.Index(s, ":"); idx != -1 { + s = s[idx+1:] + // If the IP was compressed with a leading "::", the interface + // prefix consumed one of the leading colons (e.g. "eth0::1"). + if strings.HasPrefix(s, ":") { + s = ":" + s + } + return net.ParseIP(s) + } + return nil +} + +// Move sites off the old IP to a new IP. +func (*plesk) moveSites(ctx context.Context, oldAddr, newAddr net.IP) error { + // We need the IP type. + isIPv6 := oldAddr.To4() == nil + + // Get list of IP addresses from plesk. + domains, err := runCommand(ctx, pleskBin, "bin", "domain", "--list") + if err != nil { + return err + } + + // Domain info parser. + dataParse := regexp.MustCompile(`^([^:]+):\s*([^$]+)$`) + + // Check each domain for the old IP, and update to either remove or move to the new IP. + for _, domain := range domains { + // Get the domain info. + out, err := runCommand(ctx, pleskBin, "bin", "domain", "--info", domain) + if err != nil { + return err + } + + // Parse the domain info IP addresses. + var domainIPv4 []net.IP + var domainIPv6 []net.IP + for _, line := range out { + // Parse and skip lines that do not match. + match := dataParse.FindAllStringSubmatch(line, 1) + if len(match) == 0 { + continue + } + + // Get the key/value from the parsed info. + key := strings.TrimSpace(match[0][1]) + value := strings.TrimSpace(match[0][2]) + + // If this is the IP address configuration, parse the IP addresses assigned. + if key == "IP Address" { + ipAddrs := strings.Split(value, ", ") + for _, ipAddr := range ipAddrs { + ip := net.ParseIP(ipAddr) + if ip.To4() == nil { + domainIPv6 = append(domainIPv6, ip) + } else { + domainIPv4 = append(domainIPv4, ip) + } + } + } + } + + // Look for the old IP according to its type, and remove and or replace with the new IP if there are no other + // IP addresses of its type. + foundOld := false + if isIPv6 { + for i, ip := range domainIPv6 { + if ip.Equal(oldAddr) { + foundOld = true + domainIPv6 = append(domainIPv6[:i], domainIPv6[i+1:]...) + } + } + if foundOld && len(domainIPv6) == 0 { + domainIPv6 = append(domainIPv6, newAddr) + } + } else { + for i, ip := range domainIPv4 { + if ip.Equal(oldAddr) { + foundOld = true + domainIPv4 = append(domainIPv4[:i], domainIPv4[i+1:]...) + } + } + if foundOld && len(domainIPv4) == 0 { + domainIPv4 = append(domainIPv4, newAddr) + } + } + + // If we found the old IP, run the domain update command to update the sites association. + if foundOld { + var domainIPs []string + for _, ip := range domainIPv4 { + domainIPs = append(domainIPs, ip.String()) + } + for _, ip := range domainIPv6 { + domainIPs = append(domainIPs, ip.String()) + } + out, err := runCommand(ctx, pleskBin, "bin", "domain", "--update", domain, "-ip", strings.Join(domainIPs, ",")) + if err != nil { + return err + } + logger.Println("Plesk domain output:", strings.Join(out, "\n")) + } + } + + return nil +} + +// Remove an IP address from Plesk. +func (p *plesk) removeIP(ctx context.Context, addr net.IP) error { + // We need the IP type. + isIPv6 := addr.To4() == nil + + // Get list of IP addresses from plesk. + out, err := runCommand(ctx, pleskBin, "bin", "ipmanage", "--ip_list") + if err != nil { + return err + } + + // We should capture these fields from the list. + ipHosting := 0 + var sharedIP net.IP + var publicIPs []net.IP + var privateIPs []net.IP + + // Parse each line returned from plesk. + for _, line := range out { + fields := strings.Fields(line) + + // Skip blank or malformed lines that lack the fields parsed below. + if len(fields) < 5 { + continue + } + + // Skip the header line. + if fields[2] == "IP" { + continue + } + + // Parse the IP field. + ip := parsePleskIPField(fields[2]) + if ip == nil { + continue + } + + // Skip IP addresses of a different type. + if ip.To4() == nil && !isIPv6 { + continue + } else if ip.To4() != nil && isIPv6 { + continue + } + + // Depending on IP match, apply to fields. When we find the address we are removing, + // we should determine how may sites are hosted by the IP. If the type is an shared iP, + // set the shared IP to it. Otherwise, associate other IP types to public/private IP + // lists to allow us to select an IP where a shared IP doesn't exist. + if ip.Equal(addr) { + ipHosting, _ = strconv.Atoi(fields[4]) + } else if fields[1] == "S" { + sharedIP = ip + } else if !ip.IsPrivate() { + publicIPs = append(publicIPs, ip) + } else { + if len(fields) == 6 { + pubIP := net.ParseIP(fields[5]) + if pubIP != nil { + publicIPs = append(publicIPs, ip) + } else { + privateIPs = append(privateIPs, ip) + } + } else { + privateIPs = append(privateIPs, ip) + } + } + } + + // If no shared IP, try public IP and private IP association instead. + if sharedIP == nil { + if len(publicIPs) != 0 { + sharedIP = publicIPs[0] + } else if len(privateIPs) != 0 { + sharedIP = privateIPs[0] + } else if ipHosting != 0 { + return fmt.Errorf("unable to find a shared IP to move ip addresses to") + } + } + + // If the IP we are removing is hosting sites, move the sites to the shared IP determined above. + if ipHosting != 0 { + err := p.moveSites(ctx, addr, sharedIP) + if err != nil { + return err + } + } + + // Remove the IP address from plesk. + out, err = runCommand(ctx, pleskBin, "bin", "ipmanage", "--remove", addr.String()) + if err != nil { + return err + } + logger.Println("Plesk output:", strings.Join(out, "\n")) + return nil +} + +// setMainIP repoints Plesk's main IP to addr. There is no `plesk bin ipmanage` +// flag for this, so the main flag is set directly in the psa database's +// IP_Addresses table via `plesk db`, which authenticates automatically. The new +// address is promoted first so there is never a window with no main IP, then +// every other address is demoted, leaving exactly one main row. +func (p *plesk) setMainIP(ctx context.Context, addr net.IP) error { + ip := addr.String() + queries := []string{ + fmt.Sprintf("UPDATE IP_Addresses SET main='true' WHERE ip_address='%s'", ip), + fmt.Sprintf("UPDATE IP_Addresses SET main='false' WHERE ip_address<>'%s'", ip), + } + for _, query := range queries { + out, err := runCommand(ctx, pleskBin, "db", query) + if err != nil { + return err + } + if len(out) != 0 { + logger.Println("Plesk db output:", strings.Join(out, "\n")) + } + } + return nil +} + +// Tell plesk to reload the available IP addresses. +func (*plesk) reload(ctx context.Context) error { + out, err := runCommand(ctx, pleskBin, "bin", "ipmanage", "--reread") + if err != nil { + return err + } + logger.Println("Plesk output:", strings.Join(out, "\n")) + return nil +} diff --git a/tests/cloudinit/cloudbase/network.json b/tests/cloudinit/cloudbase/network.json new file mode 100644 index 0000000..4b765fd --- /dev/null +++ b/tests/cloudinit/cloudbase/network.json @@ -0,0 +1,108 @@ +{ + "config": [ + { + "id": "test_eth0", + "mac_address": "52:54:00:e8:53:b5", + "mtu": 1500, + "name": "test_eth0", + "subnets": [ + { + "type": "manual" + } + ], + "type": "physical" + }, + { + "id": "test_eth0.1556", + "mtu": 1500, + "name": "test_eth0.1556", + "subnets": [ + { + "address": "1.2.3.4/24", + "dns_nameservers": [ + "10.0.0.1" + ], + "dns_search": [ + "example.com", + "maas" + ], + "gateway": "1.2.3.1", + "type": "static", + "routes": [ + { + "destination": "10.0.0.0/24", + "gateway": "1.2.3.5", + "metric": 100 + } + ] + } + ], + "type": "vlan", + "vlan_id": 1556, + "vlan_link": "eth0" + }, + { + "id": "test_eth1", + "mac_address": "52:54:00:5a:ff:31", + "mtu": 1500, + "name": "test_eth1", + "subnets": [ + { + "type": "manual" + } + ], + "type": "physical" + }, + { + "id": "test_eth1.1557", + "mtu": 1500, + "name": "test_eth1.1557", + "subnets": [ + { + "address": "fc00:aa8:7160:d9eb:1:0:1:3/64", + "dns_nameservers": [ + "10.0.0.1" + ], + "dns_search": [ + "example.com", + "maas" + ], + "type": "static" + } + ], + "type": "vlan", + "vlan_id": 1557, + "vlan_link": "eth1" + }, + { + "id": "test_eth3", + "mac_address": "52:54:00:e8:54:b5", + "mtu": 1500, + "name": "test_eth3", + "subnets": [ + { + "address": "abcd:ef12:3456:10::4/64", + "gateway": "abcd:ef12:3456:10::1", + "type": "static" + }, + { + "address": "203.0.113.2/24", + "gateway": "203.0.113.1", + "type": "static" + } + ], + "type": "physical" + }, + { + "address": [ + "10.0.0.1" + ], + "search": [ + "example.com", + "maas" + ], + "type": "nameserver" + } + ], + "version": 1 +} diff --git a/tests/cloudinit/cloudbase/results/1/network.json b/tests/cloudinit/cloudbase/results/1/network.json new file mode 100644 index 0000000..a960360 --- /dev/null +++ b/tests/cloudinit/cloudbase/results/1/network.json @@ -0,0 +1,141 @@ +{ + "config": [ + { + "mac_address": "52:54:00:e8:53:b5", + "mtu": 1500, + "name": "test_eth0", + "subnets": [ + { + "type": "manual" + } + ], + "type": "physical" + }, + { + "mtu": 1500, + "name": "test_eth0.1556", + "subnets": [ + { + "address": "1.2.3.4/24", + "dns_nameservers": [ + "10.0.0.1" + ], + "dns_search": [ + "example.com", + "maas" + ], + "gateway": "1.2.3.1", + "routes": [ + { + "destination": "10.0.0.0/24", + "gateway": "1.2.3.5", + "metric": 100 + } + ], + "type": "static" + }, + { + "address": "1.2.3.43/24", + "dns_nameservers": [ + "10.0.0.1" + ], + "dns_search": [ + "example.com", + "maas" + ], + "gateway": "1.2.3.1", + "type": "static" + }, + { + "address": "fc00::2/64", + "dns_nameservers": [ + "10.0.0.1" + ], + "dns_search": [ + "example.com", + "maas" + ], + "gateway": "fc00::1", + "type": "static" + } + ], + "type": "vlan", + "vlan_id": 1556, + "vlan_link": "eth0" + }, + { + "mac_address": "52:54:00:5a:ff:31", + "mtu": 1500, + "name": "test_eth1", + "subnets": [ + { + "type": "manual" + } + ], + "type": "physical" + }, + { + "mtu": 1500, + "name": "test_eth1.1557", + "subnets": [ + { + "address": "fc00:aa8:7160:d9eb:1:0:1:3/64", + "dns_nameservers": [ + "10.0.0.1" + ], + "dns_search": [ + "example.com", + "maas" + ], + "type": "static" + } + ], + "type": "vlan", + "vlan_id": 1557, + "vlan_link": "eth1" + }, + { + "mac_address": "52:54:00:e8:54:b5", + "mtu": 1500, + "name": "test_eth3", + "subnets": [ + { + "address": "abcd:ef12:3456:10::4/64", + "gateway": "abcd:ef12:3456:10::1", + "routes": [ + { + "destination": "abcd:ef12:3455:10::/64", + "gateway": "abcd:ef12:3456:10::1", + "metric": 100 + } + ], + "type": "static" + }, + { + "address": "203.0.113.2/24", + "gateway": "203.0.113.1", + "routes": [ + { + "destination": "10.253.2.0/24", + "gateway": "203.0.113.22", + "metric": 100 + } + ], + "type": "static" + } + ], + "type": "physical" + }, + { + "address": [ + "10.0.0.1" + ], + "search": [ + "example.com", + "maas" + ], + "type": "nameserver" + } + ], + "version": 1 +} diff --git a/tests/cloudinit/cloudbase/results/2/network.json b/tests/cloudinit/cloudbase/results/2/network.json new file mode 100644 index 0000000..9030317 --- /dev/null +++ b/tests/cloudinit/cloudbase/results/2/network.json @@ -0,0 +1,136 @@ +{ + "config": [ + { + "mac_address": "52:54:00:e8:53:b5", + "mtu": 1500, + "name": "test_eth0", + "subnets": [ + { + "address": "1.2.10.4/24", + "gateway": "1.2.10.254", + "type": "static" + } + ], + "type": "physical" + }, + { + "mtu": 1500, + "name": "test_eth0.1556", + "subnets": [ + { + "address": "1.2.3.4/24", + "dns_nameservers": [ + "10.0.0.1" + ], + "dns_search": [ + "example.com", + "maas" + ], + "gateway": "1.2.3.1", + "type": "static" + }, + { + "address": "1.2.3.43/24", + "dns_nameservers": [ + "10.0.0.1" + ], + "dns_search": [ + "example.com", + "maas" + ], + "gateway": "1.2.3.1", + "type": "static" + }, + { + "address": "fc00::2/64", + "dns_nameservers": [ + "10.0.0.1" + ], + "dns_search": [ + "example.com", + "maas" + ], + "gateway": "fc00::1", + "type": "static" + } + ], + "type": "vlan", + "vlan_id": 1556, + "vlan_link": "eth0" + }, + { + "mac_address": "52:54:00:5a:ff:31", + "mtu": 1500, + "name": "test_eth1", + "subnets": [ + { + "type": "manual" + } + ], + "type": "physical" + }, + { + "mtu": 1500, + "name": "test_eth1.1557", + "subnets": [ + { + "address": "fc00:aa8:7160:d9eb:1:0:1:3/64", + "dns_nameservers": [ + "10.0.0.1" + ], + "dns_search": [ + "example.com", + "maas" + ], + "type": "static" + } + ], + "type": "vlan", + "vlan_id": 1557, + "vlan_link": "eth1" + }, + { + "mac_address": "52:54:00:e8:54:b5", + "mtu": 1500, + "name": "test_eth3", + "subnets": [ + { + "address": "abcd:ef12:3456:10::4/64", + "gateway": "abcd:ef12:3456:10::1", + "routes": [ + { + "destination": "abcd:ef12:3455:10::/64", + "gateway": "abcd:ef12:3456:10::1", + "metric": 100 + } + ], + "type": "static" + }, + { + "address": "203.0.113.2/24", + "gateway": "203.0.113.1", + "routes": [ + { + "destination": "10.253.2.0/24", + "gateway": "203.0.113.22", + "metric": 100 + } + ], + "type": "static" + } + ], + "type": "physical" + }, + { + "address": [ + "10.0.0.1" + ], + "search": [ + "example.com", + "maas" + ], + "type": "nameserver" + } + ], + "version": 1 +} diff --git a/tests/cloudinit/cloudbase/results/interfaces/1.expected b/tests/cloudinit/cloudbase/results/interfaces/1.expected new file mode 100644 index 0000000..b70d98b --- /dev/null +++ b/tests/cloudinit/cloudbase/results/interfaces/1.expected @@ -0,0 +1,5 @@ +Name: test_eth0 MAC: 52:54:00:e8:53:b5 Addresses: [] Gateway4: Gateway6: Routes: [] +Name: test_eth0.1556 MAC: Addresses: [1.2.3.4/24] Gateway4: 1.2.3.1 Gateway6: Routes: [10.0.0.0/24 via 1.2.3.5 metric 100] +Name: test_eth1 MAC: 52:54:00:5a:ff:31 Addresses: [] Gateway4: Gateway6: Routes: [] +Name: test_eth1.1557 MAC: Addresses: [fc00:aa8:7160:d9eb:1:0:1:3/64] Gateway4: Gateway6: Routes: [] +Name: test_eth3 MAC: 52:54:00:e8:54:b5 Addresses: [abcd:ef12:3456:10::4/64 203.0.113.2/24] Gateway4: 203.0.113.1 Gateway6: abcd:ef12:3456:10::1 Routes: [] diff --git a/tests/cloudinit/cloudbase/results/interfaces/2.expected b/tests/cloudinit/cloudbase/results/interfaces/2.expected new file mode 100644 index 0000000..5c933d0 --- /dev/null +++ b/tests/cloudinit/cloudbase/results/interfaces/2.expected @@ -0,0 +1,5 @@ +Name: test_eth0 MAC: 52:54:00:e8:53:b5 Addresses: [] Gateway4: Gateway6: Routes: [] +Name: test_eth0.1556 MAC: Addresses: [1.2.3.4/24 1.2.3.43/24 fc00::2/64] Gateway4: 1.2.3.1 Gateway6: fc00::1 Routes: [10.0.0.0/24 via 1.2.3.5 metric 100] +Name: test_eth1 MAC: 52:54:00:5a:ff:31 Addresses: [] Gateway4: Gateway6: Routes: [] +Name: test_eth1.1557 MAC: Addresses: [fc00:aa8:7160:d9eb:1:0:1:3/64] Gateway4: Gateway6: Routes: [] +Name: test_eth3 MAC: 52:54:00:e8:54:b5 Addresses: [abcd:ef12:3456:10::4/64 203.0.113.2/24] Gateway4: 203.0.113.1 Gateway6: abcd:ef12:3456:10::1 Routes: [abcd:ef12:3455:10::/64 via abcd:ef12:3456:10::1 metric 100, 10.253.2.0/24 via 203.0.113.22 metric 100] diff --git a/tests/cloudinit/cloudbase/results/interfaces/3.expected b/tests/cloudinit/cloudbase/results/interfaces/3.expected new file mode 100644 index 0000000..c3ad4c7 --- /dev/null +++ b/tests/cloudinit/cloudbase/results/interfaces/3.expected @@ -0,0 +1,5 @@ +Name: test_eth0 MAC: 52:54:00:e8:53:b5 Addresses: [1.2.10.4/24] Gateway4: 1.2.10.254 Gateway6: Routes: [] +Name: test_eth0.1556 MAC: Addresses: [1.2.3.4/24 1.2.3.43/24 fc00::2/64] Gateway4: 1.2.3.1 Gateway6: fc00::1 Routes: [] +Name: test_eth1 MAC: 52:54:00:5a:ff:31 Addresses: [] Gateway4: Gateway6: Routes: [] +Name: test_eth1.1557 MAC: Addresses: [fc00:aa8:7160:d9eb:1:0:1:3/64] Gateway4: Gateway6: Routes: [] +Name: test_eth3 MAC: 52:54:00:e8:54:b5 Addresses: [abcd:ef12:3456:10::4/64 203.0.113.2/24] Gateway4: 203.0.113.1 Gateway6: abcd:ef12:3456:10::1 Routes: [abcd:ef12:3455:10::/64 via abcd:ef12:3456:10::1 metric 100, 10.253.2.0/24 via 203.0.113.22 metric 100] diff --git a/tests/cloudinit/network.yaml b/tests/cloudinit/network.yaml new file mode 100644 index 0000000..5870433 --- /dev/null +++ b/tests/cloudinit/network.yaml @@ -0,0 +1,47 @@ +network: + version: 2 + ethernets: + test_eth0: + match: + macaddress: 52:54:00:b9:ab:93 + set-name: test_eth0 + mtu: 1500 + test_eth1: + match: + macaddress: 52:54:00:8b:0d:93 + set-name: test_eth1 + mtu: 1500 + test_eth3: + match: + macaddress: 52:54:00:8b:cd:93 + set-name: test_eth3 + addresses: + - abcd:ef12:3456:10::4/64 + - 203.0.113.2/24 + gateway6: abcd:ef12:3456:10::1 + gateway4: 203.0.113.1 + mtu: 1500 + vlans: + test_eth0.1556: + id: 1556 + link: test_eth0 + addresses: + - 1.2.3.4/24 + gateway4: 1.2.3.1 + routes: + - to: 10.0.0.0/24 + via: 1.2.3.5 + metric: 100 + nameservers: + search: [example.com, maas] + addresses: [10.0.0.1] + mtu: 1500 + test_eth1.1557: + id: 1557 + link: test_eth1 + addresses: + - fc00:aa8:7160:d9eb:1:0:1:3/64 + nameservers: + search: [example.com, maas] + addresses: [10.0.0.1] + mtu: 1500 diff --git a/tests/cloudinit/results/1/network.yaml b/tests/cloudinit/results/1/network.yaml new file mode 100644 index 0000000..09f5531 --- /dev/null +++ b/tests/cloudinit/results/1/network.yaml @@ -0,0 +1,63 @@ +network: + ethernets: + test_eth0: + match: + macaddress: 52:54:00:b9:ab:93 + mtu: 1500 + set-name: test_eth0 + test_eth1: + match: + macaddress: 52:54:00:8b:0d:93 + mtu: 1500 + set-name: test_eth1 + test_eth3: + addresses: + - abcd:ef12:3456:10::4/64 + - 203.0.113.2/24 + gateway4: 203.0.113.1 + gateway6: abcd:ef12:3456:10::1 + match: + macaddress: 52:54:00:8b:cd:93 + mtu: 1500 + routes: + - metric: 100 + to: abcd:ef12:3455:10::/64 + via: abcd:ef12:3456:10::1 + - metric: 100 + to: 10.253.2.0/24 + via: 203.0.113.22 + set-name: test_eth3 + version: 2 + vlans: + test_eth0.1556: + addresses: + - 1.2.3.4/24 + - 1.2.3.43/24 + - fc00::2/64 + gateway4: 1.2.3.1 + gateway6: fc00::1 + id: 1556 + link: test_eth0 + mtu: 1500 + nameservers: + addresses: + - 10.0.0.1 + search: + - example.com + - maas + routes: + - metric: 100 + to: 10.0.0.0/24 + via: 1.2.3.5 + test_eth1.1557: + addresses: + - fc00:aa8:7160:d9eb:1:0:1:3/64 + id: 1557 + link: test_eth1 + mtu: 1500 + nameservers: + addresses: + - 10.0.0.1 + search: + - example.com + - maas diff --git a/tests/cloudinit/results/2/network.yaml b/tests/cloudinit/results/2/network.yaml new file mode 100644 index 0000000..3d28eb5 --- /dev/null +++ b/tests/cloudinit/results/2/network.yaml @@ -0,0 +1,62 @@ +network: + ethernets: + test_eth0: + addresses: + - 1.2.10.4/24 + gateway4: 1.2.10.254 + match: + macaddress: 52:54:00:b9:ab:93 + mtu: 1500 + set-name: test_eth0 + test_eth1: + match: + macaddress: 52:54:00:8b:0d:93 + mtu: 1500 + set-name: test_eth1 + test_eth3: + addresses: + - abcd:ef12:3456:10::4/64 + - 203.0.113.2/24 + gateway4: 203.0.113.1 + gateway6: abcd:ef12:3456:10::1 + match: + macaddress: 52:54:00:8b:cd:93 + mtu: 1500 + routes: + - metric: 100 + to: abcd:ef12:3455:10::/64 + via: abcd:ef12:3456:10::1 + - metric: 100 + to: 10.253.2.0/24 + via: 203.0.113.22 + set-name: test_eth3 + version: 2 + vlans: + test_eth0.1556: + addresses: + - 1.2.3.4/24 + - 1.2.3.43/24 + - fc00::2/64 + gateway4: 1.2.3.1 + gateway6: fc00::1 + id: 1556 + link: test_eth0 + mtu: 1500 + nameservers: + addresses: + - 10.0.0.1 + search: + - example.com + - maas + test_eth1.1557: + addresses: + - fc00:aa8:7160:d9eb:1:0:1:3/64 + id: 1557 + link: test_eth1 + mtu: 1500 + nameservers: + addresses: + - 10.0.0.1 + search: + - example.com + - maas diff --git a/tests/cloudinit/results/interfaces/1.expected b/tests/cloudinit/results/interfaces/1.expected new file mode 100644 index 0000000..f5be99b --- /dev/null +++ b/tests/cloudinit/results/interfaces/1.expected @@ -0,0 +1,5 @@ +Name: test_eth0 MAC: 52:54:00:b9:ab:93 Addresses: [] Gateway4: Gateway6: Routes: [] +Name: test_eth1 MAC: 52:54:00:8b:0d:93 Addresses: [] Gateway4: Gateway6: Routes: [] +Name: test_eth3 MAC: 52:54:00:8b:cd:93 Addresses: [abcd:ef12:3456:10::4/64 203.0.113.2/24] Gateway4: 203.0.113.1 Gateway6: abcd:ef12:3456:10::1 Routes: [] +Name: test_eth0.1556 MAC: Addresses: [1.2.3.4/24] Gateway4: 1.2.3.1 Gateway6: Routes: [10.0.0.0/24 via 1.2.3.5 metric 100] +Name: test_eth1.1557 MAC: Addresses: [fc00:aa8:7160:d9eb:1:0:1:3/64] Gateway4: Gateway6: Routes: [] diff --git a/tests/cloudinit/results/interfaces/2.expected b/tests/cloudinit/results/interfaces/2.expected new file mode 100644 index 0000000..770c65f --- /dev/null +++ b/tests/cloudinit/results/interfaces/2.expected @@ -0,0 +1,5 @@ +Name: test_eth0 MAC: 52:54:00:b9:ab:93 Addresses: [] Gateway4: Gateway6: Routes: [] +Name: test_eth1 MAC: 52:54:00:8b:0d:93 Addresses: [] Gateway4: Gateway6: Routes: [] +Name: test_eth3 MAC: 52:54:00:8b:cd:93 Addresses: [abcd:ef12:3456:10::4/64 203.0.113.2/24] Gateway4: 203.0.113.1 Gateway6: abcd:ef12:3456:10::1 Routes: [abcd:ef12:3455:10::/64 via abcd:ef12:3456:10::1 metric 100, 10.253.2.0/24 via 203.0.113.22 metric 100] +Name: test_eth0.1556 MAC: Addresses: [1.2.3.4/24 1.2.3.43/24 fc00::2/64] Gateway4: 1.2.3.1 Gateway6: fc00::1 Routes: [10.0.0.0/24 via 1.2.3.5 metric 100] +Name: test_eth1.1557 MAC: Addresses: [fc00:aa8:7160:d9eb:1:0:1:3/64] Gateway4: Gateway6: Routes: [] diff --git a/tests/cloudinit/results/interfaces/3.expected b/tests/cloudinit/results/interfaces/3.expected new file mode 100644 index 0000000..f0dc992 --- /dev/null +++ b/tests/cloudinit/results/interfaces/3.expected @@ -0,0 +1,5 @@ +Name: test_eth0 MAC: 52:54:00:b9:ab:93 Addresses: [1.2.10.4/24] Gateway4: 1.2.10.254 Gateway6: Routes: [] +Name: test_eth1 MAC: 52:54:00:8b:0d:93 Addresses: [] Gateway4: Gateway6: Routes: [] +Name: test_eth3 MAC: 52:54:00:8b:cd:93 Addresses: [abcd:ef12:3456:10::4/64 203.0.113.2/24] Gateway4: 203.0.113.1 Gateway6: abcd:ef12:3456:10::1 Routes: [abcd:ef12:3455:10::/64 via abcd:ef12:3456:10::1 metric 100, 10.253.2.0/24 via 203.0.113.22 metric 100] +Name: test_eth0.1556 MAC: Addresses: [1.2.3.4/24 1.2.3.43/24 fc00::2/64] Gateway4: 1.2.3.1 Gateway6: fc00::1 Routes: [] +Name: test_eth1.1557 MAC: Addresses: [fc00:aa8:7160:d9eb:1:0:1:3/64] Gateway4: Gateway6: Routes: [] diff --git a/tests/configurator_linux/50-cloud-init.yaml b/tests/configurator_linux/50-cloud-init.yaml new file mode 100644 index 0000000..55faac7 --- /dev/null +++ b/tests/configurator_linux/50-cloud-init.yaml @@ -0,0 +1,22 @@ +network: + version: 2 + ethernets: + test_enp1s0: + match: + macaddress: 52:54:00:8b:0d:93 + set-name: test_enp1s0 + addresses: + - 1.2.3.4/24 + gateway4: 1.2.3.1 + mtu: 1500 + test_enp2s0: + match: + macaddress: 52:54:00:8b:ad:93 + set-name: test_enp2s0 + addresses: + - fc00:5aa8:7160:d9eb:1:0:1:3/64 + routes: + - metric: 200 + to: fc00:5aa8:7160:d9eb::1/128 + via: fe80::1 + mtu: 1500 diff --git a/tests/configurator_linux/results/1/50-cloud-init.yaml b/tests/configurator_linux/results/1/50-cloud-init.yaml new file mode 100644 index 0000000..90b350a --- /dev/null +++ b/tests/configurator_linux/results/1/50-cloud-init.yaml @@ -0,0 +1,25 @@ +network: + ethernets: + test_enp1s0: + addresses: + - 1.2.3.4/24 + - 1.2.3.5/24 + gateway4: 1.2.3.1 + match: + macaddress: 52:54:00:8b:0d:93 + mtu: 1500 + set-name: test_enp1s0 + test_enp2s0: + addresses: + - fc00:5aa8:7160:d9eb:1:0:1:3/64 + - fc00:5aa8:7160:d9eb:1:0:1:5/64 + gateway6: 'fc00:5aa8:7160:d9eb::' + match: + macaddress: 52:54:00:8b:ad:93 + mtu: 1500 + routes: + - metric: 200 + to: fc00:5aa8:7160:d9eb::1/128 + via: fe80::1 + set-name: test_enp2s0 + version: 2 diff --git a/tests/configurator_linux/results/2/50-cloud-init.yaml b/tests/configurator_linux/results/2/50-cloud-init.yaml new file mode 100644 index 0000000..fafe236 --- /dev/null +++ b/tests/configurator_linux/results/2/50-cloud-init.yaml @@ -0,0 +1,23 @@ +network: + ethernets: + test_enp1s0: + addresses: + - 1.2.3.5/24 + gateway4: 1.2.3.1 + match: + macaddress: 52:54:00:8b:0d:93 + mtu: 1500 + set-name: test_enp1s0 + test_enp2s0: + addresses: + - fc00:5aa8:7160:d9eb:1:0:1:5/64 + gateway6: 'fc00:5aa8:7160:d9eb::' + match: + macaddress: 52:54:00:8b:ad:93 + mtu: 1500 + routes: + - metric: 200 + to: fc00:5aa8:7160:d9eb::1/128 + via: fe80::1 + set-name: test_enp2s0 + version: 2 diff --git a/tests/configurator_linux/results/3/50-cloud-init.yaml b/tests/configurator_linux/results/3/50-cloud-init.yaml new file mode 100644 index 0000000..b0a82f8 --- /dev/null +++ b/tests/configurator_linux/results/3/50-cloud-init.yaml @@ -0,0 +1,22 @@ +network: + ethernets: + test_enp1s0: + addresses: + - 1.2.3.5/24 + match: + macaddress: 52:54:00:8b:0d:93 + mtu: 1500 + set-name: test_enp1s0 + test_enp2s0: + addresses: + - fc00:5aa8:7160:d9eb:1:0:1:5/64 + gateway6: 'fc00:5aa8:7160:d9eb::' + match: + macaddress: 52:54:00:8b:ad:93 + mtu: 1500 + routes: + - metric: 200 + to: fc00:5aa8:7160:d9eb::1/128 + via: fe80::1 + set-name: test_enp2s0 + version: 2 diff --git a/tests/configurator_linux/results/4/50-cloud-init.yaml b/tests/configurator_linux/results/4/50-cloud-init.yaml new file mode 100644 index 0000000..98a2774 --- /dev/null +++ b/tests/configurator_linux/results/4/50-cloud-init.yaml @@ -0,0 +1,19 @@ +network: + ethernets: + test_enp1s0: + addresses: + - 1.2.3.5/24 + gateway4: 1.2.3.1 + match: + macaddress: 52:54:00:8b:0d:93 + mtu: 1500 + set-name: test_enp1s0 + test_enp2s0: + addresses: + - fc00:5aa8:7160:d9eb:1:0:1:5/64 + gateway6: 'fc00:5aa8:7160:d9eb::' + match: + macaddress: 52:54:00:8b:ad:93 + mtu: 1500 + set-name: test_enp2s0 + version: 2 diff --git a/tests/configurator_linux/results/5/50-cloud-init.yaml b/tests/configurator_linux/results/5/50-cloud-init.yaml new file mode 100644 index 0000000..4563ef1 --- /dev/null +++ b/tests/configurator_linux/results/5/50-cloud-init.yaml @@ -0,0 +1,23 @@ +network: + ethernets: + test_enp1s0: + addresses: + - 1.2.3.5/24 + gateway4: 1.2.3.1 + match: + macaddress: 52:54:00:8b:0d:93 + mtu: 1500 + routes: + - metric: 100 + to: 10.0.10.0/24 + via: 1.2.3.254 + set-name: test_enp1s0 + test_enp2s0: + addresses: + - fc00:5aa8:7160:d9eb:1:0:1:5/64 + gateway6: 'fc00:5aa8:7160:d9eb::' + match: + macaddress: 52:54:00:8b:ad:93 + mtu: 1500 + set-name: test_enp2s0 + version: 2 diff --git a/tests/configurator_linux/results/interfaces/1.expected b/tests/configurator_linux/results/interfaces/1.expected new file mode 100644 index 0000000..b0e5e7b --- /dev/null +++ b/tests/configurator_linux/results/interfaces/1.expected @@ -0,0 +1,2 @@ +Name: test_enp1s0 MAC: 52:54:00:8b:0d:93 Addresses: [1.2.3.4/24] Gateway4: 1.2.3.1 Gateway6: Routes: [] +Name: test_enp2s0 MAC: 52:54:00:8b:ad:93 Addresses: [fc00:5aa8:7160:d9eb:1:0:1:3/64] Gateway4: Gateway6: Routes: [fc00:5aa8:7160:d9eb::1/128 via fe80::1 metric 200] diff --git a/tests/configurator_linux/results/interfaces/2.expected b/tests/configurator_linux/results/interfaces/2.expected new file mode 100644 index 0000000..72257fd --- /dev/null +++ b/tests/configurator_linux/results/interfaces/2.expected @@ -0,0 +1,2 @@ +Name: test_enp1s0 MAC: 52:54:00:8b:0d:93 Addresses: [1.2.3.4/24 1.2.3.5/24] Gateway4: 1.2.3.1 Gateway6: Routes: [] +Name: test_enp2s0 MAC: 52:54:00:8b:ad:93 Addresses: [fc00:5aa8:7160:d9eb:1:0:1:5/64 fc00:5aa8:7160:d9eb:1:0:1:3/64] Gateway4: Gateway6: fc00:5aa8:7160:d9eb:: Routes: [fc00:5aa8:7160:d9eb::1/128 via fe80::1 metric 200] diff --git a/tests/configurator_linux/results/interfaces/3.expected b/tests/configurator_linux/results/interfaces/3.expected new file mode 100644 index 0000000..66856a8 --- /dev/null +++ b/tests/configurator_linux/results/interfaces/3.expected @@ -0,0 +1,2 @@ +Name: test_enp1s0 MAC: 52:54:00:8b:0d:93 Addresses: [1.2.3.5/24] Gateway4: 1.2.3.1 Gateway6: Routes: [] +Name: test_enp2s0 MAC: 52:54:00:8b:ad:93 Addresses: [fc00:5aa8:7160:d9eb:1:0:1:5/64] Gateway4: Gateway6: fc00:5aa8:7160:d9eb:: Routes: [fc00:5aa8:7160:d9eb::1/128 via fe80::1 metric 200] diff --git a/tests/configurator_linux/results/interfaces/4.expected b/tests/configurator_linux/results/interfaces/4.expected new file mode 100644 index 0000000..84c0e16 --- /dev/null +++ b/tests/configurator_linux/results/interfaces/4.expected @@ -0,0 +1,2 @@ +Name: test_enp1s0 MAC: 52:54:00:8b:0d:93 Addresses: [1.2.3.5/24] Gateway4: Gateway6: Routes: [] +Name: test_enp2s0 MAC: 52:54:00:8b:ad:93 Addresses: [fc00:5aa8:7160:d9eb:1:0:1:5/64] Gateway4: Gateway6: fc00:5aa8:7160:d9eb:: Routes: [fc00:5aa8:7160:d9eb::1/128 via fe80::1 metric 200] diff --git a/tests/configurator_linux/results/interfaces/5.expected b/tests/configurator_linux/results/interfaces/5.expected new file mode 100644 index 0000000..ddaed32 --- /dev/null +++ b/tests/configurator_linux/results/interfaces/5.expected @@ -0,0 +1,2 @@ +Name: test_enp1s0 MAC: 52:54:00:8b:0d:93 Addresses: [1.2.3.5/24] Gateway4: 1.2.3.1 Gateway6: Routes: [] +Name: test_enp2s0 MAC: 52:54:00:8b:ad:93 Addresses: [fc00:5aa8:7160:d9eb:1:0:1:5/64] Gateway4: Gateway6: fc00:5aa8:7160:d9eb:: Routes: [] diff --git a/tests/configurator_linux/results/interfaces/6.expected b/tests/configurator_linux/results/interfaces/6.expected new file mode 100644 index 0000000..599a744 --- /dev/null +++ b/tests/configurator_linux/results/interfaces/6.expected @@ -0,0 +1,2 @@ +Name: test_enp1s0 MAC: 52:54:00:8b:0d:93 Addresses: [1.2.3.5/24] Gateway4: 1.2.3.1 Gateway6: Routes: [10.0.10.0/24 via 1.2.3.254 metric 100] +Name: test_enp2s0 MAC: 52:54:00:8b:ad:93 Addresses: [fc00:5aa8:7160:d9eb:1:0:1:5/64] Gateway4: Gateway6: fc00:5aa8:7160:d9eb:: Routes: [] diff --git a/tests/ifupdown/interfaces b/tests/ifupdown/interfaces new file mode 100644 index 0000000..90a6572 --- /dev/null +++ b/tests/ifupdown/interfaces @@ -0,0 +1,57 @@ +iface test_eth0 + address 203.0.113.2/24 + gateway 203.0.113.1 + up /usr/local/bin/etho-up.sh + down /usr/local/bin/etho-down.sh + up ip route add 10.23.1.0/24 via 203.0.113.4 dev eth0 + down ip route del 10.23.1.0/24 via 203.0.113.4 dev eth0 + +auto test_wg0 +iface test_wg0 + use wireguard + address 1.4.3.4/24 + requires eth0 + +iface test_v6 + address 2001:470:1f10::1 + +iface test_v4 + address 203.0.115.2 + +iface test_eth0.8 + address 2001:db8:1000:2::2/64 + +iface test_servers + vlan-raw-device eth0 + vlan-id 8 + address 2001:db8:1002:2::2/64 + +iface test_lo inet loopback + +auto test_eth1 +iface test_eth1 + address 1.2.3.4/24 + address abcd:ef12:3456:3::4/64 + mtu 8000 + +auto test_backend +iface test_backend + address 1.2.10.4/24 + gateway 1.2.10.1 + address abcd:ef12:3456:10::4/64 + gateway abcd:ef12:3456:10::1 + mtu 8000 + vlan-raw-device eth0 + vlan_id 5 + +auto test_eth2 +iface test_eth2 + address 2001:db8:1003:2::2 + netmask 112 + gateway 2001:db8:1003:2::1 + +auto test_eth3 +iface test_eth3 + address 203.0.114.2 + netmask 255.255.255.248 + gateway 203.0.114.1 diff --git a/tests/ifupdown/results/1/interfaces b/tests/ifupdown/results/1/interfaces new file mode 100644 index 0000000..dc727fd --- /dev/null +++ b/tests/ifupdown/results/1/interfaces @@ -0,0 +1,67 @@ + +auto test_wg0 +iface test_eth0 + address 203.0.113.2/24 + address fc00::2/64 + gateway 203.0.113.6 + gateway fc00::1 + up /usr/local/bin/etho-up.sh + down /usr/local/bin/etho-down.sh + up ip addr add 203.0.113.3/24 dev $IFACE + down ip addr del 203.0.113.3/24 dev $IFACE + up ip route add 10.23.1.0/24 via 203.0.113.4 metric 256 dev $IFACE + down ip route del 10.23.1.0/24 via 203.0.113.4 metric 256 dev $IFACE + +iface test_wg0 + use wireguard + address 1.4.3.4/24 + requires eth0 + +iface test_v6 + address 2001:470:1f10::1 + +iface test_v4 + address 203.0.115.2 + +iface test_eth0.8 + address 2001:db8:1000:2::2/64 + +iface test_servers + vlan-raw-device eth0 + vlan-id 8 + address 2001:db8:1002:2::2/64 + +iface test_lo inet loopback + +auto test_eth1 + +auto test_backend +iface test_eth1 + address 1.2.3.4/24 + address abcd:ef12:3456:3::4/64 + mtu 8000 + up ip -6 route add abcd:ef12:3455:3::/64 via abcd:ef12:3456:3::1 metric 100 dev $IFACE + down ip -6 route del abcd:ef12:3455:3::/64 via abcd:ef12:3456:3::1 metric 100 dev $IFACE + up ip route add 10.253.2.0/24 via 1.2.3.1 metric 100 dev $IFACE + down ip route del 10.253.2.0/24 via 1.2.3.1 metric 100 dev $IFACE + +iface test_backend + address 1.2.10.4/24 + gateway 1.2.10.1 + address abcd:ef12:3456:10::4/64 + gateway abcd:ef12:3456:10::1 + mtu 8000 + vlan-raw-device eth0 + vlan_id 5 + +auto test_eth2 +iface test_eth2 + address 2001:db8:1003:2::2 + netmask 112 + gateway 2001:db8:1003:2::1 + +auto test_eth3 +iface test_eth3 + address 203.0.114.2 + netmask 255.255.255.248 + gateway 203.0.114.1 diff --git a/tests/ifupdown/results/2/interfaces b/tests/ifupdown/results/2/interfaces new file mode 100644 index 0000000..e8dec6c --- /dev/null +++ b/tests/ifupdown/results/2/interfaces @@ -0,0 +1,64 @@ + +auto test_wg0 + +iface test_eth0 + address 203.0.113.2/24 + address fc00::2/64 + gateway 203.0.113.6 + gateway fc00::1 + up /usr/local/bin/etho-up.sh + down /usr/local/bin/etho-down.sh + up ip addr add 203.0.113.3/24 dev $IFACE + down ip addr del 203.0.113.3/24 dev $IFACE + +iface test_wg0 + use wireguard + address 1.4.3.4/24 + requires eth0 + +iface test_v6 + address 2001:470:1f10::1 + +iface test_v4 + address 203.0.115.2 + +iface test_eth0.8 + address 2001:db8:1000:2::2/64 + +iface test_servers + vlan-raw-device eth0 + vlan-id 8 + address 2001:db8:1002:2::2/64 + +iface test_lo inet loopback + +auto test_eth1 + +auto test_backend +iface test_eth1 + address 1.2.3.4/24 + address abcd:ef12:3456:3::4/64 + mtu 8000 + up ip -6 route add abcd:ef12:3455:3::/64 via abcd:ef12:3456:3::1 metric 100 dev $IFACE + down ip -6 route del abcd:ef12:3455:3::/64 via abcd:ef12:3456:3::1 metric 100 dev $IFACE + up ip route add 10.253.2.0/24 via 1.2.3.1 metric 100 dev $IFACE + down ip route del 10.253.2.0/24 via 1.2.3.1 metric 100 dev $IFACE + +auto test_eth2 +iface test_backend + address 1.2.10.4/24 + gateway 1.2.10.254 + mtu 8000 + vlan-raw-device eth0 + vlan_id 5 + +iface test_eth2 + address 2001:db8:1003:2::2 + netmask 112 + gateway 2001:db8:1003:2::1 + +auto test_eth3 +iface test_eth3 + address 203.0.114.2 + netmask 255.255.255.248 + gateway 203.0.114.1 diff --git a/tests/ifupdown/results/interfaces/1.expected b/tests/ifupdown/results/interfaces/1.expected new file mode 100644 index 0000000..55cd21d --- /dev/null +++ b/tests/ifupdown/results/interfaces/1.expected @@ -0,0 +1,11 @@ +Name: test_eth0 MAC: Addresses: [203.0.113.2/24] Gateway4: 203.0.113.1 Gateway6: Routes: [10.23.1.0/24 via 203.0.113.4 metric 256] +Name: test_wg0 MAC: Addresses: [1.4.3.4/24] Gateway4: Gateway6: Routes: [] +Name: test_v6 MAC: Addresses: [2001:470:1f10::1/128] Gateway4: Gateway6: Routes: [] +Name: test_v4 MAC: Addresses: [203.0.115.2/32] Gateway4: Gateway6: Routes: [] +Name: test_eth0.8 MAC: Addresses: [2001:db8:1000:2::2/64] Gateway4: Gateway6: Routes: [] +Name: test_servers MAC: Addresses: [2001:db8:1002:2::2/64] Gateway4: Gateway6: Routes: [] +Name: test_lo MAC: Addresses: [] Gateway4: Gateway6: Routes: [] +Name: test_eth1 MAC: Addresses: [1.2.3.4/24 abcd:ef12:3456:3::4/64] Gateway4: Gateway6: Routes: [] +Name: test_backend MAC: Addresses: [1.2.10.4/24 abcd:ef12:3456:10::4/64] Gateway4: 1.2.10.1 Gateway6: abcd:ef12:3456:10::1 Routes: [] +Name: test_eth2 MAC: Addresses: [2001:db8:1003:2::2/112] Gateway4: Gateway6: 2001:db8:1003:2::1 Routes: [] +Name: test_eth3 MAC: Addresses: [203.0.114.2/29] Gateway4: 203.0.114.1 Gateway6: Routes: [] diff --git a/tests/ifupdown/results/interfaces/2.expected b/tests/ifupdown/results/interfaces/2.expected new file mode 100644 index 0000000..03ff306 --- /dev/null +++ b/tests/ifupdown/results/interfaces/2.expected @@ -0,0 +1,11 @@ +Name: test_eth0 MAC: Addresses: [203.0.113.2/24 203.0.113.3/24 fc00::2/64] Gateway4: 203.0.113.6 Gateway6: fc00::1 Routes: [10.23.1.0/24 via 203.0.113.4 metric 256] +Name: test_wg0 MAC: Addresses: [1.4.3.4/24] Gateway4: Gateway6: Routes: [] +Name: test_v6 MAC: Addresses: [2001:470:1f10::1/128] Gateway4: Gateway6: Routes: [] +Name: test_v4 MAC: Addresses: [203.0.115.2/32] Gateway4: Gateway6: Routes: [] +Name: test_eth0.8 MAC: Addresses: [2001:db8:1000:2::2/64] Gateway4: Gateway6: Routes: [] +Name: test_servers MAC: Addresses: [2001:db8:1002:2::2/64] Gateway4: Gateway6: Routes: [] +Name: test_lo MAC: Addresses: [] Gateway4: Gateway6: Routes: [] +Name: test_eth1 MAC: Addresses: [1.2.3.4/24 abcd:ef12:3456:3::4/64] Gateway4: Gateway6: Routes: [abcd:ef12:3455:3::/64 via abcd:ef12:3456:3::1 metric 100, 10.253.2.0/24 via 1.2.3.1 metric 100] +Name: test_backend MAC: Addresses: [1.2.10.4/24 abcd:ef12:3456:10::4/64] Gateway4: 1.2.10.1 Gateway6: abcd:ef12:3456:10::1 Routes: [] +Name: test_eth2 MAC: Addresses: [2001:db8:1003:2::2/112] Gateway4: Gateway6: 2001:db8:1003:2::1 Routes: [] +Name: test_eth3 MAC: Addresses: [203.0.114.2/29] Gateway4: 203.0.114.1 Gateway6: Routes: [] diff --git a/tests/ifupdown/results/interfaces/3.expected b/tests/ifupdown/results/interfaces/3.expected new file mode 100644 index 0000000..dcb95f9 --- /dev/null +++ b/tests/ifupdown/results/interfaces/3.expected @@ -0,0 +1,11 @@ +Name: test_eth0 MAC: Addresses: [203.0.113.2/24 203.0.113.3/24 fc00::2/64] Gateway4: 203.0.113.6 Gateway6: fc00::1 Routes: [] +Name: test_wg0 MAC: Addresses: [1.4.3.4/24] Gateway4: Gateway6: Routes: [] +Name: test_v6 MAC: Addresses: [2001:470:1f10::1/128] Gateway4: Gateway6: Routes: [] +Name: test_v4 MAC: Addresses: [203.0.115.2/32] Gateway4: Gateway6: Routes: [] +Name: test_eth0.8 MAC: Addresses: [2001:db8:1000:2::2/64] Gateway4: Gateway6: Routes: [] +Name: test_servers MAC: Addresses: [2001:db8:1002:2::2/64] Gateway4: Gateway6: Routes: [] +Name: test_lo MAC: Addresses: [] Gateway4: Gateway6: Routes: [] +Name: test_eth1 MAC: Addresses: [1.2.3.4/24 abcd:ef12:3456:3::4/64] Gateway4: Gateway6: Routes: [abcd:ef12:3455:3::/64 via abcd:ef12:3456:3::1 metric 100, 10.253.2.0/24 via 1.2.3.1 metric 100] +Name: test_backend MAC: Addresses: [1.2.10.4/24] Gateway4: 1.2.10.254 Gateway6: Routes: [] +Name: test_eth2 MAC: Addresses: [2001:db8:1003:2::2/112] Gateway4: Gateway6: 2001:db8:1003:2::1 Routes: [] +Name: test_eth3 MAC: Addresses: [203.0.114.2/29] Gateway4: 203.0.114.1 Gateway6: Routes: [] diff --git a/tests/netplan/netplan.yaml b/tests/netplan/netplan.yaml new file mode 100644 index 0000000..71096c7 --- /dev/null +++ b/tests/netplan/netplan.yaml @@ -0,0 +1,31 @@ +network: + version: 2 + ethernets: + test_eth0: + match: + macaddress: 52:54:00:b9:ab:93 + set-name: test_eth0 + mtu: 1500 + test_eth1: + match: + macaddress: 52:54:00:8b:0d:93 + set-name: test_eth1 + mtu: 1500 + test_eth3: + gateway4: 203.0.113.1 + mtu: 1500 + vlans: + test_eth0.1556: + id: 1556 + link: test_eth0 + addresses: + - 1.2.3.4/24 + gateway4: 1.2.3.1 + routes: + - to: 10.0.0.0/24 + via: 1.2.3.5 + metric: 100 + nameservers: + search: [example.com, maas] + addresses: [10.0.0.1] + mtu: 1500 diff --git a/tests/netplan/netplan2.yaml b/tests/netplan/netplan2.yaml new file mode 100644 index 0000000..837344b --- /dev/null +++ b/tests/netplan/netplan2.yaml @@ -0,0 +1,22 @@ +network: + version: 2 + ethernets: + test_eth3: + match: + macaddress: 52:54:00:8b:cd:93 + set-name: test_eth3 + addresses: + - abcd:ef12:3456:10::4/64 + - 203.0.113.2/24 + gateway6: abcd:ef12:3456:10::1 + mtu: 1500 + vlans: + test_eth1.1557: + id: 1557 + link: test_eth1 + addresses: + - fc00:aa8:7160:d9eb:1:0:1:3/64 + nameservers: + search: [example.com, maas] + addresses: [10.0.0.1] + mtu: 1500 diff --git a/tests/netplan/results/1/netplan.yaml b/tests/netplan/results/1/netplan.yaml new file mode 100644 index 0000000..564bc4c --- /dev/null +++ b/tests/netplan/results/1/netplan.yaml @@ -0,0 +1,48 @@ +network: + version: 2 + ethernets: + test_eth0: + match: + macaddress: 52:54:00:b9:ab:93 + set-name: test_eth0 + mtu: 1500 + test_eth1: + match: + macaddress: 52:54:00:8b:0d:93 + set-name: test_eth1 + mtu: 1500 + test_eth3: + match: + macaddress: 52:54:00:8b:cd:93 + set-name: test_eth3 + addresses: + - abcd:ef12:3456:10::4/64 + - 203.0.113.2/24 + gateway4: 203.0.113.1 + gateway6: abcd:ef12:3456:10::1 + mtu: 1500 + routes: + - to: abcd:ef12:3455:10::/64 + via: abcd:ef12:3456:10::1 + metric: 100 + - to: 10.253.2.0/24 + via: 203.0.113.22 + metric: 100 + vlans: + test_eth0.1556: + id: 1556 + link: test_eth0 + addresses: + - 1.2.3.4/24 + - 1.2.3.43/24 + - fc00::2/64 + gateway4: 1.2.3.1 + gateway6: fc00::1 + nameservers: + search: [example.com, maas] + addresses: [10.0.0.1] + mtu: 1500 + routes: + - to: 10.0.0.0/24 + via: 1.2.3.5 + metric: 100 diff --git a/tests/netplan/results/1/netplan2.yaml b/tests/netplan/results/1/netplan2.yaml new file mode 100644 index 0000000..5b6a426 --- /dev/null +++ b/tests/netplan/results/1/netplan2.yaml @@ -0,0 +1,12 @@ +network: + version: 2 + vlans: + test_eth1.1557: + id: 1557 + link: test_eth1 + addresses: + - fc00:aa8:7160:d9eb:1:0:1:3/64 + nameservers: + search: [example.com, maas] + addresses: [10.0.0.1] + mtu: 1500 diff --git a/tests/netplan/results/2/netplan.yaml b/tests/netplan/results/2/netplan.yaml new file mode 100644 index 0000000..5da5f69 --- /dev/null +++ b/tests/netplan/results/2/netplan.yaml @@ -0,0 +1,47 @@ +network: + version: 2 + ethernets: + test_eth0: + match: + macaddress: 52:54:00:b9:ab:93 + set-name: test_eth0 + addresses: + - 1.2.10.4/24 + gateway4: 1.2.10.254 + mtu: 1500 + test_eth1: + match: + macaddress: 52:54:00:8b:0d:93 + set-name: test_eth1 + mtu: 1500 + test_eth3: + match: + macaddress: 52:54:00:8b:cd:93 + set-name: test_eth3 + addresses: + - abcd:ef12:3456:10::4/64 + - 203.0.113.2/24 + gateway4: 203.0.113.1 + gateway6: abcd:ef12:3456:10::1 + mtu: 1500 + routes: + - to: abcd:ef12:3455:10::/64 + via: abcd:ef12:3456:10::1 + metric: 100 + - to: 10.253.2.0/24 + via: 203.0.113.22 + metric: 100 + vlans: + test_eth0.1556: + id: 1556 + link: test_eth0 + addresses: + - 1.2.3.4/24 + - 1.2.3.43/24 + - fc00::2/64 + gateway4: 1.2.3.1 + gateway6: fc00::1 + nameservers: + search: [example.com, maas] + addresses: [10.0.0.1] + mtu: 1500 diff --git a/tests/netplan/results/2/netplan2.yaml b/tests/netplan/results/2/netplan2.yaml new file mode 100644 index 0000000..5b6a426 --- /dev/null +++ b/tests/netplan/results/2/netplan2.yaml @@ -0,0 +1,12 @@ +network: + version: 2 + vlans: + test_eth1.1557: + id: 1557 + link: test_eth1 + addresses: + - fc00:aa8:7160:d9eb:1:0:1:3/64 + nameservers: + search: [example.com, maas] + addresses: [10.0.0.1] + mtu: 1500 diff --git a/tests/netplan/results/interfaces/1.expected b/tests/netplan/results/interfaces/1.expected new file mode 100644 index 0000000..f5be99b --- /dev/null +++ b/tests/netplan/results/interfaces/1.expected @@ -0,0 +1,5 @@ +Name: test_eth0 MAC: 52:54:00:b9:ab:93 Addresses: [] Gateway4: Gateway6: Routes: [] +Name: test_eth1 MAC: 52:54:00:8b:0d:93 Addresses: [] Gateway4: Gateway6: Routes: [] +Name: test_eth3 MAC: 52:54:00:8b:cd:93 Addresses: [abcd:ef12:3456:10::4/64 203.0.113.2/24] Gateway4: 203.0.113.1 Gateway6: abcd:ef12:3456:10::1 Routes: [] +Name: test_eth0.1556 MAC: Addresses: [1.2.3.4/24] Gateway4: 1.2.3.1 Gateway6: Routes: [10.0.0.0/24 via 1.2.3.5 metric 100] +Name: test_eth1.1557 MAC: Addresses: [fc00:aa8:7160:d9eb:1:0:1:3/64] Gateway4: Gateway6: Routes: [] diff --git a/tests/netplan/results/interfaces/2.expected b/tests/netplan/results/interfaces/2.expected new file mode 100644 index 0000000..770c65f --- /dev/null +++ b/tests/netplan/results/interfaces/2.expected @@ -0,0 +1,5 @@ +Name: test_eth0 MAC: 52:54:00:b9:ab:93 Addresses: [] Gateway4: Gateway6: Routes: [] +Name: test_eth1 MAC: 52:54:00:8b:0d:93 Addresses: [] Gateway4: Gateway6: Routes: [] +Name: test_eth3 MAC: 52:54:00:8b:cd:93 Addresses: [abcd:ef12:3456:10::4/64 203.0.113.2/24] Gateway4: 203.0.113.1 Gateway6: abcd:ef12:3456:10::1 Routes: [abcd:ef12:3455:10::/64 via abcd:ef12:3456:10::1 metric 100, 10.253.2.0/24 via 203.0.113.22 metric 100] +Name: test_eth0.1556 MAC: Addresses: [1.2.3.4/24 1.2.3.43/24 fc00::2/64] Gateway4: 1.2.3.1 Gateway6: fc00::1 Routes: [10.0.0.0/24 via 1.2.3.5 metric 100] +Name: test_eth1.1557 MAC: Addresses: [fc00:aa8:7160:d9eb:1:0:1:3/64] Gateway4: Gateway6: Routes: [] diff --git a/tests/netplan/results/interfaces/3.expected b/tests/netplan/results/interfaces/3.expected new file mode 100644 index 0000000..f0dc992 --- /dev/null +++ b/tests/netplan/results/interfaces/3.expected @@ -0,0 +1,5 @@ +Name: test_eth0 MAC: 52:54:00:b9:ab:93 Addresses: [1.2.10.4/24] Gateway4: 1.2.10.254 Gateway6: Routes: [] +Name: test_eth1 MAC: 52:54:00:8b:0d:93 Addresses: [] Gateway4: Gateway6: Routes: [] +Name: test_eth3 MAC: 52:54:00:8b:cd:93 Addresses: [abcd:ef12:3456:10::4/64 203.0.113.2/24] Gateway4: 203.0.113.1 Gateway6: abcd:ef12:3456:10::1 Routes: [abcd:ef12:3455:10::/64 via abcd:ef12:3456:10::1 metric 100, 10.253.2.0/24 via 203.0.113.22 metric 100] +Name: test_eth0.1556 MAC: Addresses: [1.2.3.4/24 1.2.3.43/24 fc00::2/64] Gateway4: 1.2.3.1 Gateway6: fc00::1 Routes: [] +Name: test_eth1.1557 MAC: Addresses: [fc00:aa8:7160:d9eb:1:0:1:3/64] Gateway4: Gateway6: Routes: [] diff --git a/tests/networkManager/nmcli b/tests/networkManager/nmcli new file mode 100755 index 0000000..9582eed --- /dev/null +++ b/tests/networkManager/nmcli @@ -0,0 +1,4 @@ +#!/bin/bash + +mkdir -p /tmp/networkManager-netconfig-Test +echo $@ >> /tmp/networkManager-netconfig-Test/test diff --git a/tests/networkManager/results/1/test b/tests/networkManager/results/1/test new file mode 100644 index 0000000..71f2f87 --- /dev/null +++ b/tests/networkManager/results/1/test @@ -0,0 +1,6 @@ +connection modify main ipv4.method manual ipv4.addresses 1.2.3.4/24,1.2.3.43/24 ipv4.gateway 1.2.3.1 +connection modify main ipv6.method manual ipv6.addresses fc00::2/64 ipv6.gateway fc00::1 +connection modify test ipv4.routes 10.253.2.0/24 203.0.113.22 100 +connection modify test ipv6.routes abcd:ef12:3455:10::/64 abcd:ef12:3456:10::1 100 +connection modify test_eth0 ipv4.dns 8.8.8.8,1.1.1.1 ipv4.dns-search example.com ipv4.ignore-auto-dns yes +connection modify test_eth0 ipv6.dns 2001:4860:4860::8888 ipv6.dns-search example.com ipv6.ignore-auto-dns yes diff --git a/tests/networkManager/results/2/test b/tests/networkManager/results/2/test new file mode 100644 index 0000000..38af30a --- /dev/null +++ b/tests/networkManager/results/2/test @@ -0,0 +1,10 @@ +connection modify main ipv4.method manual ipv4.addresses 1.2.3.4/24,1.2.3.43/24 ipv4.gateway 1.2.3.1 +connection modify main ipv6.method manual ipv6.addresses fc00::2/64 ipv6.gateway fc00::1 +connection modify test ipv4.routes 10.253.2.0/24 203.0.113.22 100 +connection modify test ipv6.routes abcd:ef12:3455:10::/64 abcd:ef12:3456:10::1 100 +connection modify test_eth0 ipv4.dns 8.8.8.8,1.1.1.1 ipv4.dns-search example.com ipv4.ignore-auto-dns yes +connection modify test_eth0 ipv6.dns 2001:4860:4860::8888 ipv6.dns-search example.com ipv6.ignore-auto-dns yes +connection modify test_eth0 ipv4.method manual ipv4.addresses 1.2.10.4/24 ipv4.gateway 1.2.10.254 +connection modify test_eth0 ipv6.method link-local ipv6.addresses ipv6.gateway +connection modify main ipv4.routes +connection modify main ipv6.routes diff --git a/tests/networkManager/results/interfaces/1.expected b/tests/networkManager/results/interfaces/1.expected new file mode 100644 index 0000000..06dc016 --- /dev/null +++ b/tests/networkManager/results/interfaces/1.expected @@ -0,0 +1,4 @@ +Name: test_eth0 MAC: Addresses: [] Gateway4: 1.2.3.1 Gateway6: Routes: [] +Name: test_eth0.1556 MAC: Addresses: [1.2.3.4/24] Gateway4: 1.2.3.1 Gateway6: Routes: [10.0.0.0/24 via 1.2.3.5 metric 100] +Name: test_eth1.1557 MAC: Addresses: [fc00:aa8:7160:d9eb:1:0:1:3/64] Gateway4: Gateway6: Routes: [fc00:5aa8:7160:d9eb::1/128 via fe80::1 metric 200] +Name: test_eth2 MAC: Addresses: [203.0.113.2/24 203.0.113.3/24 abcd:ef12:3456:10::4/64] Gateway4: 203.0.113.1 Gateway6: abcd:ef12:3456:10::1 Routes: [] diff --git a/tests/networkScripts/ifcfg-test_eth0 b/tests/networkScripts/ifcfg-test_eth0 new file mode 100644 index 0000000..252cc3b --- /dev/null +++ b/tests/networkScripts/ifcfg-test_eth0 @@ -0,0 +1,8 @@ +AUTOCONNECT_PRIORITY=120 +BOOTPROTO=none +DEVICE=test_eth0 +HWADDR=52:54:00:66:38:8b +MTU=1500 +ONBOOT=yes +TYPE=Ethernet +USERCTL=no diff --git a/tests/networkScripts/ifcfg-test_eth0.1556 b/tests/networkScripts/ifcfg-test_eth0.1556 new file mode 100644 index 0000000..0a6be01 --- /dev/null +++ b/tests/networkScripts/ifcfg-test_eth0.1556 @@ -0,0 +1,14 @@ +AUTOCONNECT_PRIORITY=120 +BOOTPROTO=none +DEFROUTE=yes +DEVICE=test_eth0.1556 +DNS1=10.0.0.1 +DOMAIN="example.com maas" +GATEWAY=1.2.3.1 +IPADDR=1.2.3.4 +MTU=1500 +NETMASK=255.255.255.0 +ONBOOT=yes +PHYSDEV=test_eth0 +USERCTL=no +VLAN=yes diff --git a/tests/networkScripts/ifcfg-test_eth1 b/tests/networkScripts/ifcfg-test_eth1 new file mode 100644 index 0000000..abb1136 --- /dev/null +++ b/tests/networkScripts/ifcfg-test_eth1 @@ -0,0 +1,8 @@ +AUTOCONNECT_PRIORITY=120 +BOOTPROTO=none +DEVICE=test_eth1 +HWADDR=52:54:00:fb:84:8d +MTU=1500 +ONBOOT=yes +TYPE=Ethernet +USERCTL=no diff --git a/tests/networkScripts/ifcfg-test_eth1.1557 b/tests/networkScripts/ifcfg-test_eth1.1557 new file mode 100644 index 0000000..d18f225 --- /dev/null +++ b/tests/networkScripts/ifcfg-test_eth1.1557 @@ -0,0 +1,24 @@ +AUTOCONNECT_PRIORITY=120 +DEVICE=test_eth1.1557 +IPV6ADDR=fc00:aa8:7160:d9eb:1:0:1:3/64 +IPV6INIT=yes +IPV6_AUTOCONF=no +IPV6_FORCE_ACCEPT_RA=no +MTU=1500 +ONBOOT=yes +PHYSDEV=test_eth1 +USERCTL=no +VLAN=yes +TYPE=Vlan +VLAN_ID=1557 +REORDER_HDR=yes +GVRP=no +MVRP=no +HWADDR= +PROXY_METHOD=none +BROWSER_ONLY=no +IPV6_DOMAIN="example.com maas" +IPV6_DEFROUTE=yes +IPV6_FAILURE_FATAL=no +NAME="Vlan test_eth1.1557" +UUID=ed754097-1ede-7ce8-2335-0eb8d1d0dca9 diff --git a/tests/networkScripts/ifcfg-test_eth2 b/tests/networkScripts/ifcfg-test_eth2 new file mode 100644 index 0000000..f1e8f6d --- /dev/null +++ b/tests/networkScripts/ifcfg-test_eth2 @@ -0,0 +1,18 @@ +AUTOCONNECT_PRIORITY=120 +BOOTPROTO=none +DEVICE=test_eth2 +HWADDR=52:54:00:66:28:8b +MTU=1500 +ONBOOT=yes +TYPE=Ethernet +USERCTL=no +IPV6ADDR=abcd:ef12:3456:10::4/64 +IPV6_DEFAULTGW=abcd:ef12:3456:10::1 +IPV6INIT=yes +IPV6_AUTOCONF=no +IPV6_FORCE_ACCEPT_RA=no +GATEWAY=203.0.113.1 +IPADDR0=203.0.113.2 +NETMASK0=255.255.255.0 +IPADDR1=203.0.113.3 +PREFIX1=24 diff --git a/tests/networkScripts/results/1/ifcfg-test_eth0 b/tests/networkScripts/results/1/ifcfg-test_eth0 new file mode 100644 index 0000000..252cc3b --- /dev/null +++ b/tests/networkScripts/results/1/ifcfg-test_eth0 @@ -0,0 +1,8 @@ +AUTOCONNECT_PRIORITY=120 +BOOTPROTO=none +DEVICE=test_eth0 +HWADDR=52:54:00:66:38:8b +MTU=1500 +ONBOOT=yes +TYPE=Ethernet +USERCTL=no diff --git a/tests/networkScripts/results/1/ifcfg-test_eth0.1556 b/tests/networkScripts/results/1/ifcfg-test_eth0.1556 new file mode 100644 index 0000000..48e127b --- /dev/null +++ b/tests/networkScripts/results/1/ifcfg-test_eth0.1556 @@ -0,0 +1,22 @@ +AUTOCONNECT_PRIORITY=120 +BOOTPROTO=none +BROADCAST0=1.2.3.255 +BROADCAST1=1.2.3.255 +DEFROUTE=yes +DEVICE=test_eth0.1556 +DNS1=10.0.0.1 +DOMAIN="example.com maas" +GATEWAY=1.2.3.1 +IPADDR0=1.2.3.4 +IPADDR1=1.2.3.43 +IPV6ADDR=fc00::2/64 +IPV6_DEFAULTGW=fc00::1 +MTU=1500 +NETMASK0=255.255.255.0 +NETMASK1=255.255.255.0 +ONBOOT=yes +PHYSDEV=test_eth0 +PREFIX0=24 +PREFIX1=24 +USERCTL=no +VLAN=yes diff --git a/tests/networkScripts/results/1/ifcfg-test_eth1 b/tests/networkScripts/results/1/ifcfg-test_eth1 new file mode 100644 index 0000000..abb1136 --- /dev/null +++ b/tests/networkScripts/results/1/ifcfg-test_eth1 @@ -0,0 +1,8 @@ +AUTOCONNECT_PRIORITY=120 +BOOTPROTO=none +DEVICE=test_eth1 +HWADDR=52:54:00:fb:84:8d +MTU=1500 +ONBOOT=yes +TYPE=Ethernet +USERCTL=no diff --git a/tests/networkScripts/results/1/ifcfg-test_eth1.1557 b/tests/networkScripts/results/1/ifcfg-test_eth1.1557 new file mode 100644 index 0000000..d18f225 --- /dev/null +++ b/tests/networkScripts/results/1/ifcfg-test_eth1.1557 @@ -0,0 +1,24 @@ +AUTOCONNECT_PRIORITY=120 +DEVICE=test_eth1.1557 +IPV6ADDR=fc00:aa8:7160:d9eb:1:0:1:3/64 +IPV6INIT=yes +IPV6_AUTOCONF=no +IPV6_FORCE_ACCEPT_RA=no +MTU=1500 +ONBOOT=yes +PHYSDEV=test_eth1 +USERCTL=no +VLAN=yes +TYPE=Vlan +VLAN_ID=1557 +REORDER_HDR=yes +GVRP=no +MVRP=no +HWADDR= +PROXY_METHOD=none +BROWSER_ONLY=no +IPV6_DOMAIN="example.com maas" +IPV6_DEFROUTE=yes +IPV6_FAILURE_FATAL=no +NAME="Vlan test_eth1.1557" +UUID=ed754097-1ede-7ce8-2335-0eb8d1d0dca9 diff --git a/tests/networkScripts/results/1/ifcfg-test_eth2 b/tests/networkScripts/results/1/ifcfg-test_eth2 new file mode 100644 index 0000000..f1e8f6d --- /dev/null +++ b/tests/networkScripts/results/1/ifcfg-test_eth2 @@ -0,0 +1,18 @@ +AUTOCONNECT_PRIORITY=120 +BOOTPROTO=none +DEVICE=test_eth2 +HWADDR=52:54:00:66:28:8b +MTU=1500 +ONBOOT=yes +TYPE=Ethernet +USERCTL=no +IPV6ADDR=abcd:ef12:3456:10::4/64 +IPV6_DEFAULTGW=abcd:ef12:3456:10::1 +IPV6INIT=yes +IPV6_AUTOCONF=no +IPV6_FORCE_ACCEPT_RA=no +GATEWAY=203.0.113.1 +IPADDR0=203.0.113.2 +NETMASK0=255.255.255.0 +IPADDR1=203.0.113.3 +PREFIX1=24 diff --git a/tests/networkScripts/results/1/route-test_eth0.1556 b/tests/networkScripts/results/1/route-test_eth0.1556 new file mode 100644 index 0000000..1f42b61 --- /dev/null +++ b/tests/networkScripts/results/1/route-test_eth0.1556 @@ -0,0 +1 @@ +10.0.0.0/24 via 1.2.3.5 metric 100 diff --git a/tests/networkScripts/results/1/route-test_eth2 b/tests/networkScripts/results/1/route-test_eth2 new file mode 100644 index 0000000..3e9a406 --- /dev/null +++ b/tests/networkScripts/results/1/route-test_eth2 @@ -0,0 +1 @@ +10.253.2.0/24 via 203.0.113.22 metric 100 diff --git a/tests/networkScripts/results/1/route6-test_eth1.1557 b/tests/networkScripts/results/1/route6-test_eth1.1557 new file mode 100644 index 0000000..07d8be9 --- /dev/null +++ b/tests/networkScripts/results/1/route6-test_eth1.1557 @@ -0,0 +1 @@ +fc00:5aa8:7160:d9eb::1/128 via fe80::1 metric 200 diff --git a/tests/networkScripts/results/1/route6-test_eth2 b/tests/networkScripts/results/1/route6-test_eth2 new file mode 100644 index 0000000..54a7d25 --- /dev/null +++ b/tests/networkScripts/results/1/route6-test_eth2 @@ -0,0 +1 @@ +abcd:ef12:3455:10::/64 via abcd:ef12:3456:10::1 metric 100 diff --git a/tests/networkScripts/results/2/ifcfg-test_eth0 b/tests/networkScripts/results/2/ifcfg-test_eth0 new file mode 100644 index 0000000..d2805b6 --- /dev/null +++ b/tests/networkScripts/results/2/ifcfg-test_eth0 @@ -0,0 +1,13 @@ +AUTOCONNECT_PRIORITY=120 +BOOTPROTO=none +BROADCAST0=1.2.10.255 +DEVICE=test_eth0 +GATEWAY=1.2.10.254 +HWADDR=52:54:00:66:38:8b +IPADDR0=1.2.10.4 +MTU=1500 +NETMASK0=255.255.255.0 +ONBOOT=yes +PREFIX0=24 +TYPE=Ethernet +USERCTL=no diff --git a/tests/networkScripts/results/2/ifcfg-test_eth0.1556 b/tests/networkScripts/results/2/ifcfg-test_eth0.1556 new file mode 100644 index 0000000..48e127b --- /dev/null +++ b/tests/networkScripts/results/2/ifcfg-test_eth0.1556 @@ -0,0 +1,22 @@ +AUTOCONNECT_PRIORITY=120 +BOOTPROTO=none +BROADCAST0=1.2.3.255 +BROADCAST1=1.2.3.255 +DEFROUTE=yes +DEVICE=test_eth0.1556 +DNS1=10.0.0.1 +DOMAIN="example.com maas" +GATEWAY=1.2.3.1 +IPADDR0=1.2.3.4 +IPADDR1=1.2.3.43 +IPV6ADDR=fc00::2/64 +IPV6_DEFAULTGW=fc00::1 +MTU=1500 +NETMASK0=255.255.255.0 +NETMASK1=255.255.255.0 +ONBOOT=yes +PHYSDEV=test_eth0 +PREFIX0=24 +PREFIX1=24 +USERCTL=no +VLAN=yes diff --git a/tests/networkScripts/results/2/ifcfg-test_eth1 b/tests/networkScripts/results/2/ifcfg-test_eth1 new file mode 100644 index 0000000..abb1136 --- /dev/null +++ b/tests/networkScripts/results/2/ifcfg-test_eth1 @@ -0,0 +1,8 @@ +AUTOCONNECT_PRIORITY=120 +BOOTPROTO=none +DEVICE=test_eth1 +HWADDR=52:54:00:fb:84:8d +MTU=1500 +ONBOOT=yes +TYPE=Ethernet +USERCTL=no diff --git a/tests/networkScripts/results/2/ifcfg-test_eth1.1557 b/tests/networkScripts/results/2/ifcfg-test_eth1.1557 new file mode 100644 index 0000000..d18f225 --- /dev/null +++ b/tests/networkScripts/results/2/ifcfg-test_eth1.1557 @@ -0,0 +1,24 @@ +AUTOCONNECT_PRIORITY=120 +DEVICE=test_eth1.1557 +IPV6ADDR=fc00:aa8:7160:d9eb:1:0:1:3/64 +IPV6INIT=yes +IPV6_AUTOCONF=no +IPV6_FORCE_ACCEPT_RA=no +MTU=1500 +ONBOOT=yes +PHYSDEV=test_eth1 +USERCTL=no +VLAN=yes +TYPE=Vlan +VLAN_ID=1557 +REORDER_HDR=yes +GVRP=no +MVRP=no +HWADDR= +PROXY_METHOD=none +BROWSER_ONLY=no +IPV6_DOMAIN="example.com maas" +IPV6_DEFROUTE=yes +IPV6_FAILURE_FATAL=no +NAME="Vlan test_eth1.1557" +UUID=ed754097-1ede-7ce8-2335-0eb8d1d0dca9 diff --git a/tests/networkScripts/results/2/ifcfg-test_eth2 b/tests/networkScripts/results/2/ifcfg-test_eth2 new file mode 100644 index 0000000..f1e8f6d --- /dev/null +++ b/tests/networkScripts/results/2/ifcfg-test_eth2 @@ -0,0 +1,18 @@ +AUTOCONNECT_PRIORITY=120 +BOOTPROTO=none +DEVICE=test_eth2 +HWADDR=52:54:00:66:28:8b +MTU=1500 +ONBOOT=yes +TYPE=Ethernet +USERCTL=no +IPV6ADDR=abcd:ef12:3456:10::4/64 +IPV6_DEFAULTGW=abcd:ef12:3456:10::1 +IPV6INIT=yes +IPV6_AUTOCONF=no +IPV6_FORCE_ACCEPT_RA=no +GATEWAY=203.0.113.1 +IPADDR0=203.0.113.2 +NETMASK0=255.255.255.0 +IPADDR1=203.0.113.3 +PREFIX1=24 diff --git a/tests/networkScripts/results/2/route-test_eth2 b/tests/networkScripts/results/2/route-test_eth2 new file mode 100644 index 0000000..3e9a406 --- /dev/null +++ b/tests/networkScripts/results/2/route-test_eth2 @@ -0,0 +1 @@ +10.253.2.0/24 via 203.0.113.22 metric 100 diff --git a/tests/networkScripts/results/2/route6-test_eth1.1557 b/tests/networkScripts/results/2/route6-test_eth1.1557 new file mode 100644 index 0000000..07d8be9 --- /dev/null +++ b/tests/networkScripts/results/2/route6-test_eth1.1557 @@ -0,0 +1 @@ +fc00:5aa8:7160:d9eb::1/128 via fe80::1 metric 200 diff --git a/tests/networkScripts/results/2/route6-test_eth2 b/tests/networkScripts/results/2/route6-test_eth2 new file mode 100644 index 0000000..54a7d25 --- /dev/null +++ b/tests/networkScripts/results/2/route6-test_eth2 @@ -0,0 +1 @@ +abcd:ef12:3455:10::/64 via abcd:ef12:3456:10::1 metric 100 diff --git a/tests/networkScripts/results/interfaces/1.expected b/tests/networkScripts/results/interfaces/1.expected new file mode 100644 index 0000000..a0d4e7d --- /dev/null +++ b/tests/networkScripts/results/interfaces/1.expected @@ -0,0 +1,5 @@ +Name: test_eth0 MAC: Addresses: [] Gateway4: Gateway6: Routes: [] +Name: test_eth0.1556 MAC: Addresses: [1.2.3.4/24] Gateway4: 1.2.3.1 Gateway6: Routes: [10.0.0.0/24 via 1.2.3.5 metric 100] +Name: test_eth1 MAC: Addresses: [] Gateway4: Gateway6: Routes: [] +Name: test_eth1.1557 MAC: Addresses: [fc00:aa8:7160:d9eb:1:0:1:3/64] Gateway4: Gateway6: Routes: [fc00:5aa8:7160:d9eb::1/128 via fe80::1 metric 200] +Name: test_eth2 MAC: Addresses: [203.0.113.2/24 203.0.113.3/24 abcd:ef12:3456:10::4/64] Gateway4: 203.0.113.1 Gateway6: abcd:ef12:3456:10::1 Routes: [] diff --git a/tests/networkScripts/results/interfaces/2.expected b/tests/networkScripts/results/interfaces/2.expected new file mode 100644 index 0000000..8ebfeb7 --- /dev/null +++ b/tests/networkScripts/results/interfaces/2.expected @@ -0,0 +1,5 @@ +Name: test_eth0 MAC: Addresses: [] Gateway4: Gateway6: Routes: [] +Name: test_eth0.1556 MAC: Addresses: [1.2.3.4/24 1.2.3.43/24 fc00::2/64] Gateway4: 1.2.3.1 Gateway6: fc00::1 Routes: [10.0.0.0/24 via 1.2.3.5 metric 100] +Name: test_eth1 MAC: Addresses: [] Gateway4: Gateway6: Routes: [] +Name: test_eth1.1557 MAC: Addresses: [fc00:aa8:7160:d9eb:1:0:1:3/64] Gateway4: Gateway6: Routes: [fc00:5aa8:7160:d9eb::1/128 via fe80::1 metric 200] +Name: test_eth2 MAC: Addresses: [203.0.113.2/24 203.0.113.3/24 abcd:ef12:3456:10::4/64] Gateway4: 203.0.113.1 Gateway6: abcd:ef12:3456:10::1 Routes: [10.253.2.0/24 via 203.0.113.22 metric 100, abcd:ef12:3455:10::/64 via abcd:ef12:3456:10::1 metric 100] diff --git a/tests/networkScripts/results/interfaces/3.expected b/tests/networkScripts/results/interfaces/3.expected new file mode 100644 index 0000000..863cd82 --- /dev/null +++ b/tests/networkScripts/results/interfaces/3.expected @@ -0,0 +1,5 @@ +Name: test_eth0 MAC: Addresses: [1.2.10.4/24] Gateway4: 1.2.10.254 Gateway6: Routes: [] +Name: test_eth0.1556 MAC: Addresses: [1.2.3.4/24 1.2.3.43/24 fc00::2/64] Gateway4: 1.2.3.1 Gateway6: fc00::1 Routes: [] +Name: test_eth1 MAC: Addresses: [] Gateway4: Gateway6: Routes: [] +Name: test_eth1.1557 MAC: Addresses: [fc00:aa8:7160:d9eb:1:0:1:3/64] Gateway4: Gateway6: Routes: [fc00:5aa8:7160:d9eb::1/128 via fe80::1 metric 200] +Name: test_eth2 MAC: Addresses: [203.0.113.2/24 203.0.113.3/24 abcd:ef12:3456:10::4/64] Gateway4: 203.0.113.1 Gateway6: abcd:ef12:3456:10::1 Routes: [10.253.2.0/24 via 203.0.113.22 metric 100, abcd:ef12:3455:10::/64 via abcd:ef12:3456:10::1 metric 100] diff --git a/tests/networkScripts/route-test_eth0.1556 b/tests/networkScripts/route-test_eth0.1556 new file mode 100644 index 0000000..1f42b61 --- /dev/null +++ b/tests/networkScripts/route-test_eth0.1556 @@ -0,0 +1 @@ +10.0.0.0/24 via 1.2.3.5 metric 100 diff --git a/tests/networkScripts/route6-test_eth1.1557 b/tests/networkScripts/route6-test_eth1.1557 new file mode 100644 index 0000000..07d8be9 --- /dev/null +++ b/tests/networkScripts/route6-test_eth1.1557 @@ -0,0 +1 @@ +fc00:5aa8:7160:d9eb::1/128 via fe80::1 metric 200 diff --git a/tests/networkd/results/1/test_eth0.1556.network b/tests/networkd/results/1/test_eth0.1556.network new file mode 100644 index 0000000..73c2f50 --- /dev/null +++ b/tests/networkd/results/1/test_eth0.1556.network @@ -0,0 +1,21 @@ +[Match] +Name = test_eth0.1556 + +[Link] +MTUBytes = 1500 + +[Network] +LinkLocalAddressing = ipv6 +ConfigureWithoutCarrier = yes +Address = 1.2.3.4/24 +Address = 1.2.3.43/24 +Address = fc00::2/64 +Gateway = 1.2.3.1 +Gateway = fc00::1 +DNS = 10.0.0.1 +Domains = example.com maas + +[Route] +Destination = 10.0.0.0/24 +Gateway = 1.2.3.5 +Metric = 100 diff --git a/tests/networkd/results/1/test_eth0.network b/tests/networkd/results/1/test_eth0.network new file mode 100644 index 0000000..b86a3c7 --- /dev/null +++ b/tests/networkd/results/1/test_eth0.network @@ -0,0 +1,10 @@ +[Match] +PermanentMACAddress=52:54:00:b9:ab:93 +Name=test_eth0 + +[Link] +MTUBytes=1500 + +[Network] +LinkLocalAddressing=ipv6 +VLAN=test_eth0.1556 diff --git a/tests/networkd/results/1/test_eth1.1557.network b/tests/networkd/results/1/test_eth1.1557.network new file mode 100644 index 0000000..c7a8aec --- /dev/null +++ b/tests/networkd/results/1/test_eth1.1557.network @@ -0,0 +1,17 @@ +[Match] +Name=test_eth1.1557 + +[Link] +MTUBytes=1500 + +[Network] +LinkLocalAddressing=ipv6 +Address=fc00:aa8:7160:d9eb:1:0:1:3/64 +DNS=10.0.0.1 +Domains=example.com maas +ConfigureWithoutCarrier=yes + +[Route] +Destination=fc00:5aa8:7160:d9eb::1/128 +Gateway=fe80::1 +Metric=200 diff --git a/tests/networkd/results/1/test_eth1.network b/tests/networkd/results/1/test_eth1.network new file mode 100644 index 0000000..7251e58 --- /dev/null +++ b/tests/networkd/results/1/test_eth1.network @@ -0,0 +1,10 @@ +[Match] +PermanentMACAddress=52:54:00:8b:0d:93 +Name=test_eth1 + +[Link] +MTUBytes=1500 + +[Network] +LinkLocalAddressing=ipv6 +VLAN=test_eth1.1557 diff --git a/tests/networkd/results/1/test_eth2.network b/tests/networkd/results/1/test_eth2.network new file mode 100644 index 0000000..d305c62 --- /dev/null +++ b/tests/networkd/results/1/test_eth2.network @@ -0,0 +1,25 @@ +[Match] +PermanentMACAddress = 52:54:00:8b:cd:93 +Name = test_eth2 + +[Link] +MTUBytes = 1500 + +[Network] +LinkLocalAddressing = ipv6 +Address = 203.0.113.2/24 +Address = abcd:ef12:3456:10::4/64 +Address = 203.0.113.3/24 +Gateway = 203.0.113.1 +Gateway = abcd:ef12:3456:10::1 +DNS = 10.0.0.1 + +[Route] +Destination = abcd:ef12:3455:10::/64 +Gateway = abcd:ef12:3456:10::1 +Metric = 100 + +[Route] +Destination = 10.253.2.0/24 +Gateway = 203.0.113.22 +Metric = 100 diff --git a/tests/networkd/results/2/test_eth0.1556.network b/tests/networkd/results/2/test_eth0.1556.network new file mode 100644 index 0000000..826344f --- /dev/null +++ b/tests/networkd/results/2/test_eth0.1556.network @@ -0,0 +1,16 @@ +[Match] +Name = test_eth0.1556 + +[Link] +MTUBytes = 1500 + +[Network] +LinkLocalAddressing = ipv6 +ConfigureWithoutCarrier = yes +Address = 1.2.3.4/24 +Address = 1.2.3.43/24 +Address = fc00::2/64 +Gateway = 1.2.3.1 +Gateway = fc00::1 +DNS = 10.0.0.1 +Domains = example.com maas diff --git a/tests/networkd/results/2/test_eth0.network b/tests/networkd/results/2/test_eth0.network new file mode 100644 index 0000000..ac2675d --- /dev/null +++ b/tests/networkd/results/2/test_eth0.network @@ -0,0 +1,13 @@ +[Match] +PermanentMACAddress = 52:54:00:b9:ab:93 +Name = test_eth0 + +[Link] +MTUBytes = 1500 + +[Network] +LinkLocalAddressing = ipv6 +VLAN = test_eth0.1556 +Address = 1.2.10.4/24 +Gateway = 1.2.10.254 +Domains = diff --git a/tests/networkd/results/2/test_eth1.1557.network b/tests/networkd/results/2/test_eth1.1557.network new file mode 100644 index 0000000..c7a8aec --- /dev/null +++ b/tests/networkd/results/2/test_eth1.1557.network @@ -0,0 +1,17 @@ +[Match] +Name=test_eth1.1557 + +[Link] +MTUBytes=1500 + +[Network] +LinkLocalAddressing=ipv6 +Address=fc00:aa8:7160:d9eb:1:0:1:3/64 +DNS=10.0.0.1 +Domains=example.com maas +ConfigureWithoutCarrier=yes + +[Route] +Destination=fc00:5aa8:7160:d9eb::1/128 +Gateway=fe80::1 +Metric=200 diff --git a/tests/networkd/results/2/test_eth1.network b/tests/networkd/results/2/test_eth1.network new file mode 100644 index 0000000..7251e58 --- /dev/null +++ b/tests/networkd/results/2/test_eth1.network @@ -0,0 +1,10 @@ +[Match] +PermanentMACAddress=52:54:00:8b:0d:93 +Name=test_eth1 + +[Link] +MTUBytes=1500 + +[Network] +LinkLocalAddressing=ipv6 +VLAN=test_eth1.1557 diff --git a/tests/networkd/results/2/test_eth2.network b/tests/networkd/results/2/test_eth2.network new file mode 100644 index 0000000..d305c62 --- /dev/null +++ b/tests/networkd/results/2/test_eth2.network @@ -0,0 +1,25 @@ +[Match] +PermanentMACAddress = 52:54:00:8b:cd:93 +Name = test_eth2 + +[Link] +MTUBytes = 1500 + +[Network] +LinkLocalAddressing = ipv6 +Address = 203.0.113.2/24 +Address = abcd:ef12:3456:10::4/64 +Address = 203.0.113.3/24 +Gateway = 203.0.113.1 +Gateway = abcd:ef12:3456:10::1 +DNS = 10.0.0.1 + +[Route] +Destination = abcd:ef12:3455:10::/64 +Gateway = abcd:ef12:3456:10::1 +Metric = 100 + +[Route] +Destination = 10.253.2.0/24 +Gateway = 203.0.113.22 +Metric = 100 diff --git a/tests/networkd/results/interfaces/1.expected b/tests/networkd/results/interfaces/1.expected new file mode 100644 index 0000000..4a8c209 --- /dev/null +++ b/tests/networkd/results/interfaces/1.expected @@ -0,0 +1,5 @@ +Name: test_eth0.1556 MAC: Addresses: [1.2.3.4/24] Gateway4: 1.2.3.1 Gateway6: Routes: [10.0.0.0/24 via 1.2.3.5 metric 100] +Name: test_eth0 MAC: Addresses: [] Gateway4: Gateway6: Routes: [] +Name: test_eth1.1557 MAC: Addresses: [fc00:aa8:7160:d9eb:1:0:1:3/64] Gateway4: Gateway6: Routes: [fc00:5aa8:7160:d9eb::1/128 via fe80::1 metric 200] +Name: test_eth1 MAC: Addresses: [] Gateway4: Gateway6: Routes: [] +Name: test_eth2 MAC: Addresses: [203.0.113.2/24 abcd:ef12:3456:10::4/64 203.0.113.3/24] Gateway4: 203.0.113.1 Gateway6: abcd:ef12:3456:10::1 Routes: [] diff --git a/tests/networkd/results/interfaces/2.expected b/tests/networkd/results/interfaces/2.expected new file mode 100644 index 0000000..67d2e41 --- /dev/null +++ b/tests/networkd/results/interfaces/2.expected @@ -0,0 +1,5 @@ +Name: test_eth0.1556 MAC: Addresses: [1.2.3.4/24 1.2.3.43/24 fc00::2/64] Gateway4: 1.2.3.1 Gateway6: fc00::1 Routes: [10.0.0.0/24 via 1.2.3.5 metric 100] +Name: test_eth0 MAC: Addresses: [] Gateway4: Gateway6: Routes: [] +Name: test_eth1.1557 MAC: Addresses: [fc00:aa8:7160:d9eb:1:0:1:3/64] Gateway4: Gateway6: Routes: [fc00:5aa8:7160:d9eb::1/128 via fe80::1 metric 200] +Name: test_eth1 MAC: Addresses: [] Gateway4: Gateway6: Routes: [] +Name: test_eth2 MAC: Addresses: [203.0.113.2/24 abcd:ef12:3456:10::4/64 203.0.113.3/24] Gateway4: 203.0.113.1 Gateway6: abcd:ef12:3456:10::1 Routes: [abcd:ef12:3455:10::/64 via abcd:ef12:3456:10::1 metric 100, 10.253.2.0/24 via 203.0.113.22 metric 100] diff --git a/tests/networkd/results/interfaces/3.expected b/tests/networkd/results/interfaces/3.expected new file mode 100644 index 0000000..11f32fd --- /dev/null +++ b/tests/networkd/results/interfaces/3.expected @@ -0,0 +1,5 @@ +Name: test_eth0.1556 MAC: Addresses: [1.2.3.4/24 1.2.3.43/24 fc00::2/64] Gateway4: 1.2.3.1 Gateway6: fc00::1 Routes: [] +Name: test_eth0 MAC: Addresses: [1.2.10.4/24] Gateway4: 1.2.10.254 Gateway6: Routes: [] +Name: test_eth1.1557 MAC: Addresses: [fc00:aa8:7160:d9eb:1:0:1:3/64] Gateway4: Gateway6: Routes: [fc00:5aa8:7160:d9eb::1/128 via fe80::1 metric 200] +Name: test_eth1 MAC: Addresses: [] Gateway4: Gateway6: Routes: [] +Name: test_eth2 MAC: Addresses: [203.0.113.2/24 abcd:ef12:3456:10::4/64 203.0.113.3/24] Gateway4: 203.0.113.1 Gateway6: abcd:ef12:3456:10::1 Routes: [abcd:ef12:3455:10::/64 via abcd:ef12:3456:10::1 metric 100, 10.253.2.0/24 via 203.0.113.22 metric 100] diff --git a/tests/networkd/test_eth0.1556.network b/tests/networkd/test_eth0.1556.network new file mode 100644 index 0000000..12bbc24 --- /dev/null +++ b/tests/networkd/test_eth0.1556.network @@ -0,0 +1,18 @@ +[Match] +Name=test_eth0.1556 + +[Link] +MTUBytes=1500 + +[Network] +LinkLocalAddressing=ipv6 +Address=1.2.3.4/24 +Gateway=1.2.3.1 +DNS=10.0.0.1 +Domains=example.com maas +ConfigureWithoutCarrier=yes + +[Route] +Destination=10.0.0.0/24 +Gateway=1.2.3.5 +Metric=100 diff --git a/tests/networkd/test_eth0.network b/tests/networkd/test_eth0.network new file mode 100644 index 0000000..b86a3c7 --- /dev/null +++ b/tests/networkd/test_eth0.network @@ -0,0 +1,10 @@ +[Match] +PermanentMACAddress=52:54:00:b9:ab:93 +Name=test_eth0 + +[Link] +MTUBytes=1500 + +[Network] +LinkLocalAddressing=ipv6 +VLAN=test_eth0.1556 diff --git a/tests/networkd/test_eth1.1557.network b/tests/networkd/test_eth1.1557.network new file mode 100644 index 0000000..c7a8aec --- /dev/null +++ b/tests/networkd/test_eth1.1557.network @@ -0,0 +1,17 @@ +[Match] +Name=test_eth1.1557 + +[Link] +MTUBytes=1500 + +[Network] +LinkLocalAddressing=ipv6 +Address=fc00:aa8:7160:d9eb:1:0:1:3/64 +DNS=10.0.0.1 +Domains=example.com maas +ConfigureWithoutCarrier=yes + +[Route] +Destination=fc00:5aa8:7160:d9eb::1/128 +Gateway=fe80::1 +Metric=200 diff --git a/tests/networkd/test_eth1.network b/tests/networkd/test_eth1.network new file mode 100644 index 0000000..7251e58 --- /dev/null +++ b/tests/networkd/test_eth1.network @@ -0,0 +1,10 @@ +[Match] +PermanentMACAddress=52:54:00:8b:0d:93 +Name=test_eth1 + +[Link] +MTUBytes=1500 + +[Network] +LinkLocalAddressing=ipv6 +VLAN=test_eth1.1557 diff --git a/tests/networkd/test_eth2.network b/tests/networkd/test_eth2.network new file mode 100644 index 0000000..c285241 --- /dev/null +++ b/tests/networkd/test_eth2.network @@ -0,0 +1,15 @@ +[Match] +PermanentMACAddress=52:54:00:8b:cd:93 +Name=test_eth2 + +[Link] +MTUBytes=1500 + +[Network] +LinkLocalAddressing=ipv6 +Address=203.0.113.2/24 +Address=abcd:ef12:3456:10::4/64 +Address=203.0.113.3/24 +Gateway=203.0.113.1 +Gateway=abcd:ef12:3456:10::1 +DNS=10.0.0.1 diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..ef556d3 --- /dev/null +++ b/utils.go @@ -0,0 +1,418 @@ +package netconfig + +import ( + "bufio" + "bytes" + "context" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "sync" + "syscall" + "time" + + "github.com/prometheus-community/pro-bing" +) + +// Handle values that can be an integer or a string. +type intString struct { + Int *int + String *string +} + +func (i *intString) UnmarshalYAML(unmarshal func(interface{}) error) error { + var asInt int + var err error + if err = unmarshal(&asInt); err == nil { + i.Int = &asInt + return nil + } + var asString string + if err = unmarshal(&asString); err == nil { + i.String = &asString + return nil + } + return fmt.Errorf("not valid as an int or a string: %v", err) +} + +func (i intString) MarshalYAML() (interface{}, error) { + if i.Int != nil { + return *i.Int, nil + } else if i.String != nil { + return *i.String, nil + } + return nil, nil +} + +func (i *intString) UnmarshalJSON(data []byte) error { + var asInt int + var err error + if err = json.Unmarshal(data, &asInt); err == nil { + i.Int = &asInt + return nil + } + var asString string + if err = json.Unmarshal(data, &asString); err == nil { + i.String = &asString + return nil + } + return fmt.Errorf("not valid as an int or a string: %v", err) +} + +func (i intString) MarshalJSON() ([]byte, error) { + if i.Int != nil { + return json.Marshal(*i.Int) + } else if i.String != nil { + return json.Marshal(*i.String) + } + return nil, nil +} + +// Custom type to sort an directory listing. +type sortableDirEntries []os.DirEntry + +func (fil sortableDirEntries) Len() int { + return len(fil) +} + +func (fil sortableDirEntries) Less(i, j int) bool { + return fil[i].Name() < fil[j].Name() +} + +func (fil sortableDirEntries) Swap(i, j int) { + fil[i], fil[j] = fil[j], fil[i] +} + +// Convert an uint32 to net.IP. +func uint2IP(u uint32) net.IP { + bs := []byte{0, 0, 0, 0} + binary.LittleEndian.PutUint32(bs, u) + return net.IP(bs) +} + +// Convert an net.IP to uint32 format. +func ip2Uint(ip net.IP) uint32 { + if len(ip) == 16 { + ip = ip[12:] + } + return binary.LittleEndian.Uint32(ip) +} + +// Run command and return errors and stdout. The command is bound to ctx, so a +// cancelled or expired context terminates it. +func runCommand(ctx context.Context, command string, args ...string) (out []string, err error) { + cmd := exec.CommandContext(ctx, command, args...) + + // Get output pipes. + var stdout, stderr io.ReadCloser + stdout, err = cmd.StdoutPipe() + if err != nil { + return + } + stderr, err = cmd.StderrPipe() + if err != nil { + return + } + + // Start the command. + err = cmd.Start() + if err != nil { + return + } + + // Setup wait group to wait for buffers to fully read. + var wg sync.WaitGroup + wg.Add(2) + + // Read stdout. + stdoutScanner := bufio.NewScanner(stdout) + go func() { + for stdoutScanner.Scan() { + out = append(out, stdoutScanner.Text()) + } + wg.Done() + }() + + // Read stderr. + var stderrData strings.Builder + stderrScanner := bufio.NewScanner(stderr) + go func() { + for stderrScanner.Scan() { + line := stderrScanner.Text() + stderrData.WriteString(line) + } + wg.Done() + }() + + // Wait for file reads to finish before. + wg.Wait() + + // Wait for the command to finish. + err = cmd.Wait() + if err != nil { + stderr := stderrData.String() + if stderr != "" { + err = fmt.Errorf("%v (stdout %s)", stderr, strings.Join(out, "\n")) + } + return + } + return +} + +// ipStrings converts a list of IPs to their string form, skipping nil entries. +func ipStrings(ips []net.IP) []string { + var out []string + for _, ip := range ips { + if ip == nil { + continue + } + out = append(out, ip.String()) + } + return out +} + +// IPv6 has the option to store IPv4 addresses, this is a prefix to determine that. +var v4InV6Prefix = []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff} + +// Confirm that an byte array is all FF. +func allFF(b []byte) bool { + for _, c := range b { + if c != 0xff { + return false + } + } + return true +} + +// Get broadcast address. +func getBroadcast(network *net.IPNet) net.IP { + mask := network.Mask + ip := network.IP + + // Ensure mask and IP are same family. + if len(mask) == net.IPv6len && len(ip) == net.IPv4len && allFF(mask[:12]) { + mask = mask[12:] + } + if len(mask) == net.IPv4len && len(ip) == net.IPv6len && bytes.Equal(ip[:12], v4InV6Prefix) { + ip = ip[12:] + } + n := len(ip) + + // Make byte array and make broadcast. + broadCast := make(net.IP, n) + for i := 0; i < n; i++ { + invertedMask := mask[i] ^ 0xff + broadCast[i] = (ip[i] & mask[i]) | invertedMask + } + return broadCast +} + +// Confirm ping results in packets received. The ping run is bound to ctx, so a +// cancelled context ends it early. count is the number of probes; timeout is +// the overall bound on the run. +func pingTest(ctx context.Context, dest string, count int, timeout time.Duration) bool { + pinger, err := probing.NewPinger(dest) + if err != nil { + return false + } + if count <= 0 { + count = defaultPingCount + } + pinger.Count = count + if timeout <= 0 { + timeout = defaultPingTimeout + } + pinger.Timeout = timeout + err = pinger.RunWithContext(ctx) + if err != nil { + return false + } + stats := pinger.Statistics() + if stats.PacketsRecv == 0 { + return false + } + return true +} + +// Perform an HTTP request to confirm internet connection. The request is bound +// to ctx (in addition to a hard timeout), so a cancelled context aborts it. A +// response is only treated as success when its status code indicates the +// request was actually served (2xx or 3xx), so a captive portal or error page +// does not read as connectivity. +func testInternet(ctx context.Context, testAddress string, timeout time.Duration) bool { + if testAddress == "test_success" { + return true + } else if testAddress == "test_fail" { + return false + } + if timeout <= 0 { + timeout = defaultConnectivityTimeout + } + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, testAddress, nil) + if err != nil { + logger.Printf("internet connectivity test failed: %v", err) + return false + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + logger.Printf("internet connectivity test failed: %v", err) + return false + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 400 { + logger.Printf("internet connectivity test failed: status %s", resp.Status) + return false + } + return true +} + +// A file move wrapper that handles cross device moving. +func fileMove(source, destination string) error { + // Attempt to move using normal rename. + err := os.Rename(source, destination) + + // If we get a cross device move error, try copying instead. + if err != nil { + if le, ok := err.(*os.LinkError); ok && errors.Is(le.Err, syscall.EXDEV) { + return fileMoveCopy(source, destination) + } + // Fallback for implementations that wrap EXDEV differently. + if errors.Is(err, syscall.EXDEV) { + return fileMoveCopy(source, destination) + } + } + + // Otherwise return error. + return err +} + +// Copy a file. +func fileCopy(source, destination string) error { + // Open the source for reading. + src, err := os.Open(source) + if err != nil { + return err + } + + // Open destination for wrtiting. + dst, err := os.Create(destination) + if err != nil { + src.Close() + return err + } + + // Copy file contents. + _, err = io.Copy(dst, src) + + // Close both files. + src.Close() + dst.Close() + + // If copying returned an error, return that error. + if err != nil { + os.Remove(destination) + return err + } + + // Get original file permissions. + fi, err := os.Stat(source) + if err != nil { + os.Remove(destination) + return err + } + + // Correct the destination file mode. + err = os.Chmod(destination, fi.Mode()) + if err != nil { + os.Remove(destination) + return err + } + return nil +} + +// This moves by copying a file instead of renaming. +func fileMoveCopy(source, destination string) error { + // Copy the file. + err := fileCopy(source, destination) + if err != nil { + os.Remove(destination) + return err + } + + // Remove original source. + os.Remove(source) + return nil +} + +// pruneBackups removes all but the keep most-recently-modified files in dir for +// which match returns true. A non-positive keep disables pruning (every backup +// is kept). Errors are logged and otherwise ignored, since leftover backup +// files are not fatal. Backups are ordered by modification time so the keep +// newest survive regardless of how the timestamp is encoded in the file name. +func pruneBackups(dir string, match func(name string) bool, keep int) { + if keep <= 0 { + return + } + entries, err := os.ReadDir(dir) + if err != nil { + return + } + type backup struct { + name string + mtime time.Time + } + var backups []backup + for _, e := range entries { + if e.IsDir() { + continue + } + if match != nil && !match(e.Name()) { + continue + } + info, err := e.Info() + if err != nil { + continue + } + backups = append(backups, backup{e.Name(), info.ModTime()}) + } + if len(backups) <= keep { + return + } + sort.Slice(backups, func(i, j int) bool { + if !backups[i].mtime.Equal(backups[j].mtime) { + return backups[i].mtime.After(backups[j].mtime) + } + return backups[i].name > backups[j].name + }) + for _, b := range backups[keep:] { + if err := os.Remove(filepath.Join(dir, b.name)); err != nil { + logger.Printf("pruneBackups: remove %s: %v", b.name, err) + } + } +} + +// suffixBackupMatcher matches backups named "base.bak." (the netplan, +// networkd, and cloud-init naming). +func suffixBackupMatcher(base string) func(string) bool { + prefix := base + ".bak." + return func(name string) bool { return strings.HasPrefix(name, prefix) } +} + +// prefixBackupMatcher matches backups named ".bak..base" (the network-scripts +// and ifupdown naming). +func prefixBackupMatcher(base string) func(string) bool { + return func(name string) bool { + return strings.HasPrefix(name, ".bak.") && strings.HasSuffix(name, "."+base) + } +} diff --git a/utils_test.go b/utils_test.go new file mode 100644 index 0000000..0beea2c --- /dev/null +++ b/utils_test.go @@ -0,0 +1,83 @@ +package netconfig + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/kylelemons/godebug/diff" +) + +// Check interface list against expected results in the results directory. +func testVerifyInterfaces(interfaces []*Interface, resultsDir string, check int) error { + // Get what we expect. + expectedFile := filepath.Join(resultsDir, "interfaces", fmt.Sprintf("%d.expected", check)) + expected, err := os.ReadFile(expectedFile) + if err != nil { + return err + } + + // Build a test of the current state. + var builder strings.Builder + for _, iface := range interfaces { + fmt.Fprintln(&builder, iface) + } + + // Verify the current state matches what we expect. + diffErr := diff.Diff(builder.String(), string(expected)) + if diffErr != "" { + fmt.Printf("Interface check %d failed with the following difference:\n%s\n\n%s\n\n", check, expectedFile, diffErr) + return fmt.Errorf("Interface check %d has difference.", check) + } + return nil +} + +// Function to check an directory and its sub directory for differences. +func testVerifyResultsDir(checkDir, tmpDir string, check int) error { + // Read the provided check directory. + files, err := os.ReadDir(checkDir) + if err != nil { + return err + } + + // Check each file in the directory. + for _, file := range files { + // If is an sub directory, test it as well. + if file.IsDir() { + subDir := filepath.Join(checkDir, file.Name()) + subTmpDir := filepath.Join(tmpDir, file.Name()) + return testVerifyResultsDir(subDir, subTmpDir, check) + } else { + // Read the temporary file. + tmpPath := filepath.Join(tmpDir, file.Name()) + tmpFile, err := os.ReadFile(tmpPath) + if err != nil { + return err + } + + // Read the expected results. + checkPath := filepath.Join(checkDir, file.Name()) + checkFile, err := os.ReadFile(checkPath) + if err != nil { + return err + } + + // If difference, error. + diffErr := diff.Diff(string(tmpFile), string(checkFile)) + if diffErr != "" { + fmt.Printf("Result check %d failed with the following difference:\n%s\n\n%s\n\n", check, checkPath, diffErr) + return fmt.Errorf("Result check %d has difference.", check) + } + } + } + return nil +} + +// Cech the results directory fro differences from the temporary directory. +func testVerifyResults(resultsDir, tmpDir string, check int) error { + // Try to read the directory files. + checkDir := filepath.Join(resultsDir, strconv.Itoa(check)) + return testVerifyResultsDir(checkDir, tmpDir, check) +} diff --git a/utils_unit_test.go b/utils_unit_test.go new file mode 100644 index 0000000..d057196 --- /dev/null +++ b/utils_unit_test.go @@ -0,0 +1,340 @@ +package netconfig + +import ( + "context" + "encoding/json" + "net" + "os" + "path/filepath" + "reflect" + "sort" + "testing" + + "gopkg.in/yaml.v3" +) + +// Round-tripping an IPv4 address through ip2Uint/uint2IP must return the +// original four-byte value. +func TestIP2UintRoundTrip(t *testing.T) { + cases := []string{"0.0.0.0", "1.2.3.4", "192.168.1.255", "255.255.255.255"} + for _, c := range cases { + ip := net.ParseIP(c) + if ip == nil { + t.Fatalf("could not parse %q", c) + } + got := uint2IP(ip2Uint(ip)) + if !got.Equal(ip) { + t.Errorf("round trip of %s: got %s", c, got) + } + // uint2IP should produce the canonical four-byte representation. + if len(got) != net.IPv4len { + t.Errorf("uint2IP(%s) length = %d, want %d", c, len(got), net.IPv4len) + } + } +} + +// ip2Uint must accept both the 16-byte and 4-byte representations of the same +// IPv4 address and yield an identical result. +func TestIP2UintAcceptsBothRepresentations(t *testing.T) { + ip16 := net.ParseIP("10.20.30.40") + ip4 := ip16.To4() + if ip4 == nil { + t.Fatal("expected an IPv4 address") + } + if ip2Uint(ip16) != ip2Uint(ip4) { + t.Errorf("ip2Uint disagrees for 16-byte (%d) and 4-byte (%d) forms", + ip2Uint(ip16), ip2Uint(ip4)) + } +} + +func TestAllFF(t *testing.T) { + cases := []struct { + in []byte + want bool + }{ + {[]byte{}, true}, + {[]byte{0xff}, true}, + {[]byte{0xff, 0xff, 0xff, 0xff}, true}, + {[]byte{0xff, 0x00}, false}, + {[]byte{0x00}, false}, + {[]byte{0xff, 0xff, 0xfe}, false}, + } + for _, c := range cases { + if got := allFF(c.in); got != c.want { + t.Errorf("allFF(%v) = %v, want %v", c.in, got, c.want) + } + } +} + +func TestGetBroadcast(t *testing.T) { + cases := []struct { + cidr string + want string + }{ + {"192.168.1.0/24", "192.168.1.255"}, + {"10.0.0.0/8", "10.255.255.255"}, + {"172.16.5.0/30", "172.16.5.3"}, + {"203.0.113.128/25", "203.0.113.255"}, + {"fc00::/64", "fc00::ffff:ffff:ffff:ffff"}, + } + for _, c := range cases { + _, network, err := net.ParseCIDR(c.cidr) + if err != nil { + t.Fatalf("ParseCIDR(%q): %v", c.cidr, err) + } + got := getBroadcast(network) + want := net.ParseIP(c.want) + if !got.Equal(want) { + t.Errorf("getBroadcast(%s) = %s, want %s", c.cidr, got, c.want) + } + } +} + +// getBroadcast must cope with an IPv4 address paired with an IPv4-in-IPv6 mask, +// exercising the family-normalisation branch. +func TestGetBroadcastMixedFamily(t *testing.T) { + network := &net.IPNet{ + IP: net.ParseIP("192.168.1.0").To4(), + Mask: net.CIDRMask(96+24, 128), // /24 expressed as a 16-byte mask. + } + got := getBroadcast(network) + if want := net.ParseIP("192.168.1.255"); !got.Equal(want) { + t.Errorf("getBroadcast mixed family = %s, want %s", got, want) + } +} + +func TestSortableDirEntries(t *testing.T) { + dir := t.TempDir() + names := []string{"zebra", "alpha", "mike", "bravo"} + for _, n := range names { + if err := os.WriteFile(filepath.Join(dir, n), nil, 0o644); err != nil { + t.Fatal(err) + } + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + // Shuffle into a known unsorted order before sorting. + sort.Slice(entries, func(i, j int) bool { + return entries[i].Name() > entries[j].Name() + }) + + sortable := sortableDirEntries(entries) + if sortable.Len() != len(names) { + t.Errorf("Len() = %d, want %d", sortable.Len(), len(names)) + } + sort.Sort(sortable) + + want := []string{"alpha", "bravo", "mike", "zebra"} + for i, w := range want { + if entries[i].Name() != w { + t.Errorf("sorted[%d] = %s, want %s", i, entries[i].Name(), w) + } + } +} + +func TestIntStringYAML(t *testing.T) { + t.Run("int", func(t *testing.T) { + var v intString + if err := yaml.Unmarshal([]byte("42\n"), &v); err != nil { + t.Fatal(err) + } + if v.Int == nil || *v.Int != 42 { + t.Fatalf("Int = %v, want 42", v.Int) + } + if v.String != nil { + t.Errorf("String should be nil, got %v", *v.String) + } + out, err := yaml.Marshal(v) + if err != nil { + t.Fatal(err) + } + if got := string(out); got != "42\n" { + t.Errorf("marshal = %q, want %q", got, "42\n") + } + }) + + t.Run("string", func(t *testing.T) { + var v intString + if err := yaml.Unmarshal([]byte("auto\n"), &v); err != nil { + t.Fatal(err) + } + if v.String == nil || *v.String != "auto" { + t.Fatalf("String = %v, want auto", v.String) + } + if v.Int != nil { + t.Errorf("Int should be nil, got %v", *v.Int) + } + out, err := yaml.Marshal(v) + if err != nil { + t.Fatal(err) + } + if got := string(out); got != "auto\n" { + t.Errorf("marshal = %q, want %q", got, "auto\n") + } + }) + + t.Run("empty marshals to null", func(t *testing.T) { + out, err := yaml.Marshal(intString{}) + if err != nil { + t.Fatal(err) + } + if got := string(out); got != "null\n" { + t.Errorf("marshal of empty = %q, want %q", got, "null\n") + } + }) +} + +func TestIntStringJSON(t *testing.T) { + t.Run("int", func(t *testing.T) { + var v intString + if err := json.Unmarshal([]byte("42"), &v); err != nil { + t.Fatal(err) + } + if v.Int == nil || *v.Int != 42 { + t.Fatalf("Int = %v, want 42", v.Int) + } + out, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + if got := string(out); got != "42" { + t.Errorf("marshal = %q, want %q", got, "42") + } + }) + + t.Run("string", func(t *testing.T) { + var v intString + if err := json.Unmarshal([]byte(`"auto"`), &v); err != nil { + t.Fatal(err) + } + if v.String == nil || *v.String != "auto" { + t.Fatalf("String = %v, want auto", v.String) + } + out, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + if got := string(out); got != `"auto"` { + t.Errorf("marshal = %q, want %q", got, `"auto"`) + } + }) + + t.Run("invalid", func(t *testing.T) { + var v intString + if err := json.Unmarshal([]byte("{}"), &v); err == nil { + t.Error("expected error for object input, got nil") + } + }) +} + +func TestRunCommand(t *testing.T) { + t.Run("stdout", func(t *testing.T) { + out, err := runCommand(context.Background(), "printf", "line1\nline2\n") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"line1", "line2"} + if !reflect.DeepEqual(out, want) { + t.Errorf("out = %v, want %v", out, want) + } + }) + + t.Run("nonzero exit", func(t *testing.T) { + _, err := runCommand(context.Background(), "false") + if err == nil { + t.Error("expected error for failing command, got nil") + } + }) + + t.Run("missing binary", func(t *testing.T) { + _, err := runCommand(context.Background(), "this-binary-should-not-exist-12345") + if err == nil { + t.Error("expected error for missing binary, got nil") + } + }) +} + +func TestTestInternet(t *testing.T) { + if !testInternet(context.Background(), "test_success", 0) { + t.Error("test_success sentinel should report success") + } + if testInternet(context.Background(), "test_fail", 0) { + t.Error("test_fail sentinel should report failure") + } +} + +func TestFileCopyAndMove(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "src.txt") + content := []byte("hello world\n") + if err := os.WriteFile(src, content, 0o640); err != nil { + t.Fatal(err) + } + + t.Run("fileCopy preserves contents and mode", func(t *testing.T) { + dst := filepath.Join(dir, "copy.txt") + if err := fileCopy(src, dst); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(dst) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, content) { + t.Errorf("copied contents = %q, want %q", got, content) + } + fi, err := os.Stat(dst) + if err != nil { + t.Fatal(err) + } + if fi.Mode().Perm() != 0o640 { + t.Errorf("copied mode = %v, want 0640", fi.Mode().Perm()) + } + }) + + t.Run("fileCopy missing source errors", func(t *testing.T) { + if err := fileCopy(filepath.Join(dir, "nope"), filepath.Join(dir, "out")); err == nil { + t.Error("expected error for missing source") + } + }) + + t.Run("fileMoveCopy removes source", func(t *testing.T) { + moveSrc := filepath.Join(dir, "movesrc.txt") + if err := os.WriteFile(moveSrc, content, 0o644); err != nil { + t.Fatal(err) + } + dst := filepath.Join(dir, "moved.txt") + if err := fileMoveCopy(moveSrc, dst); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(moveSrc); !os.IsNotExist(err) { + t.Errorf("source should be gone, stat err = %v", err) + } + got, err := os.ReadFile(dst) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, content) { + t.Errorf("moved contents = %q, want %q", got, content) + } + }) + + t.Run("fileMove same device renames", func(t *testing.T) { + moveSrc := filepath.Join(dir, "rename-src.txt") + if err := os.WriteFile(moveSrc, content, 0o644); err != nil { + t.Fatal(err) + } + dst := filepath.Join(dir, "renamed.txt") + if err := fileMove(moveSrc, dst); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(moveSrc); !os.IsNotExist(err) { + t.Errorf("source should be gone after move, stat err = %v", err) + } + if _, err := os.Stat(dst); err != nil { + t.Errorf("destination missing after move: %v", err) + } + }) +}