package netconfig import ( "context" "fmt" "net" "sort" "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 // Up reports whether the interface can carry traffic right now, and // Physical whether it is backed by a network device rather than created by // the kernel or the hypervisor host — a bridge, bond, VLAN, tunnel, or the // veth pair of a container is not physical, a virtio or vmxnet NIC is. // Both describe the running system and are only set by GetInterfaces; the // Interface values the configuration backends read back leave them false. Up bool Physical bool // DHCP4 and DHCP6 report whether the interface runs a DHCP client for that // address family. They describe the persisted configuration, not the // running system: the kernel cannot be asked whether an address arrived // from a lease, so these are read back from the configuration backends the // same way DNS is. An interface can run a DHCP client and still carry // static addresses of the same family. DHCP4 bool DHCP6 bool 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 // SetDHCP turns each address family's DHCP client on or off. Both families // are stated explicitly, so a caller moving an interface to DHCPv4 while // keeping a static IPv6 address passes (true, false). // // Adding a static address does not imply disabling DHCP: every backend // except ifupdown can carry static addresses alongside a lease, and which // of the two the operator wants is not something AddAddress can infer. // SetDHCP is how that choice is made. // // Enabling a family also asks the running system to acquire a lease now, // rather than at the next reboot. Disabling one only rewrites the // configuration: an interface's existing lease is left in place until it // expires or the network is reconfigured, so the call cannot strand a // caller that is connected over the leased address. SetDHCP(ctx context.Context, iface string, dhcp4, dhcp6 bool) error } // ifaceBackend persists interface address, route, DNS, and DHCP 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 SetIfaceDHCP(ctx context.Context, iface string, dhcp4, dhcp6 bool) error } // dhcpRenewer is an optional capability of a file backend: telling the running // system to pick up a DHCP client that was just enabled in the configuration, // so the interface acquires a lease without waiting for a reboot. Each backend // implements it with its own manager's reconfigure command, which is both the // least disruptive way to do it and the only one that will not fight the // manager that owns the interface. cloud-init has no such command — it only // runs at boot — and so does not implement this. type dhcpRenewer interface { renewDHCP(ctx context.Context, iface 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) } // boolPtr returns a pointer to v, for the optional booleans in the netplan and // cloud-init schemas where a nil pointer means "key absent" rather than false. func boolPtr(v bool) *bool { return &v } // 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 } // mergeBackendState reads the DNS servers, search domains, and DHCP client // state each backend has persisted and merges them into the matching runtime // Interface (matched by name). The kernel/netlink layer tracks none of these — // it cannot say which resolver an interface uses, nor whether an address came // from a lease — so the backends are the source of truth for these fields. // // DNS entries are de-duplicated so a host running several backends (e.g. // netplan and cloud-init) does not list each resolver more than once. The DHCP // flags are OR'd for the same reason a lease is a property of the interface and // not of the file describing it: if any backend has the client enabled, the // interface runs one. Per-backend errors are logged and do not abort the merge. func mergeBackendState(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 } target.DHCP4 = target.DHCP4 || bi.DHCP4 target.DHCP6 = target.DHCP6 || bi.DHCP6 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) } } } } // applyIfaceDHCP pushes the interface's DHCP client state to every registered // file backend. Unlike the other apply helpers this one reports whether any // backend accepted the change: a caller that is about to ask the running system // for a lease needs to know that at least one configuration file now asks for // one, otherwise the lease would be acquired and then lost on the next reboot. // Individual failures are logged and do not abort the others, since each // backend writes an independent configuration file — and ifupdown in particular // rejects requests its one-stanza-per-family model cannot express. func applyIfaceDHCP(ctx context.Context, backends []namedIfaceBackend, iface string, dhcp4, dhcp6 bool) (applied bool) { for _, b := range backends { if err := b.backend.SetIfaceDHCP(ctx, iface, dhcp4, dhcp6); err != nil { logger.Printf("%s error: %v", b.name, err) continue } applied = true } return applied } // renewDHCPOnBackends asks every backend that can reconfigure the running // system to do so, so an interface whose DHCP client was just enabled acquires // a lease now instead of at the next reboot. Backends that cannot — cloud-init, // which only runs at boot — are skipped. Errors are logged rather than // returned: the configuration has already been written, and a manager that // declined to reconfigure has not undone that. func renewDHCPOnBackends(ctx context.Context, backends []namedIfaceBackend, iface string) { for _, b := range backends { renewer, ok := b.backend.(dhcpRenewer) if !ok { continue } if err := renewer.renewDHCP(ctx, iface); err != nil { logger.Printf("%s: error renewing DHCP on %s: %v", b.name, iface, err) } } } // 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 } // Interface name ranks, ordered by how likely a NIC of that kind is to be the // one carrying a host's internet traffic. const ( ifaceRankWired = iota ifaceRankWireless ifaceRankOther ) // wiredNamePrefixes and wirelessNamePrefixes match the kernel's classic (eth0, // wlan0) and predictable (eno1, ens3, enp1s0, enx.., em1, wlp2s0) names as well // as the friendly names Windows reports ("Ethernet 2", "Wi-Fi"). Compared // against a lower-cased name. var ( wiredNamePrefixes = []string{"eth", "en", "em"} wirelessNamePrefixes = []string{"wl", "wifi", "wi-fi"} ) // FindPhysicalInterfaces returns the host's physical interfaces — leaving out // the bridges, bonds, VLANs, tunnels, and container veth pairs that should // never be handed a public address — ordered so the interface most likely to be // the one a caller wants to configure for internet access comes first. // // Interfaces that are up sort ahead of those that are down, then those already // carrying a default gateway (IPv4 ahead of IPv6-only), then wired ahead of // wireless, and finally by name read the way a human reads it, so eth0 comes // before eth1 and eth2 before eth10. // // This is a ranking rather than a decision: a caller looking for an interface // that meets some further requirement — one not already holding a public // address, say — filters the returned slice and takes the first survivor. func FindPhysicalInterfaces(ifaces []*Interface) []*Interface { physical := make([]*Interface, 0, len(ifaces)) for _, iface := range ifaces { if iface.Physical { physical = append(physical, iface) } } sort.SliceStable(physical, func(i, j int) bool { return preferInterface(physical[i], physical[j]) }) return physical } // preferInterface reports whether a should be offered ahead of b as the // interface to configure for internet access. func preferInterface(a, b *Interface) bool { if a.Up != b.Up { return a.Up } if ag, bg := gatewayRank(a), gatewayRank(b); ag != bg { return ag < bg } if an, bn := ifaceNameRank(a.Name), ifaceNameRank(b.Name); an != bn { return an < bn } return naturalLess(a.Name, b.Name) } // gatewayRank orders an interface by the default gateway it already carries. // An interface the host currently reaches the internet over is the surest guess // at the one it should keep reaching the internet over. func gatewayRank(iface *Interface) int { switch { case iface.Gateway4 != nil: return 0 case iface.Gateway6 != nil: return 1 } return 2 } // ifaceNameRank guesses an interface's kind from its name. The name is all // there is to go on: neither netlink nor the Windows IP Helper API reports // whether a NIC is wired or wireless in a way that survives both platforms. func ifaceNameRank(name string) int { lower := strings.ToLower(name) for _, p := range wirelessNamePrefixes { if strings.HasPrefix(lower, p) { return ifaceRankWireless } } for _, p := range wiredNamePrefixes { if strings.HasPrefix(lower, p) { return ifaceRankWired } } return ifaceRankOther } // 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 }