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)")