Files
nadir-agent/internal/modules/networking/nmcli.go
T
2026-06-22 16:06:57 +02:00

275 lines
7.7 KiB
Go

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
}
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 {
return IfaceConfig{}, fmt.Errorf("nmcli con show %s: %w", conn, err)
}
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)
}
// Build the nmcli con modify arguments. Note: conn is safe to place after
// -- since it comes from nmcli output, not directly from the user.
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
}