Files
2026-06-22 16:06:57 +02:00

240 lines
6.9 KiB
Go

package networking
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"nadir/internal/oscmd"
)
// ifupdownBackend implements backend via classic Debian ifupdown. Configuration
// is done by writing stanza files under interfacesDDir. Each managed interface
// gets "nadir-<iface>" so we never touch /etc/network/interfaces itself.
//
// Prerequisite: /etc/network/interfaces must contain
//
// source /etc/network/interfaces.d/*
//
// for these stanzas to take effect. If missing, Apply checks for this and adds
// the source directive (same auto-provision pattern as the PAM service).
type ifupdownBackend struct{}
var (
interfacesFile = "/etc/network/interfaces"
interfacesDDir = "/etc/network/interfaces.d"
)
func (b *ifupdownBackend) Name() string { return "ifupdown" }
// ifupdownFile returns the path for the nadir-managed stanza file for iface.
// iface is already validated by validateIface to prevent path traversal.
func ifupdownFile(iface string) string {
return filepath.Join(interfacesDDir, "nadir-"+iface)
}
func (b *ifupdownBackend) Snapshot(ctx context.Context, iface string) (IfaceConfig, error) {
path := ifupdownFile(iface)
data, err := os.ReadFile(path)
if err != nil {
// No nadir-managed stanza → fall back to live ip output, assume DHCP.
return snapshotFromIP(ctx, iface)
}
return parseIfupdownStanza(string(data)), nil
}
// parseIfupdownStanza extracts IfaceConfig from an ifupdown stanza file. A file
// may hold both an "inet" (IPv4) and an "inet6" (IPv6) stanza for the interface;
// family tracks which one the indented keys below belong to.
func parseIfupdownStanza(content string) IfaceConfig {
cfg := IfaceConfig{Method: "dhcp"}
family := "inet"
for line := range strings.SplitSeq(content, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
switch fields[0] {
case "iface":
// "iface eth0 inet static" / "iface eth0 inet6 auto"
if len(fields) >= 4 {
family = fields[2]
switch family {
case "inet":
if fields[3] == "static" {
cfg.Method = "static"
}
case "inet6":
switch fields[3] {
case "static":
v6(&cfg).Method = "static"
default: // auto / dhcp / manual → treat as autoconf
v6(&cfg).Method = "auto"
}
}
}
case "address":
addr, prefix := splitCIDR(fields[1])
if family == "inet6" {
g := v6(&cfg)
g.Address = addr
if prefix > 0 {
g.Prefix = prefix
}
} else if addr != "" {
cfg.Address = addr
if prefix > 0 {
cfg.Prefix = prefix
}
}
case "gateway":
if family == "inet6" {
v6(&cfg).Gateway = fields[1]
} else {
cfg.Gateway = fields[1]
}
case "dns-nameservers":
cfg.DNS = append(cfg.DNS, fields[1:]...)
case "up":
// "up ip route add 10.0.0.0/24 via 192.168.1.1"
r := parseUpRoute(fields[1:])
if r.Destination != "" {
cfg.Routes = append(cfg.Routes, r)
}
}
}
return cfg
}
// parseUpRoute extracts a Route from "ip route add <dst> via <gw>" post-up commands.
func parseUpRoute(args []string) Route {
// Expected: ["ip", "route", "add", "10.0.0.0/24", "via", "192.168.1.1"]
if len(args) < 6 || args[0] != "ip" || args[1] != "route" || args[2] != "add" {
return Route{}
}
r := Route{Destination: args[3]}
for i, a := range args {
if a == "via" && i+1 < len(args) {
r.Gateway = args[i+1]
break
}
}
return r
}
func (b *ifupdownBackend) Apply(ctx context.Context, iface string, cfg IfaceConfig) error {
// Ensure interfaces.d exists and is sourced.
if err := ensureSourceDirective(); err != nil {
return err
}
content := renderIfupdownStanza(iface, cfg)
path := ifupdownFile(iface)
if err := os.MkdirAll(interfacesDDir, 0755); err != nil {
return fmt.Errorf("mkdir %s: %w", interfacesDDir, err)
}
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
// Bring the interface down and back up with the new config.
// ifdown may fail if the interface wasn't previously managed — ignore that.
_, _ = oscmd.RunContext(ctx, "ifdown", "--force", "--", iface)
if _, err := oscmd.RunContext(ctx, "ifup", "--", iface); err != nil {
return fmt.Errorf("ifup %s: %w", iface, err)
}
return nil
}
// ensureSourceDirective checks that /etc/network/interfaces contains a source
// line for interfaces.d. If not, appends one (same auto-provision pattern as
// the PAM service file).
func ensureSourceDirective() error {
data, err := os.ReadFile(interfacesFile)
if err != nil {
// If the file doesn't exist at all, that's a broken system; don't create it.
return fmt.Errorf("read %s: %w", interfacesFile, err)
}
content := string(data)
// Check for any source line pointing at interfaces.d.
for line := range strings.SplitSeq(content, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "source") && strings.Contains(line, "interfaces.d") {
return nil // already present
}
}
// Append the source directive.
addition := "\n# Added by nadir to pick up per-interface stanzas.\nsource " + interfacesDDir + "/*\n"
if err := os.WriteFile(interfacesFile, []byte(content+addition), 0644); err != nil {
return fmt.Errorf("append source directive to %s: %w", interfacesFile, err)
}
return nil
}
// renderIfupdownStanza builds the content for an ifupdown stanza file.
func renderIfupdownStanza(iface string, cfg IfaceConfig) string {
var b strings.Builder
b.WriteString("# Managed by nadir — do not edit manually.\n")
switch cfg.Method {
case "dhcp":
fmt.Fprintf(&b, "auto %s\n", iface)
fmt.Fprintf(&b, "iface %s inet dhcp\n", iface)
case "static":
fmt.Fprintf(&b, "auto %s\n", iface)
fmt.Fprintf(&b, "iface %s inet static\n", iface)
fmt.Fprintf(&b, " address %s/%d\n", cfg.Address, cfg.Prefix)
if cfg.Gateway != "" {
fmt.Fprintf(&b, " gateway %s\n", cfg.Gateway)
}
}
if len(cfg.DNS) > 0 {
fmt.Fprintf(&b, " dns-nameservers %s\n", strings.Join(cfg.DNS, " "))
}
for _, r := range cfg.Routes {
fmt.Fprintf(&b, " up ip route add %s via %s\n", r.Destination, r.Gateway)
fmt.Fprintf(&b, " down ip route del %s via %s\n", r.Destination, r.Gateway)
}
// IPv6 goes in its own inet6 stanza (the "auto eth0" above covers both).
if cfg.IPv6 != nil {
switch cfg.IPv6.Method {
case "auto":
fmt.Fprintf(&b, "iface %s inet6 auto\n", iface)
case "static":
fmt.Fprintf(&b, "iface %s inet6 static\n", iface)
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)
}
case "ignore":
// no inet6 stanza
}
}
return b.String()
}
func (b *ifupdownBackend) SetLinkUp(ctx context.Context, iface string) error {
_, err := oscmd.RunContext(ctx, "ifup", "--", iface)
return err
}
func (b *ifupdownBackend) SetLinkDown(ctx context.Context, iface string) error {
_, err := oscmd.RunContext(ctx, "ifdown", "--", iface)
return err
}