go-network-configurator/networkManager.go

749 lines
22 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.
name, ok := settings["connection"]["interface-name"].(string)
if !ok {
err = fmt.Errorf("failed to get interface name")
return
}
conn.Name = name
// 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
}
// 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
}
// 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) {
conn, err := dbus.SystemBus()
if err != nil {
return false, err
}
owned, err := nmBusNameOwned(conn)
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.
func newNetworkManager(ctx context.Context, readyTimeout time.Duration) (nm *networkManager, err error) {
if err = waitForServiceReady(ctx, "NetworkManager", readyTimeout, networkManagerReady); err != nil {
return nil, err
}
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
}
// 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())
}
}
// Get connections from NM.
connections, err := nm.config.ListConnections()
if err != nil {
return err
}
var errs []error
// Find the connections that has the interfaces.
for _, c := range connections {
// Get the settings of the connection.
var settings gonetworkmanager.ConnectionSettings
settings, err = c.GetSettings()
if err != nil {
return err
}
// Get the interface name.
name, ok := settings["connection"]["interface-name"].(string)
if !ok {
continue
}
if name != iface {
continue
}
// Get the interface id.
id, ok := settings["connection"]["id"].(string)
if !ok {
err = fmt.Errorf("failed to get interface id")
return
}
// 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) {
// Get connections from NM.
connections, err := nm.config.ListConnections()
if err != nil {
return err
}
var errs []error
// Find the connections that has the interfaces.
for _, c := range connections {
// Get the settings of the connection.
var settings gonetworkmanager.ConnectionSettings
settings, err = c.GetSettings()
if err != nil {
return err
}
// Get the interface name.
name, ok := settings["connection"]["interface-name"].(string)
if !ok || name != iface {
continue
}
// Get the interface id.
id, ok := settings["connection"]["id"].(string)
if !ok {
return fmt.Errorf("failed to get interface id")
}
// 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))
}
}
return errors.Join(errs...)
}
// renewDHCP has NetworkManager reapply the connection profile to the device, so
// a client it was just told to run starts and acquires a lease. `device
// reapply` changes the device in place and, unlike `connection up`, does not
// tear the link down first.
func (nm *networkManager) renewDHCP(ctx context.Context, iface string) error {
_, err := runCommand(ctx, "nmcli", "device", "reapply", iface)
return err
}
// 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)
}
}
// Get connections from NM.
connections, err := nm.config.ListConnections()
if err != nil {
return err
}
var errs []error
// Find the connections that has the interfaces.
for _, c := range connections {
// Get the settings of the connection.
var settings gonetworkmanager.ConnectionSettings
settings, err = c.GetSettings()
if err != nil {
return err
}
// Get the interface name.
name, ok := settings["connection"]["interface-name"].(string)
if !ok {
continue
}
if name != iface {
continue
}
// Get the interface id.
id, ok := settings["connection"]["id"].(string)
if !ok {
err = fmt.Errorf("failed to get interface id")
return
}
// 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, ",")
// Get connections from NM.
connections, err := nm.config.ListConnections()
if err != nil {
return err
}
var errs []error
// Find the connections that has the interfaces.
for _, c := range connections {
// Get the settings of the connection.
var settings gonetworkmanager.ConnectionSettings
settings, err = c.GetSettings()
if err != nil {
return err
}
// Get the interface name.
name, ok := settings["connection"]["interface-name"].(string)
if !ok {
continue
}
if name != iface {
continue
}
// Get the interface id.
id, ok := settings["connection"]["id"].(string)
if !ok {
err = fmt.Errorf("failed to get interface id")
return
}
// 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))
}
}
return errors.Join(errs...)
}