go-network-configurator/networkConfigurator.go

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
}