package networking import ( "context" "fmt" "strconv" "strings" "nadir/internal/oscmd" ) // nmcliBackend implements backend via NetworkManager's nmcli CLI. type nmcliBackend struct{} func (b *nmcliBackend) Name() string { return "nmcli" } // connForIface resolves a network interface name to the NM connection name // that owns it. Returns an error if the interface has no active connection. // // nmcli -t uses ':' as the field separator. Connection names can contain colons // (e.g. "VLAN:100"), but Linux device names cannot, so we split on the last // colon to get the device and treat everything before it as the connection name. func connForIface(ctx context.Context, iface string) (string, error) { out, err := oscmd.RunContext(ctx, "nmcli", "-t", "-f", "NAME,DEVICE", "con", "show", "--active") if err != nil { return "", fmt.Errorf("nmcli con show: %w", err) } for line := range strings.SplitSeq(out, "\n") { // Split on the last colon: "conn:name:eth0" → ("conn:name", "eth0") idx := strings.LastIndex(line, ":") if idx < 0 { continue } name, dev := line[:idx], line[idx+1:] if dev == iface { return name, nil } } return "", fmt.Errorf("no active NM connection found for interface %s", iface) } func (b *nmcliBackend) Snapshot(ctx context.Context, iface string) (IfaceConfig, error) { conn, err := connForIface(ctx, iface) if err != nil { // No managed connection → assume DHCP (safest rollback assumption). return IfaceConfig{Method: "dhcp"}, nil } // nmcli's `con show ` parser does NOT honor `--` as an end-of-options // separator; passing it makes nmcli look for a connection literally named // "--" and fail. `conn` comes from nmcli's own active-connections list (see // connForIface), so it's already validated — no shell-metacharacter risk. // Same applies to con up / con down / con modify below. out, err := oscmd.RunContext(ctx, "nmcli", "-t", "-f", "ipv4.method,ipv4.addresses,ipv4.gateway,ipv4.dns,ipv4.routes,ipv6.method,ipv6.addresses,ipv6.gateway", "con", "show", conn) if err != nil { // nmcli can refuse the read (connection state odd, permission, terse-mode // quirks). Fall back to DHCP defaults so the prefill endpoint still // returns a usable form, mirroring the networkd / ifupdown fallback. return IfaceConfig{Method: "dhcp"}, nil } return parseNmcliSnapshot(out), nil } // parseNmcliSnapshot parses the terse output of `nmcli -t -f ... con show`. // Fields are colon-separated key:value lines. Multi-valued fields (addresses, // dns, routes) use comma separation within the value. func parseNmcliSnapshot(out string) IfaceConfig { cfg := IfaceConfig{Method: "dhcp"} for line := range strings.SplitSeq(out, "\n") { key, val, ok := strings.Cut(line, ":") if !ok || val == "" || val == "--" { continue } switch key { case "ipv4.method": if val == "manual" { cfg.Method = "static" } else { cfg.Method = "dhcp" } case "ipv4.addresses": // "192.168.1.10/24" or "192.168.1.10/24, 10.0.0.1/8" for _, part := range splitTrim(val, ",") { addr, prefix := splitCIDR(part) if addr != "" { cfg.Address = addr cfg.Prefix = prefix } } case "ipv4.gateway": cfg.Gateway = val case "ipv4.dns": cfg.DNS = append(cfg.DNS, splitTrim(val, ",")...) case "ipv4.routes": // "dst=10.0.0.0/24, nh=192.168.1.1; dst=default, nh=192.168.1.1" // or simpler "{ dst = 10.0.0.0/24, nh = 192.168.1.1 }" cfg.Routes = parseNmcliRoutes(val) case "ipv6.method": v6(&cfg).Method = nmcliV6Method(val) case "ipv6.addresses": for _, part := range splitTrim(val, ",") { if addr, prefix := splitCIDR(part); addr != "" { v6(&cfg).Address = addr v6(&cfg).Prefix = prefix } } case "ipv6.gateway": v6(&cfg).Gateway = val } } return cfg } // v6 lazily allocates the IPv6 block so a snapshot captures the live IPv6 state // (defaulting to "auto") even when only some ipv6.* fields are present. func v6(cfg *IfaceConfig) *IPv6Config { if cfg.IPv6 == nil { cfg.IPv6 = &IPv6Config{Method: "auto"} } return cfg.IPv6 } // nmcliV6Method maps nmcli's ipv6.method to our vocabulary. func nmcliV6Method(val string) string { switch val { case "manual": return "static" case "ignore", "disabled", "link-local": return "ignore" default: return "auto" } } // parseNmcliRoutes parses route entries from nmcli terse output. func parseNmcliRoutes(val string) []Route { var routes []Route // Routes are semicolon-separated, each containing "dst=..., nh=..." for _, entry := range splitTrim(val, ";") { entry = strings.Trim(entry, "{}") var r Route for _, part := range splitTrim(entry, ",") { k, v, ok := strings.Cut(part, "=") if !ok { continue } switch strings.TrimSpace(k) { case "dst": r.Destination = strings.TrimSpace(v) case "nh": r.Gateway = strings.TrimSpace(v) } } if r.Destination != "" && r.Gateway != "" { routes = append(routes, r) } } return routes } func (b *nmcliBackend) Apply(ctx context.Context, iface string, cfg IfaceConfig) error { conn, err := connForIface(ctx, iface) if err != nil { return fmt.Errorf("cannot apply: %w", err) } // conn comes from nmcli's own active list (connForIface), not user input. // nmcli's con subcommands don't honor "--" as an end-of-options separator. args := []string{"con", "modify", conn} switch cfg.Method { case "static": cidr := fmt.Sprintf("%s/%d", cfg.Address, cfg.Prefix) args = append(args, "ipv4.method", "manual", "ipv4.addresses", cidr, ) if cfg.Gateway != "" { args = append(args, "ipv4.gateway", cfg.Gateway) } else { args = append(args, "ipv4.gateway", "") } case "dhcp": args = append(args, "ipv4.method", "auto", "ipv4.addresses", "", "ipv4.gateway", "", ) } // DNS: set or clear. if len(cfg.DNS) > 0 { args = append(args, "ipv4.dns", strings.Join(cfg.DNS, ",")) } else { args = append(args, "ipv4.dns", "") } // Routes: set or clear. if len(cfg.Routes) > 0 { var routeStrs []string for _, r := range cfg.Routes { routeStrs = append(routeStrs, r.Destination+" "+r.Gateway) } args = append(args, "ipv4.routes", strings.Join(routeStrs, ",")) } else { args = append(args, "ipv4.routes", "") } // IPv6: only touched when the request includes an ipv6 block. if cfg.IPv6 != nil { switch cfg.IPv6.Method { case "static": args = append(args, "ipv6.method", "manual", "ipv6.addresses", fmt.Sprintf("%s/%d", cfg.IPv6.Address, cfg.IPv6.Prefix), "ipv6.gateway", cfg.IPv6.Gateway, // "" clears it ) case "auto": args = append(args, "ipv6.method", "auto", "ipv6.addresses", "", "ipv6.gateway", "") case "ignore": args = append(args, "ipv6.method", "ignore", "ipv6.addresses", "", "ipv6.gateway", "") } } if _, err := oscmd.RunContext(ctx, "nmcli", args...); err != nil { return fmt.Errorf("nmcli con modify: %w", err) } // Bring the connection up to apply changes. if _, err := oscmd.RunContext(ctx, "nmcli", "con", "up", conn); err != nil { return fmt.Errorf("nmcli con up: %w", err) } return nil } func (b *nmcliBackend) SetLinkUp(ctx context.Context, iface string) error { conn, err := connForIface(ctx, iface) if err != nil { // No NM connection - fall back to `ip link`. _, err = oscmd.RunContext(ctx, "ip", "link", "set", "--", iface, "up") return err } _, err = oscmd.RunContext(ctx, "nmcli", "con", "up", conn) return err } func (b *nmcliBackend) SetLinkDown(ctx context.Context, iface string) error { conn, err := connForIface(ctx, iface) if err != nil { _, err = oscmd.RunContext(ctx, "ip", "link", "set", "--", iface, "down") return err } _, err = oscmd.RunContext(ctx, "nmcli", "con", "down", conn) return err } // --- helpers ----------------------------------------------------------------- // splitTrim splits s on sep and returns the trimmed, non-empty segments. func splitTrim(s, sep string) []string { var out []string for part := range strings.SplitSeq(s, sep) { if p := strings.TrimSpace(part); p != "" { out = append(out, p) } } return out } // splitCIDR splits "192.168.1.10/24" into ("192.168.1.10", 24). A missing or // malformed prefix yields 0. func splitCIDR(cidr string) (string, int) { addr, prefixStr, ok := strings.Cut(cidr, "/") if !ok { return addr, 0 } prefix, _ := strconv.Atoi(prefixStr) return addr, prefix }