package netconfig import ( "context" "errors" "fmt" "net" "strings" "github.com/Wifx/gonetworkmanager/v3" "github.com/vishvananda/netlink" ) type nmConnection struct { ID string Name string UsingData bool Addresses4 []*net.IPNet Addresses6 []*net.IPNet Gateway4 net.IP Gateway6 net.IP Routes4 []*Route Routes6 []*Route DNS []net.IP DNSSearch []string } 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 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 } // Verify netplan exists, and try parsing its configurations. func newNetworkManager() (nm *networkManager, err error) { 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 // 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 } // Update address list for IPv4. if len(addrs4) == 0 { _, err = runCommand(ctx, "nmcli", "connection", "modify", id, "ipv4.method", "disabled", "ipv4.addresses", "", "ipv4.gateway", "") if err != nil { errs = append(errs, fmt.Errorf("failed to disable ipv4 on %s: %w", id, err)) } } else { gateway4S := "" if gateway4 != nil { gateway4S = gateway4.String() } _, err = runCommand(ctx, "nmcli", "connection", "modify", id, "ipv4.method", "manual", "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. if len(addrs6) == 0 { _, err = runCommand(ctx, "nmcli", "connection", "modify", id, "ipv6.method", "link-local", "ipv6.addresses", "", "ipv6.gateway", "") if err != nil { errs = append(errs, fmt.Errorf("failed to set ipv6 link-local on %s: %w", id, err)) } } else { gateway6S := "" if gateway6 != nil { gateway6S = gateway6.String() } _, err = runCommand(ctx, "nmcli", "connection", "modify", id, "ipv6.method", "manual", "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 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...) }