first commit
This commit is contained in:
@@ -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)")
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user