go-network-configurator/networkConfigurator.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

458 lines
16 KiB
Go

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
}