240 lines
6.9 KiB
Go
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
|
|
}
|