286 lines
7.9 KiB
Go
286 lines
7.9 KiB
Go
|
|
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
|
||
|
|
}
|