Setting DNS rewrote the connection profile and stopped there, so the running device kept resolving through whatever it had before. Nothing noticed while something else always activated the device afterwards: the profile was written first and NetworkManager picked the resolvers up when it got round to the interface on its own. Turning a DHCP client off now applies the profile when the device is not activated, which moved that activation ahead of the DNS write. A caller that sets an address, disables DHCP, and then sets resolvers -- the order a static configuration is naturally applied in -- ends with the device activated on a profile that had no DNS in it yet, and the write that follows never reaches the system. The profile carries the resolvers, resolv.conf carries none, and ipv4.ignore-auto-dns has by then ruled out the lease's resolvers as well: an interface holding an address, a route, and no way to resolve a name. Reapply the device after the change. Unlike turning a client off, this risks nothing for a caller connected over the interface -- reapply changes the device in place and does not tear the link down -- so it is not conditioned on the device being unactivated, which is exactly the case that needs it.
886 lines
29 KiB
Go
886 lines
29 KiB
Go
package netconfig
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Wifx/gonetworkmanager/v3"
|
|
"github.com/godbus/dbus/v5"
|
|
"github.com/vishvananda/netlink"
|
|
)
|
|
|
|
type nmConnection struct {
|
|
ID string
|
|
Name string
|
|
UsingData bool
|
|
Method4 string
|
|
Method6 string
|
|
Addresses4 []*net.IPNet
|
|
Addresses6 []*net.IPNet
|
|
Gateway4 net.IP
|
|
Gateway6 net.IP
|
|
Routes4 []*Route
|
|
Routes6 []*Route
|
|
DNS []net.IP
|
|
DNSSearch []string
|
|
}
|
|
|
|
// dhcpState reports whether each family's DHCP client is enabled. IPv4 "auto"
|
|
// means DHCPv4. For IPv6, "auto" means router advertisements plus DHCPv6 when
|
|
// the router asks for it, and "dhcp" means DHCPv6 alone; both run a client.
|
|
func (c *nmConnection) dhcpState() (dhcp4, dhcp6 bool) {
|
|
dhcp4 = c.Method4 == "auto"
|
|
dhcp6 = c.Method6 == "auto" || c.Method6 == "dhcp"
|
|
return dhcp4, dhcp6
|
|
}
|
|
|
|
// nmStaticMethod4 is the ipv4.method to leave behind when DHCPv4 is turned off:
|
|
// "manual" when the connection still carries static addresses to serve, and
|
|
// "disabled" when turning off the lease leaves it with no IPv4 at all.
|
|
func nmStaticMethod4(hasAddrs bool) string {
|
|
if hasAddrs {
|
|
return "manual"
|
|
}
|
|
return "disabled"
|
|
}
|
|
|
|
// nmStaticMethod6 is the ipv6.method counterpart. An IPv6 interface with no
|
|
// addresses keeps its link-local one rather than losing IPv6 entirely, which is
|
|
// what "disabled" would do.
|
|
func nmStaticMethod6(hasAddrs bool) string {
|
|
if hasAddrs {
|
|
return "manual"
|
|
}
|
|
return "link-local"
|
|
}
|
|
|
|
type networkManager struct {
|
|
config gonetworkmanager.Settings
|
|
}
|
|
|
|
// nmNameservers extracts DNS server IPs from a NetworkManager ipv4/ipv6
|
|
// settings group. It prefers the modern "dns-data" property (array of strings)
|
|
// and falls back to the legacy "dns" property, which encodes IPv4 servers as an
|
|
// array of uint32 and IPv6 servers as an array of byte arrays.
|
|
func nmNameservers(group map[string]any) []net.IP {
|
|
var servers []net.IP
|
|
if data, ok := group["dns-data"].([]string); ok {
|
|
for _, s := range data {
|
|
if ip := net.ParseIP(s); ip != nil {
|
|
servers = append(servers, ip)
|
|
}
|
|
}
|
|
return servers
|
|
}
|
|
if data, ok := group["dns"].([]uint32); ok {
|
|
for _, u := range data {
|
|
if ip := uint2IP(u); len(ip) > 0 {
|
|
servers = append(servers, ip)
|
|
}
|
|
}
|
|
return servers
|
|
}
|
|
if data, ok := group["dns"].([][]byte); ok {
|
|
for _, b := range data {
|
|
if ip := net.IP(b); ip != nil {
|
|
servers = append(servers, ip)
|
|
}
|
|
}
|
|
}
|
|
return servers
|
|
}
|
|
|
|
// nmSearchDomains extracts the DNS search list from a NetworkManager ipv4/ipv6
|
|
// settings group.
|
|
func nmSearchDomains(group map[string]any) []string {
|
|
if data, ok := group["dns-search"].([]string); ok {
|
|
return append([]string(nil), data...)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Parse a network manager connection settings map to get network configurations.
|
|
func (*networkManager) ParseConnection(settings gonetworkmanager.ConnectionSettings) (conn *nmConnection, err error) {
|
|
conn = new(nmConnection)
|
|
|
|
// Get the interface id.
|
|
id, ok := settings["connection"]["id"].(string)
|
|
if !ok {
|
|
err = fmt.Errorf("failed to get interface id")
|
|
return
|
|
}
|
|
conn.ID = id
|
|
|
|
// Get the interface name. A profile need not name one: NetworkManager binds
|
|
// its own default wired connections to a device by hardware address instead,
|
|
// and those are still connections this configures. Callers that need a name
|
|
// check for an empty one.
|
|
conn.Name, _ = settings["connection"]["interface-name"].(string)
|
|
|
|
// Get the addressing method of each family. These decide whether a DHCP
|
|
// client runs, independently of any static addresses parsed below.
|
|
conn.Method4, _ = settings["ipv4"]["method"].(string)
|
|
conn.Method6, _ = settings["ipv6"]["method"].(string)
|
|
|
|
// Get the IPv4 address map, and confirm the newer configuration style is used.
|
|
addrMap, ok := settings["ipv4"]["address-data"]
|
|
if ok {
|
|
// Update the information to show the newer configuration is used.
|
|
conn.UsingData = true
|
|
|
|
// Parse the IPv4 address data into the address list.
|
|
if addrMap != nil {
|
|
addrSlice := addrMap.([]map[string]any)
|
|
for _, addr := range addrSlice {
|
|
ip := net.ParseIP(addr["address"].(string))
|
|
prefix := addr["prefix"].(uint32)
|
|
conn.Addresses4 = append(conn.Addresses4, &net.IPNet{
|
|
IP: ip,
|
|
Mask: net.CIDRMask(int(prefix), 32),
|
|
})
|
|
|
|
}
|
|
}
|
|
|
|
// Parse the IPv4 gateway.
|
|
gateway4S, ok := settings["ipv4"]["gateway"].(string)
|
|
if ok {
|
|
conn.Gateway4 = net.ParseIP(gateway4S)
|
|
}
|
|
|
|
// Parse the IPv6 addresses.
|
|
addr6Map, ok := settings["ipv6"]["address-data"]
|
|
if ok && addr6Map != nil {
|
|
addrSlice := addr6Map.([]map[string]any)
|
|
for _, addr := range addrSlice {
|
|
ip := net.ParseIP(addr["address"].(string))
|
|
prefix := addr["prefix"].(uint32)
|
|
conn.Addresses6 = append(conn.Addresses6, &net.IPNet{
|
|
IP: ip,
|
|
Mask: net.CIDRMask(int(prefix), 128),
|
|
})
|
|
|
|
}
|
|
}
|
|
|
|
// Parse the IPv6 gateway.
|
|
gateway6S, ok := settings["ipv6"]["gateway"].(string)
|
|
if ok {
|
|
conn.Gateway6 = net.ParseIP(gateway6S)
|
|
}
|
|
|
|
// Parse the IPv4 static route data.
|
|
routeMap, ok := settings["ipv4"]["route-data"]
|
|
if ok && routeMap != nil {
|
|
routeSlice := routeMap.([]map[string]any)
|
|
for _, route := range routeSlice {
|
|
dstIP := net.ParseIP(route["dest"].(string))
|
|
prefix := route["prefix"].(uint32)
|
|
r := new(Route)
|
|
r.Destination = &net.IPNet{
|
|
IP: dstIP,
|
|
Mask: net.CIDRMask(int(prefix), 32),
|
|
}
|
|
r.Gateway = net.ParseIP(route["next-hop"].(string))
|
|
r.Metric = int(route["metric"].(uint32))
|
|
conn.Routes4 = append(conn.Routes4, r)
|
|
|
|
}
|
|
}
|
|
|
|
// Parse the IPv6 static route data.
|
|
route6Map, ok := settings["ipv6"]["route-data"]
|
|
if ok && route6Map != nil {
|
|
routeSlice := route6Map.([]map[string]any)
|
|
for _, route := range routeSlice {
|
|
dstIP := net.ParseIP(route["dest"].(string))
|
|
prefix := route["prefix"].(uint32)
|
|
r := new(Route)
|
|
r.Destination = &net.IPNet{
|
|
IP: dstIP,
|
|
Mask: net.CIDRMask(int(prefix), 128),
|
|
}
|
|
r.Gateway = net.ParseIP(route["next-hop"].(string))
|
|
r.Metric = int(route["metric"].(uint32))
|
|
conn.Routes6 = append(conn.Routes6, r)
|
|
|
|
}
|
|
}
|
|
} else {
|
|
// This is the old style configuration, we do not parse
|
|
// these unless the new style is missing.
|
|
|
|
// Get the zero IP assignment so we can ignore them for
|
|
// gateway addresses.
|
|
zeroIP := make(net.IP, 4)
|
|
zeroIP6 := make(net.IP, 16)
|
|
|
|
// Parse IPv4 address slices.
|
|
addrSlice, ok := settings["ipv4"]["addresses"].([][]uint32)
|
|
if ok {
|
|
for _, addr := range addrSlice {
|
|
gateway := uint2IP(addr[2])
|
|
if gateway != nil && !gateway.Equal(zeroIP) {
|
|
conn.Gateway4 = gateway
|
|
}
|
|
conn.Addresses4 = append(conn.Addresses4, &net.IPNet{
|
|
IP: uint2IP(addr[0]),
|
|
Mask: net.CIDRMask(int(addr[1]), 32),
|
|
})
|
|
}
|
|
}
|
|
|
|
// Parse IPv6 address slices.
|
|
addr6Slice, ok := settings["ipv6"]["addresses"].([][]any)
|
|
if ok {
|
|
for _, addr := range addr6Slice {
|
|
gateway := net.IP(addr[2].([]byte))
|
|
if gateway != nil && !gateway.Equal(zeroIP6) {
|
|
conn.Gateway6 = gateway
|
|
}
|
|
conn.Addresses6 = append(conn.Addresses6, &net.IPNet{
|
|
IP: net.IP(addr[0].([]byte)),
|
|
Mask: net.CIDRMask(int(addr[1].(uint32)), 128),
|
|
})
|
|
}
|
|
}
|
|
|
|
// Parse IPv4 static routes.
|
|
routeSlice, ok := settings["ipv4"]["routes"].([][]uint32)
|
|
if ok {
|
|
for _, route := range routeSlice {
|
|
r := new(Route)
|
|
r.Destination = &net.IPNet{
|
|
IP: uint2IP(route[0]),
|
|
Mask: net.CIDRMask(int(route[1]), 32),
|
|
}
|
|
r.Gateway = uint2IP(route[2])
|
|
r.Metric = int(route[3])
|
|
conn.Routes4 = append(conn.Routes4, r)
|
|
}
|
|
}
|
|
|
|
// Parse IPv6 static routes.
|
|
route6Slice, ok := settings["ipv6"]["routes"].([][]any)
|
|
if ok {
|
|
for _, route := range route6Slice {
|
|
r := new(Route)
|
|
r.Destination = &net.IPNet{
|
|
IP: net.IP(route[0].([]byte)),
|
|
Mask: net.CIDRMask(int(route[1].(uint32)), 128),
|
|
}
|
|
r.Gateway = net.IP(route[2].([]byte))
|
|
r.Metric = int(route[3].(uint32))
|
|
conn.Routes6 = append(conn.Routes6, r)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Parse DNS servers and search domains from both families. DNS is stored
|
|
// independently of the address style, so it is read for both new and old
|
|
// configurations.
|
|
if ipv4, ok := settings["ipv4"]; ok {
|
|
conn.DNS = append(conn.DNS, nmNameservers(ipv4)...)
|
|
conn.DNSSearch = append(conn.DNSSearch, nmSearchDomains(ipv4)...)
|
|
}
|
|
if ipv6, ok := settings["ipv6"]; ok {
|
|
conn.DNS = append(conn.DNS, nmNameservers(ipv6)...)
|
|
conn.DNSSearch = append(conn.DNSSearch, nmSearchDomains(ipv6)...)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// nmTarget is a saved connection that configures an interface, paired with the
|
|
// id nmcli is driven with.
|
|
type nmTarget struct {
|
|
ID string
|
|
Settings gonetworkmanager.ConnectionSettings
|
|
}
|
|
|
|
// nmSettingsMAC returns the hardware address a profile is pinned to, or an empty
|
|
// string when it is pinned to none. D-Bus reports mac-address as a byte array,
|
|
// but a profile read back from a keyfile can surface it already printed, so both
|
|
// are accepted.
|
|
func nmSettingsMAC(settings gonetworkmanager.ConnectionSettings) string {
|
|
for _, group := range []string{"802-3-ethernet", "802-11-wireless"} {
|
|
switch mac := settings[group]["mac-address"].(type) {
|
|
case string:
|
|
return mac
|
|
case []byte:
|
|
if len(mac) != 0 {
|
|
return net.HardwareAddr(mac).String()
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// nmActiveConnectionUUID returns the uuid of the profile NetworkManager has
|
|
// active on iface. An interface with nothing active on it, and a host whose
|
|
// nmcli cannot answer, both return an empty string: this only ever adds
|
|
// candidates to a match, so failing to resolve it costs nothing beyond what
|
|
// the interface name alone would have found.
|
|
func nmActiveConnectionUUID(ctx context.Context, iface string) string {
|
|
out, err := runCommand(ctx, "nmcli", "-g", "GENERAL.CON-UUID", "device", "show", iface)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
for _, line := range out {
|
|
line = strings.TrimSpace(line)
|
|
// nmcli prints "--" for a device that is not connected.
|
|
if line != "" && line != "--" {
|
|
return line
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// nmDeviceMAC returns the hardware address of iface as the running system
|
|
// reports it, or an empty string when there is no such device.
|
|
func nmDeviceMAC(iface string) string {
|
|
link, err := netlink.LinkByName(iface)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return link.Attrs().HardwareAddr.String()
|
|
}
|
|
|
|
// connectionsFor returns the saved connections that configure iface.
|
|
//
|
|
// A profile that names an interface is bound to it and is the whole answer when
|
|
// one exists. NetworkManager also binds profiles to a device by other means:
|
|
// the default wired connection it creates for a device with no profile of its
|
|
// own names no interface at all, and a profile can be pinned to a hardware
|
|
// address instead. Matching on the name alone left a host whose only profile
|
|
// was one of those with nothing to write to -- the change applied to the
|
|
// running system and disappeared on the next reboot -- so when nothing names
|
|
// the interface, the profile the device is actually running and any profile
|
|
// pinned to its hardware address are matched instead.
|
|
//
|
|
// The fallback is only reached when the name matches nothing, so a host whose
|
|
// profiles are named in the ordinary way pays neither of its lookups.
|
|
func (nm *networkManager) connectionsFor(ctx context.Context, iface string) ([]nmTarget, error) {
|
|
connections, err := nm.config.ListConnections()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Read every profile once; both passes below work from the same snapshot.
|
|
var all []nmTarget
|
|
for _, c := range connections {
|
|
settings, serr := c.GetSettings()
|
|
if serr != nil {
|
|
return nil, serr
|
|
}
|
|
id, ok := settings["connection"]["id"].(string)
|
|
if !ok || id == "" {
|
|
// Without an id there is nothing to point nmcli at.
|
|
continue
|
|
}
|
|
all = append(all, nmTarget{ID: id, Settings: settings})
|
|
}
|
|
|
|
// Profiles bound to the interface by name.
|
|
matched := matchByIfaceName(all, iface)
|
|
if len(matched) != 0 {
|
|
return matched, nil
|
|
}
|
|
|
|
// Nothing names it: resolve what the device is running and what it is, and
|
|
// match on those instead.
|
|
matched = matchByDevice(all, nmActiveConnectionUUID(ctx, iface), nmDeviceMAC(iface))
|
|
if len(matched) == 0 {
|
|
logger.Printf("NetworkManager has no connection profile for %s; nothing to persist to", iface)
|
|
}
|
|
return matched, nil
|
|
}
|
|
|
|
// matchByIfaceName returns the profiles bound to iface by name.
|
|
func matchByIfaceName(all []nmTarget, iface string) []nmTarget {
|
|
var matched []nmTarget
|
|
for _, t := range all {
|
|
if name, _ := t.Settings["connection"]["interface-name"].(string); name == iface {
|
|
matched = append(matched, t)
|
|
}
|
|
}
|
|
return matched
|
|
}
|
|
|
|
// matchByDevice returns the profiles bound to a device by something other than
|
|
// its name: the one it is currently running, and any pinned to its hardware
|
|
// address. A profile that names an interface is bound there and is never a
|
|
// candidate here, whichever interface that is. Either identifier may be empty
|
|
// on a host where it could not be resolved, which matches nothing rather than
|
|
// everything.
|
|
func matchByDevice(all []nmTarget, activeUUID, mac string) []nmTarget {
|
|
var matched []nmTarget
|
|
for _, t := range all {
|
|
if name, _ := t.Settings["connection"]["interface-name"].(string); name != "" {
|
|
continue
|
|
}
|
|
if uuid, _ := t.Settings["connection"]["uuid"].(string); activeUUID != "" && uuid == activeUUID {
|
|
matched = append(matched, t)
|
|
continue
|
|
}
|
|
if pinned := nmSettingsMAC(t.Settings); mac != "" && pinned != "" && strings.EqualFold(pinned, mac) {
|
|
matched = append(matched, t)
|
|
}
|
|
}
|
|
return matched
|
|
}
|
|
|
|
// nmBusName is the well-known D-Bus name the NetworkManager daemon takes once
|
|
// it is running. Until some process owns it, there is nothing on the bus to
|
|
// answer a configuration call.
|
|
const nmBusName = "org.freedesktop.NetworkManager"
|
|
|
|
// nmBusNameOwned reports whether the NetworkManager daemon currently owns its
|
|
// bus name — the "is the socket live" question. It is asked before any property
|
|
// is read, because NetworkManager is D-Bus activatable: reading a property on
|
|
// an unowned name asks the bus to *start* the daemon. Waiting for a service to
|
|
// come up on its own must not be the thing that launches it.
|
|
func nmBusNameOwned(conn *dbus.Conn) (bool, error) {
|
|
var owned bool
|
|
err := conn.BusObject().Call("org.freedesktop.DBus.NameHasOwner", 0, nmBusName).Store(&owned)
|
|
return owned, err
|
|
}
|
|
|
|
// nmOnBus reports whether the NetworkManager daemon owns its bus name, opening
|
|
// the system bus to ask. It answers the narrower question networkManagerReady
|
|
// asks first: whether there is a daemon to configure at all, regardless of how
|
|
// far along its startup is.
|
|
func nmOnBus() (bool, error) {
|
|
conn, err := dbus.SystemBus()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return nmBusNameOwned(conn)
|
|
}
|
|
|
|
// networkManagerReady reports whether NetworkManager is on the bus and has
|
|
// finished starting up. The two are distinct: the daemon takes its bus name
|
|
// early, then spends a while bringing up the connections it is configured to
|
|
// activate at boot. Its Startup property stays true for that window, and a
|
|
// connection modified during it can be overwritten as startup completes.
|
|
//
|
|
// A missing system bus is reported as "not ready" rather than as a hard error,
|
|
// because a host early enough in boot to beat NetworkManager can also be early
|
|
// enough to beat dbus-daemon.
|
|
func networkManagerReady() (bool, error) {
|
|
owned, err := nmOnBus()
|
|
if err != nil || !owned {
|
|
return false, err
|
|
}
|
|
|
|
daemon, err := gonetworkmanager.NewNetworkManager()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
startup, err := daemon.GetPropertyStartup()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return !startup, nil
|
|
}
|
|
|
|
// newNetworkManager waits for the NetworkManager daemon to be ready, then opens
|
|
// its settings interface. Unlike the file backends, NetworkManager is
|
|
// configured through a running daemon, so a configurator built while it is
|
|
// still starting would hold a settings handle that reports no connections and
|
|
// accepts no changes.
|
|
//
|
|
// Running out of that wait is not by itself a reason to give up on the backend.
|
|
// The Startup property stays true for as long as any device is still working
|
|
// through its initial activation, and a device retrying a lease no DHCP server
|
|
// will ever answer holds it true indefinitely -- a state a host is most likely
|
|
// to be in precisely when someone is about to give it a static address. What
|
|
// the wait buys is a daemon that will not rewrite the change as it finishes
|
|
// starting; a daemon that is on the bus is still configurable without it, and
|
|
// dropping the backend instead would report a NetworkManager host as having
|
|
// nowhere to persist its network configuration at all.
|
|
func newNetworkManager(ctx context.Context, readyTimeout time.Duration) (nm *networkManager, err error) {
|
|
if waitErr := waitForServiceReady(ctx, "NetworkManager", readyTimeout, networkManagerReady); waitErr != nil {
|
|
// A cancelled caller is not waiting for an answer any more.
|
|
if ctx.Err() != nil {
|
|
return nil, waitErr
|
|
}
|
|
owned, busErr := nmOnBus()
|
|
if busErr != nil || !owned {
|
|
return nil, waitErr
|
|
}
|
|
logger.Printf("NetworkManager has not finished starting up; configuring it anyway: %v", waitErr)
|
|
}
|
|
|
|
config, err := gonetworkmanager.NewSettings()
|
|
if err != nil {
|
|
return
|
|
}
|
|
nm = &networkManager{
|
|
config: config,
|
|
}
|
|
return
|
|
}
|
|
|
|
// Get interfaces configured.
|
|
func (nm *networkManager) 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
|
|
}
|
|
|
|
// Get connections from NM.
|
|
connections, err := nm.config.ListConnections()
|
|
|
|
// Add devices to the list.
|
|
for _, c := range connections {
|
|
// Get the settings of the connection.
|
|
var settings gonetworkmanager.ConnectionSettings
|
|
settings, err = c.GetSettings()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// Parse the connection. A connection that fails to parse (for example
|
|
// a VPN or bridge-slave profile with no interface-name) is skipped,
|
|
// not fatal, so this must not touch the named err return: doing so
|
|
// would let the last connection processed decide whether the whole
|
|
// call reports an error, discarding an otherwise fully-populated
|
|
// interfaces slice depending purely on D-Bus connection ordering.
|
|
connection, parseErr := nm.ParseConnection(settings)
|
|
if parseErr != nil {
|
|
continue
|
|
}
|
|
|
|
// A profile that names no interface cannot be reported as one: there is
|
|
// no device name to key the merge by. It is still written to, which
|
|
// connectionsFor resolves separately.
|
|
if connection.Name == "" {
|
|
continue
|
|
}
|
|
|
|
// Find the MAC address and link.
|
|
var mac net.HardwareAddr
|
|
var foundLink netlink.Link
|
|
for _, link := range links {
|
|
if link.Attrs().Name == connection.Name {
|
|
mac = link.Attrs().HardwareAddr
|
|
foundLink = link
|
|
}
|
|
}
|
|
|
|
// Setup new interface.
|
|
i := new(Interface)
|
|
i.Name = connection.Name
|
|
i.MAC = mac
|
|
i.Link = foundLink
|
|
i.DHCP4, i.DHCP6 = connection.dhcpState()
|
|
|
|
// Append addresses.
|
|
for _, addr := range connection.Addresses4 {
|
|
i.Addresses = append(i.Addresses, addr)
|
|
}
|
|
for _, addr := range connection.Addresses6 {
|
|
i.Addresses = append(i.Addresses, addr)
|
|
}
|
|
|
|
// Add gateways.
|
|
if connection.Gateway4 != nil {
|
|
i.Gateway4 = connection.Gateway4
|
|
}
|
|
if connection.Gateway6 != nil {
|
|
i.Gateway6 = connection.Gateway6
|
|
}
|
|
|
|
// Append routes.
|
|
for _, route := range connection.Routes4 {
|
|
i.Routes = append(i.Routes, route)
|
|
}
|
|
for _, route := range connection.Routes6 {
|
|
i.Routes = append(i.Routes, route)
|
|
}
|
|
|
|
// Add DNS servers and search domains.
|
|
i.DNS = append(i.DNS, connection.DNS...)
|
|
i.SearchDomains = append(i.SearchDomains, connection.DNSSearch...)
|
|
|
|
// Add the interface.
|
|
interfaces = append(interfaces, i)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// Set the IP addresses on an interface.
|
|
func (nm *networkManager) SetIfaceAddresses(ctx context.Context, iface string, addrs []*net.IPNet, gateway4, gateway6 net.IP) (err error) {
|
|
// Separate addresses by addr4 and addr6.
|
|
var addrs4 []string
|
|
var addrs6 []string
|
|
for _, addr := range addrs {
|
|
if addr.IP.To4() == nil {
|
|
addrs6 = append(addrs6, addr.String())
|
|
} else {
|
|
addrs4 = append(addrs4, addr.String())
|
|
}
|
|
}
|
|
|
|
// Find the connections that configure the interface.
|
|
targets, err := nm.connectionsFor(ctx, iface)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var errs []error
|
|
|
|
for _, t := range targets {
|
|
id, settings := t.ID, t.Settings
|
|
|
|
// Read the current addressing method of each family. NetworkManager
|
|
// serves static addresses alongside a lease when the method is "auto",
|
|
// so a connection already on DHCP keeps it: changing an address is not
|
|
// a request to stop using DHCP, and SetIfaceDHCP is how that is asked
|
|
// for. Only a family that is not already leasing is moved to "manual"
|
|
// (or off, when it is left with no addresses at all).
|
|
method4, _ := settings["ipv4"]["method"].(string)
|
|
method6, _ := settings["ipv6"]["method"].(string)
|
|
conn := &nmConnection{Method4: method4, Method6: method6}
|
|
dhcp4, dhcp6 := conn.dhcpState()
|
|
|
|
// Update address list for IPv4.
|
|
newMethod4 := nmStaticMethod4(len(addrs4) != 0)
|
|
if dhcp4 {
|
|
newMethod4 = "auto"
|
|
}
|
|
gateway4S := ""
|
|
if gateway4 != nil && len(addrs4) != 0 {
|
|
gateway4S = gateway4.String()
|
|
}
|
|
_, err = runCommand(ctx, "nmcli", "connection", "modify", id, "ipv4.method", newMethod4, "ipv4.addresses", strings.Join(addrs4, ","), "ipv4.gateway", gateway4S)
|
|
if err != nil {
|
|
errs = append(errs, fmt.Errorf("failed to set ipv4.addresses on %s: %w", id, err))
|
|
}
|
|
|
|
// Update address list for IPv6.
|
|
newMethod6 := nmStaticMethod6(len(addrs6) != 0)
|
|
if dhcp6 {
|
|
newMethod6 = method6
|
|
}
|
|
gateway6S := ""
|
|
if gateway6 != nil && len(addrs6) != 0 {
|
|
gateway6S = gateway6.String()
|
|
}
|
|
_, err = runCommand(ctx, "nmcli", "connection", "modify", id, "ipv6.method", newMethod6, "ipv6.addresses", strings.Join(addrs6, ","), "ipv6.gateway", gateway6S)
|
|
if err != nil {
|
|
errs = append(errs, fmt.Errorf("failed to set ipv6.addresses on %s: %w", id, err))
|
|
}
|
|
}
|
|
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
// Set the DHCP client state on an interface.
|
|
func (nm *networkManager) SetIfaceDHCP(ctx context.Context, iface string, dhcp4, dhcp6 bool) (err error) {
|
|
// Find the connections that configure the interface.
|
|
targets, err := nm.connectionsFor(ctx, iface)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var errs []error
|
|
|
|
for _, t := range targets {
|
|
id, settings := t.ID, t.Settings
|
|
|
|
// Parse the connection so turning a lease off can fall back to the
|
|
// method that still serves whatever static addresses it holds.
|
|
conn, parseErr := nm.ParseConnection(settings)
|
|
if parseErr != nil {
|
|
errs = append(errs, fmt.Errorf("failed to parse connection %s: %w", id, parseErr))
|
|
continue
|
|
}
|
|
|
|
// Switch the IPv4 method. "auto" is DHCPv4; NetworkManager keeps
|
|
// serving any ipv4.addresses the connection carries alongside it.
|
|
method4 := "auto"
|
|
if !dhcp4 {
|
|
method4 = nmStaticMethod4(len(conn.Addresses4) != 0)
|
|
}
|
|
if _, err = runCommand(ctx, "nmcli", "connection", "modify", id, "ipv4.method", method4); err != nil {
|
|
errs = append(errs, fmt.Errorf("failed to set ipv4.method on %s: %w", id, err))
|
|
}
|
|
|
|
// Switch the IPv6 method. A connection already on "auto" is left there
|
|
// rather than forced to "dhcp": both run a DHCPv6 client, and "auto"
|
|
// additionally honors router advertisements, which turning it into
|
|
// "dhcp" would silently switch off.
|
|
method6 := "auto"
|
|
if dhcp6 && conn.Method6 == "dhcp" {
|
|
method6 = "dhcp"
|
|
} else if !dhcp6 {
|
|
method6 = nmStaticMethod6(len(conn.Addresses6) != 0)
|
|
}
|
|
if _, err = runCommand(ctx, "nmcli", "connection", "modify", id, "ipv6.method", method6); err != nil {
|
|
errs = append(errs, fmt.Errorf("failed to set ipv6.method on %s: %w", id, err))
|
|
}
|
|
}
|
|
|
|
// A device that is not activated holds no lease for anyone to be connected
|
|
// over, and left alone it goes on chasing the client just turned off: an
|
|
// activation NetworkManager cannot complete fails, deconfigures the
|
|
// interface -- taking with it any address configured underneath it -- and
|
|
// is not retried until the profile's own autoconnect timer comes round,
|
|
// minutes later. Put the profile into effect now so the device comes up on
|
|
// the configuration it was just given. An activated device is left alone,
|
|
// as promised: turning a client off does not tear down the lease it holds.
|
|
if len(targets) != 0 && (!dhcp4 || !dhcp6) && !nmDeviceActivated(ctx, iface) {
|
|
if rerr := reapplyDevice(ctx, iface); rerr != nil {
|
|
logger.Printf("error applying the connection profile to %s: %v", iface, rerr)
|
|
}
|
|
}
|
|
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
// reapplyDevice has NetworkManager put the interface's connection profile into
|
|
// effect. `device reapply` changes the device in place and, unlike `connection
|
|
// up`, does not tear the link down first.
|
|
func reapplyDevice(ctx context.Context, iface string) error {
|
|
_, err := runCommand(ctx, "nmcli", "device", "reapply", iface)
|
|
return err
|
|
}
|
|
|
|
// renewDHCP reapplies the profile so a client the interface was just told to
|
|
// run starts and acquires a lease.
|
|
func (nm *networkManager) renewDHCP(ctx context.Context, iface string) error {
|
|
return reapplyDevice(ctx, iface)
|
|
}
|
|
|
|
// nmDeviceActivated reports whether NetworkManager has iface activated. A
|
|
// device it cannot be asked about reads as activated, which is the answer that
|
|
// leaves the running system alone.
|
|
func nmDeviceActivated(ctx context.Context, iface string) bool {
|
|
daemon, err := gonetworkmanager.NewNetworkManager()
|
|
if err != nil {
|
|
return true
|
|
}
|
|
device, err := daemon.GetDeviceByIpIface(iface)
|
|
if err != nil {
|
|
return true
|
|
}
|
|
state, err := device.GetPropertyState()
|
|
if err != nil {
|
|
return true
|
|
}
|
|
return state == gonetworkmanager.NmDeviceStateActivated
|
|
}
|
|
|
|
// Set static routes to interface.
|
|
func (nm *networkManager) SetIfaceRoutes(ctx 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 %s %d", route.Destination.String(), route.Gateway, route.Metric)
|
|
if route.Destination.IP.To4() == nil {
|
|
routes6 = append(routes6, r)
|
|
} else {
|
|
routes4 = append(routes4, r)
|
|
}
|
|
}
|
|
|
|
// Find the connections that configure the interface.
|
|
targets, err := nm.connectionsFor(ctx, iface)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var errs []error
|
|
|
|
for _, t := range targets {
|
|
id := t.ID
|
|
|
|
// Update routes.
|
|
_, err = runCommand(ctx, "nmcli", "connection", "modify", id, "ipv4.routes", strings.Join(routes4, ","))
|
|
if err != nil {
|
|
errs = append(errs, fmt.Errorf("failed to set ipv4.routes on %s: %w", id, err))
|
|
}
|
|
_, err = runCommand(ctx, "nmcli", "connection", "modify", id, "ipv6.routes", strings.Join(routes6, ","))
|
|
if err != nil {
|
|
errs = append(errs, fmt.Errorf("failed to set ipv6.routes on %s: %w", id, err))
|
|
}
|
|
}
|
|
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
// Set DNS servers and search domains on interface.
|
|
func (nm *networkManager) SetIfaceDNS(ctx context.Context, iface string, servers []net.IP, searchDomains []string) (err error) {
|
|
// Separate DNS servers by family; NetworkManager keeps them under the
|
|
// ipv4 and ipv6 settings.
|
|
var dns4 []string
|
|
var dns6 []string
|
|
for _, ip := range servers {
|
|
if ip == nil {
|
|
continue
|
|
}
|
|
if ip.To4() == nil {
|
|
dns6 = append(dns6, ip.String())
|
|
} else {
|
|
dns4 = append(dns4, ip.String())
|
|
}
|
|
}
|
|
search := strings.Join(searchDomains, ",")
|
|
|
|
// Find the connections that configure the interface.
|
|
targets, err := nm.connectionsFor(ctx, iface)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var errs []error
|
|
|
|
for _, t := range targets {
|
|
id := t.ID
|
|
|
|
// Update DNS servers and search domains for each family. Also disable
|
|
// automatic DNS from DHCP/RA so the static servers are the only
|
|
// resolvers used.
|
|
_, err = runCommand(ctx, "nmcli", "connection", "modify", id, "ipv4.dns", strings.Join(dns4, ","), "ipv4.dns-search", search, "ipv4.ignore-auto-dns", "yes")
|
|
if err != nil {
|
|
errs = append(errs, fmt.Errorf("failed to set ipv4.dns on %s: %w", id, err))
|
|
}
|
|
_, err = runCommand(ctx, "nmcli", "connection", "modify", id, "ipv6.dns", strings.Join(dns6, ","), "ipv6.dns-search", search, "ipv6.ignore-auto-dns", "yes")
|
|
if err != nil {
|
|
errs = append(errs, fmt.Errorf("failed to set ipv6.dns on %s: %w", id, err))
|
|
}
|
|
}
|
|
|
|
// Put the resolvers into effect. Writing them to the profile alone leaves
|
|
// the running device on whatever it resolved through before, and the
|
|
// ignore-auto-dns above means that is now nothing at all where the old
|
|
// resolvers came from a lease -- a device holding an address, a route, and
|
|
// no way to resolve a name. Unlike turning a DHCP client off, this carries
|
|
// no risk for a caller connected over the interface: reapply changes the
|
|
// device in place and does not tear the link down.
|
|
if len(targets) != 0 {
|
|
if rerr := reapplyDevice(ctx, iface); rerr != nil {
|
|
logger.Printf("error applying the connection profile to %s: %v", iface, rerr)
|
|
}
|
|
}
|
|
|
|
return errors.Join(errs...)
|
|
}
|