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 } // nsDHCPState reports whether an ifcfg file's keys enable a DHCP client for // each family. BOOTPROTO names the IPv4 method — "dhcp" and the near-extinct // "bootp" are the dynamic ones, and any other value, including an absent key, // means the address is configured statically. The IPv6 client is separate and // off unless DHCPV6C says otherwise. func nsDHCPState(config map[string]string) (dhcp4, dhcp6 bool) { switch strings.ToLower(config["BOOTPROTO"]) { case "dhcp", "bootp": dhcp4 = true } switch strings.ToLower(config["DHCPV6C"]) { case "yes", "true", "1": dhcp6 = true } return dhcp4, dhcp6 } // nsSetDHCP writes the DHCP client state into an ifcfg config map. Disabling // IPv4 leaves BOOTPROTO=none rather than deleting it: unlike the netplan and // networkd schemas, an absent BOOTPROTO is read by some initscript versions as // a prompt to guess, and "none" states the intent. PERSISTENT_DHCLIENT only has // meaning while the IPv4 client runs, so it goes with it. func nsSetDHCP(config map[string]string, dhcp4, dhcp6 bool) { if dhcp4 { config["BOOTPROTO"] = "dhcp" } else { config["BOOTPROTO"] = "none" delete(config, "PERSISTENT_DHCLIENT") } if dhcp6 { config["IPV6INIT"] = "yes" config["DHCPV6C"] = "yes" } else { delete(config, "DHCPV6C") } } // 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 { // Read the DHCP client state. Any of an interface's files enabling // a client means the interface runs one. dhcp4, dhcp6 := nsDHCPState(file.Config) i.DHCP4 = i.DHCP4 || dhcp4 i.DHCP6 = i.DHCP6 || dhcp6 // 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 the DHCP client state on an interface. func (ns *networkScripts) SetIfaceDHCP(_ context.Context, iface string, dhcp4, dhcp6 bool) (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 } } nsSetDHCP(config, dhcp4, dhcp6) // Try to save. return ifc.Save(config, ns.ConfigDir, ns.backupRetention) } // renewDHCP restarts the interface through the initscripts so the DHCP client // that was just written to its ifcfg file is started. `ifup` on an interface // that is already up is a no-op on these distributions, so it is taken down // first; this is the same ifdown/ifup pair the initscripts themselves use, and // it is what acquires the lease. func (ns *networkScripts) renewDHCP(ctx context.Context, iface string) error { // ifdown on an interface that is already down exits non-zero. That is not a // failure of this call — the interface being down is the state ifup wants — // so only the ifup result decides. _, _ = runCommand(ctx, "ifdown", iface) _, err := runCommand(ctx, "ifup", iface) return err } // 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 }