go-network-configurator/cloudinit.go
James Coleman fae43247de Switch unit tests to testify
Bring an administratively down interface up in AddAddress before writing
its addresses and routes. The kernel installs an address's connected
route only while its link is up, so adding a gateway to a down interface
was rejected as unreachable. A link raised this way is part of the
pre-change state AddAddress restores, so it is returned to down on any
path that does not complete.

Also fix the rollback masking its own error: deleting the address it
added takes the default route with it, since that route resolved its
nexthop through the address's connected subnet. The following RouteDel
then answered ESRCH and was reported in place of "aborted operation due
to loss of internet".
2026-07-10 08:52:52 -05:00

1373 lines
37 KiB
Go

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"`
}
// dhcpState reports whether each family's DHCP client is enabled. An absent key
// means the schema default, which is off.
func (c *ciInterface) dhcpState() (dhcp4, dhcp6 bool) {
return c.DHCP4 != nil && *c.DHCP4, c.DHCP6 != nil && *c.DHCP6
}
// setDHCP records the requested DHCP client state for both families, dropping
// the overrides of a family whose client is being turned off. A family being
// disabled that was already absent stays absent rather than being written out
// as false.
func (c *ciInterface) setDHCP(dhcp4, dhcp6 bool) {
if dhcp4 || c.DHCP4 != nil {
c.DHCP4 = boolPtr(dhcp4)
}
if !dhcp4 {
c.DHCP4Overrides = nil
}
if dhcp6 || c.DHCP6 != nil {
c.DHCP6 = boolPtr(dhcp6)
}
if !dhcp6 {
c.DHCP6Overrides = nil
}
}
// 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
i.DHCP4, i.DHCP6 = config.dhcpState()
// 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 {
// A v1 subnet encodes the DHCP client in its type rather than in a
// dhcp4/dhcp6 key. "dhcp" with no suffix is the IPv4 client.
switch subnet.Type {
case "dhcp", "dhcp4":
i.DHCP4 = true
case "dhcp6", "ipv6_dhcpv6-stateful", "ipv6_dhcpv6-stateless":
i.DHCP6 = true
}
// 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
}
// ciDHCPSubnetTypes are the v1 subnet types that describe a DHCP client rather
// than a static address, keyed by whether they are removed when that family's
// client is disabled.
var ciDHCPSubnetTypes = map[string]bool{
"dhcp": true, // IPv4, the unsuffixed spelling.
"dhcp4": true,
"dhcp6": false,
"ipv6_dhcpv6-stateful": false,
"ipv6_dhcpv6-stateless": false,
}
// Set the DHCP client state on an interface.
func (ci *cloudInit) SetIfaceDHCP(_ context.Context, iface string, dhcp4, dhcp6 bool) (err error) {
// Apply changes to v1 configs, where the DHCP client is a subnet whose type
// names it rather than a key. Static subnets are left in place: cloud-init
// accepts a dhcp subnet and a static subnet on the same interface.
for c, config := range ci.Network.Configs {
switch config.Type {
case "physical", "bond", "bridge", "vlan":
default:
continue
}
if config.Name != iface {
continue
}
// Drop the existing DHCP subnets of both families, then re-add the ones
// that were asked for. Rebuilding rather than editing in place keeps a
// config that listed several spellings of the same client from ending up
// with a stale one alongside the new one.
subnets := make([]ciSubnet, 0, len(config.Subnets)+2)
if dhcp4 {
subnets = append(subnets, ciSubnet{Type: "dhcp4"})
}
if dhcp6 {
subnets = append(subnets, ciSubnet{Type: "dhcp6"})
}
for _, subnet := range config.Subnets {
if _, isDHCP := ciDHCPSubnetTypes[subnet.Type]; isDHCP {
continue
}
subnets = append(subnets, subnet)
}
config.Subnets = subnets
ci.Network.Configs[c] = config
}
// Apply changes to v2 ethernets.
for name, config := range ci.Network.Ethernets {
ifName := name
if config.SetName != "" {
ifName = config.SetName
}
if ifName != iface {
continue
}
config.setDHCP(dhcp4, dhcp6)
ci.Network.Ethernets[name] = config
}
// Apply changes to v2 bridges.
if config, ok := ci.Network.Bridges[iface]; ok {
config.setDHCP(dhcp4, dhcp6)
ci.Network.Bridges[iface] = config
}
// Apply changes to v2 bonds.
if config, ok := ci.Network.Bonds[iface]; ok {
config.setDHCP(dhcp4, dhcp6)
ci.Network.Bonds[iface] = config
}
// Apply changes to v2 VLAN interfaces.
if config, ok := ci.Network.VLANs[iface]; ok {
config.setDHCP(dhcp4, dhcp6)
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
}