275 lines
7.7 KiB
Go
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
|
|
}
|