Files

286 lines
7.9 KiB
Go
Raw Permalink Normal View History

2026-06-22 16:06:57 +02:00
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-<iface>.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
}