package networking import ( "context" "fmt" "net/netip" "os" "path/filepath" "strings" "nadir/internal/oscmd" ) // networkdBackend implements backend via systemd-networkd. Configuration is done // by writing .network files under networkdDir. Each managed interface gets its // own file named "90-nadir-.network" — the 90 prefix puts nadir's config // after most distro defaults, and the "nadir-" infix ensures we never clobber // distro-provided files. type networkdBackend struct{} var networkdDir = "/etc/systemd/network" func (b *networkdBackend) Name() string { return "networkd" } // networkdFile returns the path for the nadir-managed .network file for iface. // iface is already validated by validateIface to prevent path traversal. func networkdFile(iface string) string { return filepath.Join(networkdDir, "90-nadir-"+iface+".network") } func (b *networkdBackend) Snapshot(ctx context.Context, iface string) (IfaceConfig, error) { path := networkdFile(iface) data, err := os.ReadFile(path) if err != nil { // No nadir-managed file exists. Fall back to live ip output and assume // DHCP — the safest rollback assumption (reverts to "whatever the // system was doing before nadir touched it"). return snapshotFromIP(ctx, iface) } return parseNetworkdFile(string(data)), nil } // snapshotFromIP captures the current state from ip -j, assuming DHCP as the // method since there's no way to tell from live output. func snapshotFromIP(ctx context.Context, iface string) (IfaceConfig, error) { cfg := IfaceConfig{Method: "dhcp"} // Grab addresses. out, err := oscmd.RunContext(ctx, "ip", "-j", "addr", "show", "--", iface) if err != nil { return cfg, nil // interface may not exist yet; DHCP fallback is fine } ifaces, err := parseInterfaces(out) if err != nil || len(ifaces) == 0 { return cfg, nil } // If there are IPv4 addresses, capture the first one. if len(ifaces[0].IPv4) > 0 { addr, prefix := splitCIDR(ifaces[0].IPv4[0]) cfg.Method = "static" cfg.Address = addr cfg.Prefix = prefix } // Capture a global IPv6 address if present, skipping link-local (fe80::/10), // so a rollback restores the interface's real IPv6 state. cfg.IPv6 = &IPv6Config{Method: "auto"} for _, c := range ifaces[0].IPv6 { addr, prefix := splitCIDR(c) if ip, err := netip.ParseAddr(addr); err == nil && !ip.IsLinkLocalUnicast() { cfg.IPv6 = &IPv6Config{Method: "static", Address: addr, Prefix: prefix} break } } // Grab the default gateway for this interface. routeOut, err := oscmd.RunContext(ctx, "ip", "-j", "route", "show", "dev", "--", iface) if err == nil { routes, _ := parseRoutes(routeOut) for _, r := range routes { if r.Destination == "default" && r.Gateway != "" { cfg.Gateway = r.Gateway break } } } // Grab DNS from /etc/resolv.conf if data, err := os.ReadFile(resolvConf); err == nil { cfg.DNS = parseResolv(string(data)) } return cfg, nil } // parseNetworkdFile extracts IfaceConfig from a systemd .network file. func parseNetworkdFile(content string) IfaceConfig { cfg := IfaceConfig{Method: "dhcp"} section := "" for line := range strings.SplitSeq(content, "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { section = line continue } key, val, ok := strings.Cut(line, "=") if !ok { continue } key = strings.TrimSpace(key) val = strings.TrimSpace(val) if section == "[Network]" { switch key { case "DHCP": if val == "yes" || val == "ipv4" { cfg.Method = "dhcp" } case "Address": addr, prefix := splitCIDR(val) if ip, err := netip.ParseAddr(addr); err == nil && !ip.Is4() { g := v6(&cfg) g.Method = "static" g.Address = addr g.Prefix = prefix } else if addr != "" { cfg.Method = "static" cfg.Address = addr cfg.Prefix = prefix } case "Gateway": if ip, err := netip.ParseAddr(val); err == nil && !ip.Is4() { v6(&cfg).Gateway = val } else { cfg.Gateway = val } case "DNS": for s := range strings.FieldsSeq(val) { cfg.DNS = append(cfg.DNS, s) } case "IPv6AcceptRA": if val == "yes" { if g := v6(&cfg); g.Method != "static" { g.Method = "auto" } } case "LinkLocalAddressing": if val == "no" { v6(&cfg).Method = "ignore" } } } } // Parse [Route] sections separately. cfg.Routes = parseNetworkdRoutes(content) return cfg } // parseNetworkdRoutes extracts Route entries from [Route] sections. func parseNetworkdRoutes(content string) []Route { var routes []Route var inRoute bool var current Route for line := range strings.SplitSeq(content, "\n") { line = strings.TrimSpace(line) if strings.HasPrefix(line, "[") { // Flush any pending route when entering a new section. if inRoute && (current.Destination != "" || current.Gateway != "") { routes = append(routes, current) } inRoute = line == "[Route]" current = Route{} continue } if !inRoute { continue } key, val, ok := strings.Cut(line, "=") if !ok { continue } switch strings.TrimSpace(key) { case "Destination": current.Destination = strings.TrimSpace(val) case "Gateway": current.Gateway = strings.TrimSpace(val) } } // Flush last route if we ended inside a [Route] section. if inRoute && (current.Destination != "" || current.Gateway != "") { routes = append(routes, current) } return routes } func (b *networkdBackend) Apply(ctx context.Context, iface string, cfg IfaceConfig) error { content := renderNetworkdFile(iface, cfg) path := networkdFile(iface) if err := os.MkdirAll(networkdDir, 0755); err != nil { return fmt.Errorf("mkdir %s: %w", networkdDir, err) } if err := os.WriteFile(path, []byte(content), 0644); err != nil { return fmt.Errorf("write %s: %w", path, err) } // Reload networkd and reconfigure the specific interface. if _, err := oscmd.RunContext(ctx, "networkctl", "reload"); err != nil { return fmt.Errorf("networkctl reload: %w", err) } if _, err := oscmd.RunContext(ctx, "networkctl", "reconfigure", "--", iface); err != nil { return fmt.Errorf("networkctl reconfigure %s: %w", iface, err) } return nil } // renderNetworkdFile builds the INI content for a systemd .network file. func renderNetworkdFile(iface string, cfg IfaceConfig) string { var b strings.Builder b.WriteString("# Managed by nadir — do not edit manually.\n") b.WriteString("[Match]\n") fmt.Fprintf(&b, "Name=%s\n\n", iface) b.WriteString("[Network]\n") switch cfg.Method { case "dhcp": b.WriteString("DHCP=yes\n") case "static": b.WriteString("DHCP=no\n") fmt.Fprintf(&b, "Address=%s/%d\n", cfg.Address, cfg.Prefix) if cfg.Gateway != "" { fmt.Fprintf(&b, "Gateway=%s\n", cfg.Gateway) } } if cfg.IPv6 != nil { switch cfg.IPv6.Method { case "static": fmt.Fprintf(&b, "Address=%s/%d\n", cfg.IPv6.Address, cfg.IPv6.Prefix) if cfg.IPv6.Gateway != "" { fmt.Fprintf(&b, "Gateway=%s\n", cfg.IPv6.Gateway) } b.WriteString("IPv6AcceptRA=no\n") case "auto": b.WriteString("IPv6AcceptRA=yes\n") case "ignore": b.WriteString("LinkLocalAddressing=no\nIPv6AcceptRA=no\n") } } for _, dns := range cfg.DNS { fmt.Fprintf(&b, "DNS=%s\n", dns) } for _, r := range cfg.Routes { b.WriteString("\n[Route]\n") fmt.Fprintf(&b, "Destination=%s\n", r.Destination) fmt.Fprintf(&b, "Gateway=%s\n", r.Gateway) } return b.String() } func (b *networkdBackend) SetLinkUp(ctx context.Context, iface string) error { // Note: ip link set parses DEVICE positionally, so -- is technically ignored // by ip but included here for consistency with other oscmd calls. _, err := oscmd.RunContext(ctx, "ip", "link", "set", "--", iface, "up") return err } func (b *networkdBackend) SetLinkDown(ctx context.Context, iface string) error { _, err := oscmd.RunContext(ctx, "ip", "link", "set", "--", iface, "down") return err }