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...) }