first commit

This commit is contained in:
2026-06-22 16:06:57 +02:00
commit fe485dd86d
90 changed files with 11404 additions and 0 deletions
+90
View File
@@ -0,0 +1,90 @@
package networking
import (
"context"
"fmt"
"os/exec"
"time"
"nadir/internal/oscmd"
)
// backend is the write-side abstraction. Each host network manager (nmcli,
// networkd, ifupdown) implements this. Reads go through `ip -j` and
// /etc/resolv.conf regardless - they are backend-agnostic (see read.go).
//
// When detect() finds no backend, Module.be is nil and all write endpoints
// return 501 Not Implemented. Reads still work.
// Methods that shell out take a context so a request that is cancelled (client
// disconnect, timeout) kills the slow command (e.g. `nmcli con up` waiting on
// DHCP). The timer-driven auto-revert, which must finish even with no client,
// passes context.Background().
type backend interface {
// Name returns the backend identifier ("nmcli", "networkd", "ifupdown").
Name() string
// Snapshot captures the current IPv4 configuration of iface so it can be
// restored on rollback. The returned IfaceConfig is backend-specific:
// nmcli reads from NM's connection, networkd/ifupdown read their config
// files, falling back to live `ip` output when no managed file exists
// (in which case Method is "dhcp" - safest revert assumption).
Snapshot(ctx context.Context, iface string) (IfaceConfig, error)
// Apply replaces the interface's IPv4 configuration with cfg. It is the
// caller's responsibility to have taken a Snapshot first (the rollback
// mechanism does this). Apply must be idempotent: calling it with the
// same cfg twice should leave the system in the same state.
Apply(ctx context.Context, iface string, cfg IfaceConfig) error
// SetLinkUp brings the interface up.
SetLinkUp(ctx context.Context, iface string) error
// SetLinkDown takes the interface down.
SetLinkDown(ctx context.Context, iface string) error
}
// detect probes the host for a supported network manager, in priority order:
//
// 1. nmcli (NetworkManager) - the majority of desktop and modern server installs
// 2. networkctl (systemd-networkd) - common on minimal/container hosts
// 3. ifup/ifdown (ifupdown) - classic Debian/Ubuntu servers
//
// Returns nil when none is found. The order matters: some distros ship both NM
// and networkd; NM wins because it's the active manager in that case.
func detect() backend {
if _, err := exec.LookPath("nmcli"); err == nil {
if _, err := oscmd.Run("nmcli", "general", "status"); err == nil {
return &nmcliBackend{}
}
}
if _, err := exec.LookPath("networkctl"); err == nil {
if _, err := oscmd.Run("systemctl", "is-active", "--quiet", "systemd-networkd"); err == nil {
return &networkdBackend{}
}
}
if _, err := exec.LookPath("ifup"); err == nil {
if _, err := exec.LookPath("ifdown"); err == nil {
return &ifupdownBackend{}
}
}
return nil
}
// pendingChange tracks a single in-flight change that has been applied but not
// yet confirmed. revert undoes it (re-apply the prior config, or bring a
// downed link back up). If the timer fires before confirmation, revert runs -
// protecting against lock-yourself-out mistakes.
//
// ponytail: one slot for the whole module, not per-interface. An admin makes one
// change at a time; a concurrent change to another iface is rejected with a 409
// that says so. Key it by iface (a map) if multi-interface concurrency is ever
// needed.
type pendingChange struct {
Iface string // interface that was changed
revert func() error // undoes the change, for rollback
Timer *time.Timer // fires the auto-revert
Deadline time.Time // when the timer will fire (for the status endpoint)
}
// errNoBackend is the 501 returned when no write backend was detected.
var errNoBackend = fmt.Errorf("no supported network backend detected (tried nmcli, networkctl, ifup/ifdown)")
+133
View File
@@ -0,0 +1,133 @@
package networking
import (
"net/netip"
"regexp"
"github.com/danielgtaylor/huma/v2"
)
// tagNetworking groups every networking-module operation under one OpenAPI tag,
// keeping tags 1:1 with modules.
const tagNetworking = "Networking"
var (
readErrors = []int{401, 403, 500}
writeErrors = []int{400, 401, 403, 409, 500, 501}
)
// ifaceNameRe matches a Linux interface name. Linux caps names at 15 bytes and
// forbids '/' and whitespace; we additionally reject a leading dash so a name
// can never be read as a flag by `ip`/`nmcli`/`ifup`. Every command line also
// passes user-supplied names after a "--" separator.
var ifaceNameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._@-]{0,14}$`)
func validateIface(name string) error {
if !ifaceNameRe.MatchString(name) {
return huma.Error400BadRequest("invalid interface name: " + name)
}
return nil
}
// IfaceConfig is the declarative desired state for one interface. A PUT replaces
// the interface's IPv4 configuration with this (and IPv6 too, when the IPv6 block
// is included), across whichever backend is in use. Routes are part of the config
// (declarative add/remove): the client sends the full set it wants.
type IfaceConfig struct {
Method string `json:"method" enum:"static,dhcp" example:"static" doc:"\"static\" for a fixed address, \"dhcp\" for automatic"`
Address string `json:"address,omitempty" example:"192.168.1.10" doc:"IPv4 address (static only)"`
Prefix int `json:"prefix,omitempty" minimum:"0" maximum:"32" example:"24" doc:"Network prefix length (static only)"`
Gateway string `json:"gateway,omitempty" example:"192.168.1.1" doc:"Default gateway (static only, optional)"`
IPv6 *IPv6Config `json:"ipv6,omitempty" doc:"Optional IPv6 settings. Omit to leave IPv6 untouched; include to manage it."`
DNS []string `json:"dns,omitempty" example:"[\"1.1.1.1\",\"8.8.8.8\"]" doc:"DNS servers for this interface (IPv4 or IPv6)"`
Routes []Route `json:"routes,omitempty" doc:"Static routes to install for this interface"`
RollbackSeconds int `json:"rollback_seconds,omitempty" minimum:"0" maximum:"3600" example:"60" doc:"Auto-revert after this many seconds unless confirmed. 0 uses the default (60s)."`
}
// IPv6Config is the optional IPv6 settings for an interface. Method "auto" uses
// SLAAC/router advertisements (the usual default), "static" pins an address, and
// "ignore" disables IPv6 on the interface. DHCPv6 is not modeled.
type IPv6Config struct {
Method string `json:"method" enum:"auto,static,ignore" example:"static" doc:"\"auto\" (SLAAC), \"static\", or \"ignore\" (disable IPv6)"`
Address string `json:"address,omitempty" example:"2001:db8::10" doc:"IPv6 address (static only)"`
Prefix int `json:"prefix,omitempty" minimum:"0" maximum:"128" example:"64" doc:"Prefix length (static only)"`
Gateway string `json:"gateway,omitempty" example:"2001:db8::1" doc:"IPv6 default gateway (static only, optional)"`
}
// Route is a single static route. Destination is a CIDR (or \"default\").
type Route struct {
Destination string `json:"destination" example:"10.0.0.0/24" doc:"Destination network in CIDR notation, or \"default\""`
Gateway string `json:"gateway" example:"192.168.1.1" doc:"Next-hop gateway"`
}
// validate checks the desired config independently of the backend, so a bad
// request is a 400 before we touch the system. IP/CIDR parsing uses net/netip.
func (c IfaceConfig) validate() error {
switch c.Method {
case "static":
if c.Address == "" {
return huma.Error400BadRequest("static method requires an address")
}
if _, err := netip.ParseAddr(c.Address); err != nil {
return huma.Error400BadRequest("invalid address: " + c.Address)
}
if c.Prefix < 1 || c.Prefix > 32 {
return huma.Error400BadRequest("prefix must be 1-32")
}
if c.Gateway != "" {
if _, err := netip.ParseAddr(c.Gateway); err != nil {
return huma.Error400BadRequest("invalid gateway: " + c.Gateway)
}
}
case "dhcp":
// address/gateway/prefix are ignored; nothing to validate.
default:
return huma.Error400BadRequest("method must be \"static\" or \"dhcp\"")
}
for _, s := range c.DNS {
if _, err := netip.ParseAddr(s); err != nil {
return huma.Error400BadRequest("invalid DNS server: " + s)
}
}
for _, r := range c.Routes {
if r.Destination != "default" {
if _, err := netip.ParsePrefix(r.Destination); err != nil {
return huma.Error400BadRequest("invalid route destination: " + r.Destination)
}
}
if _, err := netip.ParseAddr(r.Gateway); err != nil {
return huma.Error400BadRequest("invalid route gateway: " + r.Gateway)
}
}
if c.IPv6 != nil {
if err := c.IPv6.validate(); err != nil {
return err
}
}
return nil
}
// validate checks an IPv6 block. Static addresses/gateways must parse as IPv6
// (an IPv4 literal here is a client mistake).
func (c IPv6Config) validate() error {
switch c.Method {
case "auto", "ignore":
// no address fields to validate
case "static":
addr, err := netip.ParseAddr(c.Address)
if err != nil || addr.Is4() {
return huma.Error400BadRequest("ipv6 static requires a valid IPv6 address, got: " + c.Address)
}
if c.Prefix < 1 || c.Prefix > 128 {
return huma.Error400BadRequest("ipv6 prefix must be 1-128")
}
if c.Gateway != "" {
if gw, err := netip.ParseAddr(c.Gateway); err != nil || gw.Is4() {
return huma.Error400BadRequest("invalid ipv6 gateway: " + c.Gateway)
}
}
default:
return huma.Error400BadRequest("ipv6 method must be \"auto\", \"static\", or \"ignore\"")
}
return nil
}
@@ -0,0 +1,65 @@
package networking
import "testing"
func TestValidateIfaceConfig(t *testing.T) {
tests := []struct {
name string
cfg IfaceConfig
wantErr bool
}{
{"valid static", IfaceConfig{
Method: "static", Address: "192.168.1.10", Prefix: 24, Gateway: "192.168.1.1",
DNS: []string{"1.1.1.1", "8.8.8.8"},
}, false},
{"valid static no gateway", IfaceConfig{
Method: "static", Address: "10.0.0.5", Prefix: 8,
}, false},
{"valid dhcp", IfaceConfig{Method: "dhcp"}, false},
{"valid with routes", IfaceConfig{
Method: "static", Address: "192.168.1.10", Prefix: 24,
Routes: []Route{
{Destination: "100.64.0.0/24", Gateway: "192.168.1.1"},
{Destination: "default", Gateway: "192.168.1.1"},
},
}, false},
{"static missing address", IfaceConfig{Method: "static", Prefix: 24}, true},
{"static bad address", IfaceConfig{Method: "static", Address: "not-an-ip", Prefix: 24}, true},
{"static bad gateway", IfaceConfig{Method: "static", Address: "10.0.0.1", Prefix: 24, Gateway: "bad"}, true},
{"bad method", IfaceConfig{Method: "pppoe"}, true},
{"bad dns", IfaceConfig{Method: "dhcp", DNS: []string{"not-an-ip"}}, true},
{"bad route destination", IfaceConfig{
Method: "static", Address: "10.0.0.1", Prefix: 24,
Routes: []Route{{Destination: "nope", Gateway: "10.0.0.1"}},
}, true},
{"bad route gateway", IfaceConfig{
Method: "static", Address: "10.0.0.1", Prefix: 24,
Routes: []Route{{Destination: "10.0.0.0/24", Gateway: "nope"}},
}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.cfg.validate()
if (err != nil) != tt.wantErr {
t.Errorf("validate() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestValidateIface(t *testing.T) {
valid := []string{"eth0", "enp3s0", "wlan0", "br-lan", "veth1234567", "docker0"}
for _, name := range valid {
if err := validateIface(name); err != nil {
t.Errorf("validateIface(%q) unexpected error: %v", name, err)
}
}
invalid := []string{"", "-eth0", "/dev/net", "a b", "name_that_is_way_too_long_for_linux"}
for _, name := range invalid {
if err := validateIface(name); err == nil {
t.Errorf("validateIface(%q) expected error", name)
}
}
}
+208
View File
@@ -0,0 +1,208 @@
package networking
import (
"context"
"net/netip"
"os"
"regexp"
"strings"
"nadir/internal/oscmd"
"github.com/danielgtaylor/huma/v2"
)
// /etc/hosts management ("Host Addresses"). Edits are surgical — upsert/delete a
// single IP's line — so existing comments, ordering and the localhost entries
// are preserved rather than rewritten away.
var hostsFile = "/etc/hosts"
// hostnameRe matches a single hostname/alias. It forbids whitespace and '#' (so
// an entry can't inject extra fields or a comment) and a leading dash.
var hostnameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`)
// HostEntry is one /etc/hosts mapping: an IP and the names that resolve to it.
type HostEntry struct {
IP string `json:"ip" example:"192.168.1.10" doc:"IPv4 or IPv6 address"`
Hostnames []string `json:"hostnames" example:"[\"server\",\"server.local\"]" doc:"Names mapped to the address"`
}
type ListHostsOutput struct {
Body struct {
Entries []HostEntry `json:"entries"`
}
}
type HostUpsertInput struct {
IP string `path:"ip" example:"192.168.1.10" doc:"IP address to add or update"`
Body struct {
Hostnames []string `json:"hostnames" example:"[\"server\",\"server.local\"]" doc:"Names to map to the IP"`
}
}
type HostDeleteInput struct {
IP string `path:"ip" example:"192.168.1.10" doc:"IP address whose entry to remove"`
}
func registerHosts(api huma.API) {
huma.Register(api, huma.Operation{
OperationID: "networking-list-hosts",
Method: "GET",
Path: "/api/networking/hosts",
Summary: "List /etc/hosts entries",
Description: "Returns the static host-to-address mappings from /etc/hosts.",
Tags: []string{tagNetworking},
Metadata: op("read"),
Errors: readErrors,
}, func(ctx context.Context, _ *struct{}) (*ListHostsOutput, error) {
data, err := os.ReadFile(hostsFile)
if err != nil {
return nil, huma.Error500InternalServerError("read hosts failed", err)
}
res := &ListHostsOutput{}
res.Body.Entries = parseHosts(string(data))
return res, nil
})
huma.Register(api, huma.Operation{
OperationID: "networking-upsert-host",
Method: "PUT",
Path: "/api/networking/hosts/{ip}",
Summary: "Add or update a /etc/hosts entry",
Description: "Sets the hostnames for an IP. Replaces the existing line for that IP, " +
"or appends a new one; all other lines (comments included) are left untouched.",
Tags: []string{tagNetworking},
Metadata: op("write"),
Errors: writeErrors,
}, func(ctx context.Context, in *HostUpsertInput) (*oscmd.StatusOutput, error) {
if err := validateHost(in.IP, in.Body.Hostnames); err != nil {
return nil, err
}
if err := upsertHost(in.IP, in.Body.Hostnames); err != nil {
return nil, huma.Error500InternalServerError("write hosts failed", err)
}
return oscmd.OK(), nil
})
huma.Register(api, huma.Operation{
OperationID: "networking-delete-host",
Method: "DELETE",
Path: "/api/networking/hosts/{ip}",
Summary: "Remove a /etc/hosts entry",
Description: "Removes the line(s) mapping the given IP. 404 if no entry exists for it.",
Tags: []string{tagNetworking},
Metadata: op("write"),
Errors: []int{400, 401, 403, 404, 500},
}, func(ctx context.Context, in *HostDeleteInput) (*oscmd.StatusOutput, error) {
if _, err := netip.ParseAddr(in.IP); err != nil {
return nil, huma.Error400BadRequest("invalid IP: " + in.IP)
}
removed, err := deleteHost(in.IP)
if err != nil {
return nil, huma.Error500InternalServerError("write hosts failed", err)
}
if !removed {
return nil, huma.Error404NotFound("no hosts entry for " + in.IP)
}
return oscmd.OK(), nil
})
}
func validateHost(ip string, hostnames []string) error {
if _, err := netip.ParseAddr(ip); err != nil {
return huma.Error400BadRequest("invalid IP: " + ip)
}
if len(hostnames) == 0 {
return huma.Error400BadRequest("at least one hostname is required")
}
for _, h := range hostnames {
if !hostnameRe.MatchString(h) {
return huma.Error400BadRequest("invalid hostname: " + h)
}
}
return nil
}
// --- parse / render (pure, tested) -------------------------------------------
// hostFields returns the IP and hostnames on a hosts line, or ok=false for a
// blank or comment-only line. An inline "# comment" tail is stripped.
func hostFields(line string) (ip string, names []string, ok bool) {
if i := strings.IndexByte(line, '#'); i >= 0 {
line = line[:i]
}
f := strings.Fields(line)
if len(f) < 2 {
return "", nil, false
}
return f[0], f[1:], true
}
func parseHosts(text string) []HostEntry {
entries := []HostEntry{}
for line := range strings.SplitSeq(text, "\n") {
if ip, names, ok := hostFields(line); ok {
entries = append(entries, HostEntry{IP: ip, Hostnames: names})
}
}
return entries
}
// renderHostLine formats one entry as a hosts line.
func renderHostLine(ip string, hostnames []string) string {
return ip + "\t" + strings.Join(hostnames, " ")
}
// --- writes ------------------------------------------------------------------
// upsertHost replaces the line for ip (matched on the address field) or appends
// a new one, preserving every other line.
func upsertHost(ip string, hostnames []string) error {
data, err := os.ReadFile(hostsFile)
if err != nil {
return err
}
lines := strings.Split(string(data), "\n")
newLine := renderHostLine(ip, hostnames)
replaced := false
for i, line := range lines {
if got, _, ok := hostFields(line); ok && got == ip {
lines[i] = newLine
replaced = true
break
}
}
if !replaced {
// Append, avoiding a blank line if the file already ended with one.
if n := len(lines); n > 0 && strings.TrimSpace(lines[n-1]) == "" {
lines[n-1] = newLine
} else {
lines = append(lines, newLine)
}
lines = append(lines, "")
}
return os.WriteFile(hostsFile, []byte(strings.Join(lines, "\n")), 0644)
}
// deleteHost removes every line mapping ip and reports whether any were removed.
func deleteHost(ip string) (bool, error) {
data, err := os.ReadFile(hostsFile)
if err != nil {
return false, err
}
lines := strings.Split(string(data), "\n")
kept := make([]string, 0, len(lines))
removed := false
for _, line := range lines {
if got, _, ok := hostFields(line); ok && got == ip {
removed = true
continue
}
kept = append(kept, line)
}
if !removed {
return false, nil
}
return true, os.WriteFile(hostsFile, []byte(strings.Join(kept, "\n")), 0644)
}
+92
View File
@@ -0,0 +1,92 @@
package networking
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
const sampleHosts = `# static table
127.0.0.1 localhost
::1 localhost ip6-localhost
192.168.1.10 server server.local # the box
`
func TestParseHosts(t *testing.T) {
got := parseHosts(sampleHosts)
want := []HostEntry{
{IP: "127.0.0.1", Hostnames: []string{"localhost"}},
{IP: "::1", Hostnames: []string{"localhost", "ip6-localhost"}},
{IP: "192.168.1.10", Hostnames: []string{"server", "server.local"}},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("parseHosts:\n got %+v\nwant %+v", got, want)
}
}
func TestUpsertAndDeleteHost(t *testing.T) {
path := filepath.Join(t.TempDir(), "hosts")
if err := os.WriteFile(path, []byte(sampleHosts), 0644); err != nil {
t.Fatal(err)
}
old := hostsFile
hostsFile = path
defer func() { hostsFile = old }()
// Update an existing IP — comments and other lines must survive.
if err := upsertHost("192.168.1.10", []string{"web", "web.local"}); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(path)
if !strings.Contains(string(data), "# static table") || !strings.Contains(string(data), "ip6-localhost") {
t.Errorf("upsert clobbered other lines:\n%s", data)
}
entries := parseHosts(string(data))
if e := findHost(entries, "192.168.1.10"); e == nil || !reflect.DeepEqual(e.Hostnames, []string{"web", "web.local"}) {
t.Errorf("upsert did not update entry: %+v", entries)
}
// Add a new IP.
if err := upsertHost("10.0.0.5", []string{"db"}); err != nil {
t.Fatal(err)
}
entries = parseHosts(mustRead(t, path))
if findHost(entries, "10.0.0.5") == nil {
t.Errorf("upsert did not append new entry: %+v", entries)
}
// Delete it again.
removed, err := deleteHost("10.0.0.5")
if err != nil || !removed {
t.Fatalf("delete failed: removed=%v err=%v", removed, err)
}
if findHost(parseHosts(mustRead(t, path)), "10.0.0.5") != nil {
t.Error("entry still present after delete")
}
// Deleting a missing IP reports not-removed.
if removed, _ := deleteHost("8.8.8.8"); removed {
t.Error("expected removed=false for missing IP")
}
}
func findHost(entries []HostEntry, ip string) *HostEntry {
for i := range entries {
if entries[i].IP == ip {
return &entries[i]
}
}
return nil
}
func mustRead(t *testing.T, path string) string {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(data)
}
+239
View File
@@ -0,0 +1,239 @@
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
}
+43
View File
@@ -0,0 +1,43 @@
package networking
import (
"sync"
"nadir/internal/rbac"
"github.com/danielgtaylor/huma/v2"
)
const ModuleID = "networking"
type Module struct {
// be is the detected network backend (nmcli / networkd / ifupdown). nil when
// none was found: reads still work (they go through `ip`), writes return 501.
be backend
// pending holds the single in-flight change awaiting confirmation, for the
// timed auto-rollback. See rollback.go.
pending *pendingChange
mu sync.Mutex
}
// New detects the host's network backend once at startup.
func New() *Module { return &Module{be: detect()} }
func (m *Module) ID() string { return ModuleID }
func (m *Module) Name() string { return "Networking" }
// Permissions: read to inspect interfaces/routes/DNS; write to reconfigure them
// (apply config, bring links up/down, confirm a pending change).
func (m *Module) Permissions() []rbac.Permission {
return []rbac.Permission{rbac.Read, rbac.Write}
}
func (m *Module) Register(api huma.API) {
registerReads(api)
registerWrites(api, m)
registerHosts(api)
}
func op(permission string) map[string]any {
return map[string]any{"module": ModuleID, "permission": permission}
}
+285
View File
@@ -0,0 +1,285 @@
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
}
@@ -0,0 +1,397 @@
package networking
import (
"context"
"encoding/json"
"errors"
"net/http"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"nadir/internal/oscmd"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humago"
"github.com/danielgtaylor/huma/v2/humatest"
)
func TestMain(m *testing.M) {
if oscmd.RunHelperProcess() {
return
}
os.Exit(m.Run())
}
type mockBackend struct {
name string
snapshotResult IfaceConfig
applyCalledWith IfaceConfig
applyErr error
snapshotErr error
setUpCalled bool
setDownCalled bool
}
func (m *mockBackend) Name() string { return m.name }
func (m *mockBackend) Snapshot(_ context.Context, iface string) (IfaceConfig, error) {
return m.snapshotResult, m.snapshotErr
}
func (m *mockBackend) Apply(_ context.Context, iface string, cfg IfaceConfig) error {
m.applyCalledWith = cfg
return m.applyErr
}
func (m *mockBackend) SetLinkUp(_ context.Context, iface string) error {
m.setUpCalled = true
return nil
}
func (m *mockBackend) SetLinkDown(_ context.Context, iface string) error {
m.setDownCalled = true
return nil
}
func TestNetworkingHandlers(t *testing.T) {
mux := http.NewServeMux()
api := humatest.Wrap(t, humago.New(mux, huma.DefaultConfig("Test", "1.0.0")))
be := &mockBackend{
name: "mockbe",
snapshotResult: IfaceConfig{
Method: "dhcp",
Address: "192.168.1.10/24",
},
}
m := &Module{be: be}
m.Register(api)
tempResolv := filepath.Join(t.TempDir(), "resolv.conf")
if err := os.WriteFile(tempResolv, []byte("nameserver 1.1.1.1\nnameserver 8.8.8.8\n"), 0644); err != nil {
t.Fatal(err)
}
oldResolv := resolvConf
resolvConf = tempResolv
defer func() { resolvConf = oldResolv }()
oscmd.SetMock("ip", func(args []string) oscmd.MockCommand {
if reflect.DeepEqual(args, []string{"-j", "addr"}) {
out := `[{"ifname": "eth0", "operstate": "UP", "address": "aa:bb:cc:dd:ee:ff", "mtu": 1500, "addr_info": [{"family": "inet", "local": "192.168.1.10", "prefixlen": 24}]}]`
return oscmd.MockCommand{Stdout: out, ExitCode: 0}
}
if reflect.DeepEqual(args, []string{"-j", "route"}) {
out := `[{"dst": "default", "gateway": "192.168.1.1", "dev": "eth0"}]`
return oscmd.MockCommand{Stdout: out, ExitCode: 0}
}
return oscmd.MockCommand{ExitCode: 1}
})
defer oscmd.ClearMocks()
// 1. Test GET /api/networking/interfaces
resp := api.Get("/api/networking/interfaces")
if resp.Code != http.StatusOK {
t.Errorf("list interfaces: got %d, want %d", resp.Code, http.StatusOK)
}
// 2. Test GET /api/networking/routes
resp = api.Get("/api/networking/routes")
if resp.Code != http.StatusOK {
t.Errorf("list routes: got %d, want %d", resp.Code, http.StatusOK)
}
// 3. Test GET /api/networking/dns
resp = api.Get("/api/networking/dns")
if resp.Code != http.StatusOK {
t.Errorf("get dns: got %d, want %d", resp.Code, http.StatusOK)
}
var dnsRes DNSOutput
if err := json.Unmarshal(resp.Body.Bytes(), &dnsRes.Body); err != nil {
t.Fatal(err)
}
if len(dnsRes.Body.Servers) != 2 || dnsRes.Body.Servers[0] != "1.1.1.1" {
t.Errorf("get dns output: %+v", dnsRes.Body)
}
// 4. Test PUT /api/networking/interfaces/{name}
applyPayload := struct {
Method string `json:"method"`
Address string `json:"address,omitempty"`
Prefix int `json:"prefix,omitempty"`
Gateway string `json:"gateway,omitempty"`
DNS []string `json:"dns,omitempty"`
RollbackSeconds int `json:"rollback_seconds,omitempty"`
}{
Method: "static",
Address: "192.168.1.20",
Prefix: 24,
Gateway: "192.168.1.1",
DNS: []string{"1.1.1.1"},
RollbackSeconds: 2,
}
resp = api.Put("/api/networking/interfaces/eth0", applyPayload)
if resp.Code != http.StatusOK {
t.Errorf("apply interface config: got %d, want %d, body=%s", resp.Code, http.StatusOK, resp.Body.String())
}
resp = api.Get("/api/networking/pending")
if resp.Code != http.StatusOK {
t.Errorf("get pending: got %d, want %d", resp.Code, http.StatusOK)
}
// 5. Test POST /api/networking/interfaces/{name}/confirm
resp = api.Post("/api/networking/interfaces/eth0/confirm", struct{}{})
if resp.Code != http.StatusOK {
t.Errorf("confirm change: got %d, want %d", resp.Code, http.StatusOK)
}
resp = api.Get("/api/networking/pending")
if resp.Code != http.StatusNotFound {
t.Errorf("pending change should be cleared: got %d, want %d", resp.Code, http.StatusNotFound)
}
// 6. Test automatic rollback
applyPayload.RollbackSeconds = 1
resp = api.Put("/api/networking/interfaces/eth0", applyPayload)
if resp.Code != http.StatusOK {
t.Errorf("apply config again: got %d, want %d", resp.Code, http.StatusOK)
}
time.Sleep(1200 * time.Millisecond)
resp = api.Get("/api/networking/pending")
if resp.Code != http.StatusNotFound {
t.Errorf("pending change should be rolled back: got %d, want %d", resp.Code, http.StatusNotFound)
}
if be.applyCalledWith.Method != "dhcp" || be.applyCalledWith.Address != "192.168.1.10/24" {
t.Errorf("rollback failed to restore prior config: %+v", be.applyCalledWith)
}
// 7. Test POST link up & down
resp = api.Post("/api/networking/interfaces/eth0/up", struct{}{})
if resp.Code != http.StatusOK {
t.Errorf("set link up: got %d, want %d", resp.Code, http.StatusOK)
}
if !be.setUpCalled {
t.Errorf("expected SetLinkUp call on backend")
}
resp = api.Post("/api/networking/interfaces/eth0/down", struct{}{})
if resp.Code != http.StatusOK {
t.Errorf("set link down: got %d, want %d", resp.Code, http.StatusOK)
}
if !be.setDownCalled {
t.Errorf("expected SetLinkDown call on backend")
}
}
// #3: a failed Apply must restore the prior snapshot (no half-applied config
// left with no auto-revert) and arm no pending change.
func TestApplyFailureRestoresPrior(t *testing.T) {
be := &mockBackend{
name: "mock",
snapshotResult: IfaceConfig{Method: "dhcp"},
applyErr: errors.New("con up failed"),
}
m := &Module{be: be}
_, err := m.startRollback(t.Context(), "eth0", IfaceConfig{Method: "static", Address: "10.0.0.5", Prefix: 24})
if err == nil {
t.Fatal("expected apply to fail")
}
// The last Apply call should be the restore-to-prior (dhcp), not the new config.
if be.applyCalledWith.Method != "dhcp" {
t.Errorf("expected restore to prior config, last Apply got %+v", be.applyCalledWith)
}
if m.pending != nil {
t.Error("no pending change should be armed after a failed apply")
}
}
// #1: link-down must go through the rollback safety net (arms a pending change),
// and rolling it back brings the interface back up. #2: a change to another
// interface while one is pending is rejected with errAlreadyPending.
func TestLinkDownArmsRollback(t *testing.T) {
be := &mockBackend{name: "mock"}
m := &Module{be: be}
secs, err := m.startLinkDown(t.Context(), "eth0")
if err != nil {
t.Fatal(err)
}
if secs != defaultRollbackSeconds {
t.Errorf("seconds = %d, want default %d", secs, defaultRollbackSeconds)
}
if !be.setDownCalled {
t.Error("expected SetLinkDown to be called")
}
if m.pending == nil {
t.Fatal("expected a pending change to be armed")
}
// A concurrent change to a different interface is rejected (global lock).
if _, err := m.startRollback(t.Context(), "eth1", IfaceConfig{Method: "dhcp"}); !errors.Is(err, errAlreadyPending) {
t.Errorf("expected errAlreadyPending for eth1, got %v", err)
}
// Rolling back brings the link back up and clears the pending change.
if err := m.rollbackNow("eth0"); err != nil {
t.Fatal(err)
}
if !be.setUpCalled {
t.Error("expected SetLinkUp on rollback")
}
if m.pending != nil {
t.Error("pending change should be cleared after rollback")
}
}
func TestBackendImplementations(t *testing.T) {
tempDir := t.TempDir()
// Mock ip command for fallback snapshots and link control
oscmd.SetMock("ip", func(args []string) oscmd.MockCommand {
argStr := strings.Join(args, " ")
if strings.Contains(argStr, "addr show") {
out := `[{"ifname": "eth0", "operstate": "UP", "address": "aa:bb:cc:dd:ee:ff", "mtu": 1500, "addr_info": [{"family": "inet", "local": "192.168.1.30", "prefixlen": 24}]}]`
return oscmd.MockCommand{Stdout: out, ExitCode: 0}
}
if strings.Contains(argStr, "route show") {
out := `[{"dst": "default", "gateway": "192.168.1.1", "dev": "eth0"}]`
return oscmd.MockCommand{Stdout: out, ExitCode: 0}
}
if strings.Contains(argStr, "link set") {
return oscmd.MockCommand{ExitCode: 0}
}
return oscmd.MockCommand{ExitCode: 1}
})
defer oscmd.ClearMocks()
// 1. Test nmcliBackend
nm := &nmcliBackend{}
oscmd.SetMock("nmcli", func(args []string) oscmd.MockCommand {
argStr := strings.Join(args, " ")
t.Logf("nmcli mock called with: %s", argStr)
if strings.Contains(argStr, "con show --active") {
return oscmd.MockCommand{Stdout: "myconn:eth0\n", ExitCode: 0}
}
if strings.Contains(argStr, "con show") && strings.Contains(argStr, "myconn") {
showOut := "ipv4.method:manual\nipv4.addresses:192.168.1.10/24\nipv4.gateway:192.168.1.1\nipv4.dns:1.1.1.1\nipv4.routes:10.0.0.0/24 192.168.1.1\n"
return oscmd.MockCommand{Stdout: showOut, ExitCode: 0}
}
if (strings.Contains(argStr, "con modify") || strings.Contains(argStr, "con up") || strings.Contains(argStr, "con down")) && strings.Contains(argStr, "myconn") {
return oscmd.MockCommand{ExitCode: 0}
}
return oscmd.MockCommand{ExitCode: 1}
})
cfg, err := nm.Snapshot(t.Context(), "eth0")
if err != nil {
t.Fatal(err)
}
if cfg.Address != "192.168.1.10" || cfg.Gateway != "192.168.1.1" {
t.Errorf("nmcli snapshot failed: %+v", cfg)
}
err = nm.Apply(t.Context(), "eth0", IfaceConfig{
Method: "static",
Address: "192.168.1.20",
Prefix: 24,
Gateway: "192.168.1.1",
})
if err != nil {
t.Fatal(err)
}
if err := nm.SetLinkUp(t.Context(), "eth0"); err != nil {
t.Fatal(err)
}
if err := nm.SetLinkDown(t.Context(), "eth0"); err != nil {
t.Fatal(err)
}
// 2. Test networkdBackend
nd := &networkdBackend{}
oldNdDir := networkdDir
networkdDir = tempDir
defer func() { networkdDir = oldNdDir }()
oscmd.SetMock("networkctl", func(args []string) oscmd.MockCommand {
return oscmd.MockCommand{ExitCode: 0}
})
err = nd.Apply(t.Context(), "eth0", IfaceConfig{
Method: "static",
Address: "192.168.1.30",
Prefix: 24,
Gateway: "192.168.1.1",
DNS: []string{"8.8.8.8"},
})
if err != nil {
t.Fatal(err)
}
cfg, err = nd.Snapshot(t.Context(), "eth0")
if err != nil {
t.Fatal(err)
}
if cfg.Address != "192.168.1.30" || cfg.Method != "static" {
t.Errorf("networkd snapshot failed: %+v", cfg)
}
if err := nd.SetLinkUp(t.Context(), "eth0"); err != nil {
t.Fatal(err)
}
if err := nd.SetLinkDown(t.Context(), "eth0"); err != nil {
t.Fatal(err)
}
// 3. Test ifupdownBackend
iu := &ifupdownBackend{}
oldIfaceFile := interfacesFile
oldInterfacesDDir := interfacesDDir
interfacesFile = filepath.Join(tempDir, "interfaces")
interfacesDDir = filepath.Join(tempDir, "interfaces.d")
defer func() {
interfacesFile = oldIfaceFile
interfacesDDir = oldInterfacesDDir
}()
if err := os.MkdirAll(interfacesDDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(interfacesFile, []byte("auto lo\niface lo inet loopback\n"), 0644); err != nil {
t.Fatal(err)
}
oscmd.SetMock("ifup", func(args []string) oscmd.MockCommand { return oscmd.MockCommand{ExitCode: 0} })
oscmd.SetMock("ifdown", func(args []string) oscmd.MockCommand { return oscmd.MockCommand{ExitCode: 0} })
err = iu.Apply(t.Context(), "eth0", IfaceConfig{
Method: "static",
Address: "192.168.1.40",
Prefix: 24,
Gateway: "192.168.1.1",
DNS: []string{"1.1.1.1"},
})
if err != nil {
t.Fatal(err)
}
cfg, err = iu.Snapshot(t.Context(), "eth0")
if err != nil {
t.Fatal(err)
}
if cfg.Address != "192.168.1.40" || cfg.Method != "static" {
t.Errorf("ifupdown snapshot failed: %+v", cfg)
}
if err := iu.SetLinkUp(t.Context(), "eth0"); err != nil {
t.Fatal(err)
}
if err := iu.SetLinkDown(t.Context(), "eth0"); err != nil {
t.Fatal(err)
}
}
@@ -0,0 +1,396 @@
package networking
import (
"reflect"
"strings"
"testing"
)
func TestParseInterfaces(t *testing.T) {
// Trimmed real output from `ip -j addr` on a typical host.
input := `[
{
"ifname": "lo",
"address": "00:00:00:00:00:00",
"mtu": 65536,
"operstate": "UNKNOWN",
"addr_info": [
{"family": "inet", "local": "127.0.0.1", "prefixlen": 8},
{"family": "inet6", "local": "::1", "prefixlen": 128}
]
},
{
"ifname": "eth0",
"address": "52:54:00:12:34:56",
"mtu": 1500,
"operstate": "UP",
"addr_info": [
{"family": "inet", "local": "192.168.1.10", "prefixlen": 24},
{"family": "inet6", "local": "fe80::1", "prefixlen": 64}
]
}
]`
ifaces, err := parseInterfaces(input)
if err != nil {
t.Fatal(err)
}
if len(ifaces) != 2 {
t.Fatalf("expected 2 interfaces, got %d", len(ifaces))
}
lo := ifaces[0]
if lo.Name != "lo" || lo.State != "unknown" || lo.MTU != 65536 {
t.Errorf("lo: got %+v", lo)
}
if len(lo.IPv4) != 1 || lo.IPv4[0] != "127.0.0.1/8" {
t.Errorf("lo ipv4: got %v", lo.IPv4)
}
eth0 := ifaces[1]
if eth0.Name != "eth0" || eth0.State != "up" || eth0.MAC != "52:54:00:12:34:56" {
t.Errorf("eth0: got %+v", eth0)
}
if len(eth0.IPv4) != 1 || eth0.IPv4[0] != "192.168.1.10/24" {
t.Errorf("eth0 ipv4: got %v", eth0.IPv4)
}
if len(eth0.IPv6) != 1 || eth0.IPv6[0] != "fe80::1/64" {
t.Errorf("eth0 ipv6: got %v", eth0.IPv6)
}
}
func TestParseRoutes(t *testing.T) {
input := `[
{"dst": "default", "gateway": "192.168.1.1", "dev": "eth0", "prefsrc": "192.168.1.10", "metric": 100},
{"dst": "192.168.1.0/24", "dev": "eth0", "prefsrc": "192.168.1.10"}
]`
routes, err := parseRoutes(input)
if err != nil {
t.Fatal(err)
}
if len(routes) != 2 {
t.Fatalf("expected 2 routes, got %d", len(routes))
}
if routes[0].Destination != "default" || routes[0].Gateway != "192.168.1.1" || routes[0].Metric != 100 {
t.Errorf("route 0: got %+v", routes[0])
}
if routes[1].Destination != "192.168.1.0/24" || routes[1].Interface != "eth0" {
t.Errorf("route 1: got %+v", routes[1])
}
}
func TestParseResolv(t *testing.T) {
input := `# Generated by NetworkManager
nameserver 1.1.1.1
nameserver 8.8.8.8
; legacy comment
search example.com
nameserver 9.9.9.9
`
servers := parseResolv(input)
want := []string{"1.1.1.1", "8.8.8.8", "9.9.9.9"}
if !reflect.DeepEqual(servers, want) {
t.Errorf("parseResolv() = %v, want %v", servers, want)
}
}
func TestParseResolvEmpty(t *testing.T) {
servers := parseResolv("")
if len(servers) != 0 {
t.Errorf("expected empty, got %v", servers)
}
}
func TestParseNmcliSnapshot(t *testing.T) {
input := `ipv4.method:manual
ipv4.addresses:192.168.1.10/24
ipv4.gateway:192.168.1.1
ipv4.dns:1.1.1.1,8.8.8.8
ipv4.routes:--`
cfg := parseNmcliSnapshot(input)
if cfg.Method != "static" {
t.Errorf("method: got %q, want static", cfg.Method)
}
if cfg.Address != "192.168.1.10" || cfg.Prefix != 24 {
t.Errorf("address: got %s/%d", cfg.Address, cfg.Prefix)
}
if cfg.Gateway != "192.168.1.1" {
t.Errorf("gateway: got %q", cfg.Gateway)
}
if len(cfg.DNS) != 2 || cfg.DNS[0] != "1.1.1.1" || cfg.DNS[1] != "8.8.8.8" {
t.Errorf("dns: got %v", cfg.DNS)
}
}
func TestParseNmcliRoutes(t *testing.T) {
input := `dst=10.0.0.0/24, nh=192.168.1.1; dst=172.16.0.0/12, nh=10.0.0.1`
routes := parseNmcliRoutes(input)
if len(routes) != 2 {
t.Fatalf("expected 2 routes, got %d", len(routes))
}
if routes[0].Destination != "10.0.0.0/24" || routes[0].Gateway != "192.168.1.1" {
t.Errorf("route 0: got %v", routes[0])
}
if routes[1].Destination != "172.16.0.0/12" || routes[1].Gateway != "10.0.0.1" {
t.Errorf("route 1: got %v", routes[1])
}
}
func TestParseNmcliSnapshotDHCP(t *testing.T) {
input := `ipv4.method:auto
ipv4.addresses:--
ipv4.gateway:--
ipv4.dns:--
ipv4.routes:--`
cfg := parseNmcliSnapshot(input)
if cfg.Method != "dhcp" {
t.Errorf("method: got %q, want dhcp", cfg.Method)
}
}
func TestParseNetworkdFile(t *testing.T) {
input := `# Managed by nadir
[Match]
Name=eth0
[Network]
DHCP=no
Address=10.0.0.5/24
Gateway=10.0.0.1
DNS=1.1.1.1
DNS=8.8.8.8
[Route]
Destination=192.168.0.0/16
Gateway=10.0.0.254
`
cfg := parseNetworkdFile(input)
if cfg.Method != "static" {
t.Errorf("method: got %q, want static", cfg.Method)
}
if cfg.Address != "10.0.0.5" || cfg.Prefix != 24 {
t.Errorf("address: got %s/%d", cfg.Address, cfg.Prefix)
}
if cfg.Gateway != "10.0.0.1" {
t.Errorf("gateway: got %q", cfg.Gateway)
}
if len(cfg.DNS) != 2 {
t.Errorf("dns: got %v", cfg.DNS)
}
if len(cfg.Routes) != 1 || cfg.Routes[0].Destination != "192.168.0.0/16" || cfg.Routes[0].Gateway != "10.0.0.254" {
t.Errorf("routes: got %v", cfg.Routes)
}
}
func TestParseNetworkdFileDHCP(t *testing.T) {
input := `[Match]
Name=eth0
[Network]
DHCP=yes
`
cfg := parseNetworkdFile(input)
if cfg.Method != "dhcp" {
t.Errorf("method: got %q, want dhcp", cfg.Method)
}
}
func TestParseIfupdownStanza(t *testing.T) {
input := `# Managed by nadir
auto eth0
iface eth0 inet static
address 192.168.1.10/24
gateway 192.168.1.1
dns-nameservers 1.1.1.1 8.8.8.8
up ip route add 10.0.0.0/24 via 192.168.1.254
down ip route del 10.0.0.0/24 via 192.168.1.254
`
cfg := parseIfupdownStanza(input)
if cfg.Method != "static" {
t.Errorf("method: got %q, want static", cfg.Method)
}
if cfg.Address != "192.168.1.10" || cfg.Prefix != 24 {
t.Errorf("address: got %s/%d", cfg.Address, cfg.Prefix)
}
if cfg.Gateway != "192.168.1.1" {
t.Errorf("gateway: got %q", cfg.Gateway)
}
if len(cfg.DNS) != 2 || cfg.DNS[0] != "1.1.1.1" || cfg.DNS[1] != "8.8.8.8" {
t.Errorf("dns: got %v", cfg.DNS)
}
if len(cfg.Routes) != 1 || cfg.Routes[0].Destination != "10.0.0.0/24" || cfg.Routes[0].Gateway != "192.168.1.254" {
t.Errorf("routes: got %v", cfg.Routes)
}
}
func TestParseIfupdownStanzaDHCP(t *testing.T) {
input := `auto eth0
iface eth0 inet dhcp
`
cfg := parseIfupdownStanza(input)
if cfg.Method != "dhcp" {
t.Errorf("method: got %q, want dhcp", cfg.Method)
}
}
func TestRenderNetworkdFile(t *testing.T) {
cfg := IfaceConfig{
Method: "static",
Address: "10.0.0.5",
Prefix: 24,
Gateway: "10.0.0.1",
DNS: []string{"1.1.1.1"},
Routes: []Route{{Destination: "192.168.0.0/16", Gateway: "10.0.0.254"}},
}
out := renderNetworkdFile("eth0", cfg)
mustContain := []string{
"Name=eth0",
"DHCP=no",
"Address=10.0.0.5/24",
"Gateway=10.0.0.1",
"DNS=1.1.1.1",
"Destination=192.168.0.0/16",
}
for _, s := range mustContain {
if !strings.Contains(out, s) {
t.Errorf("renderNetworkdFile missing %q in:\n%s", s, out)
}
}
// Roundtrip test
parsed := parseNetworkdFile(out)
if !reflect.DeepEqual(parsed, cfg) {
t.Errorf("roundtrip failed: got %+v, want %+v", parsed, cfg)
}
}
func TestRenderIfupdownStanza(t *testing.T) {
cfg := IfaceConfig{
Method: "static",
Address: "192.168.1.10",
Prefix: 24,
Gateway: "192.168.1.1",
DNS: []string{"1.1.1.1", "8.8.8.8"},
Routes: []Route{{Destination: "10.0.0.0/24", Gateway: "192.168.1.254"}},
}
out := renderIfupdownStanza("eth0", cfg)
mustContain := []string{
"iface eth0 inet static",
"address 192.168.1.10/24",
"gateway 192.168.1.1",
"dns-nameservers 1.1.1.1 8.8.8.8",
"up ip route add 10.0.0.0/24 via 192.168.1.254",
"down ip route del 10.0.0.0/24 via 192.168.1.254",
}
for _, s := range mustContain {
if !strings.Contains(out, s) {
t.Errorf("renderIfupdownStanza missing %q in:\n%s", s, out)
}
}
// Roundtrip test
parsed := parseIfupdownStanza(out)
if !reflect.DeepEqual(parsed, cfg) {
t.Errorf("roundtrip failed: got %+v, want %+v", parsed, cfg)
}
}
func TestValidateIPv6(t *testing.T) {
ok := IfaceConfig{Method: "dhcp", IPv6: &IPv6Config{Method: "static", Address: "2001:db8::1", Prefix: 64, Gateway: "2001:db8::ff"}}
if err := ok.validate(); err != nil {
t.Errorf("valid ipv6 rejected: %v", err)
}
bad := []IfaceConfig{
{Method: "dhcp", IPv6: &IPv6Config{Method: "static", Address: "1.2.3.4", Prefix: 64}}, // v4 in v6 block
{Method: "dhcp", IPv6: &IPv6Config{Method: "static", Address: "2001:db8::1", Prefix: 0}},
{Method: "dhcp", IPv6: &IPv6Config{Method: "bogus"}},
}
for i, c := range bad {
if err := c.validate(); err == nil {
t.Errorf("bad ipv6 case %d accepted", i)
}
}
}
func TestParseNmcliSnapshotIPv6(t *testing.T) {
input := `ipv4.method:manual
ipv4.addresses:192.168.1.10/24
ipv6.method:manual
ipv6.addresses:2001:db8::10/64
ipv6.gateway:2001:db8::1`
cfg := parseNmcliSnapshot(input)
if cfg.IPv6 == nil {
t.Fatal("ipv6 not captured")
}
if cfg.IPv6.Method != "static" || cfg.IPv6.Address != "2001:db8::10" || cfg.IPv6.Prefix != 64 || cfg.IPv6.Gateway != "2001:db8::1" {
t.Errorf("ipv6 snapshot: %+v", cfg.IPv6)
}
}
func TestNetworkdIPv6RoundTrip(t *testing.T) {
cfg := IfaceConfig{
Method: "static",
Address: "10.0.0.5",
Prefix: 24,
IPv6: &IPv6Config{Method: "static", Address: "2001:db8::5", Prefix: 64, Gateway: "2001:db8::1"},
}
got := parseNetworkdFile(renderNetworkdFile("eth0", cfg))
if got.Address != "10.0.0.5" || got.Prefix != 24 {
t.Errorf("ipv4 lost in roundtrip: %+v", got)
}
if !reflect.DeepEqual(got.IPv6, cfg.IPv6) {
t.Errorf("ipv6 roundtrip: got %+v want %+v", got.IPv6, cfg.IPv6)
}
}
func TestIfupdownIPv6RoundTrip(t *testing.T) {
cfg := IfaceConfig{
Method: "static",
Address: "192.168.1.10",
Prefix: 24,
IPv6: &IPv6Config{Method: "static", Address: "2001:db8::10", Prefix: 64, Gateway: "2001:db8::1"},
}
got := parseIfupdownStanza(renderIfupdownStanza("eth0", cfg))
if got.Address != "192.168.1.10" || got.Prefix != 24 || got.Method != "static" {
t.Errorf("ipv4 lost in roundtrip: %+v", got)
}
if !reflect.DeepEqual(got.IPv6, cfg.IPv6) {
t.Errorf("ipv6 roundtrip: got %+v want %+v", got.IPv6, cfg.IPv6)
}
}
func TestNetworkdIPv6AutoIgnore(t *testing.T) {
auto := parseNetworkdFile(renderNetworkdFile("eth0", IfaceConfig{Method: "dhcp", IPv6: &IPv6Config{Method: "auto"}}))
if auto.IPv6 == nil || auto.IPv6.Method != "auto" {
t.Errorf("auto roundtrip: %+v", auto.IPv6)
}
ign := parseNetworkdFile(renderNetworkdFile("eth0", IfaceConfig{Method: "dhcp", IPv6: &IPv6Config{Method: "ignore"}}))
if ign.IPv6 == nil || ign.IPv6.Method != "ignore" {
t.Errorf("ignore roundtrip: %+v", ign.IPv6)
}
}
func TestSplitCIDR(t *testing.T) {
tests := []struct {
input string
wantAddr string
wantPrefix int
}{
{"192.168.1.10/24", "192.168.1.10", 24},
{"10.0.0.1/8", "10.0.0.1", 8},
{"10.0.0.1", "10.0.0.1", 0},
}
for _, tt := range tests {
addr, prefix := splitCIDR(tt.input)
if addr != tt.wantAddr || prefix != tt.wantPrefix {
t.Errorf("splitCIDR(%q) = (%q, %d), want (%q, %d)", tt.input, addr, prefix, tt.wantAddr, tt.wantPrefix)
}
}
}
+274
View File
@@ -0,0 +1,274 @@
package networking
import (
"context"
"fmt"
"strconv"
"strings"
"nadir/internal/oscmd"
)
// nmcliBackend implements backend via NetworkManager's nmcli CLI.
type nmcliBackend struct{}
func (b *nmcliBackend) Name() string { return "nmcli" }
// connForIface resolves a network interface name to the NM connection name
// that owns it. Returns an error if the interface has no active connection.
//
// nmcli -t uses ':' as the field separator. Connection names can contain colons
// (e.g. "VLAN:100"), but Linux device names cannot, so we split on the last
// colon to get the device and treat everything before it as the connection name.
func connForIface(ctx context.Context, iface string) (string, error) {
out, err := oscmd.RunContext(ctx, "nmcli", "-t", "-f", "NAME,DEVICE", "con", "show", "--active")
if err != nil {
return "", fmt.Errorf("nmcli con show: %w", err)
}
for line := range strings.SplitSeq(out, "\n") {
// Split on the last colon: "conn:name:eth0" → ("conn:name", "eth0")
idx := strings.LastIndex(line, ":")
if idx < 0 {
continue
}
name, dev := line[:idx], line[idx+1:]
if dev == iface {
return name, nil
}
}
return "", fmt.Errorf("no active NM connection found for interface %s", iface)
}
func (b *nmcliBackend) Snapshot(ctx context.Context, iface string) (IfaceConfig, error) {
conn, err := connForIface(ctx, iface)
if err != nil {
// No managed connection → assume DHCP (safest rollback assumption).
return IfaceConfig{Method: "dhcp"}, nil
}
out, err := oscmd.RunContext(ctx, "nmcli", "-t", "-f",
"ipv4.method,ipv4.addresses,ipv4.gateway,ipv4.dns,ipv4.routes,ipv6.method,ipv6.addresses,ipv6.gateway",
"con", "show", "--", conn)
if err != nil {
return IfaceConfig{}, fmt.Errorf("nmcli con show %s: %w", conn, err)
}
return parseNmcliSnapshot(out), nil
}
// parseNmcliSnapshot parses the terse output of `nmcli -t -f ... con show`.
// Fields are colon-separated key:value lines. Multi-valued fields (addresses,
// dns, routes) use comma separation within the value.
func parseNmcliSnapshot(out string) IfaceConfig {
cfg := IfaceConfig{Method: "dhcp"}
for line := range strings.SplitSeq(out, "\n") {
key, val, ok := strings.Cut(line, ":")
if !ok || val == "" || val == "--" {
continue
}
switch key {
case "ipv4.method":
if val == "manual" {
cfg.Method = "static"
} else {
cfg.Method = "dhcp"
}
case "ipv4.addresses":
// "192.168.1.10/24" or "192.168.1.10/24, 10.0.0.1/8"
for _, part := range splitTrim(val, ",") {
addr, prefix := splitCIDR(part)
if addr != "" {
cfg.Address = addr
cfg.Prefix = prefix
}
}
case "ipv4.gateway":
cfg.Gateway = val
case "ipv4.dns":
cfg.DNS = append(cfg.DNS, splitTrim(val, ",")...)
case "ipv4.routes":
// "dst=10.0.0.0/24, nh=192.168.1.1; dst=default, nh=192.168.1.1"
// or simpler "{ dst = 10.0.0.0/24, nh = 192.168.1.1 }"
cfg.Routes = parseNmcliRoutes(val)
case "ipv6.method":
v6(&cfg).Method = nmcliV6Method(val)
case "ipv6.addresses":
for _, part := range splitTrim(val, ",") {
if addr, prefix := splitCIDR(part); addr != "" {
v6(&cfg).Address = addr
v6(&cfg).Prefix = prefix
}
}
case "ipv6.gateway":
v6(&cfg).Gateway = val
}
}
return cfg
}
// v6 lazily allocates the IPv6 block so a snapshot captures the live IPv6 state
// (defaulting to "auto") even when only some ipv6.* fields are present.
func v6(cfg *IfaceConfig) *IPv6Config {
if cfg.IPv6 == nil {
cfg.IPv6 = &IPv6Config{Method: "auto"}
}
return cfg.IPv6
}
// nmcliV6Method maps nmcli's ipv6.method to our vocabulary.
func nmcliV6Method(val string) string {
switch val {
case "manual":
return "static"
case "ignore", "disabled", "link-local":
return "ignore"
default:
return "auto"
}
}
// parseNmcliRoutes parses route entries from nmcli terse output.
func parseNmcliRoutes(val string) []Route {
var routes []Route
// Routes are semicolon-separated, each containing "dst=..., nh=..."
for _, entry := range splitTrim(val, ";") {
entry = strings.Trim(entry, "{}")
var r Route
for _, part := range splitTrim(entry, ",") {
k, v, ok := strings.Cut(part, "=")
if !ok {
continue
}
switch strings.TrimSpace(k) {
case "dst":
r.Destination = strings.TrimSpace(v)
case "nh":
r.Gateway = strings.TrimSpace(v)
}
}
if r.Destination != "" && r.Gateway != "" {
routes = append(routes, r)
}
}
return routes
}
func (b *nmcliBackend) Apply(ctx context.Context, iface string, cfg IfaceConfig) error {
conn, err := connForIface(ctx, iface)
if err != nil {
return fmt.Errorf("cannot apply: %w", err)
}
// Build the nmcli con modify arguments. Note: conn is safe to place after
// -- since it comes from nmcli output, not directly from the user.
args := []string{"con", "modify", "--", conn}
switch cfg.Method {
case "static":
cidr := fmt.Sprintf("%s/%d", cfg.Address, cfg.Prefix)
args = append(args,
"ipv4.method", "manual",
"ipv4.addresses", cidr,
)
if cfg.Gateway != "" {
args = append(args, "ipv4.gateway", cfg.Gateway)
} else {
args = append(args, "ipv4.gateway", "")
}
case "dhcp":
args = append(args,
"ipv4.method", "auto",
"ipv4.addresses", "",
"ipv4.gateway", "",
)
}
// DNS: set or clear.
if len(cfg.DNS) > 0 {
args = append(args, "ipv4.dns", strings.Join(cfg.DNS, ","))
} else {
args = append(args, "ipv4.dns", "")
}
// Routes: set or clear.
if len(cfg.Routes) > 0 {
var routeStrs []string
for _, r := range cfg.Routes {
routeStrs = append(routeStrs, r.Destination+" "+r.Gateway)
}
args = append(args, "ipv4.routes", strings.Join(routeStrs, ","))
} else {
args = append(args, "ipv4.routes", "")
}
// IPv6: only touched when the request includes an ipv6 block.
if cfg.IPv6 != nil {
switch cfg.IPv6.Method {
case "static":
args = append(args,
"ipv6.method", "manual",
"ipv6.addresses", fmt.Sprintf("%s/%d", cfg.IPv6.Address, cfg.IPv6.Prefix),
"ipv6.gateway", cfg.IPv6.Gateway, // "" clears it
)
case "auto":
args = append(args, "ipv6.method", "auto", "ipv6.addresses", "", "ipv6.gateway", "")
case "ignore":
args = append(args, "ipv6.method", "ignore", "ipv6.addresses", "", "ipv6.gateway", "")
}
}
if _, err := oscmd.RunContext(ctx, "nmcli", args...); err != nil {
return fmt.Errorf("nmcli con modify: %w", err)
}
// Bring the connection up to apply changes.
if _, err := oscmd.RunContext(ctx, "nmcli", "con", "up", "--", conn); err != nil {
return fmt.Errorf("nmcli con up: %w", err)
}
return nil
}
func (b *nmcliBackend) SetLinkUp(ctx context.Context, iface string) error {
conn, err := connForIface(ctx, iface)
if err != nil {
// No NM connection - fall back to `ip link`.
_, err = oscmd.RunContext(ctx, "ip", "link", "set", "--", iface, "up")
return err
}
_, err = oscmd.RunContext(ctx, "nmcli", "con", "up", "--", conn)
return err
}
func (b *nmcliBackend) SetLinkDown(ctx context.Context, iface string) error {
conn, err := connForIface(ctx, iface)
if err != nil {
_, err = oscmd.RunContext(ctx, "ip", "link", "set", "--", iface, "down")
return err
}
_, err = oscmd.RunContext(ctx, "nmcli", "con", "down", "--", conn)
return err
}
// --- helpers -----------------------------------------------------------------
// splitTrim splits s on sep and returns the trimmed, non-empty segments.
func splitTrim(s, sep string) []string {
var out []string
for part := range strings.SplitSeq(s, sep) {
if p := strings.TrimSpace(part); p != "" {
out = append(out, p)
}
}
return out
}
// splitCIDR splits "192.168.1.10/24" into ("192.168.1.10", 24). A missing or
// malformed prefix yields 0.
func splitCIDR(cidr string) (string, int) {
addr, prefixStr, ok := strings.Cut(cidr, "/")
if !ok {
return addr, 0
}
prefix, _ := strconv.Atoi(prefixStr)
return addr, prefix
}
+211
View File
@@ -0,0 +1,211 @@
package networking
import (
"context"
"encoding/json"
"os"
"strconv"
"strings"
"nadir/internal/oscmd"
"github.com/danielgtaylor/huma/v2"
)
// Reads go through `ip -j` (JSON) and /etc/resolv.conf, which behave the same
// regardless of which backend manages the interfaces - so reads need no backend
// detection.
var resolvConf = "/etc/resolv.conf"
type Interface struct {
Name string `json:"name" example:"eth0"`
State string `json:"state" example:"up" doc:"operstate: up / down / unknown"`
MAC string `json:"mac" example:"52:54:00:12:34:56"`
MTU int `json:"mtu" example:"1500"`
IPv4 []string `json:"ipv4" example:"[\"192.168.1.10/24\"]" doc:"IPv4 addresses in CIDR form"`
IPv6 []string `json:"ipv6" example:"[\"fe80::1/64\"]" doc:"IPv6 addresses in CIDR form"`
}
type ListInterfacesOutput struct {
Body struct {
Interfaces []Interface `json:"interfaces"`
}
}
type RouteEntry struct {
Destination string `json:"destination" example:"default" doc:"Destination network, or \"default\""`
Gateway string `json:"gateway,omitempty" example:"192.168.1.1"`
Interface string `json:"interface" example:"eth0"`
Source string `json:"source,omitempty" example:"192.168.1.10" doc:"Preferred source address"`
Metric int `json:"metric,omitempty" example:"100"`
}
type ListRoutesOutput struct {
Body struct {
Routes []RouteEntry `json:"routes"`
}
}
type DNSOutput struct {
Body struct {
Servers []string `json:"servers" example:"[\"1.1.1.1\"]" doc:"Nameservers from /etc/resolv.conf"`
}
}
func registerReads(api huma.API) {
huma.Register(api, huma.Operation{
OperationID: "networking-list-interfaces",
Method: "GET",
Path: "/api/networking/interfaces",
Summary: "List network interfaces",
Description: "Returns every interface with its state, MAC, MTU and addresses, via `ip -j addr`.",
Tags: []string{tagNetworking},
Metadata: op("read"),
Errors: readErrors,
}, func(ctx context.Context, _ *struct{}) (*ListInterfacesOutput, error) {
out, err := oscmd.RunContext(ctx, "ip", "-j", "addr")
if err != nil {
return nil, huma.Error500InternalServerError("ip addr failed", err)
}
ifaces, err := parseInterfaces(out)
if err != nil {
return nil, huma.Error500InternalServerError("parse ip addr failed", err)
}
res := &ListInterfacesOutput{}
res.Body.Interfaces = ifaces
return res, nil
})
huma.Register(api, huma.Operation{
OperationID: "networking-list-routes",
Method: "GET",
Path: "/api/networking/routes",
Summary: "List the IPv4 route table",
Description: "Returns the kernel IPv4 route table via `ip -j route`.",
Tags: []string{tagNetworking},
Metadata: op("read"),
Errors: readErrors,
}, func(ctx context.Context, _ *struct{}) (*ListRoutesOutput, error) {
out, err := oscmd.RunContext(ctx, "ip", "-j", "route")
if err != nil {
return nil, huma.Error500InternalServerError("ip route failed", err)
}
routes, err := parseRoutes(out)
if err != nil {
return nil, huma.Error500InternalServerError("parse ip route failed", err)
}
res := &ListRoutesOutput{}
res.Body.Routes = routes
return res, nil
})
huma.Register(api, huma.Operation{
OperationID: "networking-get-dns",
Method: "GET",
Path: "/api/networking/dns",
Summary: "Get configured DNS servers",
Description: "Returns the nameservers listed in /etc/resolv.conf. DNS is set " +
"per-interface as part of the interface config (PUT /api/networking/interfaces/{name}), " +
"so there is no standalone DNS write endpoint.",
Tags: []string{tagNetworking},
Metadata: op("read"),
Errors: readErrors,
}, func(ctx context.Context, _ *struct{}) (*DNSOutput, error) {
data, err := os.ReadFile(resolvConf)
if err != nil {
return nil, huma.Error500InternalServerError("read resolv.conf failed", err)
}
res := &DNSOutput{}
res.Body.Servers = parseResolv(string(data))
return res, nil
})
}
// --- parsers (pure, tested) --------------------------------------------------
// ipAddr mirrors the fields we use from one `ip -j addr` element.
type ipAddr struct {
Name string `json:"ifname"`
MAC string `json:"address"`
MTU int `json:"mtu"`
OperState string `json:"operstate"`
AddrInfo []struct {
Family string `json:"family"` // "inet" / "inet6"
Local string `json:"local"`
Prefix int `json:"prefixlen"`
} `json:"addr_info"`
}
func parseInterfaces(jsonOut string) ([]Interface, error) {
var raw []ipAddr
if err := json.Unmarshal([]byte(jsonOut), &raw); err != nil {
return nil, err
}
ifaces := make([]Interface, 0, len(raw))
for _, r := range raw {
iface := Interface{
Name: r.Name,
State: strings.ToLower(r.OperState),
MAC: r.MAC,
MTU: r.MTU,
IPv4: []string{},
IPv6: []string{},
}
for _, a := range r.AddrInfo {
cidr := a.Local + "/" + strconv.Itoa(a.Prefix)
if a.Family == "inet6" {
iface.IPv6 = append(iface.IPv6, cidr)
} else {
iface.IPv4 = append(iface.IPv4, cidr)
}
}
ifaces = append(ifaces, iface)
}
return ifaces, nil
}
// ipRoute mirrors the fields we use from one `ip -j route` element.
type ipRoute struct {
Dst string `json:"dst"`
Gateway string `json:"gateway"`
Dev string `json:"dev"`
PrefSrc string `json:"prefsrc"`
Metric int `json:"metric"`
}
func parseRoutes(jsonOut string) ([]RouteEntry, error) {
var raw []ipRoute
if err := json.Unmarshal([]byte(jsonOut), &raw); err != nil {
return nil, err
}
routes := make([]RouteEntry, 0, len(raw))
for _, r := range raw {
routes = append(routes, RouteEntry{
Destination: r.Dst,
Gateway: r.Gateway,
Interface: r.Dev,
Source: r.PrefSrc,
Metric: r.Metric,
})
}
return routes, nil
}
// parseResolv extracts "nameserver X" entries, ignoring comments and other
// directives.
func parseResolv(text string) []string {
servers := []string{}
for line := range strings.SplitSeq(text, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
continue
}
if rest, ok := strings.CutPrefix(line, "nameserver"); ok {
if s := strings.TrimSpace(rest); s != "" {
servers = append(servers, s)
}
}
}
return servers
}
+193
View File
@@ -0,0 +1,193 @@
package networking
import (
"context"
"errors"
"fmt"
"log"
"time"
)
const defaultRollbackSeconds = 60
// errAlreadyPending is returned when another change is awaiting confirmation.
// The write handlers map this to 409 Conflict.
var errAlreadyPending = errors.New("already pending")
// errPending builds the 409 message. The lock is global across all interfaces
// (see pendingChange), so the message says so to avoid confusing a user who is
// touching a different interface than the one that holds the lock.
func errPending(iface string) error {
return fmt.Errorf("%w: a change to %s is awaiting confirmation. This is a global lock across all interfaces — confirm or roll that change back first", errAlreadyPending, iface)
}
// startRollback snapshots the current state, applies the new config, and arms a
// timer that auto-reverts if not confirmed. Returns errAlreadyPending (409) if
// another change is in flight, or a wrapped error (500) if apply fails.
//
// Snapshot and Apply run WITHOUT the mutex held, so they don't block reads or
// the pending-status endpoint while shelling out to nmcli/networkctl/ifup.
func (m *Module) startRollback(ctx context.Context, iface string, cfg IfaceConfig) (int, error) {
// Fast pre-check so we don't snapshot/apply when something is already
// pending. armPending re-checks under the lock to close the race.
if err := m.checkNoPending(); err != nil {
return 0, err
}
prior, err := m.be.Snapshot(ctx, iface)
if err != nil {
return 0, fmt.Errorf("snapshot %s: %w", iface, err)
}
if err := m.be.Apply(ctx, iface, cfg); err != nil {
// Apply is not atomic: nmcli `con modify` may succeed before `con up`
// fails, and networkd writes the .network file before `reconfigure`
// runs. A failed Apply can therefore leave a half-applied config that
// would otherwise have NO auto-revert (we bail before arming the timer).
// Best-effort restore the snapshot so we never leave that unprotected.
if rerr := m.be.Apply(ctx, iface, prior); rerr != nil {
log.Printf("networking: apply %s failed and restore also failed: %v", iface, rerr)
}
return 0, fmt.Errorf("apply %s: %w", iface, err)
}
// The revert runs from the timer or an explicit rollback, possibly with no
// client attached, so it uses context.Background() rather than ctx.
return m.armPending(iface, func() error { return m.be.Apply(context.Background(), iface, prior) }, cfg.RollbackSeconds)
}
// startLinkDown takes the interface down behind the same rollback safety net: if
// the change is not confirmed, the interface is brought back up. Taking a remote
// interface down is just as much a lock-yourself-out risk as a bad static config.
//
// Bringing a link UP needs no protection (it cannot lock you out), so link-up
// stays a direct, un-wrapped call in the handler.
func (m *Module) startLinkDown(ctx context.Context, iface string) (int, error) {
if err := m.checkNoPending(); err != nil {
return 0, err
}
if err := m.be.SetLinkDown(ctx, iface); err != nil {
return 0, fmt.Errorf("link down %s: %w", iface, err)
}
// Revert (bring the link back up) may run from the timer with no client.
return m.armPending(iface, func() error { return m.be.SetLinkUp(context.Background(), iface) }, 0)
}
// checkNoPending reports a 409 error if a change is already pending.
func (m *Module) checkNoPending() error {
m.mu.Lock()
defer m.mu.Unlock()
if m.pending != nil {
return errPending(m.pending.Iface)
}
return nil
}
// armPending installs the pending change and starts its auto-revert timer. The
// caller has already applied the change; revert is the closure that undoes it.
// It is invoked on timer expiry, explicit rollback, or if a concurrent change
// raced us between the pre-check and here (in which case we revert immediately
// and report the conflict). seconds <= 0 uses the default timeout.
func (m *Module) armPending(iface string, revert func() error, seconds int) (int, error) {
if seconds <= 0 {
seconds = defaultRollbackSeconds
}
dur := time.Duration(seconds) * time.Second
m.mu.Lock()
defer m.mu.Unlock()
if m.pending != nil {
// Lost the race — undo what we just applied and report conflict.
if err := revert(); err != nil {
log.Printf("networking: failed to undo raced change on %s: %v", iface, err)
}
return 0, errPending(m.pending.Iface)
}
pc := &pendingChange{
Iface: iface,
revert: revert,
Deadline: time.Now().Add(dur),
}
// The timer fires the auto-revert. It captures m and pc by closure so it can
// revert even if the server is otherwise idle — the whole point is protecting
// against being locked out of a remote box.
pc.Timer = time.AfterFunc(dur, func() {
m.mu.Lock()
defer m.mu.Unlock()
// Only revert if this exact change is still pending (it may have been
// confirmed or manually rolled back in the meantime).
if m.pending != pc {
return
}
log.Printf("networking: rollback timer expired for %s — reverting", iface)
if err := pc.revert(); err != nil {
log.Printf("networking: auto-rollback of %s failed: %v", iface, err)
}
m.pending = nil
})
m.pending = pc
return seconds, nil
}
// confirm cancels the rollback timer and clears the pending change, making it
// permanent. Errors if there is no pending change or it's for another interface.
func (m *Module) confirm(iface string) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.pending == nil {
return fmt.Errorf("no pending change to confirm")
}
if m.pending.Iface != iface {
return fmt.Errorf("pending change is for %s, not %s", m.pending.Iface, iface)
}
m.pending.Timer.Stop()
m.pending = nil
return nil
}
// rollbackNow immediately reverts the pending change and clears it. Errors if
// there is no pending change or it's for another interface.
func (m *Module) rollbackNow(iface string) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.pending == nil {
return fmt.Errorf("no pending change to rollback")
}
if m.pending.Iface != iface {
return fmt.Errorf("pending change is for %s, not %s", m.pending.Iface, iface)
}
m.pending.Timer.Stop()
err := m.pending.revert()
m.pending = nil
if err != nil {
return fmt.Errorf("rollback %s: %w", iface, err)
}
return nil
}
// PendingInfo is the JSON body returned by the pending-change status endpoint.
type PendingInfo struct {
Iface string `json:"interface" example:"eth0" doc:"Interface with a pending change"`
SecondsRemaining int `json:"seconds_remaining" example:"45" doc:"Seconds until auto-rollback"`
}
// pendingInfo returns the current pending change status, or nil if none.
func (m *Module) pendingInfo() *PendingInfo {
m.mu.Lock()
defer m.mu.Unlock()
if m.pending == nil {
return nil
}
remaining := max(int(time.Until(m.pending.Deadline).Seconds()), 0)
return &PendingInfo{
Iface: m.pending.Iface,
SecondsRemaining: remaining,
}
}
+194
View File
@@ -0,0 +1,194 @@
package networking
import (
"context"
"errors"
"nadir/internal/oscmd"
"github.com/danielgtaylor/huma/v2"
)
// registerWrites adds all write endpoints for the networking module. Every
// handler checks m.be != nil first and returns 501 when no backend was detected.
type ApplyInput struct {
Name string `path:"name" example:"eth0" doc:"Interface name"`
Body IfaceConfig `doc:"Desired interface configuration"`
}
type ApplyOutput struct {
Body struct {
Status string `json:"status" example:"pending" doc:"Always \"pending\" — confirm to make permanent"`
Interface string `json:"interface" example:"eth0"`
Backend string `json:"backend" example:"nmcli" doc:"Network backend that applied the change"`
RollbackSeconds int `json:"rollback_seconds" example:"60" doc:"Seconds until auto-rollback unless confirmed"`
}
}
type IfacePathInput struct {
Name string `path:"name" example:"eth0" doc:"Interface name"`
}
type PendingOutput struct {
Body PendingInfo
}
func registerWrites(api huma.API, m *Module) {
huma.Register(api, huma.Operation{
OperationID: "networking-apply-config",
Method: "PUT",
Path: "/api/networking/interfaces/{name}",
Summary: "Apply interface configuration",
Description: "Replaces the interface's IPv4 configuration. The change is applied " +
"immediately but starts a rollback timer — if not confirmed within the timeout " +
"(default 60s), the prior configuration is automatically restored. This prevents " +
"lock-yourself-out mistakes on remote hosts.",
Tags: []string{tagNetworking},
Metadata: op("write"),
Errors: writeErrors,
}, func(ctx context.Context, in *ApplyInput) (*ApplyOutput, error) {
if m.be == nil {
return nil, huma.Error501NotImplemented("", errNoBackend)
}
if err := validateIface(in.Name); err != nil {
return nil, err
}
if err := in.Body.validate(); err != nil {
return nil, err
}
seconds, err := m.startRollback(ctx, in.Name, in.Body)
if err != nil {
if errors.Is(err, errAlreadyPending) {
return nil, huma.Error409Conflict(err.Error())
}
return nil, huma.Error500InternalServerError("apply failed", err)
}
out := &ApplyOutput{}
out.Body.Status = "pending"
out.Body.Interface = in.Name
out.Body.Backend = m.be.Name()
out.Body.RollbackSeconds = seconds
return out, nil
})
huma.Register(api, huma.Operation{
OperationID: "networking-confirm-change",
Method: "POST",
Path: "/api/networking/interfaces/{name}/confirm",
Summary: "Confirm a pending change",
Description: "Cancels the rollback timer, making the applied configuration permanent.",
Tags: []string{tagNetworking},
Metadata: op("write"),
Errors: writeErrors,
}, func(ctx context.Context, in *IfacePathInput) (*oscmd.StatusOutput, error) {
if m.be == nil {
return nil, huma.Error501NotImplemented("", errNoBackend)
}
if err := validateIface(in.Name); err != nil {
return nil, err
}
if err := m.confirm(in.Name); err != nil {
return nil, huma.Error409Conflict(err.Error())
}
return oscmd.OK(), nil
})
huma.Register(api, huma.Operation{
OperationID: "networking-rollback-change",
Method: "POST",
Path: "/api/networking/interfaces/{name}/rollback",
Summary: "Immediately revert a pending change",
Description: "Reverts the interface to its prior configuration and clears the pending change.",
Tags: []string{tagNetworking},
Metadata: op("write"),
Errors: writeErrors,
}, func(ctx context.Context, in *IfacePathInput) (*oscmd.StatusOutput, error) {
if m.be == nil {
return nil, huma.Error501NotImplemented("", errNoBackend)
}
if err := validateIface(in.Name); err != nil {
return nil, err
}
if err := m.rollbackNow(in.Name); err != nil {
return nil, huma.Error500InternalServerError("rollback failed", err)
}
return oscmd.OK(), nil
})
huma.Register(api, huma.Operation{
OperationID: "networking-link-up",
Method: "POST",
Path: "/api/networking/interfaces/{name}/up",
Summary: "Bring an interface up",
Tags: []string{tagNetworking},
Metadata: op("write"),
Errors: writeErrors,
}, func(ctx context.Context, in *IfacePathInput) (*oscmd.StatusOutput, error) {
if m.be == nil {
return nil, huma.Error501NotImplemented("", errNoBackend)
}
if err := validateIface(in.Name); err != nil {
return nil, err
}
if err := m.be.SetLinkUp(ctx, in.Name); err != nil {
return nil, huma.Error500InternalServerError("link up failed", err)
}
return oscmd.OK(), nil
})
huma.Register(api, huma.Operation{
OperationID: "networking-link-down",
Method: "POST",
Path: "/api/networking/interfaces/{name}/down",
Summary: "Take an interface down",
Description: "Brings the interface down behind the rollback safety net: it is brought " +
"back up automatically if not confirmed within the timeout (default 60s). This " +
"prevents taking down the link you're managing the host over and losing access.",
Tags: []string{tagNetworking},
Metadata: op("write"),
Errors: writeErrors,
}, func(ctx context.Context, in *IfacePathInput) (*ApplyOutput, error) {
if m.be == nil {
return nil, huma.Error501NotImplemented("", errNoBackend)
}
if err := validateIface(in.Name); err != nil {
return nil, err
}
seconds, err := m.startLinkDown(ctx, in.Name)
if err != nil {
if errors.Is(err, errAlreadyPending) {
return nil, huma.Error409Conflict(err.Error())
}
return nil, huma.Error500InternalServerError("link down failed", err)
}
out := &ApplyOutput{}
out.Body.Status = "pending"
out.Body.Interface = in.Name
out.Body.Backend = m.be.Name()
out.Body.RollbackSeconds = seconds
return out, nil
})
huma.Register(api, huma.Operation{
OperationID: "networking-get-pending",
Method: "GET",
Path: "/api/networking/pending",
Summary: "Get pending change status",
Description: "Returns the currently pending change (interface name and seconds until " +
"auto-rollback), or 404 if there is no pending change.",
Tags: []string{tagNetworking},
Metadata: op("read"),
Errors: []int{401, 403, 404, 500},
}, func(ctx context.Context, _ *struct{}) (*PendingOutput, error) {
info := m.pendingInfo()
if info == nil {
return nil, huma.Error404NotFound("no pending change")
}
out := &PendingOutput{}
out.Body = *info
return out, nil
})
}