first commit

This commit is contained in:
2026-06-22 16:06:57 +02:00
commit fe485dd86d
90 changed files with 11404 additions and 0 deletions
+148
View File
@@ -0,0 +1,148 @@
// Package audit records privileged write operations to an embedded SQLite
// database so there is a durable "who did what" trail.
package auditlog
import (
"database/sql"
"fmt"
"log"
"os"
"path/filepath"
"sync"
"time"
_ "modernc.org/sqlite"
)
// buffer sizes the channel that decouples Record (request path) from the DB
// writer goroutine. Bursts up to this many entries return immediately; only a
// sustained overflow falls back to a synchronous write.
const buffer = 1024
// Entry is one recorded action.
type Entry struct {
Time string `json:"time" example:"2026-06-20T08:15:04Z" doc:"When the action occurred (RFC3339, UTC)"`
Username string `json:"username" example:"alice" doc:"Who performed it"`
Method string `json:"method" example:"POST" doc:"HTTP method"`
Path string `json:"path" example:"/api/users" doc:"Request path"`
Module string `json:"module" example:"users" doc:"Target module"`
Status int `json:"status" example:"200" doc:"HTTP response status"`
// ts is the capture time (unix seconds), carried through the writer channel
// so the stored timestamp reflects when the action happened, not when the
// background writer got to it.
ts int64
}
type Store struct {
db *sql.DB
ch chan Entry
done chan struct{} // closed when the writer has drained and exited
// mu guards the channel against a Record racing Close. Record takes RLock
// (many concurrent senders); Close takes the write lock to flip closed and
// close the channel exactly once. Without it, a Record after Close would
// panic sending on a closed channel.
mu sync.RWMutex
closed bool
}
// New opens (creating if needed) the audit database at path and starts the
// background writer that drains recorded entries to disk.
func New(path string) (*Store, error) {
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return nil, fmt.Errorf("audit db dir: %w", err)
}
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("open audit db: %w", err)
}
// ponytail: single connection serializes writes, same rationale as the
// session store; the background writer is the only writer.
db.SetMaxOpenConns(1)
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
username TEXT NOT NULL,
method TEXT NOT NULL,
path TEXT NOT NULL,
module TEXT NOT NULL,
status INTEGER NOT NULL
)`); err != nil {
return nil, fmt.Errorf("create audit table: %w", err)
}
s := &Store{db: db, ch: make(chan Entry, buffer), done: make(chan struct{})}
go s.writer()
return s, nil
}
// writer is the single goroutine that persists entries, keeping all DB writes
// off the request path. It exits once Close closes the channel, after draining
// whatever is buffered.
func (s *Store) writer() {
defer close(s.done)
for e := range s.ch {
s.insert(e)
}
}
// Close stops accepting entries, drains those still buffered, and closes the
// database. Call it during shutdown, after the HTTP server has stopped so no
// further Record calls can race with the channel close.
func (s *Store) Close() error {
s.mu.Lock()
s.closed = true
close(s.ch)
s.mu.Unlock()
<-s.done
return s.db.Close()
}
func (s *Store) insert(e Entry) {
if _, err := s.db.Exec(
`INSERT INTO audit (ts, username, method, path, module, status) VALUES (?, ?, ?, ?, ?, ?)`,
e.ts, e.Username, e.Method, e.Path, e.Module, e.Status,
); err != nil {
log.Printf("audit: insert failed (%s %s by %s): %v", e.Method, e.Path, e.Username, err)
}
}
// Record queues an entry for the background writer and returns immediately. If
// the buffer is full (sustained overload), it writes synchronously instead so an
// audit entry is never silently dropped - only then does it pay DB latency.
func (s *Store) Record(username, method, path, module string, status int) {
e := Entry{ts: time.Now().Unix(), Username: username, Method: method, Path: path, Module: module, Status: status}
s.mu.RLock()
defer s.mu.RUnlock()
if s.closed {
s.insert(e) // post-shutdown straggler: write directly, channel is gone
return
}
select {
case s.ch <- e:
default:
s.insert(e)
}
}
// List returns the most recent entries, newest first, capped at limit.
func (s *Store) List(limit int) ([]Entry, error) {
rows, err := s.db.Query(
`SELECT ts, username, method, path, module, status FROM audit ORDER BY id DESC LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
entries := []Entry{}
for rows.Next() {
var e Entry
var ts int64
if err := rows.Scan(&ts, &e.Username, &e.Method, &e.Path, &e.Module, &e.Status); err != nil {
return nil, err
}
e.Time = time.Unix(ts, 0).UTC().Format(time.RFC3339)
entries = append(entries, e)
}
return entries, rows.Err()
}
+107
View File
@@ -0,0 +1,107 @@
package auditlog
import (
"path/filepath"
"testing"
"time"
)
func newTestStore(t *testing.T) *Store {
t.Helper()
s, err := New(filepath.Join(t.TempDir(), "audit.db"))
if err != nil {
t.Fatal(err)
}
return s
}
// eventually polls List until at least want entries are persisted (writes are
// async) or it times out.
func eventually(t *testing.T, s *Store, want int) []Entry {
t.Helper()
deadline := time.Now().Add(time.Second)
for {
got, err := s.List(100)
if err != nil {
t.Fatal(err)
}
if len(got) >= want {
return got
}
if time.Now().After(deadline) {
t.Fatalf("timed out waiting for %d entries, have %d", want, len(got))
}
time.Sleep(5 * time.Millisecond)
}
}
func TestRecordAndList(t *testing.T) {
s := newTestStore(t)
s.Record("alice", "POST", "/api/users", "users", 200)
s.Record("bob", "DELETE", "/api/users/x", "users", 403)
got := eventually(t, s, 2)
// Newest first.
if got[0].Username != "bob" || got[0].Status != 403 {
t.Errorf("newest entry wrong: %+v", got[0])
}
if got[1].Username != "alice" || got[1].Method != "POST" {
t.Errorf("oldest entry wrong: %+v", got[1])
}
if got[0].Time == "" {
t.Error("time not populated")
}
}
func TestListLimit(t *testing.T) {
s := newTestStore(t)
for range 5 {
s.Record("u", "POST", "/api/x", "system", 200)
}
eventually(t, s, 5)
got, err := s.List(3)
if err != nil {
t.Fatal(err)
}
if len(got) != 3 {
t.Errorf("limit not honored: got %d, want 3", len(got))
}
}
func TestCloseDrains(t *testing.T) {
path := filepath.Join(t.TempDir(), "audit.db")
s, err := New(path)
if err != nil {
t.Fatal(err)
}
for range 50 {
s.Record("u", "POST", "/api/x", "system", 200)
}
// Close must flush everything still buffered before returning.
if err := s.Close(); err != nil {
t.Fatal(err)
}
// Reopen and confirm all 50 made it to disk.
s2, err := New(path)
if err != nil {
t.Fatal(err)
}
got, err := s2.List(100)
if err != nil {
t.Fatal(err)
}
if len(got) != 50 {
t.Errorf("Close did not drain: got %d entries, want 50", len(got))
}
}
func TestListEmpty(t *testing.T) {
got, err := newTestStore(t).List(10)
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Errorf("fresh store should be empty, got %d", len(got))
}
}
+48
View File
@@ -0,0 +1,48 @@
package auth
import (
"strings"
"time"
)
// TokenAuth verifies Bearer credentials against the TokenStore and throttles
// brute force. Unlike the login throttle (keyed on username+IP), a Bearer token
// has no "login" step to rate-limit, so guesses are throttled by source IP
// alone. The window is looser than login's because a legitimate dashboard may
// fire many requests in a minute - only repeated *failures* count.
type TokenAuth struct {
store *TokenStore
throttle *failLimiter
}
// NewTokenAuth wraps a store with an IP-keyed failure throttle.
func NewTokenAuth(store *TokenStore) *TokenAuth {
return &TokenAuth{store: store, throttle: newFailLimiter(20, time.Minute)}
}
// Verify resolves a presented Bearer token to its name. throttled is true when
// the source IP is in cooldown after too many bad tokens; the caller should
// answer 429 without consulting the store.
func (a *TokenAuth) Verify(ip, raw string) (name string, ok, throttled bool) {
if a.throttle.blocked(ip) {
return "", false, true
}
name, found := a.store.Lookup(raw)
if !found {
a.throttle.fail(ip)
return "", false, false
}
a.throttle.reset(ip)
return name, true, false
}
// BearerToken extracts the credential from an Authorization header value,
// reporting whether it was a Bearer scheme. Returns ("", false) for cookie-only
// or unauthenticated requests.
func BearerToken(authHeader string) (string, bool) {
const scheme = "Bearer "
if len(authHeader) <= len(scheme) || !strings.EqualFold(authHeader[:len(scheme)], scheme) {
return "", false
}
return strings.TrimSpace(authHeader[len(scheme):]), true
}
+90
View File
@@ -0,0 +1,90 @@
package auth
import (
"context"
"net/http"
"time"
"nadir/internal/auditlog"
"github.com/danielgtaylor/huma/v2"
)
// authenticator verifies a username/password (PAM in production). It's a field
// of the login handler rather than a package global so tests can inject a stub
// without mutating shared state.
type authenticator func(username, password string) error
type LoginInput struct {
Body struct {
Username string `json:"username" doc:"System username"`
Password string `json:"password" doc:"System password"`
}
}
type LoginOutput struct {
SetCookie http.Cookie `header:"Set-Cookie"`
Body struct {
Status string `json:"status" example:"logged in"`
}
}
// RegisterLogin wires the login operation into the Huma API. It has no
// permission metadata, so the RBAC middleware lets it through unauthenticated.
//
// secure sets the Secure attribute on the session cookie. Keep it true in
// production (the cookie is then only sent over HTTPS); set it false for local
// development over plain HTTP, where a Secure cookie would never be sent back.
func RegisterLogin(api huma.API, sessions *SessionStore, auditor *auditlog.Store, secure bool) {
// loginThrottle blunts brute force: 5 failures for a username+source IP
// trigger a one-minute cooldown. See throttle.go for the ceiling/upgrade path.
registerLogin(api, sessions, auditor, secure, Authenticate, newFailLimiter(5, time.Minute))
}
func registerLogin(api huma.API, sessions *SessionStore, auditor *auditlog.Store, secure bool, authenticate authenticator, throttle *failLimiter) {
huma.Register(api, huma.Operation{
OperationID: "login",
Method: "POST",
Path: "/api/login",
Summary: "Authenticate and start a session",
Description: "Verifies the username and password against PAM (the " +
"dedicated nadir service) and, on success, sets an HttpOnly " +
"session cookie used to authorize all other endpoints.",
Tags: []string{"Authentication"},
Errors: []int{401, 429},
}, func(ctx context.Context, in *LoginInput) (*LoginOutput, error) {
// Throttle brute force: too many recent failures for this account/source
// put it in a short cooldown before the password is even checked.
throttleKey := in.Body.Username + "|" + ClientIP(ctx)
if throttle.blocked(throttleKey) {
return nil, huma.Error429TooManyRequests("too many failed login attempts; wait a minute")
}
// Record both outcomes: failed logins are the brute-force signal, and the
// username is captured even on failure (which account is being targeted).
if err := authenticate(in.Body.Username, in.Body.Password); err != nil {
throttle.fail(throttleKey)
auditor.Record(in.Body.Username, "POST", "/api/login", "auth", http.StatusUnauthorized)
return nil, huma.Error401Unauthorized("invalid credentials")
}
throttle.reset(throttleKey)
auditor.Record(in.Body.Username, "POST", "/api/login", "auth", http.StatusOK)
sessionID, err := sessions.Create(in.Body.Username)
if err != nil {
return nil, huma.Error500InternalServerError("could not create session", err)
}
out := &LoginOutput{
SetCookie: http.Cookie{
Name: "nadir_session_id",
Value: sessionID,
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteStrictMode,
Expires: time.Now().Add(24 * time.Hour),
},
}
out.Body.Status = "logged in"
return out, nil
})
}
+185
View File
@@ -0,0 +1,185 @@
package auth
import (
"errors"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"nadir/internal/auditlog"
"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())
}
func TestEnsurePAMService(t *testing.T) {
tempFile := filepath.Join(t.TempDir(), "nadir-pam-test")
oldPath := pamServicePath
pamServicePath = tempFile
defer func() { pamServicePath = oldPath }()
if err := EnsurePAMService(); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(tempFile)
if err != nil {
t.Fatal(err)
}
if string(data) != pamServiceContent {
t.Errorf("got content %q, want %q", string(data), pamServiceContent)
}
customContent := "custom pam content"
if err := os.WriteFile(tempFile, []byte(customContent), 0644); err != nil {
t.Fatal(err)
}
if err := EnsurePAMService(); err != nil {
t.Fatal(err)
}
data, err = os.ReadFile(tempFile)
if err != nil {
t.Fatal(err)
}
if string(data) != customContent {
t.Errorf("EnsurePAMService clobbered file: got %q, want %q", string(data), customContent)
}
}
func TestLoginLogoutThrottling(t *testing.T) {
tempDir := t.TempDir()
auditStore, err := auditlog.New(filepath.Join(tempDir, "audit.db"))
if err != nil {
t.Fatal(err)
}
defer auditStore.Close()
sessions, err := NewSessionStore(filepath.Join(tempDir, "sessions.db"))
if err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
api := humatest.Wrap(t, humago.New(mux, huma.DefaultConfig("Test", "1.0.0")))
// Inject a stub authenticator and a short-window throttle (3 failures) at
// registration — no package globals to mutate. The throttle keys on
// username+IP, so the success/failure cases below and the throttle case (a
// distinct user) don't interfere.
authMock := func(username, password string) error {
if password == "correct" {
return nil
}
return errors.New("pam error")
}
registerLogin(api, sessions, auditStore, false, authMock, newFailLimiter(3, 500*time.Millisecond))
RegisterLogout(api, sessions, false)
// 1. Test failed login
resp := api.Post("/api/login", struct {
Username string `json:"username"`
Password string `json:"password"`
}{
Username: "admin",
Password: "wrong",
})
if resp.Code != http.StatusUnauthorized {
t.Errorf("failed login: got code %d, want %d", resp.Code, http.StatusUnauthorized)
}
// 2. Test successful login
resp = api.Post("/api/login", struct {
Username string `json:"username"`
Password string `json:"password"`
}{
Username: "admin",
Password: "correct",
})
if resp.Code != http.StatusOK {
t.Errorf("successful login: got code %d, want %d", resp.Code, http.StatusOK)
}
cookieHeader := resp.Header().Get("Set-Cookie")
if !strings.Contains(cookieHeader, "nadir_session_id=") {
t.Fatalf("Set-Cookie header missing nadir_session_id: %q", cookieHeader)
}
var sessionID string
parts := strings.SplitSeq(cookieHeader, ";")
for part := range parts {
part = strings.TrimSpace(part)
if after, ok := strings.CutPrefix(part, "nadir_session_id="); ok {
sessionID = after
break
}
}
if sessionID == "" {
t.Fatal("nadir_session_id cookie not found")
}
_, ok := sessions.GetByToken(sessionID)
if !ok {
t.Fatal("session not found in session store")
}
// 3. Test logout
resp = api.Post("/api/logout", "Cookie: nadir_session_id="+sessionID, struct{}{})
if resp.Code != http.StatusOK {
t.Errorf("logout failed: got code %d, want %d", resp.Code, http.StatusOK)
}
_, ok = sessions.GetByToken(sessionID)
if ok {
t.Fatal("session still valid after logout")
}
// 4. Test throttling (the handler was registered with a 3-failure limiter).
for range 3 {
api.Post("/api/login", struct {
Username string `json:"username"`
Password string `json:"password"`
}{
Username: "throttled-user",
Password: "wrong",
})
}
resp = api.Post("/api/login", struct {
Username string `json:"username"`
Password string `json:"password"`
}{
Username: "throttled-user",
Password: "correct",
})
if resp.Code != http.StatusTooManyRequests {
t.Errorf("throttled login: got code %d, want %d", resp.Code, http.StatusTooManyRequests)
}
time.Sleep(600 * time.Millisecond)
resp = api.Post("/api/login", struct {
Username string `json:"username"`
Password string `json:"password"`
}{
Username: "throttled-user",
Password: "correct",
})
if resp.Code != http.StatusOK {
t.Errorf("login after cooldown: got code %d, want %d", resp.Code, http.StatusOK)
}
}
+53
View File
@@ -0,0 +1,53 @@
package auth
import (
"context"
"net/http"
"github.com/danielgtaylor/huma/v2"
)
type LogoutInput struct {
SessionID string `cookie:"nadir_session_id"`
}
type LogoutOutput struct {
SetCookie http.Cookie `header:"Set-Cookie"`
Body struct {
Status string `json:"status" example:"logged out"`
}
}
// RegisterLogout wires the logout operation. Like login it carries no permission
// metadata, so the RBAC middleware lets it through: it deletes whatever session
// the cookie names (a no-op for an unknown/expired token) and clears the cookie.
func RegisterLogout(api huma.API, sessions *SessionStore, secure bool) {
huma.Register(api, huma.Operation{
OperationID: "logout",
Method: "POST",
Path: "/api/logout",
Summary: "End the current session",
Description: "Invalidates the session named by the cookie and clears it. " +
"Always succeeds, even without a valid session.",
Tags: []string{"Authentication"},
}, func(ctx context.Context, in *LogoutInput) (*LogoutOutput, error) {
if in.SessionID != "" {
if err := sessions.Delete(in.SessionID); err != nil {
return nil, huma.Error500InternalServerError("could not end session", err)
}
}
out := &LogoutOutput{
SetCookie: http.Cookie{
Name: "nadir_session_id",
Value: "",
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteStrictMode,
MaxAge: -1, // delete the cookie now
},
}
out.Body.Status = "logged out"
return out, nil
})
}
+18
View File
@@ -0,0 +1,18 @@
package auth
import "github.com/msteinert/pam"
func Authenticate(username, password string) error {
t, err := pam.StartFunc(PAMService, username, func(s pam.Style, msg string) (string, error) {
if s == pam.PromptEchoOff {
return password, nil
}
return "", nil
})
if err != nil {
return err
}
return t.Authenticate(0)
}
+51
View File
@@ -0,0 +1,51 @@
package auth
import (
"fmt"
"os"
)
// PAMService is the name of the PAM service nadir authenticates against.
// It maps to /etc/pam.d/<PAMService>. We use a dedicated service rather than
// a stock one like "login" so we control exactly which modules run during
// authentication. See README.md ("PAM service") for the full rationale.
const PAMService = "nadir"
var pamServicePath = "/etc/pam.d/" + PAMService
// pamServiceContent is the minimal stack nadir needs: verify the password
// against /etc/shadow and confirm the account is valid. It deliberately omits
// pam_fprintd (blocks ~30s waiting for a fingerprint swipe that never comes),
// pam_systemd, pam_env, and the rest of the distro's login stack.
const pamServiceContent = `#%PAM-1.0
# Managed by nadir. Do not rely on hand edits surviving - nadir recreates this
# file on startup only if it is missing. Minimal auth stack: verify the
# password against /etc/shadow and confirm the account is valid. Deliberately
# omits pam_fprintd (blocks ~30s on a fingerprint swipe), pam_systemd, pam_env.
auth required pam_unix.so
account required pam_unix.so
`
// EnsurePAMService writes the PAM service file if it is missing. nadir already
// runs as root (pam_unix needs to read /etc/shadow), so it can install its own
// PAM config rather than relying on a separate install step.
//
// It will not overwrite an existing file: an admin who has customized
// /etc/pam.d/nadir keeps their version. Returns an error only on a real I/O
// problem so main can fail loudly instead of later looking like bad
// credentials (a missing file falls through to pam_deny via /etc/pam.d/other).
func EnsurePAMService() error {
switch _, err := os.Stat(pamServicePath); {
case err == nil:
return nil // already present - leave admin customizations intact
case os.IsNotExist(err):
// fall through and create it
default:
return fmt.Errorf("stat %s: %w", pamServicePath, err)
}
if err := os.WriteFile(pamServicePath, []byte(pamServiceContent), 0644); err != nil {
return fmt.Errorf("write %s: %w (need root, and /etc/pam.d must be writable)", pamServicePath, err)
}
return nil
}
+99
View File
@@ -0,0 +1,99 @@
package auth
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"time"
_ "modernc.org/sqlite"
)
const sessionTTL = 24 * time.Hour
type Session struct {
Username string
}
// SessionStore persists sessions in an embedded SQLite database so they survive
// process restarts. Expired rows are dropped lazily on read.
type SessionStore struct {
db *sql.DB
}
// NewSessionStore opens (creating if needed) the SQLite database at path and
// ensures the sessions table exists. The parent directory is created too.
func NewSessionStore(path string) (*SessionStore, error) {
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return nil, fmt.Errorf("session db dir: %w", err)
}
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("open session db: %w", err)
}
// ponytail: single connection serializes access, avoiding SQLite's
// "database is locked" under concurrent writes. A sysadmin panel's session
// rate is trivial; switch to WAL + a real pool only if that ever bites.
db.SetMaxOpenConns(1)
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
username TEXT NOT NULL,
expires_at INTEGER NOT NULL
)`); err != nil {
return nil, fmt.Errorf("create sessions table: %w", err)
}
return &SessionStore{db: db}, nil
}
// Ping reports whether the session database is reachable. Used by the health
// check.
func (s *SessionStore) Ping() error { return s.db.Ping() }
func (s *SessionStore) Create(username string) (string, error) {
token := randomToken()
expires := time.Now().Add(sessionTTL)
if _, err := s.db.Exec(
`INSERT INTO sessions (token, username, expires_at) VALUES (?, ?, ?)`,
token, username, expires.Unix(),
); err != nil {
return "", err
}
return token, nil
}
// Delete removes a session, invalidating it immediately (logout). Deleting an
// unknown token is a no-op.
func (s *SessionStore) Delete(token string) error {
_, err := s.db.Exec(`DELETE FROM sessions WHERE token = ?`, token)
return err
}
func (s *SessionStore) GetByToken(token string) (Session, bool) {
var username string
var expires int64
err := s.db.QueryRow(
`SELECT username, expires_at FROM sessions WHERE token = ?`, token,
).Scan(&username, &expires)
if err != nil {
return Session{}, false
}
if time.Now().Unix() > expires {
s.db.Exec(`DELETE FROM sessions WHERE token = ?`, token)
return Session{}, false
}
return Session{Username: username}, true
}
func randomToken() string {
b := make([]byte, 32)
// crypto/rand.Read never returns an error on supported platforms; if it
// somehow does, an all-zero (guessable) token would be a security hole, so
// fail hard rather than hand one out.
if _, err := rand.Read(b); err != nil {
panic("crypto/rand failed: " + err.Error())
}
return hex.EncodeToString(b)
}
+88
View File
@@ -0,0 +1,88 @@
package auth
import (
"path/filepath"
"testing"
"time"
)
func TestSessionPersistsAcrossReopen(t *testing.T) {
path := filepath.Join(t.TempDir(), "sessions.db")
store, err := NewSessionStore(path)
if err != nil {
t.Fatal(err)
}
token, err := store.Create("urania")
if err != nil {
t.Fatal(err)
}
// Reopen the same file: a fresh process must still see the session.
reopened, err := NewSessionStore(path)
if err != nil {
t.Fatal(err)
}
sess, ok := reopened.GetByToken(token)
if !ok || sess.Username != "urania" {
t.Fatalf("session lost after reopen: got %+v ok=%v", sess, ok)
}
}
func TestExpiredSessionRejected(t *testing.T) {
store, err := NewSessionStore(filepath.Join(t.TempDir(), "sessions.db"))
if err != nil {
t.Fatal(err)
}
// Write a row that expired an hour ago, bypassing Create's TTL.
_, err = store.db.Exec(
`INSERT INTO sessions (token, username, expires_at) VALUES (?, ?, ?)`,
"stale", "urania", time.Now().Add(-time.Hour).Unix(),
)
if err != nil {
t.Fatal(err)
}
if _, ok := store.GetByToken("stale"); ok {
t.Fatal("expired session was accepted")
}
// Lazy cleanup should have deleted the row.
var n int
store.db.QueryRow(`SELECT count(*) FROM sessions WHERE token = ?`, "stale").Scan(&n)
if n != 0 {
t.Fatalf("expired row not cleaned up: %d rows remain", n)
}
}
func TestDeleteInvalidatesSession(t *testing.T) {
store, err := NewSessionStore(filepath.Join(t.TempDir(), "sessions.db"))
if err != nil {
t.Fatal(err)
}
token, err := store.Create("urania")
if err != nil {
t.Fatal(err)
}
if _, ok := store.GetByToken(token); !ok {
t.Fatal("session should exist before logout")
}
if err := store.Delete(token); err != nil {
t.Fatal(err)
}
if _, ok := store.GetByToken(token); ok {
t.Fatal("session still valid after logout")
}
// Deleting an unknown/already-deleted token is a no-op, not an error.
if err := store.Delete(token); err != nil {
t.Errorf("deleting unknown token should be a no-op, got %v", err)
}
}
func TestUnknownTokenRejected(t *testing.T) {
store, err := NewSessionStore(filepath.Join(t.TempDir(), "sessions.db"))
if err != nil {
t.Fatal(err)
}
if _, ok := store.GetByToken("nope"); ok {
t.Fatal("unknown token was accepted")
}
}
+110
View File
@@ -0,0 +1,110 @@
package auth
import (
"context"
"net"
"net/http"
"strings"
"sync"
"time"
)
// failLimiter throttles repeated login failures keyed by "username|ip". After
// max failures it imposes a cooldown of window before any further attempt for
// that key is accepted, blunting brute force against PAM/shadow.
//
// ponytail: in-memory, single-process, fixed cooldown - correct for a single
// app instance. State is lost on restart, but only an operator restarts the
// process (an attacker can't), so persisting it would buy nothing. Source
// spoofing is handled by the network (VPN-only + trusted proxy sets XFF), not
// here. Reach for pam_faillock only if a failed web login should also lock the
// OS account against ssh/console - a different layer we deliberately don't span.
type failLimiter struct {
mu sync.Mutex
attempts map[string]*attemptState
max int
window time.Duration
}
type attemptState struct {
count int
until time.Time
}
// maxTrackedKeys bounds memory: an attacker rotating username/IP can't grow the
// map without limit. When exceeded we drop all throttle state - a crude reset
// that briefly forgets cooldowns, acceptable for a single-node panel.
const maxTrackedKeys = 10000
func newFailLimiter(max int, window time.Duration) *failLimiter {
return &failLimiter{attempts: map[string]*attemptState{}, max: max, window: window}
}
// blocked reports whether the key is currently in cooldown.
func (l *failLimiter) blocked(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
s := l.attempts[key]
return s != nil && time.Now().Before(s.until)
}
// fail records a failed attempt and starts a cooldown once max is reached.
func (l *failLimiter) fail(key string) {
l.mu.Lock()
defer l.mu.Unlock()
if len(l.attempts) > maxTrackedKeys {
l.attempts = map[string]*attemptState{}
}
s := l.attempts[key]
if s == nil {
s = &attemptState{}
l.attempts[key] = s
}
s.count++
if s.count >= l.max {
s.until = time.Now().Add(l.window)
s.count = 0 // restart the window after the cooldown is set
}
}
// reset clears state for a key after a successful login.
func (l *failLimiter) reset(key string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.attempts, key)
}
type ctxKey int
const clientIPKey ctxKey = 0
// WithClientIP wraps a handler so the client's IP is available downstream via
// ClientIP, so the login throttle can key on source IP without coupling to a
// specific HTTP adapter.
//
// When trustProxy is set, the IP is taken from the first X-Forwarded-For hop
// (the original client behind the reverse proxy). X-Forwarded-For is fully
// caller-controlled, so this is only honored when an admin has opted in by
// putting nadir behind a proxy - and nadir must then be reachable only by that
// proxy. When trustProxy is false the header is ignored and RemoteAddr wins.
func WithClientIP(trustProxy bool, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
ip = r.RemoteAddr
}
if trustProxy {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
first, _, _ := strings.Cut(xff, ",")
ip = strings.TrimSpace(first)
}
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), clientIPKey, ip)))
})
}
// ClientIP returns the IP recorded by WithClientIP, or "" if absent.
func ClientIP(ctx context.Context) string {
ip, _ := ctx.Value(clientIPKey).(string)
return ip
}
+131
View File
@@ -0,0 +1,131 @@
package auth
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strings"
"time"
_ "modernc.org/sqlite"
)
// tokenPrefix marks a Nadir machine credential, like GitHub's "ghp_". It's
// cosmetic (helps secret scanners and humans recognize a leaked token), not a
// security boundary - the prefix is hashed and stored along with the rest.
const tokenPrefix = "nad_"
// TokenStore persists machine credentials for non-interactive callers (a
// central dashboard managing N nodes), so they authenticate with a static
// Bearer token instead of a per-host PAM session. Only the SHA-256 of each
// token is stored - a leaked DB or backup can't hand out live credentials.
//
// It lives in its own SQLite file because both the server (read, on every
// Bearer request) and the `nadir token` CLI (write, when minting/revoking)
// touch it. WAL + a busy timeout let those two processes share the file without
// "database is locked".
type TokenStore struct {
db *sql.DB
}
// TokenInfo is one stored credential's public metadata (never the secret).
type TokenInfo struct {
Name string
Created time.Time
}
// NewTokenStore opens (creating if needed) the token database at path.
func NewTokenStore(path string) (*TokenStore, error) {
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return nil, fmt.Errorf("token db dir: %w", err)
}
// WAL + busy_timeout: the server process and the CLI process both open this
// file, so a plain rollback journal would surface transient lock errors.
dsn := path + "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)"
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open token db: %w", err)
}
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS tokens (
name TEXT PRIMARY KEY,
token_hash TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL
)`); err != nil {
return nil, fmt.Errorf("create tokens table: %w", err)
}
return &TokenStore{db: db}, nil
}
func (s *TokenStore) Close() error { return s.db.Close() }
// Create mints a new token named name and returns the raw secret. The secret is
// shown only here, at generation time - only its hash is stored. A duplicate
// name is rejected (the caller should revoke and re-mint to rotate).
func (s *TokenStore) Create(name string) (string, error) {
if name == "" {
return "", fmt.Errorf("token name required")
}
raw := tokenPrefix + randomToken()
if _, err := s.db.Exec(
`INSERT INTO tokens (name, token_hash, created_at) VALUES (?, ?, ?)`,
name, hashToken(raw), time.Now().Unix(),
); err != nil {
return "", fmt.Errorf("store token %q (already exists?): %w", name, err)
}
return raw, nil
}
// Lookup returns the token name for a presented raw secret.
//
// No constant-time compare is needed: we look the secret up by its SHA-256, so
// the value compared in the index is already a hash of attacker-controlled
// input. A timing side-channel could at most leak a stored hash, which is
// useless without a SHA-256 preimage (the actual token).
func (s *TokenStore) Lookup(raw string) (string, bool) {
if !strings.HasPrefix(raw, tokenPrefix) {
return "", false
}
var name string
err := s.db.QueryRow(
`SELECT name FROM tokens WHERE token_hash = ?`, hashToken(raw),
).Scan(&name)
if err != nil {
return "", false
}
return name, true
}
// Delete revokes a token by name. Revoking an unknown name is a no-op. The
// change is effective immediately - the server reads this DB live, no restart.
func (s *TokenStore) Delete(name string) error {
_, err := s.db.Exec(`DELETE FROM tokens WHERE name = ?`, name)
return err
}
// List returns all token names with their creation time, newest first.
func (s *TokenStore) List() ([]TokenInfo, error) {
rows, err := s.db.Query(`SELECT name, created_at FROM tokens ORDER BY created_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
infos := []TokenInfo{}
for rows.Next() {
var t TokenInfo
var created int64
if err := rows.Scan(&t.Name, &created); err != nil {
return nil, err
}
t.Created = time.Unix(created, 0).UTC()
infos = append(infos, t)
}
return infos, rows.Err()
}
func hashToken(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
+45
View File
@@ -0,0 +1,45 @@
package auth
import (
"path/filepath"
"testing"
)
func TestTokenStore(t *testing.T) {
store, err := NewTokenStore(filepath.Join(t.TempDir(), "tokens.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
raw, err := store.Create("dash")
if err != nil {
t.Fatal(err)
}
if len(raw) < len(tokenPrefix)+32 || raw[:len(tokenPrefix)] != tokenPrefix {
t.Fatalf("token %q lacks %q prefix or is too short", raw, tokenPrefix)
}
// Round-trip: the minted secret resolves to its name.
if name, ok := store.Lookup(raw); !ok || name != "dash" {
t.Errorf("Lookup(valid) = %q,%v; want dash,true", name, ok)
}
// A wrong secret (and a non-prefixed one) must not resolve.
if _, ok := store.Lookup(tokenPrefix + "wrong"); ok {
t.Error("Lookup(wrong) succeeded")
}
if _, ok := store.Lookup("no-prefix"); ok {
t.Error("Lookup(no prefix) succeeded")
}
// Duplicate name is rejected.
if _, err := store.Create("dash"); err == nil {
t.Error("Create duplicate name succeeded; want error")
}
// Revocation is immediate.
if err := store.Delete("dash"); err != nil {
t.Fatal(err)
}
if _, ok := store.Lookup(raw); ok {
t.Error("Lookup after Delete succeeded")
}
}
+173
View File
@@ -0,0 +1,173 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
"nadir/internal/module"
"nadir/internal/rbac"
)
// File is the on-disk YAML shape. Module keys and permission values may be
// "*" (must be quoted in YAML to avoid alias syntax).
//
// Example:
//
// server:
// secure_tls: false
// roles:
// admin:
// "*": ["*"]
// auditor:
// "*": [read]
// assignments:
// urania: [admin]
//
// LogFiles is the allowlist of log files readable per unit via the file log
// source. The path query parameter is matched against this set, so an admin
// (not the caller) decides which files are exposable. Example:
//
// log_files:
// nginx.service:
// - /var/log/nginx/access.log
// - /var/log/nginx/error.log
type File struct {
Server Server `yaml:"server"`
Roles map[string]map[string][]string `yaml:"roles"`
Assignments map[string][]string `yaml:"assignments"`
LogFiles map[string][]string `yaml:"log_files"`
}
// Server holds process-level settings.
type Server struct {
// SecureTLS controls the Secure attribute on the session cookie. A pointer
// so an omitted key means "unset" (defaults to true / production-safe)
// rather than the zero value false. Set false for local HTTP development.
SecureTLS *bool `yaml:"secure_tls"`
Hostname string `yaml:"hostname"`
Port string `yaml:"port"`
// TLS is provided one of three ways, in priority order:
// 1. TrustProxy true - a reverse proxy (e.g. https://example.com) does
// TLS; nadir serves plaintext HTTP and trusts X-Forwarded-For. nadir
// must then be reachable ONLY by the proxy (bind localhost).
// 2. TLSCert+TLSKey - nadir terminates TLS with this PEM pair.
// 3. neither - nadir self-signs in memory (dev only).
// Keep secure_tls true in modes 1 and 2 so the session cookie stays Secure.
TrustProxy bool `yaml:"trust_proxy"`
TLSCert string `yaml:"tls_cert"`
TLSKey string `yaml:"tls_key"`
}
// SecureCookie reports whether the session cookie should carry the Secure
// attribute, defaulting to true when server.secure_tls is omitted.
func (f *File) SecureCookie() bool {
if f.Server.SecureTLS == nil {
return true
}
return *f.Server.SecureTLS
}
// DefaultPath returns the default configuration path at
// ~/.config/nadir/config.yaml (honoring XDG_CONFIG_HOME via os.UserConfigDir).
func DefaultPath() (string, error) {
dir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("detect config dir: %w", err)
}
return filepath.Join(dir, "nadir", "config.yaml"), nil
}
// ExpandPath expands leading ~ to the current user's home directory.
func ExpandPath(path string) (string, error) {
if strings.HasPrefix(path, "~/") {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("detect home dir: %w", err)
}
return filepath.Join(home, path[2:]), nil
}
return path, nil
}
// Load reads and parses the YAML file at path.
func Load(path string) (*File, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config %s: %w", path, err)
}
var f File
if err := yaml.Unmarshal(data, &f); err != nil {
return nil, fmt.Errorf("parse config %s: %w", path, err)
}
return &f, nil
}
// Apply validates the config against the loaded modules and installs roles
// + assignments into the RBAC store. Any reference to an unknown module or
// unknown permission causes a startup error.
func Apply(f *File, roles *rbac.RBAC, mods []module.Module) error {
// Build a lookup: module ID -> the permissions it exposes.
knownPerms := map[string]map[rbac.Permission]bool{}
for _, m := range mods {
set := map[rbac.Permission]bool{}
for _, p := range m.Permissions() {
set[p] = true
}
knownPerms[m.ID()] = set
}
// 1. Define roles, validating every named module + permission.
for roleName, grants := range f.Roles {
converted := map[string][]rbac.Permission{}
for modKey, perms := range grants {
if modKey != rbac.Wildcard {
if _, ok := knownPerms[modKey]; !ok {
return fmt.Errorf("role %q references unknown module %q", roleName, modKey)
}
}
permList := make([]rbac.Permission, 0, len(perms))
for _, raw := range perms {
p := rbac.Permission(raw)
if p == rbac.All {
permList = append(permList, p)
continue
}
if !permissionExists(p, modKey, knownPerms) {
return fmt.Errorf("role %q grants %q on module %q, but that permission is not exported by any matching module", roleName, raw, modKey)
}
permList = append(permList, p)
}
converted[modKey] = permList
}
roles.DefineRole(rbac.Role{Name: roleName, ModuleGrants: converted})
}
// 2. Apply assignments, validating role names.
for user, assigned := range f.Assignments {
for _, roleName := range assigned {
if !roles.RoleExists(roleName) {
return fmt.Errorf("user %q assigned to unknown role %q", user, roleName)
}
roles.AssignRole(user, roleName)
}
}
return nil
}
// permissionExists returns true if perm is exported by the named module,
// or by any module when modKey is the wildcard.
func permissionExists(perm rbac.Permission, modKey string, known map[string]map[rbac.Permission]bool) bool {
if modKey == rbac.Wildcard {
for _, perms := range known {
if perms[perm] {
return true
}
}
return false
}
return known[modKey][perm]
}
+152
View File
@@ -0,0 +1,152 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
"nadir/internal/module"
"nadir/internal/rbac"
"github.com/danielgtaylor/huma/v2"
)
// fakeModule implements module.Module with a fixed permission set, so config
// tests don't depend on the concrete modules' exec behavior.
type fakeModule struct {
id string
perms []rbac.Permission
}
func (f fakeModule) ID() string { return f.id }
func (f fakeModule) Name() string { return f.id }
func (f fakeModule) Permissions() []rbac.Permission { return f.perms }
func (f fakeModule) Register(huma.API) {}
func mods() []module.Module {
return []module.Module{
fakeModule{id: "system", perms: []rbac.Permission{rbac.Read, rbac.Write, rbac.Root}},
fakeModule{id: "services", perms: []rbac.Permission{rbac.Read, rbac.Write}},
}
}
func TestSecureCookieDefaultsTrue(t *testing.T) {
if !(&File{}).SecureCookie() {
t.Error("omitted secure_tls should default to true")
}
no := false
if (&File{Server: Server{SecureTLS: &no}}).SecureCookie() {
t.Error("secure_tls: false should disable the Secure flag")
}
}
func TestLoad(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
os.WriteFile(path, []byte(`
server:
secure_tls: false
roles:
admin:
"*": ["*"]
assignments:
urania: [admin]
`), 0600)
f, err := Load(path)
if err != nil {
t.Fatal(err)
}
if f.SecureCookie() {
t.Error("secure_tls: false not parsed")
}
if len(f.Roles["admin"]) == 0 || len(f.Assignments["urania"]) == 0 {
t.Error("roles/assignments not parsed")
}
}
func TestLoadMissingFile(t *testing.T) {
if _, err := Load(filepath.Join(t.TempDir(), "nope.yaml")); err == nil {
t.Fatal("expected error for missing file")
}
}
func TestApplyValid(t *testing.T) {
f := &File{
Roles: map[string]map[string][]string{
"admin": {"*": {"*"}},
"auditor": {"*": {"read"}},
"sysop": {"system": {"read", "root"}},
},
Assignments: map[string][]string{"urania": {"admin"}, "bob": {"sysop"}},
}
roles := rbac.New()
if err := Apply(f, roles, mods()); err != nil {
t.Fatal(err)
}
if !roles.Can("urania", "services", rbac.Write) {
t.Error("admin wildcard not applied")
}
if !roles.Can("bob", "system", rbac.Root) || roles.Can("bob", "system", rbac.Write) {
t.Error("sysop grants not applied correctly")
}
}
func TestApplyErrors(t *testing.T) {
tests := []struct {
name string
f *File
}{
{"unknown module", &File{Roles: map[string]map[string][]string{
"r": {"firewall": {"read"}}}}},
{"unknown perm on known module", &File{Roles: map[string]map[string][]string{
"r": {"system": {"banana"}}}}},
{"wildcard module with perm no module exports", &File{Roles: map[string]map[string][]string{
"r": {"*": {"banana"}}}}},
{"assignment to unknown role", &File{
Roles: map[string]map[string][]string{"r": {"system": {"read"}}},
Assignments: map[string][]string{"u": {"ghost"}}}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := Apply(tt.f, rbac.New(), mods()); err == nil {
t.Errorf("expected error for %s", tt.name)
}
})
}
}
func TestApplyWildcardPermAlwaysOK(t *testing.T) {
f := &File{Roles: map[string]map[string][]string{"r": {"system": {"*"}}}}
if err := Apply(f, rbac.New(), mods()); err != nil {
t.Fatalf("wildcard permission should validate: %v", err)
}
}
func TestDefaultPathAndExpandPath(t *testing.T) {
defaultPath, err := DefaultPath()
if err != nil {
t.Fatalf("DefaultPath failed: %v", err)
}
expectedSuffix := filepath.Join("nadir", "config.yaml")
if !filepath.IsAbs(defaultPath) || !strings.HasSuffix(defaultPath, expectedSuffix) {
t.Errorf("expected default path to end with %q and be absolute, got %q", expectedSuffix, defaultPath)
}
expanded, err := ExpandPath("~/foo/config.yaml")
if err != nil {
t.Fatalf("ExpandPath failed: %v", err)
}
if !filepath.IsAbs(expanded) || !strings.HasSuffix(expanded, filepath.Join("foo", "config.yaml")) {
t.Errorf("ExpandPath did not resolve ~/ correctly, got %q", expanded)
}
plain := "/etc/nadir/config.yaml"
expandedPlain, err := ExpandPath(plain)
if err != nil {
t.Fatalf("ExpandPath failed for plain path: %v", err)
}
if expandedPlain != plain {
t.Errorf("expected no-op for %q, got %q", plain, expandedPlain)
}
}
+44
View File
@@ -0,0 +1,44 @@
package meta
import (
"context"
"nadir"
"nadir/internal/auth"
"github.com/danielgtaylor/huma/v2"
)
type HealthOutput struct {
Body struct {
Status string `json:"status" example:"ok" doc:"Overall health"`
Database string `json:"database" example:"ok" doc:"Embedded SQLite session store state"`
Version string `json:"version" example:"1.0.0" doc:"Application version"`
}
}
// RegisterHealth adds a public liveness/readiness probe. It is intentionally
// unauthenticated (no permission metadata) so load balancers and orchestrators
// can reach it. Returns 503 when the SQLite session store is unreachable, so
// probes can key off the status code without parsing the body.
func RegisterHealth(api huma.API, sessions *auth.SessionStore) {
huma.Register(api, huma.Operation{
OperationID: "health",
Method: "GET",
Path: "/api/health",
Summary: "Health check",
Description: "Public liveness/readiness probe. Reports whether the embedded " +
"SQLite session store is reachable. Returns 503 when it is not.",
Tags: []string{"Meta"},
Errors: []int{503},
}, func(ctx context.Context, _ *struct{}) (*HealthOutput, error) {
if err := sessions.Ping(); err != nil {
return nil, huma.Error503ServiceUnavailable("session database unreachable", err)
}
out := &HealthOutput{}
out.Body.Status = "ok"
out.Body.Database = "ok"
out.Body.Version = nadir.Version
return out, nil
})
}
+58
View File
@@ -0,0 +1,58 @@
package meta
import (
"cmp"
"context"
"slices"
"nadir/internal/module"
"github.com/danielgtaylor/huma/v2"
)
// ModuleInfo describes a registered module: its stable ID, display name, and
// the permissions it exposes. The frontend uses this to drive navigation and
// render the role/permission matrix.
type ModuleInfo struct {
ID string `json:"id" example:"system" doc:"Stable module identifier"`
Name string `json:"name" example:"System" doc:"Human-readable module name"`
Permissions []string `json:"permissions" doc:"Permissions this module exposes (never includes the \"*\" wildcard)"`
}
type ModulesOutput struct {
Body struct {
Modules []ModuleInfo `json:"modules" doc:"Registered modules, sorted by ID"`
}
}
// Register adds the read-only module-discovery endpoint. It is intentionally
// public: it exposes only the API's static shape (module IDs and permission
// vocabulary), the same information already served by /openapi.json. The module
// list is fixed at startup, so the response is computed once here.
func Register(api huma.API, mods []module.Module) {
infos := make([]ModuleInfo, 0, len(mods))
for _, m := range mods {
perms := m.Permissions()
ps := make([]string, len(perms))
for i, p := range perms {
ps[i] = string(p)
}
infos = append(infos, ModuleInfo{ID: m.ID(), Name: m.Name(), Permissions: ps})
}
slices.SortFunc(infos, func(a, b ModuleInfo) int { return cmp.Compare(a.ID, b.ID) })
huma.Register(api, huma.Operation{
OperationID: "list-modules",
Method: "GET",
Path: "/api/_modules",
Summary: "List registered modules",
Description: "Returns every registered module with its ID, display name, " +
"and exported permissions. Public (same static shape as /openapi.json); " +
"used by the frontend for navigation and the role/permission matrix.",
Tags: []string{"Meta"},
}, func(ctx context.Context, _ *struct{}) (*ModulesOutput, error) {
out := &ModulesOutput{}
out.Body.Modules = infos
return out, nil
})
}
+67
View File
@@ -0,0 +1,67 @@
package meta
import (
"context"
"nadir/internal/auth"
"nadir/internal/module"
"nadir/internal/rbac"
"github.com/danielgtaylor/huma/v2"
)
// WhoamiInput carries the session cookie. The endpoint is not behind the RBAC
// middleware (it requires no specific permission), so it validates the session
// itself.
type WhoamiInput struct {
SessionID string `cookie:"nadir_session_id"`
}
// WhoamiBody reports who the caller is and, per module, which permissions they
// actually hold. Combined with /api/_modules (the full module/permission grid),
// this gives the frontend everything it needs to render the permission matrix.
type WhoamiBody struct {
Username string `json:"username" example:"urania" doc:"Authenticated username"`
Permissions map[string][]string `json:"permissions" doc:"Module ID -> permissions the caller holds. Modules where they hold none are omitted."`
}
type WhoamiOutput struct{ Body WhoamiBody }
// RegisterWhoami adds the current-user endpoint. It resolves the caller's
// concrete grants by asking the RBAC store about each module's permissions,
// so "*" wildcards in roles are expanded for free.
func RegisterWhoami(api huma.API, sessions *auth.SessionStore, roles *rbac.RBAC, mods []module.Module) {
huma.Register(api, huma.Operation{
OperationID: "whoami",
Method: "GET",
Path: "/api/whoami",
Summary: "Get the current user and their permissions",
Description: "Returns the authenticated username and, per module, the " +
"permissions the caller holds (wildcards resolved). Pair with " +
"/api/_modules to render the full permission matrix.",
Tags: []string{"Meta"},
Errors: []int{401},
}, func(ctx context.Context, in *WhoamiInput) (*WhoamiOutput, error) {
sess, ok := sessions.GetByToken(in.SessionID)
if !ok {
return nil, huma.Error401Unauthorized("unauthorized")
}
held := make(map[string][]string)
for _, m := range mods {
var perms []string
for _, p := range m.Permissions() {
if roles.Can(sess.Username, m.ID(), p) {
perms = append(perms, string(p))
}
}
if len(perms) > 0 {
held[m.ID()] = perms
}
}
out := &WhoamiOutput{}
out.Body = WhoamiBody{Username: sess.Username, Permissions: held}
return out, nil
})
}
+14
View File
@@ -0,0 +1,14 @@
package module
import (
"nadir/internal/rbac"
"github.com/danielgtaylor/huma/v2"
)
type Module interface {
ID() string
Name() string
Permissions() []rbac.Permission // permissions this module exposes (no "*")
Register(api huma.API)
}
+62
View File
@@ -0,0 +1,62 @@
package audit
import (
"context"
"nadir/internal/auditlog"
"nadir/internal/rbac"
"github.com/danielgtaylor/huma/v2"
)
const ModuleID = "audit"
type Module struct {
store *auditlog.Store
}
func New(store *auditlog.Store) *Module { return &Module{store: store} }
func (m *Module) ID() string { return ModuleID }
func (m *Module) Name() string { return "Audit" }
// Permissions: read to view the audit trail. There is no write - entries are
// produced by the middleware, never by an API call.
func (m *Module) Permissions() []rbac.Permission {
return []rbac.Permission{rbac.Read}
}
// Types are named AuditList* (not ListInput/ListOutput) because Huma derives
// OpenAPI schema names from the Go type name alone, not package-qualified, so a
// bare "ListOutput" here would collide with the packages module's.
type AuditListInput struct {
Limit int `query:"limit" default:"200" minimum:"1" maximum:"10000" doc:"Max entries to return, newest first"`
}
type AuditListOutput struct {
Body struct {
Entries []auditlog.Entry `json:"entries" doc:"Recorded actions, newest first"`
}
}
func (m *Module) Register(api huma.API) {
huma.Register(api, huma.Operation{
OperationID: "audit-list",
Method: "GET",
Path: "/api/audit",
Summary: "List recorded actions",
Description: "Returns the audit trail of privileged write operations " +
"(who, what, when, result), newest first.",
Tags: []string{"Audit"},
Metadata: map[string]any{"module": ModuleID, "permission": "read"},
Errors: []int{401, 403, 500},
}, func(ctx context.Context, in *AuditListInput) (*AuditListOutput, error) {
entries, err := m.store.List(in.Limit)
if err != nil {
return nil, huma.Error500InternalServerError("read audit log failed", err)
}
out := &AuditListOutput{}
out.Body.Entries = entries
return out, nil
})
}
+225
View File
@@ -0,0 +1,225 @@
package groups
import (
"context"
"os"
"regexp"
"strconv"
"strings"
"nadir/internal/oscmd"
"github.com/danielgtaylor/huma/v2"
)
const tagGroups = "Groups"
var groupPath = "/etc/group"
// systemGIDMax mirrors the user convention: regular groups start at 1000.
const systemGIDMax = 1000
var (
readErrors = []int{401, 403, 500}
writeErrors = []int{400, 401, 403, 404, 409, 500}
)
// groupNameRe matches valid group names (same rule as usernames). Starting with
// a letter/underscore also rejects leading-dash flag injection.
var groupNameRe = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}\$?$`)
// Group mirrors one /etc/group entry.
type Group struct {
Name string `json:"name" example:"wheel" doc:"Group name"`
GID int `json:"gid" example:"10" doc:"Group ID"`
Members []string `json:"members" doc:"Supplementary members (primary-group members are not listed here)"`
System bool `json:"system" doc:"True for system groups (gid < 1000)"`
}
type ListGroupsOutput struct {
Body struct {
Groups []Group `json:"groups" doc:"All groups from /etc/group"`
}
}
type GetGroupOutput struct{ Body Group }
type GroupPath struct {
Group string `path:"group" example:"wheel" doc:"Group name"`
}
type CreateGroupInput struct {
Body struct {
Name string `json:"name" example:"developers" doc:"Group name"`
GID *int `json:"gid,omitempty" example:"1500" doc:"Explicit GID (omit to auto-assign)"`
System bool `json:"system,omitempty" doc:"Create a system group (groupadd --system)"`
}
}
func registerGroups(api huma.API) {
huma.Register(api, huma.Operation{
OperationID: "groups-list",
Method: "GET",
Path: "/api/groups",
Summary: "List groups",
Description: "Returns every group in /etc/group, including system groups " +
"(flagged via `system`).",
Tags: []string{tagGroups},
Metadata: op("read"),
Errors: readErrors,
}, func(ctx context.Context, _ *struct{}) (*ListGroupsOutput, error) {
list, err := listGroups()
if err != nil {
return nil, huma.Error500InternalServerError("read "+groupPath+" failed", err)
}
out := &ListGroupsOutput{}
out.Body.Groups = list
return out, nil
})
huma.Register(api, huma.Operation{
OperationID: "groups-get",
Method: "GET",
Path: "/api/groups/{group}",
Summary: "Get a single group",
Description: "Returns one group by name. 404 if it does not exist.",
Tags: []string{tagGroups},
Metadata: op("read"),
Errors: []int{400, 401, 403, 404, 500},
}, func(ctx context.Context, in *GroupPath) (*GetGroupOutput, error) {
if err := validateGroupName(in.Group); err != nil {
return nil, err
}
g, ok, err := lookupGroup(in.Group)
if err != nil {
return nil, huma.Error500InternalServerError("read "+groupPath+" failed", err)
}
if !ok {
return nil, huma.Error404NotFound("group not found: " + in.Group)
}
return &GetGroupOutput{Body: g}, nil
})
huma.Register(api, huma.Operation{
OperationID: "groups-create",
Method: "POST",
Path: "/api/groups",
Summary: "Create a group",
Description: "Creates a group via groupadd. 409 if the group already exists.",
Tags: []string{tagGroups},
Metadata: op("write"),
Errors: writeErrors,
}, func(ctx context.Context, in *CreateGroupInput) (*GetGroupOutput, error) {
if err := validateGroupName(in.Body.Name); err != nil {
return nil, err
}
if _, ok, err := lookupGroup(in.Body.Name); err != nil {
return nil, huma.Error500InternalServerError("read "+groupPath+" failed", err)
} else if ok {
return nil, huma.Error409Conflict("group already exists: " + in.Body.Name)
}
args := []string{}
if in.Body.System {
args = append(args, "--system")
}
if in.Body.GID != nil {
args = append(args, "-g", strconv.Itoa(*in.Body.GID))
}
args = append(args, "--", in.Body.Name)
if _, err := oscmd.Run("groupadd", args...); err != nil {
return nil, huma.Error500InternalServerError("groupadd failed", err)
}
g, ok, err := lookupGroup(in.Body.Name)
if err != nil || !ok {
return nil, huma.Error500InternalServerError("group created but could not be read back", err)
}
return &GetGroupOutput{Body: g}, nil
})
huma.Register(api, huma.Operation{
OperationID: "groups-delete",
Method: "DELETE",
Path: "/api/groups/{group}",
Summary: "Delete a group",
Description: "Removes a group via groupdel. Returns 409 if it is the primary " +
"group of an existing user (groupdel refuses), 404 if it does not exist.",
Tags: []string{tagGroups},
// Deleting a group is irreversible - gated behind root, not write.
Metadata: op("root"),
Errors: []int{400, 401, 403, 404, 409, 500},
}, func(ctx context.Context, in *GroupPath) (*oscmd.StatusOutput, error) {
if err := validateGroupName(in.Group); err != nil {
return nil, err
}
if _, ok, err := lookupGroup(in.Group); err != nil {
return nil, huma.Error500InternalServerError("read "+groupPath+" failed", err)
} else if !ok {
return nil, huma.Error404NotFound("group not found: " + in.Group)
}
if _, err := oscmd.Run("groupdel", "--", in.Group); err != nil {
// groupdel refuses to remove a user's primary group; make that actionable.
return nil, huma.Error409Conflict("groupdel failed (is it a user's primary group?)", err)
}
return oscmd.OK(), nil
})
}
func validateGroupName(name string) error {
if !groupNameRe.MatchString(name) {
return huma.Error400BadRequest("invalid group name: " + name)
}
return nil
}
func listGroups() ([]Group, error) {
data, err := os.ReadFile(groupPath)
if err != nil {
return nil, err
}
return parseGroup(data), nil
}
func lookupGroup(name string) (Group, bool, error) {
list, err := listGroups()
if err != nil {
return Group{}, false, err
}
for _, g := range list {
if g.Name == name {
return g, true, nil
}
}
return Group{}, false, nil
}
// parseGroup parses /etc/group content. Blank, commented, or malformed lines
// (fewer than 4 fields, non-numeric gid) are skipped.
func parseGroup(data []byte) []Group {
var groups []Group
for line := range strings.SplitSeq(string(data), "\n") {
if line == "" || strings.HasPrefix(line, "#") {
continue
}
f := strings.Split(line, ":")
if len(f) < 4 {
continue
}
gid, err := strconv.Atoi(f[2])
if err != nil {
continue
}
var members []string
if f[3] != "" {
members = strings.Split(f[3], ",")
}
groups = append(groups, Group{
Name: f[0],
GID: gid,
Members: members,
System: gid < systemGIDMax,
})
}
return groups
}
@@ -0,0 +1,109 @@
package groups
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"reflect"
"testing"
"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())
}
func TestGroupsHandlers(t *testing.T) {
tempGroup := filepath.Join(t.TempDir(), "group")
initialContent := "root:x:0:\nwheel:x:10:alice,bob\n"
if err := os.WriteFile(tempGroup, []byte(initialContent), 0644); err != nil {
t.Fatal(err)
}
oldGroup := groupPath
groupPath = tempGroup
defer func() { groupPath = oldGroup }()
mux := http.NewServeMux()
api := humatest.Wrap(t, humago.New(mux, huma.DefaultConfig("Test", "1.0.0")))
registerGroups(api)
// 1. Test GET /api/groups
resp := api.Get("/api/groups")
if resp.Code != http.StatusOK {
t.Errorf("list groups: got %d, want %d", resp.Code, http.StatusOK)
}
var listRes ListGroupsOutput
if err := json.Unmarshal(resp.Body.Bytes(), &listRes.Body); err != nil {
t.Fatal(err)
}
if len(listRes.Body.Groups) != 2 {
t.Errorf("got %d groups, want 2", len(listRes.Body.Groups))
}
// 2. Test GET /api/groups/{group}
resp = api.Get("/api/groups/wheel")
if resp.Code != http.StatusOK {
t.Errorf("get group: got %d, want %d", resp.Code, http.StatusOK)
}
var getRes GetGroupOutput
if err := json.Unmarshal(resp.Body.Bytes(), &getRes.Body); err != nil {
t.Fatal(err)
}
if getRes.Body.Name != "wheel" || getRes.Body.GID != 10 {
t.Errorf("get group: got %+v", getRes.Body)
}
resp = api.Get("/api/groups/nonexistent")
if resp.Code != http.StatusNotFound {
t.Errorf("get non-existent group: got %d, want %d", resp.Code, http.StatusNotFound)
}
// 3. Test POST /api/groups
oscmd.SetMock("groupadd", func(args []string) oscmd.MockCommand {
wantArgs := []string{"-g", "1500", "--", "dev"}
if !reflect.DeepEqual(args, wantArgs) {
t.Errorf("groupadd args: got %v, want %v", args, wantArgs)
}
devContent := initialContent + "dev:x:1500:\n"
os.WriteFile(tempGroup, []byte(devContent), 0644)
return oscmd.MockCommand{ExitCode: 0}
})
defer oscmd.ClearMocks()
gidVal := 1500
resp = api.Post("/api/groups", struct {
Name string `json:"name"`
GID *int `json:"gid"`
}{
Name: "dev",
GID: &gidVal,
})
if resp.Code != http.StatusOK {
t.Errorf("create group: got %d, want %d", resp.Code, http.StatusOK)
}
// 4. Test DELETE /api/groups/{group}
oscmd.SetMock("groupdel", func(args []string) oscmd.MockCommand {
wantArgs := []string{"--", "dev"}
if !reflect.DeepEqual(args, wantArgs) {
t.Errorf("groupdel args: got %v, want %v", args, wantArgs)
}
os.WriteFile(tempGroup, []byte(initialContent), 0644)
return oscmd.MockCommand{ExitCode: 0}
})
resp = api.Delete("/api/groups/dev")
if resp.Code != http.StatusOK {
t.Errorf("delete group: got %d, want %d", resp.Code, http.StatusOK)
}
}
+51
View File
@@ -0,0 +1,51 @@
package groups
import (
"reflect"
"testing"
)
func TestParseGroup(t *testing.T) {
data := []byte(`root:x:0:
# comment
wheel:x:10:alice,bob
developers:x:1500:alice
empty:x:1600:
broken:x:notanumber:x
short:x:5
`)
got := parseGroup(data)
if len(got) != 4 {
t.Fatalf("expected 4 valid groups, got %d: %+v", len(got), got)
}
wheel := got[1]
if wheel.Name != "wheel" || wheel.GID != 10 || !wheel.System ||
!reflect.DeepEqual(wheel.Members, []string{"alice", "bob"}) {
t.Errorf("wheel parsed wrong: %+v", wheel)
}
dev := got[2]
if dev.GID != 1500 || dev.System {
t.Errorf("developers should be a non-system group: %+v", dev)
}
empty := got[3]
if len(empty.Members) != 0 {
t.Errorf("empty group should have no members, got %v", empty.Members)
}
}
func TestValidateGroupName(t *testing.T) {
for _, n := range []string{"wheel", "_svc", "dev-team", "g1"} {
if err := validateGroupName(n); err != nil {
t.Errorf("validateGroupName(%q) = %v, want nil", n, err)
}
}
for _, n := range []string{"", "-x", "Wheel", "a,b", "foo;rm", "1grp"} {
if err := validateGroupName(n); err == nil {
t.Errorf("validateGroupName(%q) = nil, want error", n)
}
}
}
+30
View File
@@ -0,0 +1,30 @@
package groups
import (
"nadir/internal/rbac"
"github.com/danielgtaylor/huma/v2"
)
const ModuleID = "groups"
type Module struct{}
func New() *Module { return &Module{} }
func (m *Module) ID() string { return ModuleID }
func (m *Module) Name() string { return "Groups" }
// Permissions: read to list/inspect groups; write to create; root to delete
// (irreversible). Group membership lives in the users module.
func (m *Module) Permissions() []rbac.Permission {
return []rbac.Permission{rbac.Read, rbac.Write, rbac.Root}
}
func (m *Module) Register(api huma.API) {
registerGroups(api)
}
func op(permission string) map[string]any {
return map[string]any{"module": ModuleID, "permission": permission}
}
+90
View File
@@ -0,0 +1,90 @@
package networking
import (
"context"
"fmt"
"os/exec"
"time"
"nadir/internal/oscmd"
)
// backend is the write-side abstraction. Each host network manager (nmcli,
// networkd, ifupdown) implements this. Reads go through `ip -j` and
// /etc/resolv.conf regardless - they are backend-agnostic (see read.go).
//
// When detect() finds no backend, Module.be is nil and all write endpoints
// return 501 Not Implemented. Reads still work.
// Methods that shell out take a context so a request that is cancelled (client
// disconnect, timeout) kills the slow command (e.g. `nmcli con up` waiting on
// DHCP). The timer-driven auto-revert, which must finish even with no client,
// passes context.Background().
type backend interface {
// Name returns the backend identifier ("nmcli", "networkd", "ifupdown").
Name() string
// Snapshot captures the current IPv4 configuration of iface so it can be
// restored on rollback. The returned IfaceConfig is backend-specific:
// nmcli reads from NM's connection, networkd/ifupdown read their config
// files, falling back to live `ip` output when no managed file exists
// (in which case Method is "dhcp" - safest revert assumption).
Snapshot(ctx context.Context, iface string) (IfaceConfig, error)
// Apply replaces the interface's IPv4 configuration with cfg. It is the
// caller's responsibility to have taken a Snapshot first (the rollback
// mechanism does this). Apply must be idempotent: calling it with the
// same cfg twice should leave the system in the same state.
Apply(ctx context.Context, iface string, cfg IfaceConfig) error
// SetLinkUp brings the interface up.
SetLinkUp(ctx context.Context, iface string) error
// SetLinkDown takes the interface down.
SetLinkDown(ctx context.Context, iface string) error
}
// detect probes the host for a supported network manager, in priority order:
//
// 1. nmcli (NetworkManager) - the majority of desktop and modern server installs
// 2. networkctl (systemd-networkd) - common on minimal/container hosts
// 3. ifup/ifdown (ifupdown) - classic Debian/Ubuntu servers
//
// Returns nil when none is found. The order matters: some distros ship both NM
// and networkd; NM wins because it's the active manager in that case.
func detect() backend {
if _, err := exec.LookPath("nmcli"); err == nil {
if _, err := oscmd.Run("nmcli", "general", "status"); err == nil {
return &nmcliBackend{}
}
}
if _, err := exec.LookPath("networkctl"); err == nil {
if _, err := oscmd.Run("systemctl", "is-active", "--quiet", "systemd-networkd"); err == nil {
return &networkdBackend{}
}
}
if _, err := exec.LookPath("ifup"); err == nil {
if _, err := exec.LookPath("ifdown"); err == nil {
return &ifupdownBackend{}
}
}
return nil
}
// pendingChange tracks a single in-flight change that has been applied but not
// yet confirmed. revert undoes it (re-apply the prior config, or bring a
// downed link back up). If the timer fires before confirmation, revert runs -
// protecting against lock-yourself-out mistakes.
//
// ponytail: one slot for the whole module, not per-interface. An admin makes one
// change at a time; a concurrent change to another iface is rejected with a 409
// that says so. Key it by iface (a map) if multi-interface concurrency is ever
// needed.
type pendingChange struct {
Iface string // interface that was changed
revert func() error // undoes the change, for rollback
Timer *time.Timer // fires the auto-revert
Deadline time.Time // when the timer will fire (for the status endpoint)
}
// errNoBackend is the 501 returned when no write backend was detected.
var errNoBackend = fmt.Errorf("no supported network backend detected (tried nmcli, networkctl, ifup/ifdown)")
+133
View File
@@ -0,0 +1,133 @@
package networking
import (
"net/netip"
"regexp"
"github.com/danielgtaylor/huma/v2"
)
// tagNetworking groups every networking-module operation under one OpenAPI tag,
// keeping tags 1:1 with modules.
const tagNetworking = "Networking"
var (
readErrors = []int{401, 403, 500}
writeErrors = []int{400, 401, 403, 409, 500, 501}
)
// ifaceNameRe matches a Linux interface name. Linux caps names at 15 bytes and
// forbids '/' and whitespace; we additionally reject a leading dash so a name
// can never be read as a flag by `ip`/`nmcli`/`ifup`. Every command line also
// passes user-supplied names after a "--" separator.
var ifaceNameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._@-]{0,14}$`)
func validateIface(name string) error {
if !ifaceNameRe.MatchString(name) {
return huma.Error400BadRequest("invalid interface name: " + name)
}
return nil
}
// IfaceConfig is the declarative desired state for one interface. A PUT replaces
// the interface's IPv4 configuration with this (and IPv6 too, when the IPv6 block
// is included), across whichever backend is in use. Routes are part of the config
// (declarative add/remove): the client sends the full set it wants.
type IfaceConfig struct {
Method string `json:"method" enum:"static,dhcp" example:"static" doc:"\"static\" for a fixed address, \"dhcp\" for automatic"`
Address string `json:"address,omitempty" example:"192.168.1.10" doc:"IPv4 address (static only)"`
Prefix int `json:"prefix,omitempty" minimum:"0" maximum:"32" example:"24" doc:"Network prefix length (static only)"`
Gateway string `json:"gateway,omitempty" example:"192.168.1.1" doc:"Default gateway (static only, optional)"`
IPv6 *IPv6Config `json:"ipv6,omitempty" doc:"Optional IPv6 settings. Omit to leave IPv6 untouched; include to manage it."`
DNS []string `json:"dns,omitempty" example:"[\"1.1.1.1\",\"8.8.8.8\"]" doc:"DNS servers for this interface (IPv4 or IPv6)"`
Routes []Route `json:"routes,omitempty" doc:"Static routes to install for this interface"`
RollbackSeconds int `json:"rollback_seconds,omitempty" minimum:"0" maximum:"3600" example:"60" doc:"Auto-revert after this many seconds unless confirmed. 0 uses the default (60s)."`
}
// IPv6Config is the optional IPv6 settings for an interface. Method "auto" uses
// SLAAC/router advertisements (the usual default), "static" pins an address, and
// "ignore" disables IPv6 on the interface. DHCPv6 is not modeled.
type IPv6Config struct {
Method string `json:"method" enum:"auto,static,ignore" example:"static" doc:"\"auto\" (SLAAC), \"static\", or \"ignore\" (disable IPv6)"`
Address string `json:"address,omitempty" example:"2001:db8::10" doc:"IPv6 address (static only)"`
Prefix int `json:"prefix,omitempty" minimum:"0" maximum:"128" example:"64" doc:"Prefix length (static only)"`
Gateway string `json:"gateway,omitempty" example:"2001:db8::1" doc:"IPv6 default gateway (static only, optional)"`
}
// Route is a single static route. Destination is a CIDR (or \"default\").
type Route struct {
Destination string `json:"destination" example:"10.0.0.0/24" doc:"Destination network in CIDR notation, or \"default\""`
Gateway string `json:"gateway" example:"192.168.1.1" doc:"Next-hop gateway"`
}
// validate checks the desired config independently of the backend, so a bad
// request is a 400 before we touch the system. IP/CIDR parsing uses net/netip.
func (c IfaceConfig) validate() error {
switch c.Method {
case "static":
if c.Address == "" {
return huma.Error400BadRequest("static method requires an address")
}
if _, err := netip.ParseAddr(c.Address); err != nil {
return huma.Error400BadRequest("invalid address: " + c.Address)
}
if c.Prefix < 1 || c.Prefix > 32 {
return huma.Error400BadRequest("prefix must be 1-32")
}
if c.Gateway != "" {
if _, err := netip.ParseAddr(c.Gateway); err != nil {
return huma.Error400BadRequest("invalid gateway: " + c.Gateway)
}
}
case "dhcp":
// address/gateway/prefix are ignored; nothing to validate.
default:
return huma.Error400BadRequest("method must be \"static\" or \"dhcp\"")
}
for _, s := range c.DNS {
if _, err := netip.ParseAddr(s); err != nil {
return huma.Error400BadRequest("invalid DNS server: " + s)
}
}
for _, r := range c.Routes {
if r.Destination != "default" {
if _, err := netip.ParsePrefix(r.Destination); err != nil {
return huma.Error400BadRequest("invalid route destination: " + r.Destination)
}
}
if _, err := netip.ParseAddr(r.Gateway); err != nil {
return huma.Error400BadRequest("invalid route gateway: " + r.Gateway)
}
}
if c.IPv6 != nil {
if err := c.IPv6.validate(); err != nil {
return err
}
}
return nil
}
// validate checks an IPv6 block. Static addresses/gateways must parse as IPv6
// (an IPv4 literal here is a client mistake).
func (c IPv6Config) validate() error {
switch c.Method {
case "auto", "ignore":
// no address fields to validate
case "static":
addr, err := netip.ParseAddr(c.Address)
if err != nil || addr.Is4() {
return huma.Error400BadRequest("ipv6 static requires a valid IPv6 address, got: " + c.Address)
}
if c.Prefix < 1 || c.Prefix > 128 {
return huma.Error400BadRequest("ipv6 prefix must be 1-128")
}
if c.Gateway != "" {
if gw, err := netip.ParseAddr(c.Gateway); err != nil || gw.Is4() {
return huma.Error400BadRequest("invalid ipv6 gateway: " + c.Gateway)
}
}
default:
return huma.Error400BadRequest("ipv6 method must be \"auto\", \"static\", or \"ignore\"")
}
return nil
}
@@ -0,0 +1,65 @@
package networking
import "testing"
func TestValidateIfaceConfig(t *testing.T) {
tests := []struct {
name string
cfg IfaceConfig
wantErr bool
}{
{"valid static", IfaceConfig{
Method: "static", Address: "192.168.1.10", Prefix: 24, Gateway: "192.168.1.1",
DNS: []string{"1.1.1.1", "8.8.8.8"},
}, false},
{"valid static no gateway", IfaceConfig{
Method: "static", Address: "10.0.0.5", Prefix: 8,
}, false},
{"valid dhcp", IfaceConfig{Method: "dhcp"}, false},
{"valid with routes", IfaceConfig{
Method: "static", Address: "192.168.1.10", Prefix: 24,
Routes: []Route{
{Destination: "100.64.0.0/24", Gateway: "192.168.1.1"},
{Destination: "default", Gateway: "192.168.1.1"},
},
}, false},
{"static missing address", IfaceConfig{Method: "static", Prefix: 24}, true},
{"static bad address", IfaceConfig{Method: "static", Address: "not-an-ip", Prefix: 24}, true},
{"static bad gateway", IfaceConfig{Method: "static", Address: "10.0.0.1", Prefix: 24, Gateway: "bad"}, true},
{"bad method", IfaceConfig{Method: "pppoe"}, true},
{"bad dns", IfaceConfig{Method: "dhcp", DNS: []string{"not-an-ip"}}, true},
{"bad route destination", IfaceConfig{
Method: "static", Address: "10.0.0.1", Prefix: 24,
Routes: []Route{{Destination: "nope", Gateway: "10.0.0.1"}},
}, true},
{"bad route gateway", IfaceConfig{
Method: "static", Address: "10.0.0.1", Prefix: 24,
Routes: []Route{{Destination: "10.0.0.0/24", Gateway: "nope"}},
}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.cfg.validate()
if (err != nil) != tt.wantErr {
t.Errorf("validate() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestValidateIface(t *testing.T) {
valid := []string{"eth0", "enp3s0", "wlan0", "br-lan", "veth1234567", "docker0"}
for _, name := range valid {
if err := validateIface(name); err != nil {
t.Errorf("validateIface(%q) unexpected error: %v", name, err)
}
}
invalid := []string{"", "-eth0", "/dev/net", "a b", "name_that_is_way_too_long_for_linux"}
for _, name := range invalid {
if err := validateIface(name); err == nil {
t.Errorf("validateIface(%q) expected error", name)
}
}
}
+208
View File
@@ -0,0 +1,208 @@
package networking
import (
"context"
"net/netip"
"os"
"regexp"
"strings"
"nadir/internal/oscmd"
"github.com/danielgtaylor/huma/v2"
)
// /etc/hosts management ("Host Addresses"). Edits are surgical — upsert/delete a
// single IP's line — so existing comments, ordering and the localhost entries
// are preserved rather than rewritten away.
var hostsFile = "/etc/hosts"
// hostnameRe matches a single hostname/alias. It forbids whitespace and '#' (so
// an entry can't inject extra fields or a comment) and a leading dash.
var hostnameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`)
// HostEntry is one /etc/hosts mapping: an IP and the names that resolve to it.
type HostEntry struct {
IP string `json:"ip" example:"192.168.1.10" doc:"IPv4 or IPv6 address"`
Hostnames []string `json:"hostnames" example:"[\"server\",\"server.local\"]" doc:"Names mapped to the address"`
}
type ListHostsOutput struct {
Body struct {
Entries []HostEntry `json:"entries"`
}
}
type HostUpsertInput struct {
IP string `path:"ip" example:"192.168.1.10" doc:"IP address to add or update"`
Body struct {
Hostnames []string `json:"hostnames" example:"[\"server\",\"server.local\"]" doc:"Names to map to the IP"`
}
}
type HostDeleteInput struct {
IP string `path:"ip" example:"192.168.1.10" doc:"IP address whose entry to remove"`
}
func registerHosts(api huma.API) {
huma.Register(api, huma.Operation{
OperationID: "networking-list-hosts",
Method: "GET",
Path: "/api/networking/hosts",
Summary: "List /etc/hosts entries",
Description: "Returns the static host-to-address mappings from /etc/hosts.",
Tags: []string{tagNetworking},
Metadata: op("read"),
Errors: readErrors,
}, func(ctx context.Context, _ *struct{}) (*ListHostsOutput, error) {
data, err := os.ReadFile(hostsFile)
if err != nil {
return nil, huma.Error500InternalServerError("read hosts failed", err)
}
res := &ListHostsOutput{}
res.Body.Entries = parseHosts(string(data))
return res, nil
})
huma.Register(api, huma.Operation{
OperationID: "networking-upsert-host",
Method: "PUT",
Path: "/api/networking/hosts/{ip}",
Summary: "Add or update a /etc/hosts entry",
Description: "Sets the hostnames for an IP. Replaces the existing line for that IP, " +
"or appends a new one; all other lines (comments included) are left untouched.",
Tags: []string{tagNetworking},
Metadata: op("write"),
Errors: writeErrors,
}, func(ctx context.Context, in *HostUpsertInput) (*oscmd.StatusOutput, error) {
if err := validateHost(in.IP, in.Body.Hostnames); err != nil {
return nil, err
}
if err := upsertHost(in.IP, in.Body.Hostnames); err != nil {
return nil, huma.Error500InternalServerError("write hosts failed", err)
}
return oscmd.OK(), nil
})
huma.Register(api, huma.Operation{
OperationID: "networking-delete-host",
Method: "DELETE",
Path: "/api/networking/hosts/{ip}",
Summary: "Remove a /etc/hosts entry",
Description: "Removes the line(s) mapping the given IP. 404 if no entry exists for it.",
Tags: []string{tagNetworking},
Metadata: op("write"),
Errors: []int{400, 401, 403, 404, 500},
}, func(ctx context.Context, in *HostDeleteInput) (*oscmd.StatusOutput, error) {
if _, err := netip.ParseAddr(in.IP); err != nil {
return nil, huma.Error400BadRequest("invalid IP: " + in.IP)
}
removed, err := deleteHost(in.IP)
if err != nil {
return nil, huma.Error500InternalServerError("write hosts failed", err)
}
if !removed {
return nil, huma.Error404NotFound("no hosts entry for " + in.IP)
}
return oscmd.OK(), nil
})
}
func validateHost(ip string, hostnames []string) error {
if _, err := netip.ParseAddr(ip); err != nil {
return huma.Error400BadRequest("invalid IP: " + ip)
}
if len(hostnames) == 0 {
return huma.Error400BadRequest("at least one hostname is required")
}
for _, h := range hostnames {
if !hostnameRe.MatchString(h) {
return huma.Error400BadRequest("invalid hostname: " + h)
}
}
return nil
}
// --- parse / render (pure, tested) -------------------------------------------
// hostFields returns the IP and hostnames on a hosts line, or ok=false for a
// blank or comment-only line. An inline "# comment" tail is stripped.
func hostFields(line string) (ip string, names []string, ok bool) {
if i := strings.IndexByte(line, '#'); i >= 0 {
line = line[:i]
}
f := strings.Fields(line)
if len(f) < 2 {
return "", nil, false
}
return f[0], f[1:], true
}
func parseHosts(text string) []HostEntry {
entries := []HostEntry{}
for line := range strings.SplitSeq(text, "\n") {
if ip, names, ok := hostFields(line); ok {
entries = append(entries, HostEntry{IP: ip, Hostnames: names})
}
}
return entries
}
// renderHostLine formats one entry as a hosts line.
func renderHostLine(ip string, hostnames []string) string {
return ip + "\t" + strings.Join(hostnames, " ")
}
// --- writes ------------------------------------------------------------------
// upsertHost replaces the line for ip (matched on the address field) or appends
// a new one, preserving every other line.
func upsertHost(ip string, hostnames []string) error {
data, err := os.ReadFile(hostsFile)
if err != nil {
return err
}
lines := strings.Split(string(data), "\n")
newLine := renderHostLine(ip, hostnames)
replaced := false
for i, line := range lines {
if got, _, ok := hostFields(line); ok && got == ip {
lines[i] = newLine
replaced = true
break
}
}
if !replaced {
// Append, avoiding a blank line if the file already ended with one.
if n := len(lines); n > 0 && strings.TrimSpace(lines[n-1]) == "" {
lines[n-1] = newLine
} else {
lines = append(lines, newLine)
}
lines = append(lines, "")
}
return os.WriteFile(hostsFile, []byte(strings.Join(lines, "\n")), 0644)
}
// deleteHost removes every line mapping ip and reports whether any were removed.
func deleteHost(ip string) (bool, error) {
data, err := os.ReadFile(hostsFile)
if err != nil {
return false, err
}
lines := strings.Split(string(data), "\n")
kept := make([]string, 0, len(lines))
removed := false
for _, line := range lines {
if got, _, ok := hostFields(line); ok && got == ip {
removed = true
continue
}
kept = append(kept, line)
}
if !removed {
return false, nil
}
return true, os.WriteFile(hostsFile, []byte(strings.Join(kept, "\n")), 0644)
}
+92
View File
@@ -0,0 +1,92 @@
package networking
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
const sampleHosts = `# static table
127.0.0.1 localhost
::1 localhost ip6-localhost
192.168.1.10 server server.local # the box
`
func TestParseHosts(t *testing.T) {
got := parseHosts(sampleHosts)
want := []HostEntry{
{IP: "127.0.0.1", Hostnames: []string{"localhost"}},
{IP: "::1", Hostnames: []string{"localhost", "ip6-localhost"}},
{IP: "192.168.1.10", Hostnames: []string{"server", "server.local"}},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("parseHosts:\n got %+v\nwant %+v", got, want)
}
}
func TestUpsertAndDeleteHost(t *testing.T) {
path := filepath.Join(t.TempDir(), "hosts")
if err := os.WriteFile(path, []byte(sampleHosts), 0644); err != nil {
t.Fatal(err)
}
old := hostsFile
hostsFile = path
defer func() { hostsFile = old }()
// Update an existing IP — comments and other lines must survive.
if err := upsertHost("192.168.1.10", []string{"web", "web.local"}); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(path)
if !strings.Contains(string(data), "# static table") || !strings.Contains(string(data), "ip6-localhost") {
t.Errorf("upsert clobbered other lines:\n%s", data)
}
entries := parseHosts(string(data))
if e := findHost(entries, "192.168.1.10"); e == nil || !reflect.DeepEqual(e.Hostnames, []string{"web", "web.local"}) {
t.Errorf("upsert did not update entry: %+v", entries)
}
// Add a new IP.
if err := upsertHost("10.0.0.5", []string{"db"}); err != nil {
t.Fatal(err)
}
entries = parseHosts(mustRead(t, path))
if findHost(entries, "10.0.0.5") == nil {
t.Errorf("upsert did not append new entry: %+v", entries)
}
// Delete it again.
removed, err := deleteHost("10.0.0.5")
if err != nil || !removed {
t.Fatalf("delete failed: removed=%v err=%v", removed, err)
}
if findHost(parseHosts(mustRead(t, path)), "10.0.0.5") != nil {
t.Error("entry still present after delete")
}
// Deleting a missing IP reports not-removed.
if removed, _ := deleteHost("8.8.8.8"); removed {
t.Error("expected removed=false for missing IP")
}
}
func findHost(entries []HostEntry, ip string) *HostEntry {
for i := range entries {
if entries[i].IP == ip {
return &entries[i]
}
}
return nil
}
func mustRead(t *testing.T, path string) string {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(data)
}
+239
View File
@@ -0,0 +1,239 @@
package networking
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"nadir/internal/oscmd"
)
// ifupdownBackend implements backend via classic Debian ifupdown. Configuration
// is done by writing stanza files under interfacesDDir. Each managed interface
// gets "nadir-<iface>" so we never touch /etc/network/interfaces itself.
//
// Prerequisite: /etc/network/interfaces must contain
//
// source /etc/network/interfaces.d/*
//
// for these stanzas to take effect. If missing, Apply checks for this and adds
// the source directive (same auto-provision pattern as the PAM service).
type ifupdownBackend struct{}
var (
interfacesFile = "/etc/network/interfaces"
interfacesDDir = "/etc/network/interfaces.d"
)
func (b *ifupdownBackend) Name() string { return "ifupdown" }
// ifupdownFile returns the path for the nadir-managed stanza file for iface.
// iface is already validated by validateIface to prevent path traversal.
func ifupdownFile(iface string) string {
return filepath.Join(interfacesDDir, "nadir-"+iface)
}
func (b *ifupdownBackend) Snapshot(ctx context.Context, iface string) (IfaceConfig, error) {
path := ifupdownFile(iface)
data, err := os.ReadFile(path)
if err != nil {
// No nadir-managed stanza → fall back to live ip output, assume DHCP.
return snapshotFromIP(ctx, iface)
}
return parseIfupdownStanza(string(data)), nil
}
// parseIfupdownStanza extracts IfaceConfig from an ifupdown stanza file. A file
// may hold both an "inet" (IPv4) and an "inet6" (IPv6) stanza for the interface;
// family tracks which one the indented keys below belong to.
func parseIfupdownStanza(content string) IfaceConfig {
cfg := IfaceConfig{Method: "dhcp"}
family := "inet"
for line := range strings.SplitSeq(content, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
switch fields[0] {
case "iface":
// "iface eth0 inet static" / "iface eth0 inet6 auto"
if len(fields) >= 4 {
family = fields[2]
switch family {
case "inet":
if fields[3] == "static" {
cfg.Method = "static"
}
case "inet6":
switch fields[3] {
case "static":
v6(&cfg).Method = "static"
default: // auto / dhcp / manual → treat as autoconf
v6(&cfg).Method = "auto"
}
}
}
case "address":
addr, prefix := splitCIDR(fields[1])
if family == "inet6" {
g := v6(&cfg)
g.Address = addr
if prefix > 0 {
g.Prefix = prefix
}
} else if addr != "" {
cfg.Address = addr
if prefix > 0 {
cfg.Prefix = prefix
}
}
case "gateway":
if family == "inet6" {
v6(&cfg).Gateway = fields[1]
} else {
cfg.Gateway = fields[1]
}
case "dns-nameservers":
cfg.DNS = append(cfg.DNS, fields[1:]...)
case "up":
// "up ip route add 10.0.0.0/24 via 192.168.1.1"
r := parseUpRoute(fields[1:])
if r.Destination != "" {
cfg.Routes = append(cfg.Routes, r)
}
}
}
return cfg
}
// parseUpRoute extracts a Route from "ip route add <dst> via <gw>" post-up commands.
func parseUpRoute(args []string) Route {
// Expected: ["ip", "route", "add", "10.0.0.0/24", "via", "192.168.1.1"]
if len(args) < 6 || args[0] != "ip" || args[1] != "route" || args[2] != "add" {
return Route{}
}
r := Route{Destination: args[3]}
for i, a := range args {
if a == "via" && i+1 < len(args) {
r.Gateway = args[i+1]
break
}
}
return r
}
func (b *ifupdownBackend) Apply(ctx context.Context, iface string, cfg IfaceConfig) error {
// Ensure interfaces.d exists and is sourced.
if err := ensureSourceDirective(); err != nil {
return err
}
content := renderIfupdownStanza(iface, cfg)
path := ifupdownFile(iface)
if err := os.MkdirAll(interfacesDDir, 0755); err != nil {
return fmt.Errorf("mkdir %s: %w", interfacesDDir, err)
}
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
// Bring the interface down and back up with the new config.
// ifdown may fail if the interface wasn't previously managed — ignore that.
_, _ = oscmd.RunContext(ctx, "ifdown", "--force", "--", iface)
if _, err := oscmd.RunContext(ctx, "ifup", "--", iface); err != nil {
return fmt.Errorf("ifup %s: %w", iface, err)
}
return nil
}
// ensureSourceDirective checks that /etc/network/interfaces contains a source
// line for interfaces.d. If not, appends one (same auto-provision pattern as
// the PAM service file).
func ensureSourceDirective() error {
data, err := os.ReadFile(interfacesFile)
if err != nil {
// If the file doesn't exist at all, that's a broken system; don't create it.
return fmt.Errorf("read %s: %w", interfacesFile, err)
}
content := string(data)
// Check for any source line pointing at interfaces.d.
for line := range strings.SplitSeq(content, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "source") && strings.Contains(line, "interfaces.d") {
return nil // already present
}
}
// Append the source directive.
addition := "\n# Added by nadir to pick up per-interface stanzas.\nsource " + interfacesDDir + "/*\n"
if err := os.WriteFile(interfacesFile, []byte(content+addition), 0644); err != nil {
return fmt.Errorf("append source directive to %s: %w", interfacesFile, err)
}
return nil
}
// renderIfupdownStanza builds the content for an ifupdown stanza file.
func renderIfupdownStanza(iface string, cfg IfaceConfig) string {
var b strings.Builder
b.WriteString("# Managed by nadir — do not edit manually.\n")
switch cfg.Method {
case "dhcp":
fmt.Fprintf(&b, "auto %s\n", iface)
fmt.Fprintf(&b, "iface %s inet dhcp\n", iface)
case "static":
fmt.Fprintf(&b, "auto %s\n", iface)
fmt.Fprintf(&b, "iface %s inet static\n", iface)
fmt.Fprintf(&b, " address %s/%d\n", cfg.Address, cfg.Prefix)
if cfg.Gateway != "" {
fmt.Fprintf(&b, " gateway %s\n", cfg.Gateway)
}
}
if len(cfg.DNS) > 0 {
fmt.Fprintf(&b, " dns-nameservers %s\n", strings.Join(cfg.DNS, " "))
}
for _, r := range cfg.Routes {
fmt.Fprintf(&b, " up ip route add %s via %s\n", r.Destination, r.Gateway)
fmt.Fprintf(&b, " down ip route del %s via %s\n", r.Destination, r.Gateway)
}
// IPv6 goes in its own inet6 stanza (the "auto eth0" above covers both).
if cfg.IPv6 != nil {
switch cfg.IPv6.Method {
case "auto":
fmt.Fprintf(&b, "iface %s inet6 auto\n", iface)
case "static":
fmt.Fprintf(&b, "iface %s inet6 static\n", iface)
fmt.Fprintf(&b, " address %s/%d\n", cfg.IPv6.Address, cfg.IPv6.Prefix)
if cfg.IPv6.Gateway != "" {
fmt.Fprintf(&b, " gateway %s\n", cfg.IPv6.Gateway)
}
case "ignore":
// no inet6 stanza
}
}
return b.String()
}
func (b *ifupdownBackend) SetLinkUp(ctx context.Context, iface string) error {
_, err := oscmd.RunContext(ctx, "ifup", "--", iface)
return err
}
func (b *ifupdownBackend) SetLinkDown(ctx context.Context, iface string) error {
_, err := oscmd.RunContext(ctx, "ifdown", "--", iface)
return err
}
+43
View File
@@ -0,0 +1,43 @@
package networking
import (
"sync"
"nadir/internal/rbac"
"github.com/danielgtaylor/huma/v2"
)
const ModuleID = "networking"
type Module struct {
// be is the detected network backend (nmcli / networkd / ifupdown). nil when
// none was found: reads still work (they go through `ip`), writes return 501.
be backend
// pending holds the single in-flight change awaiting confirmation, for the
// timed auto-rollback. See rollback.go.
pending *pendingChange
mu sync.Mutex
}
// New detects the host's network backend once at startup.
func New() *Module { return &Module{be: detect()} }
func (m *Module) ID() string { return ModuleID }
func (m *Module) Name() string { return "Networking" }
// Permissions: read to inspect interfaces/routes/DNS; write to reconfigure them
// (apply config, bring links up/down, confirm a pending change).
func (m *Module) Permissions() []rbac.Permission {
return []rbac.Permission{rbac.Read, rbac.Write}
}
func (m *Module) Register(api huma.API) {
registerReads(api)
registerWrites(api, m)
registerHosts(api)
}
func op(permission string) map[string]any {
return map[string]any{"module": ModuleID, "permission": permission}
}
+285
View File
@@ -0,0 +1,285 @@
package networking
import (
"context"
"fmt"
"net/netip"
"os"
"path/filepath"
"strings"
"nadir/internal/oscmd"
)
// networkdBackend implements backend via systemd-networkd. Configuration is done
// by writing .network files under networkdDir. Each managed interface gets its
// own file named "90-nadir-<iface>.network" — the 90 prefix puts nadir's config
// after most distro defaults, and the "nadir-" infix ensures we never clobber
// distro-provided files.
type networkdBackend struct{}
var networkdDir = "/etc/systemd/network"
func (b *networkdBackend) Name() string { return "networkd" }
// networkdFile returns the path for the nadir-managed .network file for iface.
// iface is already validated by validateIface to prevent path traversal.
func networkdFile(iface string) string {
return filepath.Join(networkdDir, "90-nadir-"+iface+".network")
}
func (b *networkdBackend) Snapshot(ctx context.Context, iface string) (IfaceConfig, error) {
path := networkdFile(iface)
data, err := os.ReadFile(path)
if err != nil {
// No nadir-managed file exists. Fall back to live ip output and assume
// DHCP — the safest rollback assumption (reverts to "whatever the
// system was doing before nadir touched it").
return snapshotFromIP(ctx, iface)
}
return parseNetworkdFile(string(data)), nil
}
// snapshotFromIP captures the current state from ip -j, assuming DHCP as the
// method since there's no way to tell from live output.
func snapshotFromIP(ctx context.Context, iface string) (IfaceConfig, error) {
cfg := IfaceConfig{Method: "dhcp"}
// Grab addresses.
out, err := oscmd.RunContext(ctx, "ip", "-j", "addr", "show", "--", iface)
if err != nil {
return cfg, nil // interface may not exist yet; DHCP fallback is fine
}
ifaces, err := parseInterfaces(out)
if err != nil || len(ifaces) == 0 {
return cfg, nil
}
// If there are IPv4 addresses, capture the first one.
if len(ifaces[0].IPv4) > 0 {
addr, prefix := splitCIDR(ifaces[0].IPv4[0])
cfg.Method = "static"
cfg.Address = addr
cfg.Prefix = prefix
}
// Capture a global IPv6 address if present, skipping link-local (fe80::/10),
// so a rollback restores the interface's real IPv6 state.
cfg.IPv6 = &IPv6Config{Method: "auto"}
for _, c := range ifaces[0].IPv6 {
addr, prefix := splitCIDR(c)
if ip, err := netip.ParseAddr(addr); err == nil && !ip.IsLinkLocalUnicast() {
cfg.IPv6 = &IPv6Config{Method: "static", Address: addr, Prefix: prefix}
break
}
}
// Grab the default gateway for this interface.
routeOut, err := oscmd.RunContext(ctx, "ip", "-j", "route", "show", "dev", "--", iface)
if err == nil {
routes, _ := parseRoutes(routeOut)
for _, r := range routes {
if r.Destination == "default" && r.Gateway != "" {
cfg.Gateway = r.Gateway
break
}
}
}
// Grab DNS from /etc/resolv.conf
if data, err := os.ReadFile(resolvConf); err == nil {
cfg.DNS = parseResolv(string(data))
}
return cfg, nil
}
// parseNetworkdFile extracts IfaceConfig from a systemd .network file.
func parseNetworkdFile(content string) IfaceConfig {
cfg := IfaceConfig{Method: "dhcp"}
section := ""
for line := range strings.SplitSeq(content, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
section = line
continue
}
key, val, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
val = strings.TrimSpace(val)
if section == "[Network]" {
switch key {
case "DHCP":
if val == "yes" || val == "ipv4" {
cfg.Method = "dhcp"
}
case "Address":
addr, prefix := splitCIDR(val)
if ip, err := netip.ParseAddr(addr); err == nil && !ip.Is4() {
g := v6(&cfg)
g.Method = "static"
g.Address = addr
g.Prefix = prefix
} else if addr != "" {
cfg.Method = "static"
cfg.Address = addr
cfg.Prefix = prefix
}
case "Gateway":
if ip, err := netip.ParseAddr(val); err == nil && !ip.Is4() {
v6(&cfg).Gateway = val
} else {
cfg.Gateway = val
}
case "DNS":
for s := range strings.FieldsSeq(val) {
cfg.DNS = append(cfg.DNS, s)
}
case "IPv6AcceptRA":
if val == "yes" {
if g := v6(&cfg); g.Method != "static" {
g.Method = "auto"
}
}
case "LinkLocalAddressing":
if val == "no" {
v6(&cfg).Method = "ignore"
}
}
}
}
// Parse [Route] sections separately.
cfg.Routes = parseNetworkdRoutes(content)
return cfg
}
// parseNetworkdRoutes extracts Route entries from [Route] sections.
func parseNetworkdRoutes(content string) []Route {
var routes []Route
var inRoute bool
var current Route
for line := range strings.SplitSeq(content, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "[") {
// Flush any pending route when entering a new section.
if inRoute && (current.Destination != "" || current.Gateway != "") {
routes = append(routes, current)
}
inRoute = line == "[Route]"
current = Route{}
continue
}
if !inRoute {
continue
}
key, val, ok := strings.Cut(line, "=")
if !ok {
continue
}
switch strings.TrimSpace(key) {
case "Destination":
current.Destination = strings.TrimSpace(val)
case "Gateway":
current.Gateway = strings.TrimSpace(val)
}
}
// Flush last route if we ended inside a [Route] section.
if inRoute && (current.Destination != "" || current.Gateway != "") {
routes = append(routes, current)
}
return routes
}
func (b *networkdBackend) Apply(ctx context.Context, iface string, cfg IfaceConfig) error {
content := renderNetworkdFile(iface, cfg)
path := networkdFile(iface)
if err := os.MkdirAll(networkdDir, 0755); err != nil {
return fmt.Errorf("mkdir %s: %w", networkdDir, err)
}
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
// Reload networkd and reconfigure the specific interface.
if _, err := oscmd.RunContext(ctx, "networkctl", "reload"); err != nil {
return fmt.Errorf("networkctl reload: %w", err)
}
if _, err := oscmd.RunContext(ctx, "networkctl", "reconfigure", "--", iface); err != nil {
return fmt.Errorf("networkctl reconfigure %s: %w", iface, err)
}
return nil
}
// renderNetworkdFile builds the INI content for a systemd .network file.
func renderNetworkdFile(iface string, cfg IfaceConfig) string {
var b strings.Builder
b.WriteString("# Managed by nadir — do not edit manually.\n")
b.WriteString("[Match]\n")
fmt.Fprintf(&b, "Name=%s\n\n", iface)
b.WriteString("[Network]\n")
switch cfg.Method {
case "dhcp":
b.WriteString("DHCP=yes\n")
case "static":
b.WriteString("DHCP=no\n")
fmt.Fprintf(&b, "Address=%s/%d\n", cfg.Address, cfg.Prefix)
if cfg.Gateway != "" {
fmt.Fprintf(&b, "Gateway=%s\n", cfg.Gateway)
}
}
if cfg.IPv6 != nil {
switch cfg.IPv6.Method {
case "static":
fmt.Fprintf(&b, "Address=%s/%d\n", cfg.IPv6.Address, cfg.IPv6.Prefix)
if cfg.IPv6.Gateway != "" {
fmt.Fprintf(&b, "Gateway=%s\n", cfg.IPv6.Gateway)
}
b.WriteString("IPv6AcceptRA=no\n")
case "auto":
b.WriteString("IPv6AcceptRA=yes\n")
case "ignore":
b.WriteString("LinkLocalAddressing=no\nIPv6AcceptRA=no\n")
}
}
for _, dns := range cfg.DNS {
fmt.Fprintf(&b, "DNS=%s\n", dns)
}
for _, r := range cfg.Routes {
b.WriteString("\n[Route]\n")
fmt.Fprintf(&b, "Destination=%s\n", r.Destination)
fmt.Fprintf(&b, "Gateway=%s\n", r.Gateway)
}
return b.String()
}
func (b *networkdBackend) SetLinkUp(ctx context.Context, iface string) error {
// Note: ip link set parses DEVICE positionally, so -- is technically ignored
// by ip but included here for consistency with other oscmd calls.
_, err := oscmd.RunContext(ctx, "ip", "link", "set", "--", iface, "up")
return err
}
func (b *networkdBackend) SetLinkDown(ctx context.Context, iface string) error {
_, err := oscmd.RunContext(ctx, "ip", "link", "set", "--", iface, "down")
return err
}
@@ -0,0 +1,397 @@
package networking
import (
"context"
"encoding/json"
"errors"
"net/http"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"nadir/internal/oscmd"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humago"
"github.com/danielgtaylor/huma/v2/humatest"
)
func TestMain(m *testing.M) {
if oscmd.RunHelperProcess() {
return
}
os.Exit(m.Run())
}
type mockBackend struct {
name string
snapshotResult IfaceConfig
applyCalledWith IfaceConfig
applyErr error
snapshotErr error
setUpCalled bool
setDownCalled bool
}
func (m *mockBackend) Name() string { return m.name }
func (m *mockBackend) Snapshot(_ context.Context, iface string) (IfaceConfig, error) {
return m.snapshotResult, m.snapshotErr
}
func (m *mockBackend) Apply(_ context.Context, iface string, cfg IfaceConfig) error {
m.applyCalledWith = cfg
return m.applyErr
}
func (m *mockBackend) SetLinkUp(_ context.Context, iface string) error {
m.setUpCalled = true
return nil
}
func (m *mockBackend) SetLinkDown(_ context.Context, iface string) error {
m.setDownCalled = true
return nil
}
func TestNetworkingHandlers(t *testing.T) {
mux := http.NewServeMux()
api := humatest.Wrap(t, humago.New(mux, huma.DefaultConfig("Test", "1.0.0")))
be := &mockBackend{
name: "mockbe",
snapshotResult: IfaceConfig{
Method: "dhcp",
Address: "192.168.1.10/24",
},
}
m := &Module{be: be}
m.Register(api)
tempResolv := filepath.Join(t.TempDir(), "resolv.conf")
if err := os.WriteFile(tempResolv, []byte("nameserver 1.1.1.1\nnameserver 8.8.8.8\n"), 0644); err != nil {
t.Fatal(err)
}
oldResolv := resolvConf
resolvConf = tempResolv
defer func() { resolvConf = oldResolv }()
oscmd.SetMock("ip", func(args []string) oscmd.MockCommand {
if reflect.DeepEqual(args, []string{"-j", "addr"}) {
out := `[{"ifname": "eth0", "operstate": "UP", "address": "aa:bb:cc:dd:ee:ff", "mtu": 1500, "addr_info": [{"family": "inet", "local": "192.168.1.10", "prefixlen": 24}]}]`
return oscmd.MockCommand{Stdout: out, ExitCode: 0}
}
if reflect.DeepEqual(args, []string{"-j", "route"}) {
out := `[{"dst": "default", "gateway": "192.168.1.1", "dev": "eth0"}]`
return oscmd.MockCommand{Stdout: out, ExitCode: 0}
}
return oscmd.MockCommand{ExitCode: 1}
})
defer oscmd.ClearMocks()
// 1. Test GET /api/networking/interfaces
resp := api.Get("/api/networking/interfaces")
if resp.Code != http.StatusOK {
t.Errorf("list interfaces: got %d, want %d", resp.Code, http.StatusOK)
}
// 2. Test GET /api/networking/routes
resp = api.Get("/api/networking/routes")
if resp.Code != http.StatusOK {
t.Errorf("list routes: got %d, want %d", resp.Code, http.StatusOK)
}
// 3. Test GET /api/networking/dns
resp = api.Get("/api/networking/dns")
if resp.Code != http.StatusOK {
t.Errorf("get dns: got %d, want %d", resp.Code, http.StatusOK)
}
var dnsRes DNSOutput
if err := json.Unmarshal(resp.Body.Bytes(), &dnsRes.Body); err != nil {
t.Fatal(err)
}
if len(dnsRes.Body.Servers) != 2 || dnsRes.Body.Servers[0] != "1.1.1.1" {
t.Errorf("get dns output: %+v", dnsRes.Body)
}
// 4. Test PUT /api/networking/interfaces/{name}
applyPayload := struct {
Method string `json:"method"`
Address string `json:"address,omitempty"`
Prefix int `json:"prefix,omitempty"`
Gateway string `json:"gateway,omitempty"`
DNS []string `json:"dns,omitempty"`
RollbackSeconds int `json:"rollback_seconds,omitempty"`
}{
Method: "static",
Address: "192.168.1.20",
Prefix: 24,
Gateway: "192.168.1.1",
DNS: []string{"1.1.1.1"},
RollbackSeconds: 2,
}
resp = api.Put("/api/networking/interfaces/eth0", applyPayload)
if resp.Code != http.StatusOK {
t.Errorf("apply interface config: got %d, want %d, body=%s", resp.Code, http.StatusOK, resp.Body.String())
}
resp = api.Get("/api/networking/pending")
if resp.Code != http.StatusOK {
t.Errorf("get pending: got %d, want %d", resp.Code, http.StatusOK)
}
// 5. Test POST /api/networking/interfaces/{name}/confirm
resp = api.Post("/api/networking/interfaces/eth0/confirm", struct{}{})
if resp.Code != http.StatusOK {
t.Errorf("confirm change: got %d, want %d", resp.Code, http.StatusOK)
}
resp = api.Get("/api/networking/pending")
if resp.Code != http.StatusNotFound {
t.Errorf("pending change should be cleared: got %d, want %d", resp.Code, http.StatusNotFound)
}
// 6. Test automatic rollback
applyPayload.RollbackSeconds = 1
resp = api.Put("/api/networking/interfaces/eth0", applyPayload)
if resp.Code != http.StatusOK {
t.Errorf("apply config again: got %d, want %d", resp.Code, http.StatusOK)
}
time.Sleep(1200 * time.Millisecond)
resp = api.Get("/api/networking/pending")
if resp.Code != http.StatusNotFound {
t.Errorf("pending change should be rolled back: got %d, want %d", resp.Code, http.StatusNotFound)
}
if be.applyCalledWith.Method != "dhcp" || be.applyCalledWith.Address != "192.168.1.10/24" {
t.Errorf("rollback failed to restore prior config: %+v", be.applyCalledWith)
}
// 7. Test POST link up & down
resp = api.Post("/api/networking/interfaces/eth0/up", struct{}{})
if resp.Code != http.StatusOK {
t.Errorf("set link up: got %d, want %d", resp.Code, http.StatusOK)
}
if !be.setUpCalled {
t.Errorf("expected SetLinkUp call on backend")
}
resp = api.Post("/api/networking/interfaces/eth0/down", struct{}{})
if resp.Code != http.StatusOK {
t.Errorf("set link down: got %d, want %d", resp.Code, http.StatusOK)
}
if !be.setDownCalled {
t.Errorf("expected SetLinkDown call on backend")
}
}
// #3: a failed Apply must restore the prior snapshot (no half-applied config
// left with no auto-revert) and arm no pending change.
func TestApplyFailureRestoresPrior(t *testing.T) {
be := &mockBackend{
name: "mock",
snapshotResult: IfaceConfig{Method: "dhcp"},
applyErr: errors.New("con up failed"),
}
m := &Module{be: be}
_, err := m.startRollback(t.Context(), "eth0", IfaceConfig{Method: "static", Address: "10.0.0.5", Prefix: 24})
if err == nil {
t.Fatal("expected apply to fail")
}
// The last Apply call should be the restore-to-prior (dhcp), not the new config.
if be.applyCalledWith.Method != "dhcp" {
t.Errorf("expected restore to prior config, last Apply got %+v", be.applyCalledWith)
}
if m.pending != nil {
t.Error("no pending change should be armed after a failed apply")
}
}
// #1: link-down must go through the rollback safety net (arms a pending change),
// and rolling it back brings the interface back up. #2: a change to another
// interface while one is pending is rejected with errAlreadyPending.
func TestLinkDownArmsRollback(t *testing.T) {
be := &mockBackend{name: "mock"}
m := &Module{be: be}
secs, err := m.startLinkDown(t.Context(), "eth0")
if err != nil {
t.Fatal(err)
}
if secs != defaultRollbackSeconds {
t.Errorf("seconds = %d, want default %d", secs, defaultRollbackSeconds)
}
if !be.setDownCalled {
t.Error("expected SetLinkDown to be called")
}
if m.pending == nil {
t.Fatal("expected a pending change to be armed")
}
// A concurrent change to a different interface is rejected (global lock).
if _, err := m.startRollback(t.Context(), "eth1", IfaceConfig{Method: "dhcp"}); !errors.Is(err, errAlreadyPending) {
t.Errorf("expected errAlreadyPending for eth1, got %v", err)
}
// Rolling back brings the link back up and clears the pending change.
if err := m.rollbackNow("eth0"); err != nil {
t.Fatal(err)
}
if !be.setUpCalled {
t.Error("expected SetLinkUp on rollback")
}
if m.pending != nil {
t.Error("pending change should be cleared after rollback")
}
}
func TestBackendImplementations(t *testing.T) {
tempDir := t.TempDir()
// Mock ip command for fallback snapshots and link control
oscmd.SetMock("ip", func(args []string) oscmd.MockCommand {
argStr := strings.Join(args, " ")
if strings.Contains(argStr, "addr show") {
out := `[{"ifname": "eth0", "operstate": "UP", "address": "aa:bb:cc:dd:ee:ff", "mtu": 1500, "addr_info": [{"family": "inet", "local": "192.168.1.30", "prefixlen": 24}]}]`
return oscmd.MockCommand{Stdout: out, ExitCode: 0}
}
if strings.Contains(argStr, "route show") {
out := `[{"dst": "default", "gateway": "192.168.1.1", "dev": "eth0"}]`
return oscmd.MockCommand{Stdout: out, ExitCode: 0}
}
if strings.Contains(argStr, "link set") {
return oscmd.MockCommand{ExitCode: 0}
}
return oscmd.MockCommand{ExitCode: 1}
})
defer oscmd.ClearMocks()
// 1. Test nmcliBackend
nm := &nmcliBackend{}
oscmd.SetMock("nmcli", func(args []string) oscmd.MockCommand {
argStr := strings.Join(args, " ")
t.Logf("nmcli mock called with: %s", argStr)
if strings.Contains(argStr, "con show --active") {
return oscmd.MockCommand{Stdout: "myconn:eth0\n", ExitCode: 0}
}
if strings.Contains(argStr, "con show") && strings.Contains(argStr, "myconn") {
showOut := "ipv4.method:manual\nipv4.addresses:192.168.1.10/24\nipv4.gateway:192.168.1.1\nipv4.dns:1.1.1.1\nipv4.routes:10.0.0.0/24 192.168.1.1\n"
return oscmd.MockCommand{Stdout: showOut, ExitCode: 0}
}
if (strings.Contains(argStr, "con modify") || strings.Contains(argStr, "con up") || strings.Contains(argStr, "con down")) && strings.Contains(argStr, "myconn") {
return oscmd.MockCommand{ExitCode: 0}
}
return oscmd.MockCommand{ExitCode: 1}
})
cfg, err := nm.Snapshot(t.Context(), "eth0")
if err != nil {
t.Fatal(err)
}
if cfg.Address != "192.168.1.10" || cfg.Gateway != "192.168.1.1" {
t.Errorf("nmcli snapshot failed: %+v", cfg)
}
err = nm.Apply(t.Context(), "eth0", IfaceConfig{
Method: "static",
Address: "192.168.1.20",
Prefix: 24,
Gateway: "192.168.1.1",
})
if err != nil {
t.Fatal(err)
}
if err := nm.SetLinkUp(t.Context(), "eth0"); err != nil {
t.Fatal(err)
}
if err := nm.SetLinkDown(t.Context(), "eth0"); err != nil {
t.Fatal(err)
}
// 2. Test networkdBackend
nd := &networkdBackend{}
oldNdDir := networkdDir
networkdDir = tempDir
defer func() { networkdDir = oldNdDir }()
oscmd.SetMock("networkctl", func(args []string) oscmd.MockCommand {
return oscmd.MockCommand{ExitCode: 0}
})
err = nd.Apply(t.Context(), "eth0", IfaceConfig{
Method: "static",
Address: "192.168.1.30",
Prefix: 24,
Gateway: "192.168.1.1",
DNS: []string{"8.8.8.8"},
})
if err != nil {
t.Fatal(err)
}
cfg, err = nd.Snapshot(t.Context(), "eth0")
if err != nil {
t.Fatal(err)
}
if cfg.Address != "192.168.1.30" || cfg.Method != "static" {
t.Errorf("networkd snapshot failed: %+v", cfg)
}
if err := nd.SetLinkUp(t.Context(), "eth0"); err != nil {
t.Fatal(err)
}
if err := nd.SetLinkDown(t.Context(), "eth0"); err != nil {
t.Fatal(err)
}
// 3. Test ifupdownBackend
iu := &ifupdownBackend{}
oldIfaceFile := interfacesFile
oldInterfacesDDir := interfacesDDir
interfacesFile = filepath.Join(tempDir, "interfaces")
interfacesDDir = filepath.Join(tempDir, "interfaces.d")
defer func() {
interfacesFile = oldIfaceFile
interfacesDDir = oldInterfacesDDir
}()
if err := os.MkdirAll(interfacesDDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(interfacesFile, []byte("auto lo\niface lo inet loopback\n"), 0644); err != nil {
t.Fatal(err)
}
oscmd.SetMock("ifup", func(args []string) oscmd.MockCommand { return oscmd.MockCommand{ExitCode: 0} })
oscmd.SetMock("ifdown", func(args []string) oscmd.MockCommand { return oscmd.MockCommand{ExitCode: 0} })
err = iu.Apply(t.Context(), "eth0", IfaceConfig{
Method: "static",
Address: "192.168.1.40",
Prefix: 24,
Gateway: "192.168.1.1",
DNS: []string{"1.1.1.1"},
})
if err != nil {
t.Fatal(err)
}
cfg, err = iu.Snapshot(t.Context(), "eth0")
if err != nil {
t.Fatal(err)
}
if cfg.Address != "192.168.1.40" || cfg.Method != "static" {
t.Errorf("ifupdown snapshot failed: %+v", cfg)
}
if err := iu.SetLinkUp(t.Context(), "eth0"); err != nil {
t.Fatal(err)
}
if err := iu.SetLinkDown(t.Context(), "eth0"); err != nil {
t.Fatal(err)
}
}
@@ -0,0 +1,396 @@
package networking
import (
"reflect"
"strings"
"testing"
)
func TestParseInterfaces(t *testing.T) {
// Trimmed real output from `ip -j addr` on a typical host.
input := `[
{
"ifname": "lo",
"address": "00:00:00:00:00:00",
"mtu": 65536,
"operstate": "UNKNOWN",
"addr_info": [
{"family": "inet", "local": "127.0.0.1", "prefixlen": 8},
{"family": "inet6", "local": "::1", "prefixlen": 128}
]
},
{
"ifname": "eth0",
"address": "52:54:00:12:34:56",
"mtu": 1500,
"operstate": "UP",
"addr_info": [
{"family": "inet", "local": "192.168.1.10", "prefixlen": 24},
{"family": "inet6", "local": "fe80::1", "prefixlen": 64}
]
}
]`
ifaces, err := parseInterfaces(input)
if err != nil {
t.Fatal(err)
}
if len(ifaces) != 2 {
t.Fatalf("expected 2 interfaces, got %d", len(ifaces))
}
lo := ifaces[0]
if lo.Name != "lo" || lo.State != "unknown" || lo.MTU != 65536 {
t.Errorf("lo: got %+v", lo)
}
if len(lo.IPv4) != 1 || lo.IPv4[0] != "127.0.0.1/8" {
t.Errorf("lo ipv4: got %v", lo.IPv4)
}
eth0 := ifaces[1]
if eth0.Name != "eth0" || eth0.State != "up" || eth0.MAC != "52:54:00:12:34:56" {
t.Errorf("eth0: got %+v", eth0)
}
if len(eth0.IPv4) != 1 || eth0.IPv4[0] != "192.168.1.10/24" {
t.Errorf("eth0 ipv4: got %v", eth0.IPv4)
}
if len(eth0.IPv6) != 1 || eth0.IPv6[0] != "fe80::1/64" {
t.Errorf("eth0 ipv6: got %v", eth0.IPv6)
}
}
func TestParseRoutes(t *testing.T) {
input := `[
{"dst": "default", "gateway": "192.168.1.1", "dev": "eth0", "prefsrc": "192.168.1.10", "metric": 100},
{"dst": "192.168.1.0/24", "dev": "eth0", "prefsrc": "192.168.1.10"}
]`
routes, err := parseRoutes(input)
if err != nil {
t.Fatal(err)
}
if len(routes) != 2 {
t.Fatalf("expected 2 routes, got %d", len(routes))
}
if routes[0].Destination != "default" || routes[0].Gateway != "192.168.1.1" || routes[0].Metric != 100 {
t.Errorf("route 0: got %+v", routes[0])
}
if routes[1].Destination != "192.168.1.0/24" || routes[1].Interface != "eth0" {
t.Errorf("route 1: got %+v", routes[1])
}
}
func TestParseResolv(t *testing.T) {
input := `# Generated by NetworkManager
nameserver 1.1.1.1
nameserver 8.8.8.8
; legacy comment
search example.com
nameserver 9.9.9.9
`
servers := parseResolv(input)
want := []string{"1.1.1.1", "8.8.8.8", "9.9.9.9"}
if !reflect.DeepEqual(servers, want) {
t.Errorf("parseResolv() = %v, want %v", servers, want)
}
}
func TestParseResolvEmpty(t *testing.T) {
servers := parseResolv("")
if len(servers) != 0 {
t.Errorf("expected empty, got %v", servers)
}
}
func TestParseNmcliSnapshot(t *testing.T) {
input := `ipv4.method:manual
ipv4.addresses:192.168.1.10/24
ipv4.gateway:192.168.1.1
ipv4.dns:1.1.1.1,8.8.8.8
ipv4.routes:--`
cfg := parseNmcliSnapshot(input)
if cfg.Method != "static" {
t.Errorf("method: got %q, want static", cfg.Method)
}
if cfg.Address != "192.168.1.10" || cfg.Prefix != 24 {
t.Errorf("address: got %s/%d", cfg.Address, cfg.Prefix)
}
if cfg.Gateway != "192.168.1.1" {
t.Errorf("gateway: got %q", cfg.Gateway)
}
if len(cfg.DNS) != 2 || cfg.DNS[0] != "1.1.1.1" || cfg.DNS[1] != "8.8.8.8" {
t.Errorf("dns: got %v", cfg.DNS)
}
}
func TestParseNmcliRoutes(t *testing.T) {
input := `dst=10.0.0.0/24, nh=192.168.1.1; dst=172.16.0.0/12, nh=10.0.0.1`
routes := parseNmcliRoutes(input)
if len(routes) != 2 {
t.Fatalf("expected 2 routes, got %d", len(routes))
}
if routes[0].Destination != "10.0.0.0/24" || routes[0].Gateway != "192.168.1.1" {
t.Errorf("route 0: got %v", routes[0])
}
if routes[1].Destination != "172.16.0.0/12" || routes[1].Gateway != "10.0.0.1" {
t.Errorf("route 1: got %v", routes[1])
}
}
func TestParseNmcliSnapshotDHCP(t *testing.T) {
input := `ipv4.method:auto
ipv4.addresses:--
ipv4.gateway:--
ipv4.dns:--
ipv4.routes:--`
cfg := parseNmcliSnapshot(input)
if cfg.Method != "dhcp" {
t.Errorf("method: got %q, want dhcp", cfg.Method)
}
}
func TestParseNetworkdFile(t *testing.T) {
input := `# Managed by nadir
[Match]
Name=eth0
[Network]
DHCP=no
Address=10.0.0.5/24
Gateway=10.0.0.1
DNS=1.1.1.1
DNS=8.8.8.8
[Route]
Destination=192.168.0.0/16
Gateway=10.0.0.254
`
cfg := parseNetworkdFile(input)
if cfg.Method != "static" {
t.Errorf("method: got %q, want static", cfg.Method)
}
if cfg.Address != "10.0.0.5" || cfg.Prefix != 24 {
t.Errorf("address: got %s/%d", cfg.Address, cfg.Prefix)
}
if cfg.Gateway != "10.0.0.1" {
t.Errorf("gateway: got %q", cfg.Gateway)
}
if len(cfg.DNS) != 2 {
t.Errorf("dns: got %v", cfg.DNS)
}
if len(cfg.Routes) != 1 || cfg.Routes[0].Destination != "192.168.0.0/16" || cfg.Routes[0].Gateway != "10.0.0.254" {
t.Errorf("routes: got %v", cfg.Routes)
}
}
func TestParseNetworkdFileDHCP(t *testing.T) {
input := `[Match]
Name=eth0
[Network]
DHCP=yes
`
cfg := parseNetworkdFile(input)
if cfg.Method != "dhcp" {
t.Errorf("method: got %q, want dhcp", cfg.Method)
}
}
func TestParseIfupdownStanza(t *testing.T) {
input := `# Managed by nadir
auto eth0
iface eth0 inet static
address 192.168.1.10/24
gateway 192.168.1.1
dns-nameservers 1.1.1.1 8.8.8.8
up ip route add 10.0.0.0/24 via 192.168.1.254
down ip route del 10.0.0.0/24 via 192.168.1.254
`
cfg := parseIfupdownStanza(input)
if cfg.Method != "static" {
t.Errorf("method: got %q, want static", cfg.Method)
}
if cfg.Address != "192.168.1.10" || cfg.Prefix != 24 {
t.Errorf("address: got %s/%d", cfg.Address, cfg.Prefix)
}
if cfg.Gateway != "192.168.1.1" {
t.Errorf("gateway: got %q", cfg.Gateway)
}
if len(cfg.DNS) != 2 || cfg.DNS[0] != "1.1.1.1" || cfg.DNS[1] != "8.8.8.8" {
t.Errorf("dns: got %v", cfg.DNS)
}
if len(cfg.Routes) != 1 || cfg.Routes[0].Destination != "10.0.0.0/24" || cfg.Routes[0].Gateway != "192.168.1.254" {
t.Errorf("routes: got %v", cfg.Routes)
}
}
func TestParseIfupdownStanzaDHCP(t *testing.T) {
input := `auto eth0
iface eth0 inet dhcp
`
cfg := parseIfupdownStanza(input)
if cfg.Method != "dhcp" {
t.Errorf("method: got %q, want dhcp", cfg.Method)
}
}
func TestRenderNetworkdFile(t *testing.T) {
cfg := IfaceConfig{
Method: "static",
Address: "10.0.0.5",
Prefix: 24,
Gateway: "10.0.0.1",
DNS: []string{"1.1.1.1"},
Routes: []Route{{Destination: "192.168.0.0/16", Gateway: "10.0.0.254"}},
}
out := renderNetworkdFile("eth0", cfg)
mustContain := []string{
"Name=eth0",
"DHCP=no",
"Address=10.0.0.5/24",
"Gateway=10.0.0.1",
"DNS=1.1.1.1",
"Destination=192.168.0.0/16",
}
for _, s := range mustContain {
if !strings.Contains(out, s) {
t.Errorf("renderNetworkdFile missing %q in:\n%s", s, out)
}
}
// Roundtrip test
parsed := parseNetworkdFile(out)
if !reflect.DeepEqual(parsed, cfg) {
t.Errorf("roundtrip failed: got %+v, want %+v", parsed, cfg)
}
}
func TestRenderIfupdownStanza(t *testing.T) {
cfg := IfaceConfig{
Method: "static",
Address: "192.168.1.10",
Prefix: 24,
Gateway: "192.168.1.1",
DNS: []string{"1.1.1.1", "8.8.8.8"},
Routes: []Route{{Destination: "10.0.0.0/24", Gateway: "192.168.1.254"}},
}
out := renderIfupdownStanza("eth0", cfg)
mustContain := []string{
"iface eth0 inet static",
"address 192.168.1.10/24",
"gateway 192.168.1.1",
"dns-nameservers 1.1.1.1 8.8.8.8",
"up ip route add 10.0.0.0/24 via 192.168.1.254",
"down ip route del 10.0.0.0/24 via 192.168.1.254",
}
for _, s := range mustContain {
if !strings.Contains(out, s) {
t.Errorf("renderIfupdownStanza missing %q in:\n%s", s, out)
}
}
// Roundtrip test
parsed := parseIfupdownStanza(out)
if !reflect.DeepEqual(parsed, cfg) {
t.Errorf("roundtrip failed: got %+v, want %+v", parsed, cfg)
}
}
func TestValidateIPv6(t *testing.T) {
ok := IfaceConfig{Method: "dhcp", IPv6: &IPv6Config{Method: "static", Address: "2001:db8::1", Prefix: 64, Gateway: "2001:db8::ff"}}
if err := ok.validate(); err != nil {
t.Errorf("valid ipv6 rejected: %v", err)
}
bad := []IfaceConfig{
{Method: "dhcp", IPv6: &IPv6Config{Method: "static", Address: "1.2.3.4", Prefix: 64}}, // v4 in v6 block
{Method: "dhcp", IPv6: &IPv6Config{Method: "static", Address: "2001:db8::1", Prefix: 0}},
{Method: "dhcp", IPv6: &IPv6Config{Method: "bogus"}},
}
for i, c := range bad {
if err := c.validate(); err == nil {
t.Errorf("bad ipv6 case %d accepted", i)
}
}
}
func TestParseNmcliSnapshotIPv6(t *testing.T) {
input := `ipv4.method:manual
ipv4.addresses:192.168.1.10/24
ipv6.method:manual
ipv6.addresses:2001:db8::10/64
ipv6.gateway:2001:db8::1`
cfg := parseNmcliSnapshot(input)
if cfg.IPv6 == nil {
t.Fatal("ipv6 not captured")
}
if cfg.IPv6.Method != "static" || cfg.IPv6.Address != "2001:db8::10" || cfg.IPv6.Prefix != 64 || cfg.IPv6.Gateway != "2001:db8::1" {
t.Errorf("ipv6 snapshot: %+v", cfg.IPv6)
}
}
func TestNetworkdIPv6RoundTrip(t *testing.T) {
cfg := IfaceConfig{
Method: "static",
Address: "10.0.0.5",
Prefix: 24,
IPv6: &IPv6Config{Method: "static", Address: "2001:db8::5", Prefix: 64, Gateway: "2001:db8::1"},
}
got := parseNetworkdFile(renderNetworkdFile("eth0", cfg))
if got.Address != "10.0.0.5" || got.Prefix != 24 {
t.Errorf("ipv4 lost in roundtrip: %+v", got)
}
if !reflect.DeepEqual(got.IPv6, cfg.IPv6) {
t.Errorf("ipv6 roundtrip: got %+v want %+v", got.IPv6, cfg.IPv6)
}
}
func TestIfupdownIPv6RoundTrip(t *testing.T) {
cfg := IfaceConfig{
Method: "static",
Address: "192.168.1.10",
Prefix: 24,
IPv6: &IPv6Config{Method: "static", Address: "2001:db8::10", Prefix: 64, Gateway: "2001:db8::1"},
}
got := parseIfupdownStanza(renderIfupdownStanza("eth0", cfg))
if got.Address != "192.168.1.10" || got.Prefix != 24 || got.Method != "static" {
t.Errorf("ipv4 lost in roundtrip: %+v", got)
}
if !reflect.DeepEqual(got.IPv6, cfg.IPv6) {
t.Errorf("ipv6 roundtrip: got %+v want %+v", got.IPv6, cfg.IPv6)
}
}
func TestNetworkdIPv6AutoIgnore(t *testing.T) {
auto := parseNetworkdFile(renderNetworkdFile("eth0", IfaceConfig{Method: "dhcp", IPv6: &IPv6Config{Method: "auto"}}))
if auto.IPv6 == nil || auto.IPv6.Method != "auto" {
t.Errorf("auto roundtrip: %+v", auto.IPv6)
}
ign := parseNetworkdFile(renderNetworkdFile("eth0", IfaceConfig{Method: "dhcp", IPv6: &IPv6Config{Method: "ignore"}}))
if ign.IPv6 == nil || ign.IPv6.Method != "ignore" {
t.Errorf("ignore roundtrip: %+v", ign.IPv6)
}
}
func TestSplitCIDR(t *testing.T) {
tests := []struct {
input string
wantAddr string
wantPrefix int
}{
{"192.168.1.10/24", "192.168.1.10", 24},
{"10.0.0.1/8", "10.0.0.1", 8},
{"10.0.0.1", "10.0.0.1", 0},
}
for _, tt := range tests {
addr, prefix := splitCIDR(tt.input)
if addr != tt.wantAddr || prefix != tt.wantPrefix {
t.Errorf("splitCIDR(%q) = (%q, %d), want (%q, %d)", tt.input, addr, prefix, tt.wantAddr, tt.wantPrefix)
}
}
}
+274
View File
@@ -0,0 +1,274 @@
package networking
import (
"context"
"fmt"
"strconv"
"strings"
"nadir/internal/oscmd"
)
// nmcliBackend implements backend via NetworkManager's nmcli CLI.
type nmcliBackend struct{}
func (b *nmcliBackend) Name() string { return "nmcli" }
// connForIface resolves a network interface name to the NM connection name
// that owns it. Returns an error if the interface has no active connection.
//
// nmcli -t uses ':' as the field separator. Connection names can contain colons
// (e.g. "VLAN:100"), but Linux device names cannot, so we split on the last
// colon to get the device and treat everything before it as the connection name.
func connForIface(ctx context.Context, iface string) (string, error) {
out, err := oscmd.RunContext(ctx, "nmcli", "-t", "-f", "NAME,DEVICE", "con", "show", "--active")
if err != nil {
return "", fmt.Errorf("nmcli con show: %w", err)
}
for line := range strings.SplitSeq(out, "\n") {
// Split on the last colon: "conn:name:eth0" → ("conn:name", "eth0")
idx := strings.LastIndex(line, ":")
if idx < 0 {
continue
}
name, dev := line[:idx], line[idx+1:]
if dev == iface {
return name, nil
}
}
return "", fmt.Errorf("no active NM connection found for interface %s", iface)
}
func (b *nmcliBackend) Snapshot(ctx context.Context, iface string) (IfaceConfig, error) {
conn, err := connForIface(ctx, iface)
if err != nil {
// No managed connection → assume DHCP (safest rollback assumption).
return IfaceConfig{Method: "dhcp"}, nil
}
out, err := oscmd.RunContext(ctx, "nmcli", "-t", "-f",
"ipv4.method,ipv4.addresses,ipv4.gateway,ipv4.dns,ipv4.routes,ipv6.method,ipv6.addresses,ipv6.gateway",
"con", "show", "--", conn)
if err != nil {
return IfaceConfig{}, fmt.Errorf("nmcli con show %s: %w", conn, err)
}
return parseNmcliSnapshot(out), nil
}
// parseNmcliSnapshot parses the terse output of `nmcli -t -f ... con show`.
// Fields are colon-separated key:value lines. Multi-valued fields (addresses,
// dns, routes) use comma separation within the value.
func parseNmcliSnapshot(out string) IfaceConfig {
cfg := IfaceConfig{Method: "dhcp"}
for line := range strings.SplitSeq(out, "\n") {
key, val, ok := strings.Cut(line, ":")
if !ok || val == "" || val == "--" {
continue
}
switch key {
case "ipv4.method":
if val == "manual" {
cfg.Method = "static"
} else {
cfg.Method = "dhcp"
}
case "ipv4.addresses":
// "192.168.1.10/24" or "192.168.1.10/24, 10.0.0.1/8"
for _, part := range splitTrim(val, ",") {
addr, prefix := splitCIDR(part)
if addr != "" {
cfg.Address = addr
cfg.Prefix = prefix
}
}
case "ipv4.gateway":
cfg.Gateway = val
case "ipv4.dns":
cfg.DNS = append(cfg.DNS, splitTrim(val, ",")...)
case "ipv4.routes":
// "dst=10.0.0.0/24, nh=192.168.1.1; dst=default, nh=192.168.1.1"
// or simpler "{ dst = 10.0.0.0/24, nh = 192.168.1.1 }"
cfg.Routes = parseNmcliRoutes(val)
case "ipv6.method":
v6(&cfg).Method = nmcliV6Method(val)
case "ipv6.addresses":
for _, part := range splitTrim(val, ",") {
if addr, prefix := splitCIDR(part); addr != "" {
v6(&cfg).Address = addr
v6(&cfg).Prefix = prefix
}
}
case "ipv6.gateway":
v6(&cfg).Gateway = val
}
}
return cfg
}
// v6 lazily allocates the IPv6 block so a snapshot captures the live IPv6 state
// (defaulting to "auto") even when only some ipv6.* fields are present.
func v6(cfg *IfaceConfig) *IPv6Config {
if cfg.IPv6 == nil {
cfg.IPv6 = &IPv6Config{Method: "auto"}
}
return cfg.IPv6
}
// nmcliV6Method maps nmcli's ipv6.method to our vocabulary.
func nmcliV6Method(val string) string {
switch val {
case "manual":
return "static"
case "ignore", "disabled", "link-local":
return "ignore"
default:
return "auto"
}
}
// parseNmcliRoutes parses route entries from nmcli terse output.
func parseNmcliRoutes(val string) []Route {
var routes []Route
// Routes are semicolon-separated, each containing "dst=..., nh=..."
for _, entry := range splitTrim(val, ";") {
entry = strings.Trim(entry, "{}")
var r Route
for _, part := range splitTrim(entry, ",") {
k, v, ok := strings.Cut(part, "=")
if !ok {
continue
}
switch strings.TrimSpace(k) {
case "dst":
r.Destination = strings.TrimSpace(v)
case "nh":
r.Gateway = strings.TrimSpace(v)
}
}
if r.Destination != "" && r.Gateway != "" {
routes = append(routes, r)
}
}
return routes
}
func (b *nmcliBackend) Apply(ctx context.Context, iface string, cfg IfaceConfig) error {
conn, err := connForIface(ctx, iface)
if err != nil {
return fmt.Errorf("cannot apply: %w", err)
}
// Build the nmcli con modify arguments. Note: conn is safe to place after
// -- since it comes from nmcli output, not directly from the user.
args := []string{"con", "modify", "--", conn}
switch cfg.Method {
case "static":
cidr := fmt.Sprintf("%s/%d", cfg.Address, cfg.Prefix)
args = append(args,
"ipv4.method", "manual",
"ipv4.addresses", cidr,
)
if cfg.Gateway != "" {
args = append(args, "ipv4.gateway", cfg.Gateway)
} else {
args = append(args, "ipv4.gateway", "")
}
case "dhcp":
args = append(args,
"ipv4.method", "auto",
"ipv4.addresses", "",
"ipv4.gateway", "",
)
}
// DNS: set or clear.
if len(cfg.DNS) > 0 {
args = append(args, "ipv4.dns", strings.Join(cfg.DNS, ","))
} else {
args = append(args, "ipv4.dns", "")
}
// Routes: set or clear.
if len(cfg.Routes) > 0 {
var routeStrs []string
for _, r := range cfg.Routes {
routeStrs = append(routeStrs, r.Destination+" "+r.Gateway)
}
args = append(args, "ipv4.routes", strings.Join(routeStrs, ","))
} else {
args = append(args, "ipv4.routes", "")
}
// IPv6: only touched when the request includes an ipv6 block.
if cfg.IPv6 != nil {
switch cfg.IPv6.Method {
case "static":
args = append(args,
"ipv6.method", "manual",
"ipv6.addresses", fmt.Sprintf("%s/%d", cfg.IPv6.Address, cfg.IPv6.Prefix),
"ipv6.gateway", cfg.IPv6.Gateway, // "" clears it
)
case "auto":
args = append(args, "ipv6.method", "auto", "ipv6.addresses", "", "ipv6.gateway", "")
case "ignore":
args = append(args, "ipv6.method", "ignore", "ipv6.addresses", "", "ipv6.gateway", "")
}
}
if _, err := oscmd.RunContext(ctx, "nmcli", args...); err != nil {
return fmt.Errorf("nmcli con modify: %w", err)
}
// Bring the connection up to apply changes.
if _, err := oscmd.RunContext(ctx, "nmcli", "con", "up", "--", conn); err != nil {
return fmt.Errorf("nmcli con up: %w", err)
}
return nil
}
func (b *nmcliBackend) SetLinkUp(ctx context.Context, iface string) error {
conn, err := connForIface(ctx, iface)
if err != nil {
// No NM connection - fall back to `ip link`.
_, err = oscmd.RunContext(ctx, "ip", "link", "set", "--", iface, "up")
return err
}
_, err = oscmd.RunContext(ctx, "nmcli", "con", "up", "--", conn)
return err
}
func (b *nmcliBackend) SetLinkDown(ctx context.Context, iface string) error {
conn, err := connForIface(ctx, iface)
if err != nil {
_, err = oscmd.RunContext(ctx, "ip", "link", "set", "--", iface, "down")
return err
}
_, err = oscmd.RunContext(ctx, "nmcli", "con", "down", "--", conn)
return err
}
// --- helpers -----------------------------------------------------------------
// splitTrim splits s on sep and returns the trimmed, non-empty segments.
func splitTrim(s, sep string) []string {
var out []string
for part := range strings.SplitSeq(s, sep) {
if p := strings.TrimSpace(part); p != "" {
out = append(out, p)
}
}
return out
}
// splitCIDR splits "192.168.1.10/24" into ("192.168.1.10", 24). A missing or
// malformed prefix yields 0.
func splitCIDR(cidr string) (string, int) {
addr, prefixStr, ok := strings.Cut(cidr, "/")
if !ok {
return addr, 0
}
prefix, _ := strconv.Atoi(prefixStr)
return addr, prefix
}
+211
View File
@@ -0,0 +1,211 @@
package networking
import (
"context"
"encoding/json"
"os"
"strconv"
"strings"
"nadir/internal/oscmd"
"github.com/danielgtaylor/huma/v2"
)
// Reads go through `ip -j` (JSON) and /etc/resolv.conf, which behave the same
// regardless of which backend manages the interfaces - so reads need no backend
// detection.
var resolvConf = "/etc/resolv.conf"
type Interface struct {
Name string `json:"name" example:"eth0"`
State string `json:"state" example:"up" doc:"operstate: up / down / unknown"`
MAC string `json:"mac" example:"52:54:00:12:34:56"`
MTU int `json:"mtu" example:"1500"`
IPv4 []string `json:"ipv4" example:"[\"192.168.1.10/24\"]" doc:"IPv4 addresses in CIDR form"`
IPv6 []string `json:"ipv6" example:"[\"fe80::1/64\"]" doc:"IPv6 addresses in CIDR form"`
}
type ListInterfacesOutput struct {
Body struct {
Interfaces []Interface `json:"interfaces"`
}
}
type RouteEntry struct {
Destination string `json:"destination" example:"default" doc:"Destination network, or \"default\""`
Gateway string `json:"gateway,omitempty" example:"192.168.1.1"`
Interface string `json:"interface" example:"eth0"`
Source string `json:"source,omitempty" example:"192.168.1.10" doc:"Preferred source address"`
Metric int `json:"metric,omitempty" example:"100"`
}
type ListRoutesOutput struct {
Body struct {
Routes []RouteEntry `json:"routes"`
}
}
type DNSOutput struct {
Body struct {
Servers []string `json:"servers" example:"[\"1.1.1.1\"]" doc:"Nameservers from /etc/resolv.conf"`
}
}
func registerReads(api huma.API) {
huma.Register(api, huma.Operation{
OperationID: "networking-list-interfaces",
Method: "GET",
Path: "/api/networking/interfaces",
Summary: "List network interfaces",
Description: "Returns every interface with its state, MAC, MTU and addresses, via `ip -j addr`.",
Tags: []string{tagNetworking},
Metadata: op("read"),
Errors: readErrors,
}, func(ctx context.Context, _ *struct{}) (*ListInterfacesOutput, error) {
out, err := oscmd.RunContext(ctx, "ip", "-j", "addr")
if err != nil {
return nil, huma.Error500InternalServerError("ip addr failed", err)
}
ifaces, err := parseInterfaces(out)
if err != nil {
return nil, huma.Error500InternalServerError("parse ip addr failed", err)
}
res := &ListInterfacesOutput{}
res.Body.Interfaces = ifaces
return res, nil
})
huma.Register(api, huma.Operation{
OperationID: "networking-list-routes",
Method: "GET",
Path: "/api/networking/routes",
Summary: "List the IPv4 route table",
Description: "Returns the kernel IPv4 route table via `ip -j route`.",
Tags: []string{tagNetworking},
Metadata: op("read"),
Errors: readErrors,
}, func(ctx context.Context, _ *struct{}) (*ListRoutesOutput, error) {
out, err := oscmd.RunContext(ctx, "ip", "-j", "route")
if err != nil {
return nil, huma.Error500InternalServerError("ip route failed", err)
}
routes, err := parseRoutes(out)
if err != nil {
return nil, huma.Error500InternalServerError("parse ip route failed", err)
}
res := &ListRoutesOutput{}
res.Body.Routes = routes
return res, nil
})
huma.Register(api, huma.Operation{
OperationID: "networking-get-dns",
Method: "GET",
Path: "/api/networking/dns",
Summary: "Get configured DNS servers",
Description: "Returns the nameservers listed in /etc/resolv.conf. DNS is set " +
"per-interface as part of the interface config (PUT /api/networking/interfaces/{name}), " +
"so there is no standalone DNS write endpoint.",
Tags: []string{tagNetworking},
Metadata: op("read"),
Errors: readErrors,
}, func(ctx context.Context, _ *struct{}) (*DNSOutput, error) {
data, err := os.ReadFile(resolvConf)
if err != nil {
return nil, huma.Error500InternalServerError("read resolv.conf failed", err)
}
res := &DNSOutput{}
res.Body.Servers = parseResolv(string(data))
return res, nil
})
}
// --- parsers (pure, tested) --------------------------------------------------
// ipAddr mirrors the fields we use from one `ip -j addr` element.
type ipAddr struct {
Name string `json:"ifname"`
MAC string `json:"address"`
MTU int `json:"mtu"`
OperState string `json:"operstate"`
AddrInfo []struct {
Family string `json:"family"` // "inet" / "inet6"
Local string `json:"local"`
Prefix int `json:"prefixlen"`
} `json:"addr_info"`
}
func parseInterfaces(jsonOut string) ([]Interface, error) {
var raw []ipAddr
if err := json.Unmarshal([]byte(jsonOut), &raw); err != nil {
return nil, err
}
ifaces := make([]Interface, 0, len(raw))
for _, r := range raw {
iface := Interface{
Name: r.Name,
State: strings.ToLower(r.OperState),
MAC: r.MAC,
MTU: r.MTU,
IPv4: []string{},
IPv6: []string{},
}
for _, a := range r.AddrInfo {
cidr := a.Local + "/" + strconv.Itoa(a.Prefix)
if a.Family == "inet6" {
iface.IPv6 = append(iface.IPv6, cidr)
} else {
iface.IPv4 = append(iface.IPv4, cidr)
}
}
ifaces = append(ifaces, iface)
}
return ifaces, nil
}
// ipRoute mirrors the fields we use from one `ip -j route` element.
type ipRoute struct {
Dst string `json:"dst"`
Gateway string `json:"gateway"`
Dev string `json:"dev"`
PrefSrc string `json:"prefsrc"`
Metric int `json:"metric"`
}
func parseRoutes(jsonOut string) ([]RouteEntry, error) {
var raw []ipRoute
if err := json.Unmarshal([]byte(jsonOut), &raw); err != nil {
return nil, err
}
routes := make([]RouteEntry, 0, len(raw))
for _, r := range raw {
routes = append(routes, RouteEntry{
Destination: r.Dst,
Gateway: r.Gateway,
Interface: r.Dev,
Source: r.PrefSrc,
Metric: r.Metric,
})
}
return routes, nil
}
// parseResolv extracts "nameserver X" entries, ignoring comments and other
// directives.
func parseResolv(text string) []string {
servers := []string{}
for line := range strings.SplitSeq(text, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
continue
}
if rest, ok := strings.CutPrefix(line, "nameserver"); ok {
if s := strings.TrimSpace(rest); s != "" {
servers = append(servers, s)
}
}
}
return servers
}
+193
View File
@@ -0,0 +1,193 @@
package networking
import (
"context"
"errors"
"fmt"
"log"
"time"
)
const defaultRollbackSeconds = 60
// errAlreadyPending is returned when another change is awaiting confirmation.
// The write handlers map this to 409 Conflict.
var errAlreadyPending = errors.New("already pending")
// errPending builds the 409 message. The lock is global across all interfaces
// (see pendingChange), so the message says so to avoid confusing a user who is
// touching a different interface than the one that holds the lock.
func errPending(iface string) error {
return fmt.Errorf("%w: a change to %s is awaiting confirmation. This is a global lock across all interfaces — confirm or roll that change back first", errAlreadyPending, iface)
}
// startRollback snapshots the current state, applies the new config, and arms a
// timer that auto-reverts if not confirmed. Returns errAlreadyPending (409) if
// another change is in flight, or a wrapped error (500) if apply fails.
//
// Snapshot and Apply run WITHOUT the mutex held, so they don't block reads or
// the pending-status endpoint while shelling out to nmcli/networkctl/ifup.
func (m *Module) startRollback(ctx context.Context, iface string, cfg IfaceConfig) (int, error) {
// Fast pre-check so we don't snapshot/apply when something is already
// pending. armPending re-checks under the lock to close the race.
if err := m.checkNoPending(); err != nil {
return 0, err
}
prior, err := m.be.Snapshot(ctx, iface)
if err != nil {
return 0, fmt.Errorf("snapshot %s: %w", iface, err)
}
if err := m.be.Apply(ctx, iface, cfg); err != nil {
// Apply is not atomic: nmcli `con modify` may succeed before `con up`
// fails, and networkd writes the .network file before `reconfigure`
// runs. A failed Apply can therefore leave a half-applied config that
// would otherwise have NO auto-revert (we bail before arming the timer).
// Best-effort restore the snapshot so we never leave that unprotected.
if rerr := m.be.Apply(ctx, iface, prior); rerr != nil {
log.Printf("networking: apply %s failed and restore also failed: %v", iface, rerr)
}
return 0, fmt.Errorf("apply %s: %w", iface, err)
}
// The revert runs from the timer or an explicit rollback, possibly with no
// client attached, so it uses context.Background() rather than ctx.
return m.armPending(iface, func() error { return m.be.Apply(context.Background(), iface, prior) }, cfg.RollbackSeconds)
}
// startLinkDown takes the interface down behind the same rollback safety net: if
// the change is not confirmed, the interface is brought back up. Taking a remote
// interface down is just as much a lock-yourself-out risk as a bad static config.
//
// Bringing a link UP needs no protection (it cannot lock you out), so link-up
// stays a direct, un-wrapped call in the handler.
func (m *Module) startLinkDown(ctx context.Context, iface string) (int, error) {
if err := m.checkNoPending(); err != nil {
return 0, err
}
if err := m.be.SetLinkDown(ctx, iface); err != nil {
return 0, fmt.Errorf("link down %s: %w", iface, err)
}
// Revert (bring the link back up) may run from the timer with no client.
return m.armPending(iface, func() error { return m.be.SetLinkUp(context.Background(), iface) }, 0)
}
// checkNoPending reports a 409 error if a change is already pending.
func (m *Module) checkNoPending() error {
m.mu.Lock()
defer m.mu.Unlock()
if m.pending != nil {
return errPending(m.pending.Iface)
}
return nil
}
// armPending installs the pending change and starts its auto-revert timer. The
// caller has already applied the change; revert is the closure that undoes it.
// It is invoked on timer expiry, explicit rollback, or if a concurrent change
// raced us between the pre-check and here (in which case we revert immediately
// and report the conflict). seconds <= 0 uses the default timeout.
func (m *Module) armPending(iface string, revert func() error, seconds int) (int, error) {
if seconds <= 0 {
seconds = defaultRollbackSeconds
}
dur := time.Duration(seconds) * time.Second
m.mu.Lock()
defer m.mu.Unlock()
if m.pending != nil {
// Lost the race — undo what we just applied and report conflict.
if err := revert(); err != nil {
log.Printf("networking: failed to undo raced change on %s: %v", iface, err)
}
return 0, errPending(m.pending.Iface)
}
pc := &pendingChange{
Iface: iface,
revert: revert,
Deadline: time.Now().Add(dur),
}
// The timer fires the auto-revert. It captures m and pc by closure so it can
// revert even if the server is otherwise idle — the whole point is protecting
// against being locked out of a remote box.
pc.Timer = time.AfterFunc(dur, func() {
m.mu.Lock()
defer m.mu.Unlock()
// Only revert if this exact change is still pending (it may have been
// confirmed or manually rolled back in the meantime).
if m.pending != pc {
return
}
log.Printf("networking: rollback timer expired for %s — reverting", iface)
if err := pc.revert(); err != nil {
log.Printf("networking: auto-rollback of %s failed: %v", iface, err)
}
m.pending = nil
})
m.pending = pc
return seconds, nil
}
// confirm cancels the rollback timer and clears the pending change, making it
// permanent. Errors if there is no pending change or it's for another interface.
func (m *Module) confirm(iface string) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.pending == nil {
return fmt.Errorf("no pending change to confirm")
}
if m.pending.Iface != iface {
return fmt.Errorf("pending change is for %s, not %s", m.pending.Iface, iface)
}
m.pending.Timer.Stop()
m.pending = nil
return nil
}
// rollbackNow immediately reverts the pending change and clears it. Errors if
// there is no pending change or it's for another interface.
func (m *Module) rollbackNow(iface string) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.pending == nil {
return fmt.Errorf("no pending change to rollback")
}
if m.pending.Iface != iface {
return fmt.Errorf("pending change is for %s, not %s", m.pending.Iface, iface)
}
m.pending.Timer.Stop()
err := m.pending.revert()
m.pending = nil
if err != nil {
return fmt.Errorf("rollback %s: %w", iface, err)
}
return nil
}
// PendingInfo is the JSON body returned by the pending-change status endpoint.
type PendingInfo struct {
Iface string `json:"interface" example:"eth0" doc:"Interface with a pending change"`
SecondsRemaining int `json:"seconds_remaining" example:"45" doc:"Seconds until auto-rollback"`
}
// pendingInfo returns the current pending change status, or nil if none.
func (m *Module) pendingInfo() *PendingInfo {
m.mu.Lock()
defer m.mu.Unlock()
if m.pending == nil {
return nil
}
remaining := max(int(time.Until(m.pending.Deadline).Seconds()), 0)
return &PendingInfo{
Iface: m.pending.Iface,
SecondsRemaining: remaining,
}
}
+194
View File
@@ -0,0 +1,194 @@
package networking
import (
"context"
"errors"
"nadir/internal/oscmd"
"github.com/danielgtaylor/huma/v2"
)
// registerWrites adds all write endpoints for the networking module. Every
// handler checks m.be != nil first and returns 501 when no backend was detected.
type ApplyInput struct {
Name string `path:"name" example:"eth0" doc:"Interface name"`
Body IfaceConfig `doc:"Desired interface configuration"`
}
type ApplyOutput struct {
Body struct {
Status string `json:"status" example:"pending" doc:"Always \"pending\" — confirm to make permanent"`
Interface string `json:"interface" example:"eth0"`
Backend string `json:"backend" example:"nmcli" doc:"Network backend that applied the change"`
RollbackSeconds int `json:"rollback_seconds" example:"60" doc:"Seconds until auto-rollback unless confirmed"`
}
}
type IfacePathInput struct {
Name string `path:"name" example:"eth0" doc:"Interface name"`
}
type PendingOutput struct {
Body PendingInfo
}
func registerWrites(api huma.API, m *Module) {
huma.Register(api, huma.Operation{
OperationID: "networking-apply-config",
Method: "PUT",
Path: "/api/networking/interfaces/{name}",
Summary: "Apply interface configuration",
Description: "Replaces the interface's IPv4 configuration. The change is applied " +
"immediately but starts a rollback timer — if not confirmed within the timeout " +
"(default 60s), the prior configuration is automatically restored. This prevents " +
"lock-yourself-out mistakes on remote hosts.",
Tags: []string{tagNetworking},
Metadata: op("write"),
Errors: writeErrors,
}, func(ctx context.Context, in *ApplyInput) (*ApplyOutput, error) {
if m.be == nil {
return nil, huma.Error501NotImplemented("", errNoBackend)
}
if err := validateIface(in.Name); err != nil {
return nil, err
}
if err := in.Body.validate(); err != nil {
return nil, err
}
seconds, err := m.startRollback(ctx, in.Name, in.Body)
if err != nil {
if errors.Is(err, errAlreadyPending) {
return nil, huma.Error409Conflict(err.Error())
}
return nil, huma.Error500InternalServerError("apply failed", err)
}
out := &ApplyOutput{}
out.Body.Status = "pending"
out.Body.Interface = in.Name
out.Body.Backend = m.be.Name()
out.Body.RollbackSeconds = seconds
return out, nil
})
huma.Register(api, huma.Operation{
OperationID: "networking-confirm-change",
Method: "POST",
Path: "/api/networking/interfaces/{name}/confirm",
Summary: "Confirm a pending change",
Description: "Cancels the rollback timer, making the applied configuration permanent.",
Tags: []string{tagNetworking},
Metadata: op("write"),
Errors: writeErrors,
}, func(ctx context.Context, in *IfacePathInput) (*oscmd.StatusOutput, error) {
if m.be == nil {
return nil, huma.Error501NotImplemented("", errNoBackend)
}
if err := validateIface(in.Name); err != nil {
return nil, err
}
if err := m.confirm(in.Name); err != nil {
return nil, huma.Error409Conflict(err.Error())
}
return oscmd.OK(), nil
})
huma.Register(api, huma.Operation{
OperationID: "networking-rollback-change",
Method: "POST",
Path: "/api/networking/interfaces/{name}/rollback",
Summary: "Immediately revert a pending change",
Description: "Reverts the interface to its prior configuration and clears the pending change.",
Tags: []string{tagNetworking},
Metadata: op("write"),
Errors: writeErrors,
}, func(ctx context.Context, in *IfacePathInput) (*oscmd.StatusOutput, error) {
if m.be == nil {
return nil, huma.Error501NotImplemented("", errNoBackend)
}
if err := validateIface(in.Name); err != nil {
return nil, err
}
if err := m.rollbackNow(in.Name); err != nil {
return nil, huma.Error500InternalServerError("rollback failed", err)
}
return oscmd.OK(), nil
})
huma.Register(api, huma.Operation{
OperationID: "networking-link-up",
Method: "POST",
Path: "/api/networking/interfaces/{name}/up",
Summary: "Bring an interface up",
Tags: []string{tagNetworking},
Metadata: op("write"),
Errors: writeErrors,
}, func(ctx context.Context, in *IfacePathInput) (*oscmd.StatusOutput, error) {
if m.be == nil {
return nil, huma.Error501NotImplemented("", errNoBackend)
}
if err := validateIface(in.Name); err != nil {
return nil, err
}
if err := m.be.SetLinkUp(ctx, in.Name); err != nil {
return nil, huma.Error500InternalServerError("link up failed", err)
}
return oscmd.OK(), nil
})
huma.Register(api, huma.Operation{
OperationID: "networking-link-down",
Method: "POST",
Path: "/api/networking/interfaces/{name}/down",
Summary: "Take an interface down",
Description: "Brings the interface down behind the rollback safety net: it is brought " +
"back up automatically if not confirmed within the timeout (default 60s). This " +
"prevents taking down the link you're managing the host over and losing access.",
Tags: []string{tagNetworking},
Metadata: op("write"),
Errors: writeErrors,
}, func(ctx context.Context, in *IfacePathInput) (*ApplyOutput, error) {
if m.be == nil {
return nil, huma.Error501NotImplemented("", errNoBackend)
}
if err := validateIface(in.Name); err != nil {
return nil, err
}
seconds, err := m.startLinkDown(ctx, in.Name)
if err != nil {
if errors.Is(err, errAlreadyPending) {
return nil, huma.Error409Conflict(err.Error())
}
return nil, huma.Error500InternalServerError("link down failed", err)
}
out := &ApplyOutput{}
out.Body.Status = "pending"
out.Body.Interface = in.Name
out.Body.Backend = m.be.Name()
out.Body.RollbackSeconds = seconds
return out, nil
})
huma.Register(api, huma.Operation{
OperationID: "networking-get-pending",
Method: "GET",
Path: "/api/networking/pending",
Summary: "Get pending change status",
Description: "Returns the currently pending change (interface name and seconds until " +
"auto-rollback), or 404 if there is no pending change.",
Tags: []string{tagNetworking},
Metadata: op("read"),
Errors: []int{401, 403, 404, 500},
}, func(ctx context.Context, _ *struct{}) (*PendingOutput, error) {
info := m.pendingInfo()
if info == nil {
return nil, huma.Error404NotFound("no pending change")
}
out := &PendingOutput{}
out.Body = *info
return out, nil
})
}
+33
View File
@@ -0,0 +1,33 @@
package packages
import (
"nadir/internal/rbac"
"github.com/danielgtaylor/huma/v2"
)
const ModuleID = "packages"
type Module struct {
pm manager // detected package manager; zero value means none found
}
// New detects the host's package manager once at startup.
func New() *Module { return &Module{pm: detect()} }
func (m *Module) ID() string { return ModuleID }
func (m *Module) Name() string { return "Packages" }
// Permissions: read to list installed/available; write to install, remove, and
// upgrade.
func (m *Module) Permissions() []rbac.Permission {
return []rbac.Permission{rbac.Read, rbac.Write}
}
func (m *Module) Register(api huma.API) {
registerPackages(api, m.pm)
}
func op(permission string) map[string]any {
return map[string]any{"module": ModuleID, "permission": permission}
}
+370
View File
@@ -0,0 +1,370 @@
package packages
import (
"context"
"os/exec"
"regexp"
"strings"
"nadir/internal/oscmd"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/sse"
)
const tagPackages = "Packages"
var (
readErrors = []int{401, 403, 500}
writeErrors = []int{400, 401, 403, 500}
)
// pkgNameRe matches a plain package name (no version specifiers, no shell
// metacharacters). Combined with a leading-dash reject and "--" before the name
// on every command line, it keeps user input from being read as a flag.
var pkgNameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9@._+-]*$`)
// manager identifies the host package manager. Its name drives a switch in each
// operation rather than an interface - three concrete tools, no polymorphism.
type manager struct{ name string }
// detect picks the package manager. dnf/pacman are checked before apt because a
// few rpm-based boxes also ship an apt-get shim; the native tool should win.
func detect() manager {
for _, m := range []struct{ name, bin string }{
{"dnf", "dnf"},
{"pacman", "pacman"},
{"apt", "apt-get"},
} {
if _, err := exec.LookPath(m.bin); err == nil {
return manager{name: m.name}
}
}
return manager{}
}
type Package struct {
Name string `json:"name" example:"openssh-server" doc:"Package name"`
Version string `json:"version" example:"9.6p1" doc:"Installed version, or the available version for updates"`
}
type ListOutput struct {
Body struct {
Manager string `json:"manager" example:"dnf" doc:"Detected package manager"`
Packages []Package `json:"packages"`
}
}
type InstallInput struct {
Body struct {
Name string `json:"name" example:"htop" doc:"Package to install"`
}
}
type RemoveInput struct {
Name string `path:"name" example:"htop" doc:"Package to remove"`
}
// SSE event types for streaming package operations.
type PkgOutputEvent struct {
Line string `json:"line" doc:"One line of the package manager's terminal output"`
}
type PkgErrorEvent struct {
Message string `json:"message"`
}
type PkgDoneEvent struct {
Success bool `json:"success" doc:"True if the package manager exited 0"`
Error string `json:"error,omitempty" doc:"Exit error when it failed"`
}
// pkgEvents maps SSE event names to their payload types for the streaming
// install/remove/upgrade operations.
var pkgEvents = map[string]any{
"output": PkgOutputEvent{},
"done": PkgDoneEvent{},
"error": PkgErrorEvent{},
}
func registerPackages(api huma.API, pm manager) {
huma.Register(api, huma.Operation{
OperationID: "packages-list-installed",
Method: "GET",
Path: "/api/packages",
Summary: "List installed packages",
Tags: []string{tagPackages},
Metadata: op("read"),
Errors: readErrors,
}, func(ctx context.Context, _ *struct{}) (*ListOutput, error) {
return listInstalled(pm)
})
huma.Register(api, huma.Operation{
OperationID: "packages-list-updates",
Method: "GET",
Path: "/api/packages/updates",
Summary: "List available updates",
Tags: []string{tagPackages},
Metadata: op("read"),
Errors: readErrors,
}, func(ctx context.Context, _ *struct{}) (*ListOutput, error) {
return listUpdates(pm)
})
// Install/remove/upgrade stream the package manager's terminal output live
// via SSE (one `output` event per line, a final `done` with exit status),
// rather than blocking the request until a long operation finishes.
sse.Register(api, huma.Operation{
OperationID: "packages-install",
Method: "POST",
Path: "/api/packages",
Summary: "Install a package (streamed)",
Description: "Installs a package, streaming the package manager's output as " +
"`output` events and ending with a `done` event carrying the exit status.",
Tags: []string{tagPackages},
Metadata: op("write"),
}, pkgEvents, func(ctx context.Context, in *InstallInput, send sse.Sender) {
if validateName(in.Body.Name) != nil {
send.Data(PkgErrorEvent{Message: "invalid package name: " + in.Body.Name})
return
}
bin, args := pm.installArgs(in.Body.Name)
streamOp(ctx, send, bin, args)
})
sse.Register(api, huma.Operation{
OperationID: "packages-remove",
Method: "DELETE",
Path: "/api/packages/{name}",
Summary: "Remove a package (streamed)",
Tags: []string{tagPackages},
Metadata: op("write"),
}, pkgEvents, func(ctx context.Context, in *RemoveInput, send sse.Sender) {
if validateName(in.Name) != nil {
send.Data(PkgErrorEvent{Message: "invalid package name: " + in.Name})
return
}
bin, args := pm.removeArgs(in.Name)
streamOp(ctx, send, bin, args)
})
sse.Register(api, huma.Operation{
OperationID: "packages-upgrade",
Method: "POST",
Path: "/api/packages/upgrade",
Summary: "Upgrade all packages (streamed)",
Description: "Upgrades every package to its latest version, streaming the " +
"package manager's output live.",
Tags: []string{tagPackages},
Metadata: op("write"),
}, pkgEvents, func(ctx context.Context, _ *struct{}, send sse.Sender) {
bin, args := pm.upgradeArgs()
streamOp(ctx, send, bin, args)
})
}
// streamOp runs a package write and streams its combined output to the client.
func streamOp(ctx context.Context, send sse.Sender, bin string, args []string) {
if bin == "" {
send.Data(PkgErrorEvent{Message: "no supported package manager found"})
return
}
// DEBIAN_FRONTEND keeps apt from blocking on an interactive prompt.
lines, errc, err := oscmd.RunStreamCombined(ctx, []string{"DEBIAN_FRONTEND=noninteractive"}, bin, args...)
if err != nil {
send.Data(PkgErrorEvent{Message: err.Error()})
return
}
for line := range lines {
if send.Data(PkgOutputEvent{Line: line}) != nil {
return // client gone; ctx cancel kills the process
}
}
done := PkgDoneEvent{Success: true}
if werr := <-errc; werr != nil {
done.Success = false
done.Error = werr.Error()
}
send.Data(done)
}
func validateName(name string) error {
if !pkgNameRe.MatchString(name) {
return huma.Error400BadRequest("invalid package name: " + name)
}
return nil
}
// --- reads -------------------------------------------------------------------
func listInstalled(pm manager) (*ListOutput, error) {
var out string
var err error
switch pm.name {
case "dnf":
out, err = oscmd.Run("rpm", "-qa", "--qf", "%{NAME}\t%{VERSION}-%{RELEASE}\n")
case "apt":
out, err = oscmd.Run("dpkg-query", "-W", "-f=${Package}\t${Version}\n")
case "pacman":
out, err = oscmd.Run("pacman", "-Q")
default:
return nil, huma.Error500InternalServerError("no supported package manager found")
}
if err != nil {
return nil, huma.Error500InternalServerError("listing installed packages failed", err)
}
pkgs := parseTabbed(out)
if pm.name == "pacman" {
pkgs = parseSpaced(out)
}
return result(pm, pkgs), nil
}
func listUpdates(pm manager) (*ListOutput, error) {
switch pm.name {
case "dnf":
// check-update exits 100 when updates exist, 0 when none - both fine.
out, code, err := oscmd.RunStatus("dnf", "-q", "check-update")
if err != nil || (code != 0 && code != 100) {
return nil, huma.Error500InternalServerError("dnf check-update failed", err)
}
return result(pm, parseDnf(out)), nil
case "apt":
out, err := oscmd.Run("apt", "list", "--upgradable", "-qq")
if err != nil {
return nil, huma.Error500InternalServerError("apt list failed", err)
}
return result(pm, parseApt(out)), nil
case "pacman":
// -Qu exits 1 when there is nothing to upgrade - not an error.
out, code, err := oscmd.RunStatus("pacman", "-Qu")
if err != nil || (code != 0 && code != 1) {
return nil, huma.Error500InternalServerError("pacman -Qu failed", err)
}
return result(pm, parsePacmanUpdates(out)), nil
default:
return nil, huma.Error500InternalServerError("no supported package manager found")
}
}
func result(pm manager, pkgs []Package) *ListOutput {
out := &ListOutput{}
out.Body.Manager = pm.name
out.Body.Packages = pkgs
return out
}
// --- writes ------------------------------------------------------------------
func (m manager) installArgs(name string) (string, []string) {
switch m.name {
case "dnf":
return "dnf", []string{"install", "-y", "--", name}
case "apt":
return "apt-get", []string{"install", "-y", "--", name}
case "pacman":
return "pacman", []string{"-S", "--noconfirm", "--", name}
}
return "", nil
}
func (m manager) removeArgs(name string) (string, []string) {
switch m.name {
case "dnf":
return "dnf", []string{"remove", "-y", "--", name}
case "apt":
return "apt-get", []string{"remove", "-y", "--", name}
case "pacman":
return "pacman", []string{"-R", "--noconfirm", "--", name}
}
return "", nil
}
func (m manager) upgradeArgs() (string, []string) {
switch m.name {
case "dnf":
return "dnf", []string{"upgrade", "-y"}
case "apt":
return "apt-get", []string{"upgrade", "-y"}
case "pacman":
return "pacman", []string{"-Su", "--noconfirm"}
}
return "", nil
}
// --- parsers (pure, tested) --------------------------------------------------
// parseTabbed reads "name\tversion" lines (dpkg-query / rpm output).
func parseTabbed(out string) []Package {
pkgs := []Package{}
for line := range strings.SplitSeq(out, "\n") {
if name, ver, ok := strings.Cut(line, "\t"); ok && name != "" {
pkgs = append(pkgs, Package{Name: name, Version: ver})
}
}
return pkgs
}
// parseSpaced reads "name version" lines (pacman -Q).
func parseSpaced(out string) []Package {
pkgs := []Package{}
for line := range strings.SplitSeq(out, "\n") {
if f := strings.Fields(line); len(f) >= 2 {
pkgs = append(pkgs, Package{Name: f[0], Version: f[1]})
}
}
return pkgs
}
// parseDnf reads `dnf check-update` lines ("name.arch version repo"), skipping
// the section headers and blank lines that dnf5 emits.
func parseDnf(out string) []Package {
pkgs := []Package{}
for line := range strings.SplitSeq(out, "\n") {
f := strings.Fields(line)
// Real rows have 3 columns and an arch suffix on the name; headers like
// "Upgrades" or "Obsoleting Packages" don't.
if len(f) != 3 || !strings.Contains(f[0], ".") {
continue
}
pkgs = append(pkgs, Package{Name: stripArch(f[0]), Version: f[1]})
}
return pkgs
}
// parseApt reads `apt list --upgradable` lines ("name/repo version arch [...]").
func parseApt(out string) []Package {
pkgs := []Package{}
for line := range strings.SplitSeq(out, "\n") {
f := strings.Fields(line)
if len(f) < 2 || !strings.Contains(f[0], "/") {
continue
}
name, _, _ := strings.Cut(f[0], "/")
pkgs = append(pkgs, Package{Name: name, Version: f[1]})
}
return pkgs
}
// parsePacmanUpdates reads `pacman -Qu` lines ("name oldver -> newver").
func parsePacmanUpdates(out string) []Package {
pkgs := []Package{}
for line := range strings.SplitSeq(out, "\n") {
f := strings.Fields(line)
if len(f) < 4 || f[2] != "->" {
continue
}
pkgs = append(pkgs, Package{Name: f[0], Version: f[3]})
}
return pkgs
}
// stripArch removes the trailing ".arch" from an rpm "name.arch" token. The
// arch is always the final dotted segment, so cut at the last dot.
func stripArch(s string) string {
if i := strings.LastIndex(s, "."); i > 0 {
return s[:i]
}
return s
}
@@ -0,0 +1,125 @@
package packages
import (
"encoding/json"
"net/http"
"os"
"reflect"
"strings"
"testing"
"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())
}
func TestPackagesHandlers(t *testing.T) {
managers := []string{"dnf", "apt", "pacman"}
for _, mgrName := range managers {
t.Run(mgrName, func(t *testing.T) {
mux := http.NewServeMux()
api := humatest.Wrap(t, humago.New(mux, huma.DefaultConfig("Test", "1.0.0")))
m := &Module{pm: manager{name: mgrName}}
m.Register(api)
oscmd.SetMock("rpm", func(args []string) oscmd.MockCommand {
return oscmd.MockCommand{Stdout: "htop\t3.3.0-1\n", ExitCode: 0}
})
oscmd.SetMock("dpkg-query", func(args []string) oscmd.MockCommand {
return oscmd.MockCommand{Stdout: "htop\t3.3.0-1\n", ExitCode: 0}
})
oscmd.SetMock("pacman", func(args []string) oscmd.MockCommand {
if reflect.DeepEqual(args, []string{"-Q"}) {
return oscmd.MockCommand{Stdout: "htop 3.3.0-1\n", ExitCode: 0}
}
if reflect.DeepEqual(args, []string{"-Qu"}) {
return oscmd.MockCommand{Stdout: "linux 6.9.1-1 -> 6.9.2-1\n", ExitCode: 0}
}
return oscmd.MockCommand{Lines: []string{"pacman output"}, ExitCode: 0}
})
oscmd.SetMock("dnf", func(args []string) oscmd.MockCommand {
if reflect.DeepEqual(args, []string{"-q", "check-update"}) {
return oscmd.MockCommand{Stdout: "Upgrades\ncode.x86_64 1.125.1-1 code\n", ExitCode: 100}
}
return oscmd.MockCommand{Lines: []string{"dnf output"}, ExitCode: 0}
})
oscmd.SetMock("apt", func(args []string) oscmd.MockCommand {
return oscmd.MockCommand{Stdout: "Listing...\nvim/jammy 2:8.2.3995 amd64 [upgradable]\n", ExitCode: 0}
})
oscmd.SetMock("apt-get", func(args []string) oscmd.MockCommand {
return oscmd.MockCommand{Lines: []string{"apt-get output"}, ExitCode: 0}
})
defer oscmd.ClearMocks()
// Test list installed
resp := api.Get("/api/packages")
if resp.Code != http.StatusOK {
t.Errorf("list installed: got %d, want %d", resp.Code, http.StatusOK)
}
var listRes ListOutput
if err := json.Unmarshal(resp.Body.Bytes(), &listRes.Body); err != nil {
t.Fatal(err)
}
if len(listRes.Body.Packages) != 1 || listRes.Body.Packages[0].Name != "htop" {
t.Errorf("unexpected installed packages: %+v", listRes.Body)
}
// Test list updates
resp = api.Get("/api/packages/updates")
if resp.Code != http.StatusOK {
t.Errorf("list updates: got %d, want %d", resp.Code, http.StatusOK)
}
if err := json.Unmarshal(resp.Body.Bytes(), &listRes.Body); err != nil {
t.Fatal(err)
}
if len(listRes.Body.Packages) != 1 {
t.Errorf("unexpected updates: %+v", listRes.Body)
}
// Test install (SSE)
resp = api.Post("/api/packages", struct {
Name string `json:"name"`
}{
Name: "htop",
})
if resp.Code != http.StatusOK {
t.Errorf("install: got %d, want %d", resp.Code, http.StatusOK)
}
bodyStr := resp.Body.String()
if !strings.Contains(bodyStr, "done") {
t.Errorf("install stream output missing done: %q", bodyStr)
}
// Test remove (SSE)
resp = api.Delete("/api/packages/htop")
if resp.Code != http.StatusOK {
t.Errorf("remove: got %d, want %d", resp.Code, http.StatusOK)
}
bodyStr = resp.Body.String()
if !strings.Contains(bodyStr, "done") {
t.Errorf("remove stream output missing done: %q", bodyStr)
}
// Test upgrade (SSE)
resp = api.Post("/api/packages/upgrade", struct{}{})
if resp.Code != http.StatusOK {
t.Errorf("upgrade: got %d, want %d", resp.Code, http.StatusOK)
}
bodyStr = resp.Body.String()
if !strings.Contains(bodyStr, "done") {
t.Errorf("upgrade stream output missing done: %q", bodyStr)
}
})
}
}
@@ -0,0 +1,80 @@
package packages
import (
"reflect"
"testing"
)
func TestParseTabbed(t *testing.T) {
out := "zeromq\t4.3.5-22.fc43\nspice-server\t0.16.0-2.fc43\n\nbad-no-tab\n"
got := parseTabbed(out)
want := []Package{{"zeromq", "4.3.5-22.fc43"}, {"spice-server", "0.16.0-2.fc43"}}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %+v, want %+v", got, want)
}
}
func TestParseSpaced(t *testing.T) {
got := parseSpaced("linux 6.9.1\nhtop 3.3.0\n")
want := []Package{{"linux", "6.9.1"}, {"htop", "3.3.0"}}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %+v, want %+v", got, want)
}
}
func TestParseDnf(t *testing.T) {
// dnf5 emits a section header ("Upgrades") that must be skipped.
out := "Upgrades\n" +
"code.x86_64 1.125.1-1781859648.el8 code\n" +
"containerd.io.x86_64 2.2.5-1.fc44 docker-ce-stable\n" +
"\nObsoleting Packages\n"
got := parseDnf(out)
want := []Package{{"code", "1.125.1-1781859648.el8"}, {"containerd.io", "2.2.5-1.fc44"}}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %+v, want %+v", got, want)
}
}
func TestParseApt(t *testing.T) {
out := "Listing...\n" +
"vim/jammy-updates 2:8.2.3995-1ubuntu2.15 amd64 [upgradable from: 2:8.2.3995-1ubuntu2.1]\n"
got := parseApt(out)
want := []Package{{"vim", "2:8.2.3995-1ubuntu2.15"}}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %+v, want %+v", got, want)
}
}
func TestParsePacmanUpdates(t *testing.T) {
got := parsePacmanUpdates("linux 6.9.1-1 -> 6.9.2-1\nfoo 1.0 1.0\n")
want := []Package{{"linux", "6.9.2-1"}}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %+v, want %+v", got, want)
}
}
func TestStripArch(t *testing.T) {
cases := map[string]string{
"code.x86_64": "code",
"python3.11.noarch": "python3.11", // arch is only the final segment
"noarchhere": "noarchhere",
}
for in, want := range cases {
if got := stripArch(in); got != want {
t.Errorf("stripArch(%q) = %q, want %q", in, got, want)
}
}
}
func TestValidateName(t *testing.T) {
for _, n := range []string{"htop", "openssh-server", "lib32-glibc", "g++", "python3.11"} {
if err := validateName(n); err != nil {
t.Errorf("validateName(%q) = %v, want nil", n, err)
}
}
for _, n := range []string{"", "-rf", "foo;rm", "foo bar", "pkg=1.0", "a/b"} {
if err := validateName(n); err == nil {
t.Errorf("validateName(%q) = nil, want error", n)
}
}
}
+237
View File
@@ -0,0 +1,237 @@
package services
import (
"context"
"encoding/json"
"slices"
"strconv"
"strings"
"time"
"nadir/internal/oscmd"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/sse"
)
const (
defaultLogLines = 100
maxLogLines = 10000
)
// LogEntry is one log record. For the journal source it is distilled from
// journalctl's JSON; for the file source only Message is set (the raw line,
// which usually carries its own embedded timestamp).
type LogEntry struct {
Time string `json:"time" example:"2026-06-20T08:15:04Z" doc:"Record timestamp (RFC3339, UTC); empty for file lines"`
Priority int `json:"priority" example:"6" doc:"syslog priority 0 (emerg) 7 (debug); 6 for file lines"`
Message string `json:"message" example:"Started OpenSSH server daemon."`
}
// ErrorEvent is an SSE event carrying a stream-level error (e.g. bad unit).
type ErrorEvent struct {
Message string `json:"message"`
}
type LogsInput struct {
Unit string `path:"unit" example:"docker.service" doc:"Unit name as listed by GET /api/services; the trailing .service is optional"`
Source string `query:"source" enum:"journal,file" default:"journal" doc:"Where to read logs from"`
Path string `query:"path" example:"/var/log/nginx/error.log" doc:"Log file (file source only); must be allowlisted for this unit in config"`
Lines int `query:"lines" default:"100" doc:"How many recent records to return (max 10000)"`
Since string `query:"since" example:"-1h" doc:"journalctl time filter (journal source only)"`
Priority int `query:"priority" default:"7" minimum:"0" maximum:"7" doc:"Max syslog priority to include: 0 emerg .. 7 debug (journal source only). 7 = all."`
}
type LogsOutput struct {
Body struct {
Entries []LogEntry `json:"entries" doc:"Log records, oldest first"`
}
}
type LogStreamInput struct {
Unit string `path:"unit" example:"docker.service" doc:"Unit name as listed by GET /api/services; the trailing .service is optional"`
Source string `query:"source" enum:"journal,file" default:"journal" doc:"Where to stream logs from"`
Path string `query:"path" example:"/var/log/nginx/error.log" doc:"Log file (file source only); must be allowlisted for this unit in config"`
Since string `query:"since" example:"-1h" doc:"Backfill window (journal source only)"`
Priority int `query:"priority" default:"7" minimum:"0" maximum:"7" doc:"Max syslog priority to include: 0 emerg .. 7 debug (journal source only). 7 = all."`
}
func registerLogs(api huma.API, logFiles map[string][]string) {
huma.Register(api, huma.Operation{
OperationID: "services-logs",
Method: "GET",
Path: "/api/services/{unit}/logs",
Summary: "Get recent log records for a service",
Description: "Returns a snapshot of the unit's logs from the journal " +
"(default) or an allowlisted file (source=file&path=). Use /logs/stream " +
"to follow new records live.",
Tags: []string{tagServices},
Metadata: op("read"),
// No 404: the journal is historical, so logs are returned even for units
// that aren't currently loaded; an unknown unit just yields an empty list.
Errors: []int{400, 401, 403, 500},
}, func(ctx context.Context, in *LogsInput) (*LogsOutput, error) {
if err := validateUnit(in.Unit); err != nil {
return nil, err
}
var lines []string
var err error
if in.Source == "file" {
path, perr := resolveLogPath(logFiles, in.Unit, in.Path)
if perr != nil {
return nil, perr
}
lines, err = oscmd.RunLines("tail", "-n", strconv.Itoa(clampLines(in.Lines)), "--", path)
if err != nil {
return nil, huma.Error500InternalServerError("tail failed", err)
}
return fileOutput(lines), nil
}
args := []string{"-u", journalUnit(in.Unit), "--no-pager", "-o", "json", "-p", strconv.Itoa(in.Priority), "-n", strconv.Itoa(clampLines(in.Lines))}
if in.Since != "" {
args = append(args, "--since", in.Since)
}
lines, err = oscmd.RunLines("journalctl", args...)
if err != nil {
return nil, huma.Error500InternalServerError("journalctl failed", err)
}
out := &LogsOutput{}
out.Body.Entries = []LogEntry{}
for _, l := range lines {
if e, ok := parseJournalLine([]byte(l)); ok {
out.Body.Entries = append(out.Body.Entries, e)
}
}
return out, nil
})
// Streaming via huma's sse package keeps the route inside huma, so the RBAC
// middleware still enforces op("read") - a raw mux handler would bypass it.
sse.Register(api, huma.Operation{
OperationID: "services-logs-stream",
Method: "GET",
Path: "/api/services/{unit}/logs/stream",
Summary: "Stream a service's logs (Server-Sent Events)",
Description: "Follows the unit's journal (journalctl -f) or an allowlisted " +
"file (source=file&path=, via tail -F) and emits a `log` event per " +
"record. Stops when the client disconnects.",
Tags: []string{tagServices},
Metadata: op("read"),
}, map[string]any{
"log": LogEntry{},
"error": ErrorEvent{},
}, func(ctx context.Context, in *LogStreamInput, send sse.Sender) {
if err := validateUnit(in.Unit); err != nil {
send.Data(ErrorEvent{Message: "invalid unit name"})
return
}
var cmd string
var args []string
if in.Source == "file" {
path, perr := resolveLogPath(logFiles, in.Unit, in.Path)
if perr != nil {
send.Data(ErrorEvent{Message: perr.Error()})
return
}
cmd, args = "tail", []string{"-n", strconv.Itoa(defaultLogLines), "-F", "--", path}
} else {
cmd = "journalctl"
args = []string{"-u", journalUnit(in.Unit), "--no-pager", "-o", "json", "-p", strconv.Itoa(in.Priority), "-f"}
if in.Since != "" {
args = append(args, "--since", in.Since)
}
}
lines, err := oscmd.RunStream(ctx, cmd, args...)
if err != nil {
send.Data(ErrorEvent{Message: cmd + " failed: " + err.Error()})
return
}
for l := range lines {
e, ok := LogEntry{Priority: 6, Message: l}, true
if in.Source != "file" {
e, ok = parseJournalLine([]byte(l))
}
if ok {
if send.Data(e) != nil {
return // client gone; ctx cancel will kill the command
}
}
}
})
}
// resolveLogPath validates that path is allowlisted for unit. The caller never
// gets to point exec at an arbitrary file - only paths an admin listed under
// log_files for this unit are accepted.
func resolveLogPath(logFiles map[string][]string, unit, path string) (string, error) {
if path == "" {
return "", huma.Error400BadRequest("source=file requires a path")
}
// Match the allowlist key suffix-insensitively (nginx == nginx.service), so
// it behaves like the journal source regardless of which form the caller and
// the config author each used.
want := journalUnit(unit)
for key, paths := range logFiles {
if journalUnit(key) == want && slices.Contains(paths, path) {
return path, nil
}
}
return "", huma.Error403Forbidden("log file not allowlisted for unit " + unit + ": " + path)
}
func fileOutput(lines []string) *LogsOutput {
out := &LogsOutput{}
out.Body.Entries = make([]LogEntry, 0, len(lines))
for _, l := range lines {
out.Body.Entries = append(out.Body.Entries, LogEntry{Priority: 6, Message: l})
}
return out
}
// journalUnit normalizes a unit name for `journalctl -u`. journalctl treats a
// bare name as a .service, and on some setups only the bare form matches the
// recorded _SYSTEMD_UNIT, so we always strip the suffix. This is the services
// module, so .service is the only suffix we expect.
func journalUnit(unit string) string {
return strings.TrimSuffix(unit, ".service")
}
func clampLines(n int) int {
switch {
case n <= 0:
return defaultLogLines
case n > maxLogLines:
return maxLogLines
default:
return n
}
}
// parseJournalLine distills one journalctl `-o json` record. Returns false for
// unparseable lines. Binary MESSAGE fields (encoded as a byte array rather than
// a string) yield an empty message rather than an error.
func parseJournalLine(line []byte) (LogEntry, bool) {
var raw struct {
Message any `json:"MESSAGE"`
Priority string `json:"PRIORITY"`
TS string `json:"__REALTIME_TIMESTAMP"`
}
if err := json.Unmarshal(line, &raw); err != nil {
return LogEntry{}, false
}
// Records without a PRIORITY (it's often absent) default to info (6), not
// the zero value 0 which is emerg - that would fake critical alerts.
e := LogEntry{Priority: 6}
e.Message, _ = raw.Message.(string)
if p, err := strconv.Atoi(raw.Priority); err == nil {
e.Priority = p
}
if us, err := strconv.ParseInt(raw.TS, 10, 64); err == nil {
e.Time = time.UnixMicro(us).UTC().Format(time.RFC3339)
}
return e, true
}
+96
View File
@@ -0,0 +1,96 @@
package services
import "testing"
func TestParseJournalLine(t *testing.T) {
line := []byte(`{"__REALTIME_TIMESTAMP":"1750406104000000","PRIORITY":"6","MESSAGE":"Started OpenSSH server daemon.","_PID":"123"}`)
e, ok := parseJournalLine(line)
if !ok {
t.Fatal("expected parse to succeed")
}
if e.Message != "Started OpenSSH server daemon." {
t.Errorf("message = %q", e.Message)
}
if e.Priority != 6 {
t.Errorf("priority = %d", e.Priority)
}
if e.Time != "2025-06-20T07:55:04Z" {
t.Errorf("time = %q", e.Time)
}
}
func TestParseJournalLineBinaryMessage(t *testing.T) {
// Binary MESSAGE is encoded as a byte array; we yield an empty message, not an error.
line := []byte(`{"__REALTIME_TIMESTAMP":"1750406104000000","PRIORITY":"3","MESSAGE":[104,105]}`)
e, ok := parseJournalLine(line)
if !ok || e.Message != "" || e.Priority != 3 {
t.Errorf("got ok=%v entry=%+v", ok, e)
}
}
func TestParseJournalLineMissingPriority(t *testing.T) {
// PRIORITY is often absent; it must default to info (6), not emerg (0).
line := []byte(`{"__REALTIME_TIMESTAMP":"1750406104000000","MESSAGE":"hi"}`)
e, ok := parseJournalLine(line)
if !ok || e.Priority != 6 {
t.Errorf("got ok=%v priority=%d, want priority 6", ok, e.Priority)
}
}
func TestParseJournalLineGarbage(t *testing.T) {
if _, ok := parseJournalLine([]byte("not json")); ok {
t.Error("garbage line should not parse")
}
}
func TestResolveLogPath(t *testing.T) {
allow := map[string][]string{
"nginx.service": {"/var/log/nginx/access.log", "/var/log/nginx/error.log"},
}
// Allowlisted path resolves whether the caller uses the bare or .service
// form, regardless of which form the config key used.
for _, unit := range []string{"nginx.service", "nginx"} {
if p, err := resolveLogPath(allow, unit, "/var/log/nginx/error.log"); err != nil || p != "/var/log/nginx/error.log" {
t.Errorf("allowlisted path for %q: got %q, %v", unit, p, err)
}
}
// Everything else is rejected: empty path, non-listed path (traversal),
// listed path but wrong unit, and unit with no allowlist at all.
bad := []struct{ unit, path string }{
{"nginx.service", ""},
{"nginx.service", "/etc/shadow"},
{"nginx.service", "/var/log/nginx/access.log/../../../etc/shadow"},
{"sshd.service", "/var/log/nginx/error.log"},
{"unknown.service", "/var/log/nginx/error.log"},
}
for _, b := range bad {
if _, err := resolveLogPath(allow, b.unit, b.path); err == nil {
t.Errorf("resolveLogPath(%q, %q) = nil error, want rejection", b.unit, b.path)
}
}
}
func TestJournalUnit(t *testing.T) {
cases := map[string]string{
"docker.service": "docker",
"docker": "docker",
"sshd.service": "sshd",
"foo.socket": "foo.socket", // only .service is stripped
}
for in, want := range cases {
if got := journalUnit(in); got != want {
t.Errorf("journalUnit(%q) = %q, want %q", in, got, want)
}
}
}
func TestClampLines(t *testing.T) {
cases := map[int]int{0: defaultLogLines, -5: defaultLogLines, 50: 50, 999999: maxLogLines}
for in, want := range cases {
if got := clampLines(in); got != want {
t.Errorf("clampLines(%d) = %d, want %d", in, got, want)
}
}
}
+38
View File
@@ -0,0 +1,38 @@
package services
import (
"nadir/internal/rbac"
"github.com/danielgtaylor/huma/v2"
)
const ModuleID = "services"
type Module struct {
// logFiles is the per-unit allowlist of readable log files (from config),
// consulted by the file log source.
logFiles map[string][]string
}
func New(logFiles map[string][]string) *Module { return &Module{logFiles: logFiles} }
func (m *Module) ID() string { return ModuleID }
func (m *Module) Name() string { return "Services" }
// Permissions: read to list and inspect units; write to control them
// (start/stop/restart/enable/disable).
func (m *Module) Permissions() []rbac.Permission {
return []rbac.Permission{rbac.Read, rbac.Write}
}
func (m *Module) Register(api huma.API) {
registerServices(api)
registerLogs(api, m.logFiles)
}
func op(permission string) map[string]any {
return map[string]any{
"module": ModuleID,
"permission": permission,
}
}
+180
View File
@@ -0,0 +1,180 @@
package services
import (
"context"
"encoding/json"
"regexp"
"strings"
"nadir/internal/oscmd"
"github.com/danielgtaylor/huma/v2"
)
const tagServices = "Services"
var (
readErrors = []int{401, 403, 500}
writeErrors = []int{400, 401, 403, 404, 500}
)
// unitNameRe matches valid systemd unit names. Combined with a leading-dash
// reject and the "--" separator on every systemctl call, it keeps user-supplied
// unit names from being interpreted as options.
var unitNameRe = regexp.MustCompile(`^[a-zA-Z0-9@._:-]+$`)
// ServiceUnit mirrors one entry of `systemctl list-units --type=service -o json`.
type ServiceUnit struct {
Unit string `json:"unit" example:"sshd.service" doc:"Unit name"`
Load string `json:"load" example:"loaded" doc:"Load state"`
Active string `json:"active" example:"active" doc:"High-level active state"`
Sub string `json:"sub" example:"running" doc:"Low-level sub state"`
Description string `json:"description" example:"OpenSSH server daemon" doc:"Unit description"`
}
type ListServicesOutput struct {
Body struct {
Services []ServiceUnit `json:"services" doc:"All service units, active and inactive"`
}
}
// ServiceStatusBody is the detailed status of a single unit from `systemctl show`.
type ServiceStatusBody struct {
Unit string `json:"unit" example:"sshd.service"`
Description string `json:"description" example:"OpenSSH server daemon"`
LoadState string `json:"load_state" example:"loaded" doc:"loaded / not-found / masked"`
ActiveState string `json:"active_state" example:"active" doc:"active / inactive / failed"`
SubState string `json:"sub_state" example:"running"`
UnitFileState string `json:"unit_file_state" example:"enabled" doc:"enabled / disabled / static"`
}
type GetServiceOutput struct{ Body ServiceStatusBody }
// UnitPath is the shared path parameter for per-unit operations.
type UnitPath struct {
Unit string `path:"unit" example:"sshd.service" doc:"systemd unit name"`
}
func registerServices(api huma.API) {
huma.Register(api, huma.Operation{
OperationID: "services-list",
Method: "GET",
Path: "/api/services",
Summary: "List service units",
Description: "Returns all service units (active and inactive) via " +
"`systemctl list-units --type=service --all`.",
Tags: []string{tagServices},
Metadata: op("read"),
Errors: readErrors,
}, func(ctx context.Context, _ *struct{}) (*ListServicesOutput, error) {
out, err := oscmd.Run("systemctl", "list-units", "--type=service", "--all", "-o", "json", "--no-pager")
if err != nil {
return nil, huma.Error500InternalServerError("systemctl list-units failed", err)
}
var units []ServiceUnit
if err := json.Unmarshal([]byte(out), &units); err != nil {
return nil, huma.Error500InternalServerError("parse systemctl json failed", err)
}
res := &ListServicesOutput{}
res.Body.Services = units
return res, nil
})
huma.Register(api, huma.Operation{
OperationID: "services-get",
Method: "GET",
Path: "/api/services/{unit}",
Summary: "Get a service's status",
Description: "Returns load/active/sub/unit-file state for one unit via " +
"`systemctl show`. Returns 404 when the unit does not exist.",
Tags: []string{tagServices},
Metadata: op("read"),
Errors: []int{400, 401, 403, 404, 500},
}, func(ctx context.Context, in *UnitPath) (*GetServiceOutput, error) {
if err := validateUnit(in.Unit); err != nil {
return nil, err
}
m, err := showUnit(in.Unit)
if err != nil {
return nil, huma.Error500InternalServerError("systemctl show failed", err)
}
if m["LoadState"] == "not-found" {
return nil, huma.Error404NotFound("unit not found: " + in.Unit)
}
out := &GetServiceOutput{Body: ServiceStatusBody{
Unit: m["Id"],
Description: m["Description"],
LoadState: m["LoadState"],
ActiveState: m["ActiveState"],
SubState: m["SubState"],
UnitFileState: m["UnitFileState"],
}}
return out, nil
})
controls := []struct{ action, summary, desc string }{
{"start", "Start a service", "Starts the unit (`systemctl start`)."},
{"stop", "Stop a service", "Stops the unit (`systemctl stop`)."},
{"restart", "Restart a service", "Restarts the unit (`systemctl restart`)."},
{"enable", "Enable a service at boot", "Enables the unit (`systemctl enable`)."},
{"disable", "Disable a service at boot", "Disables the unit (`systemctl disable`)."},
}
for _, c := range controls {
huma.Register(api, huma.Operation{
OperationID: "services-" + c.action,
Method: "POST",
Path: "/api/services/{unit}/" + c.action,
Summary: c.summary,
Description: c.desc + " Returns 404 when the unit does not exist.",
Tags: []string{tagServices},
Metadata: op("write"),
Errors: writeErrors,
}, func(ctx context.Context, in *UnitPath) (*oscmd.StatusOutput, error) {
if err := validateUnit(in.Unit); err != nil {
return nil, err
}
if err := ensureExists(in.Unit); err != nil {
return nil, err
}
if _, err := oscmd.Run("systemctl", c.action, "--", in.Unit); err != nil {
return nil, huma.Error500InternalServerError("systemctl "+c.action+" failed", err)
}
return oscmd.OK(), nil
})
}
}
// validateUnit guards against empty, flag-like, or malformed unit names.
func validateUnit(unit string) error {
if unit == "" || strings.HasPrefix(unit, "-") || !unitNameRe.MatchString(unit) {
return huma.Error400BadRequest("invalid unit name: " + unit)
}
return nil
}
// showUnit returns selected properties of a unit as a key=value map. systemctl
// show exits 0 even for unknown units (LoadState=not-found), so callers must
// check LoadState to detect non-existence.
func showUnit(unit string) (map[string]string, error) {
lines, err := oscmd.RunLines("systemctl", "show",
"-p", "Id", "-p", "Description", "-p", "LoadState",
"-p", "ActiveState", "-p", "SubState", "-p", "UnitFileState",
"--", unit)
if err != nil {
return nil, err
}
return oscmd.ParseKV(lines), nil
}
// ensureExists returns a 404 if the unit is unknown, mapping the systemctl
// show probe to an HTTP error for the control endpoints.
func ensureExists(unit string) error {
m, err := showUnit(unit)
if err != nil {
return huma.Error500InternalServerError("systemctl show failed", err)
}
if m["LoadState"] == "not-found" {
return huma.Error404NotFound("unit not found: " + unit)
}
return nil
}
@@ -0,0 +1,161 @@
package services
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"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())
}
func TestServicesHandlers(t *testing.T) {
mux := http.NewServeMux()
api := humatest.Wrap(t, humago.New(mux, huma.DefaultConfig("Test", "1.0.0")))
// Set up allowlisted log files for testing the file source
logFiles := map[string][]string{
"nginx.service": {filepath.Join(t.TempDir(), "nginx-error.log")},
}
// Create the dummy log file
errLogPath := logFiles["nginx.service"][0]
if err := os.WriteFile(errLogPath, []byte("file log line 1\nfile log line 2\n"), 0644); err != nil {
t.Fatal(err)
}
m := New(logFiles)
m.Register(api)
// 1. Test GET /api/services (list services)
oscmd.SetMock("systemctl", func(args []string) oscmd.MockCommand {
if reflect.DeepEqual(args, []string{"list-units", "--type=service", "--all", "-o", "json", "--no-pager"}) {
units := []ServiceUnit{
{Unit: "sshd.service", Load: "loaded", Active: "active", Sub: "running", Description: "OpenSSH"},
}
data, _ := json.Marshal(units)
return oscmd.MockCommand{Stdout: string(data) + "\n", ExitCode: 0}
}
if reflect.DeepEqual(args, []string{"show", "-p", "Id", "-p", "Description", "-p", "LoadState", "-p", "ActiveState", "-p", "SubState", "-p", "UnitFileState", "--", "sshd.service"}) {
showOut := "Id=sshd.service\nDescription=OpenSSH\nLoadState=loaded\nActiveState=active\nSubState=running\nUnitFileState=enabled\n"
return oscmd.MockCommand{Stdout: showOut, ExitCode: 0}
}
if reflect.DeepEqual(args, []string{"start", "--", "sshd.service"}) {
return oscmd.MockCommand{ExitCode: 0}
}
return oscmd.MockCommand{ExitCode: 1}
})
defer oscmd.ClearMocks()
resp := api.Get("/api/services")
if resp.Code != http.StatusOK {
t.Errorf("list services: got %d, want %d", resp.Code, http.StatusOK)
}
var listRes ListServicesOutput
if err := json.Unmarshal(resp.Body.Bytes(), &listRes.Body); err != nil {
t.Fatal(err)
}
if len(listRes.Body.Services) != 1 || listRes.Body.Services[0].Unit != "sshd.service" {
t.Errorf("list services output: %+v", listRes.Body)
}
// 2. Test GET /api/services/{unit} (get service status)
resp = api.Get("/api/services/sshd.service")
if resp.Code != http.StatusOK {
t.Errorf("get service status: got %d, want %d", resp.Code, http.StatusOK)
}
var getRes GetServiceOutput
if err := json.Unmarshal(resp.Body.Bytes(), &getRes.Body); err != nil {
t.Fatal(err)
}
if getRes.Body.Unit != "sshd.service" || getRes.Body.ActiveState != "active" {
t.Errorf("get service output: %+v", getRes.Body)
}
// 3. Test POST /api/services/{unit}/start
resp = api.Post("/api/services/sshd.service/start", struct{}{})
if resp.Code != http.StatusOK {
t.Errorf("start service: got %d, want %d", resp.Code, http.StatusOK)
}
// 4. Test GET /api/services/{unit}/logs (journal source)
oscmd.SetMock("journalctl", func(args []string) oscmd.MockCommand {
if strings.Contains(strings.Join(args, " "), "-f") {
// Streaming mock
lines := []string{
`{"MESSAGE":"streaming line 1","PRIORITY":"6","__REALTIME_TIMESTAMP":"1718873704000000"}`,
}
return oscmd.MockCommand{Lines: lines, DelayMs: 1, ExitCode: 0}
}
// Regular snapshot mock
lines := []string{
`{"MESSAGE":"journal line 1","PRIORITY":"6","__REALTIME_TIMESTAMP":"1718873704000000"}`,
}
return oscmd.MockCommand{Stdout: strings.Join(lines, "\n") + "\n", ExitCode: 0}
})
resp = api.Get("/api/services/sshd.service/logs")
if resp.Code != http.StatusOK {
t.Errorf("get journal logs: got %d, want %d", resp.Code, http.StatusOK)
}
var logsRes LogsOutput
if err := json.Unmarshal(resp.Body.Bytes(), &logsRes.Body); err != nil {
t.Fatal(err)
}
if len(logsRes.Body.Entries) != 1 || logsRes.Body.Entries[0].Message != "journal line 1" {
t.Errorf("journal logs output: %+v", logsRes.Body)
}
// 5. Test GET /api/services/{unit}/logs (file source)
oscmd.SetMock("tail", func(args []string) oscmd.MockCommand {
if strings.Contains(strings.Join(args, " "), "-F") {
// Streaming mock
return oscmd.MockCommand{Lines: []string{"stream file line 1"}, DelayMs: 1, ExitCode: 0}
}
return oscmd.MockCommand{Stdout: "file log line 1\nfile log line 2\n", ExitCode: 0}
})
resp = api.Get("/api/services/nginx.service/logs?source=file&path=" + errLogPath)
if resp.Code != http.StatusOK {
t.Errorf("get file logs: got %d, want %d", resp.Code, http.StatusOK)
}
if err := json.Unmarshal(resp.Body.Bytes(), &logsRes.Body); err != nil {
t.Fatal(err)
}
if len(logsRes.Body.Entries) != 2 || logsRes.Body.Entries[0].Message != "file log line 1" {
t.Errorf("file logs output: %+v", logsRes.Body)
}
// 6. Test GET /api/services/{unit}/logs/stream (journal stream)
resp = api.Get("/api/services/sshd.service/logs/stream")
if resp.Code != http.StatusOK {
t.Errorf("stream journal logs: got %d, want %d", resp.Code, http.StatusOK)
}
bodyStr := resp.Body.String()
if !strings.Contains(bodyStr, "streaming line 1") {
t.Errorf("stream journal logs missing message, got: %q", bodyStr)
}
// 7. Test GET /api/services/{unit}/logs/stream (file stream)
resp = api.Get("/api/services/nginx.service/logs/stream?source=file&path=" + errLogPath)
if resp.Code != http.StatusOK {
t.Errorf("stream file logs: got %d, want %d", resp.Code, http.StatusOK)
}
bodyStr = resp.Body.String()
if !strings.Contains(bodyStr, "stream file line 1") {
t.Errorf("stream file logs missing message, got: %q", bodyStr)
}
}
@@ -0,0 +1,21 @@
package services
import "testing"
func TestValidateUnit(t *testing.T) {
valid := []string{"sshd.service", "getty@tty1.service", "foo.bar:baz-1.service", "a_b.timer"}
for _, u := range valid {
if err := validateUnit(u); err != nil {
t.Errorf("validateUnit(%q) = %v, want nil", u, err)
}
}
// Empty, flag-injection, and anything with shell/path metacharacters must
// be rejected before reaching systemctl.
invalid := []string{"", "-rf", "--now", "a b", "foo;rm -rf /", "a/b", "naughty$()", "x|y"}
for _, u := range invalid {
if err := validateUnit(u); err == nil {
t.Errorf("validateUnit(%q) = nil, want error", u)
}
}
}
+48
View File
@@ -0,0 +1,48 @@
package storage
import (
"regexp"
"strings"
"github.com/danielgtaylor/huma/v2"
)
const tagStorage = "Storage"
var (
readErrors = []int{401, 403, 500}
writeErrors = []int{400, 401, 403, 409, 500}
)
// Validation regexes. Every value is also passed after "--" on the command line.
// We forbid whitespace and shell metacharacters outright (mount/umount take no
// such values for our purposes, and fstab fields with spaces would need octal
// escaping we don't emit).
var (
// deviceRe allows a /dev path or a UUID=/LABEL= specifier.
deviceRe = regexp.MustCompile(`^(/dev/[a-zA-Z0-9/._-]+|UUID=[a-zA-Z0-9-]+|LABEL=[a-zA-Z0-9._-]+|PARTUUID=[a-zA-Z0-9-]+)$`)
// mountpointRe is an absolute path without traversal or whitespace.
mountpointRe = regexp.MustCompile(`^/[a-zA-Z0-9/._-]*$`)
// fstypeRe matches a filesystem type token (ext4, xfs, btrfs, vfat, nfs, …).
fstypeRe = regexp.MustCompile(`^[a-z0-9]+$`)
// optionsRe matches a comma-separated mount option list (defaults, noatime,
// uid=1000, …).
optionsRe = regexp.MustCompile(`^[a-zA-Z0-9=,._:+-]+$`)
)
func validateMountpoint(mp string) error {
if !mountpointRe.MatchString(mp) || hasDotDot(mp) {
return huma.Error400BadRequest("invalid mountpoint: " + mp)
}
return nil
}
// hasDotDot rejects a ".." path segment so a mountpoint can't escape upward.
func hasDotDot(p string) bool {
for _, seg := range strings.Split(p, "/") {
if seg == ".." {
return true
}
}
return false
}
+30
View File
@@ -0,0 +1,30 @@
package storage
import (
"nadir/internal/rbac"
"github.com/danielgtaylor/huma/v2"
)
const ModuleID = "storage"
type Module struct{}
func New() *Module { return &Module{} }
func (m *Module) ID() string { return ModuleID }
func (m *Module) Name() string { return "Storage" }
// Permissions: read to list mounts and fstab; write to mount/unmount and edit
// fstab entries.
func (m *Module) Permissions() []rbac.Permission {
return []rbac.Permission{rbac.Read, rbac.Write}
}
func (m *Module) Register(api huma.API) {
registerStorage(api)
}
func op(permission string) map[string]any {
return map[string]any{"module": ModuleID, "permission": permission}
}
+311
View File
@@ -0,0 +1,311 @@
package storage
import (
"context"
"fmt"
"os"
"strconv"
"strings"
"nadir/internal/mounts"
"nadir/internal/oscmd"
"github.com/danielgtaylor/huma/v2"
)
// fstabFile is a var so tests can point it at a fixture.
var fstabFile = "/etc/fstab"
// FstabEntry is one /etc/fstab line. Dump and Pass are the last two numeric
// fields (fs_freq and fs_passno).
type FstabEntry struct {
Device string `json:"device" example:"UUID=1234-5678"`
Mountpoint string `json:"mountpoint" example:"/mnt/data"`
FSType string `json:"fstype" example:"ext4"`
Options string `json:"options" example:"defaults"`
Dump int `json:"dump" example:"0"`
Pass int `json:"pass" example:"2" doc:"fsck order (0 = skip)"`
}
type ListMountsOutput struct {
Body struct {
Mounts []mounts.Mount `json:"mounts"`
}
}
type ListFstabOutput struct {
Body struct {
Entries []FstabEntry `json:"entries"`
}
}
type MountInput struct {
Body struct {
Device string `json:"device" example:"/dev/sdb1" doc:"Block device or UUID=/LABEL= specifier"`
Mountpoint string `json:"mountpoint" example:"/mnt/data" doc:"Absolute mount path"`
FSType string `json:"fstype" example:"ext4"`
Options string `json:"options,omitempty" example:"defaults" doc:"Mount options (default: defaults)"`
Dump int `json:"dump,omitempty"`
Pass int `json:"pass,omitempty" doc:"fsck order (default 2 for real filesystems, 0 to skip)"`
}
}
type UnmountInput struct {
Mountpoint string `query:"mountpoint" example:"/mnt/data" doc:"Mountpoint to unmount and remove from fstab"`
}
func registerStorage(api huma.API) {
huma.Register(api, huma.Operation{
OperationID: "storage-list-mounts",
Method: "GET",
Path: "/api/storage/mounts",
Summary: "List active mounts",
Description: "Returns the kernel mount table (/proc/mounts).",
Tags: []string{tagStorage},
Metadata: op("read"),
Errors: readErrors,
}, func(ctx context.Context, _ *struct{}) (*ListMountsOutput, error) {
entries, err := mounts.Proc()
if err != nil {
return nil, huma.Error500InternalServerError("read mounts failed", err)
}
res := &ListMountsOutput{}
res.Body.Mounts = entries
return res, nil
})
huma.Register(api, huma.Operation{
OperationID: "storage-list-fstab",
Method: "GET",
Path: "/api/storage/fstab",
Summary: "List /etc/fstab entries",
Description: "Returns the persistent mount definitions from /etc/fstab.",
Tags: []string{tagStorage},
Metadata: op("read"),
Errors: readErrors,
}, func(ctx context.Context, _ *struct{}) (*ListFstabOutput, error) {
data, err := os.ReadFile(fstabFile)
if err != nil {
return nil, huma.Error500InternalServerError("read fstab failed", err)
}
res := &ListFstabOutput{}
res.Body.Entries = parseFstab(string(data))
return res, nil
})
huma.Register(api, huma.Operation{
OperationID: "storage-add-mount",
Method: "POST",
Path: "/api/storage/mounts",
Summary: "Add and mount a filesystem",
Description: "Appends an /etc/fstab entry and mounts it. If the mount fails the " +
"fstab entry is rolled back, so a bad request leaves the system unchanged.",
Tags: []string{tagStorage},
Metadata: op("write"),
Errors: writeErrors,
}, func(ctx context.Context, in *MountInput) (*oscmd.StatusOutput, error) {
e := FstabEntry{
Device: in.Body.Device,
Mountpoint: in.Body.Mountpoint,
FSType: in.Body.FSType,
Options: in.Body.Options,
Dump: in.Body.Dump,
Pass: in.Body.Pass,
}
if e.Options == "" {
e.Options = "defaults"
}
if err := validateEntry(e); err != nil {
return nil, err
}
existing, err := readFstab()
if err != nil {
return nil, huma.Error500InternalServerError("read fstab failed", err)
}
if findEntry(existing, e.Mountpoint) != nil {
return nil, huma.Error409Conflict("an fstab entry already exists for " + e.Mountpoint)
}
if err := appendFstabLine(e); err != nil {
return nil, huma.Error500InternalServerError("write fstab failed", err)
}
// mount reads the fstab line we just wrote. On failure, roll the line back
// so a bad device/options doesn't linger in fstab and break the next boot.
if _, err := oscmd.RunContext(ctx, "mount", "--", e.Mountpoint); err != nil {
if _, werr := removeFstabLines(e.Mountpoint); werr != nil {
return nil, huma.Error500InternalServerError("mount failed and fstab rollback failed", werr)
}
return nil, huma.Error400BadRequest("mount failed: " + err.Error())
}
return oscmd.OK(), nil
})
huma.Register(api, huma.Operation{
OperationID: "storage-remove-mount",
Method: "DELETE",
Path: "/api/storage/mounts",
Summary: "Unmount and remove a filesystem",
Description: "Unmounts the filesystem (if mounted) and removes its /etc/fstab entry. " +
"The mountpoint is passed as a query parameter since it contains slashes.",
Tags: []string{tagStorage},
Metadata: op("write"),
Errors: []int{400, 401, 403, 404, 409, 500},
}, func(ctx context.Context, in *UnmountInput) (*oscmd.StatusOutput, error) {
if err := validateMountpoint(in.Mountpoint); err != nil {
return nil, err
}
entries, err := readFstab()
if err != nil {
return nil, huma.Error500InternalServerError("read fstab failed", err)
}
inFstab := findEntry(entries, in.Mountpoint) != nil
mounted, err := isMounted(in.Mountpoint)
if err != nil {
return nil, huma.Error500InternalServerError("read mounts failed", err)
}
if !inFstab && !mounted {
return nil, huma.Error404NotFound("no mount or fstab entry for " + in.Mountpoint)
}
if mounted {
if _, err := oscmd.RunContext(ctx, "umount", "--", in.Mountpoint); err != nil {
return nil, huma.Error409Conflict("unmount failed (in use?): " + err.Error())
}
}
if inFstab {
if _, err := removeFstabLines(in.Mountpoint); err != nil {
return nil, huma.Error500InternalServerError("write fstab failed", err)
}
}
return oscmd.OK(), nil
})
}
// --- fstab helpers -----------------------------------------------------------
func readFstab() ([]FstabEntry, error) {
data, err := os.ReadFile(fstabFile)
if err != nil {
return nil, err
}
return parseFstab(string(data)), nil
}
// parseFstab parses /etc/fstab, skipping comments and blank lines.
func parseFstab(data string) []FstabEntry {
entries := []FstabEntry{}
for line := range strings.SplitSeq(data, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
f := strings.Fields(trimmed)
if len(f) < 4 { // device mountpoint fstype options [dump] [pass]
continue
}
e := FstabEntry{
Device: mounts.Unescape(f[0]),
Mountpoint: mounts.Unescape(f[1]),
FSType: f[2],
Options: f[3],
}
if len(f) >= 5 {
e.Dump, _ = strconv.Atoi(f[4])
}
if len(f) >= 6 {
e.Pass, _ = strconv.Atoi(f[5])
}
entries = append(entries, e)
}
return entries
}
// fstab edits are surgical (append one line / drop matching lines) rather than a
// whole-file rewrite, so an admin's existing entries, comments and formatting in
// this boot-critical file are preserved — same approach as /etc/hosts.
func renderFstabLine(e FstabEntry) string {
return fmt.Sprintf("%s\t%s\t%s\t%s\t%d\t%d", e.Device, e.Mountpoint, e.FSType, e.Options, e.Dump, e.Pass)
}
// appendFstabLine adds one entry, leaving every existing line untouched.
func appendFstabLine(e FstabEntry) error {
data, err := os.ReadFile(fstabFile)
if err != nil {
return err
}
content := string(data)
if content != "" && !strings.HasSuffix(content, "\n") {
content += "\n"
}
content += renderFstabLine(e) + "\n"
return os.WriteFile(fstabFile, []byte(content), 0644)
}
// removeFstabLines drops every line mapping mountpoint, preserving comments and
// other entries. Reports whether anything was removed.
func removeFstabLines(mountpoint string) (bool, error) {
data, err := os.ReadFile(fstabFile)
if err != nil {
return false, err
}
lines := strings.Split(string(data), "\n")
kept := make([]string, 0, len(lines))
removed := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed != "" && !strings.HasPrefix(trimmed, "#") {
if f := strings.Fields(trimmed); len(f) >= 2 && mounts.Unescape(f[1]) == mountpoint {
removed = true
continue
}
}
kept = append(kept, line)
}
if !removed {
return false, nil
}
return true, os.WriteFile(fstabFile, []byte(strings.Join(kept, "\n")), 0644)
}
func findEntry(entries []FstabEntry, mountpoint string) *FstabEntry {
for i := range entries {
if entries[i].Mountpoint == mountpoint {
return &entries[i]
}
}
return nil
}
// isMounted reports whether mountpoint currently appears in /proc/mounts.
func isMounted(mountpoint string) (bool, error) {
active, err := mounts.Proc()
if err != nil {
return false, err
}
for _, e := range active {
if e.Mountpoint == mountpoint {
return true, nil
}
}
return false, nil
}
func validateEntry(e FstabEntry) error {
if !deviceRe.MatchString(e.Device) {
return huma.Error400BadRequest("invalid device: " + e.Device)
}
if err := validateMountpoint(e.Mountpoint); err != nil {
return err
}
if !fstypeRe.MatchString(e.FSType) {
return huma.Error400BadRequest("invalid fstype: " + e.FSType)
}
if !optionsRe.MatchString(e.Options) {
return huma.Error400BadRequest("invalid options: " + e.Options)
}
return nil
}
@@ -0,0 +1,83 @@
package storage
import (
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"nadir/internal/oscmd"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humago"
"github.com/danielgtaylor/huma/v2/humatest"
)
func TestStorageHandlers(t *testing.T) {
path := filepath.Join(t.TempDir(), "fstab")
if err := os.WriteFile(path, []byte("# seed\nUUID=root / ext4 defaults 0 1\n"), 0644); err != nil {
t.Fatal(err)
}
old := fstabFile
fstabFile = path
defer func() { fstabFile = old }()
mux := http.NewServeMux()
api := humatest.Wrap(t, humago.New(mux, huma.DefaultConfig("Test", "1.0.0")))
New().Register(api)
mountOK := true
oscmd.SetMock("mount", func([]string) oscmd.MockCommand {
if mountOK {
return oscmd.MockCommand{ExitCode: 0}
}
return oscmd.MockCommand{Stderr: "mount: wrong fs type", ExitCode: 32}
})
oscmd.SetMock("umount", func([]string) oscmd.MockCommand { return oscmd.MockCommand{ExitCode: 0} })
defer oscmd.ClearMocks()
body := map[string]any{"device": "/dev/sdb1", "mountpoint": "/mnt/data", "fstype": "ext4"}
// Add + mount succeeds.
if resp := api.Post("/api/storage/mounts", body); resp.Code != http.StatusOK {
t.Fatalf("add mount: got %d, body=%s", resp.Code, resp.Body.String())
}
if findEntry(mustReadFstab(t), "/mnt/data") == nil {
t.Fatal("entry not written to fstab")
}
// Duplicate mountpoint → 409.
if resp := api.Post("/api/storage/mounts", body); resp.Code != http.StatusConflict {
t.Errorf("duplicate: got %d, want 409", resp.Code)
}
// mount fails → 400 and the fstab line is rolled back.
mountOK = false
bad := map[string]any{"device": "/dev/sdc1", "mountpoint": "/mnt/bad", "fstype": "ext4"}
if resp := api.Post("/api/storage/mounts", bad); resp.Code != http.StatusBadRequest {
t.Errorf("failed mount: got %d, want 400", resp.Code)
}
if findEntry(mustReadFstab(t), "/mnt/bad") != nil {
t.Error("fstab entry not rolled back after mount failure")
}
// Invalid device is rejected before touching the system.
if resp := api.Post("/api/storage/mounts", map[string]any{"device": "/dev/x; rm", "mountpoint": "/mnt/x", "fstype": "ext4"}); resp.Code != http.StatusBadRequest {
t.Errorf("invalid device: got %d, want 400", resp.Code)
}
// Delete the good entry (not actually mounted, so no umount needed).
if resp := api.Delete("/api/storage/mounts?mountpoint=/mnt/data"); resp.Code != http.StatusOK {
t.Errorf("delete: got %d, body=%s", resp.Code, resp.Body.String())
}
data, _ := os.ReadFile(path)
if strings.Contains(string(data), "/mnt/data") || !strings.Contains(string(data), "# seed") {
t.Errorf("delete result wrong:\n%s", data)
}
// Deleting a nonexistent mountpoint → 404.
if resp := api.Delete("/api/storage/mounts?mountpoint=/mnt/none"); resp.Code != http.StatusNotFound {
t.Errorf("delete missing: got %d, want 404", resp.Code)
}
}
+110
View File
@@ -0,0 +1,110 @@
package storage
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"nadir/internal/oscmd"
)
func TestMain(m *testing.M) {
if oscmd.RunHelperProcess() {
return
}
os.Exit(m.Run())
}
func TestParseFstab(t *testing.T) {
data := `# /etc/fstab
UUID=1111-2222 / ext4 defaults 0 1
/dev/sdb1 /mnt/data ext4 rw,noatime 0 2
LABEL=swap none swap sw
`
got := parseFstab(data)
want := []FstabEntry{
{Device: "UUID=1111-2222", Mountpoint: "/", FSType: "ext4", Options: "defaults", Dump: 0, Pass: 1},
{Device: "/dev/sdb1", Mountpoint: "/mnt/data", FSType: "ext4", Options: "rw,noatime", Dump: 0, Pass: 2},
{Device: "LABEL=swap", Mountpoint: "none", FSType: "swap", Options: "sw", Dump: 0, Pass: 0},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("parseFstab:\n got %+v\nwant %+v", got, want)
}
}
func TestFstabSurgicalEdits(t *testing.T) {
path := filepath.Join(t.TempDir(), "fstab")
old := fstabFile
fstabFile = path
defer func() { fstabFile = old }()
// Seed with a comment + an existing entry that must survive edits.
seed := "# keep me\nUUID=root / ext4 defaults 0 1\n"
if err := os.WriteFile(path, []byte(seed), 0644); err != nil {
t.Fatal(err)
}
// Append a new entry.
if err := appendFstabLine(FstabEntry{Device: "/dev/sdb1", Mountpoint: "/mnt/data", FSType: "xfs", Options: "noatime", Pass: 2}); err != nil {
t.Fatal(err)
}
entries, err := readFstab()
if err != nil {
t.Fatal(err)
}
if len(entries) != 2 || findEntry(entries, "/mnt/data") == nil {
t.Fatalf("append failed: %+v", entries)
}
// Remove it again; the comment and original entry must remain.
removed, err := removeFstabLines("/mnt/data")
if err != nil || !removed {
t.Fatalf("remove failed: removed=%v err=%v", removed, err)
}
data, _ := os.ReadFile(path)
if !strings.Contains(string(data), "# keep me") || !strings.Contains(string(data), "UUID=root") {
t.Errorf("surgical edit clobbered other lines:\n%s", data)
}
if findEntry(mustReadFstab(t), "/mnt/data") != nil {
t.Error("entry still present after remove")
}
// Removing a missing mountpoint reports not-removed.
if removed, _ := removeFstabLines("/nope"); removed {
t.Error("expected removed=false for missing mountpoint")
}
}
func mustReadFstab(t *testing.T) []FstabEntry {
t.Helper()
e, err := readFstab()
if err != nil {
t.Fatal(err)
}
return e
}
func TestValidateEntry(t *testing.T) {
ok := FstabEntry{Device: "/dev/sdb1", Mountpoint: "/mnt/data", FSType: "ext4", Options: "defaults"}
if err := validateEntry(ok); err != nil {
t.Errorf("valid entry rejected: %v", err)
}
if err := validateEntry(FstabEntry{Device: "UUID=ab-cd", Mountpoint: "/mnt/x", FSType: "xfs", Options: "rw,noatime"}); err != nil {
t.Errorf("UUID entry rejected: %v", err)
}
bad := []FstabEntry{
{Device: "/dev/sdb1; rm -rf /", Mountpoint: "/mnt/x", FSType: "ext4", Options: "defaults"}, // shell metachars
{Device: "/dev/sdb1", Mountpoint: "../etc", FSType: "ext4", Options: "defaults"}, // not absolute
{Device: "/dev/sdb1", Mountpoint: "/mnt/../../etc", FSType: "ext4", Options: "defaults"}, // traversal
{Device: "/dev/sdb1", Mountpoint: "/mnt/x", FSType: "ext4!", Options: "defaults"}, // bad fstype
{Device: "/dev/sdb1", Mountpoint: "/mnt/x", FSType: "ext4", Options: "defaults; reboot"}, // bad options
}
for i, e := range bad {
if err := validateEntry(e); err == nil {
t.Errorf("bad entry %d accepted: %+v", i, e)
}
}
}
+10
View File
@@ -0,0 +1,10 @@
package system
// tagSystem groups every system-module operation (info, hostname, time/date,
// locale, power) under one OpenAPI tag, so tags map 1:1 to modules.
const tagSystem = "System"
var (
readErrors = []int{401, 403, 500}
writeErrors = []int{400, 401, 403, 500}
)
+57
View File
@@ -0,0 +1,57 @@
package system
import (
"context"
"strings"
"nadir/internal/oscmd"
"github.com/danielgtaylor/huma/v2"
)
type HostnameBody struct {
Hostname string `json:"hostname" example:"server01" doc:"System hostname"`
}
type GetHostnameOutput struct{ Body HostnameBody }
type SetHostnameInput struct{ Body HostnameBody }
func registerHostname(api huma.API) {
huma.Register(api, huma.Operation{
OperationID: "system-get-hostname",
Method: "GET",
Path: "/api/system/hostname",
Summary: "Get system hostname",
Description: "Returns the current hostname as reported by hostnamectl.",
Tags: []string{tagSystem},
Metadata: op("read"),
Errors: readErrors,
}, func(ctx context.Context, _ *struct{}) (*GetHostnameOutput, error) {
name, err := oscmd.Run("hostnamectl", "hostname")
if err != nil {
return nil, huma.Error500InternalServerError("hostnamectl failed", err)
}
return &GetHostnameOutput{Body: HostnameBody{Hostname: name}}, nil
})
huma.Register(api, huma.Operation{
OperationID: "system-set-hostname",
Method: "POST",
Path: "/api/system/hostname",
Summary: "Set system hostname",
Description: "Sets the static hostname via hostnamectl, which owns " +
"/etc/hostname and manages the static/pretty/transient names.",
Tags: []string{tagSystem},
Metadata: op("write"),
Errors: writeErrors,
}, func(ctx context.Context, in *SetHostnameInput) (*oscmd.StatusOutput, error) {
name := strings.TrimSpace(in.Body.Hostname)
if name == "" {
return nil, huma.Error400BadRequest("empty hostname")
}
if _, err := oscmd.Run("hostnamectl", "set-hostname", name); err != nil {
return nil, huma.Error500InternalServerError("hostnamectl failed", err)
}
return oscmd.OK(), nil
})
}
+544
View File
@@ -0,0 +1,544 @@
package system
import (
"context"
"math"
"net"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"syscall"
"time"
"nadir/internal/mounts"
"nadir/internal/oscmd"
"github.com/danielgtaylor/huma/v2"
)
// SystemInfoBody is the dashboard overview: OS identity plus live CPU, memory,
// disk, load, network, and temperature readings. Every section is best-effort —
// a source that's unavailable (e.g. no thermal zones in a VM) yields a zero
// value or empty list rather than failing the whole call.
type SystemInfoBody struct {
OS OSInfo `json:"os" doc:"OS and kernel identity"`
CPU CPUInfo `json:"cpu" doc:"Processor model and core count"`
Memory MemoryInfo `json:"memory" doc:"RAM and swap usage in bytes"`
Load LoadInfo `json:"load" doc:"Load averages (1/5/15 min)"`
UptimeSec int64 `json:"uptime_seconds" example:"12490" doc:"Seconds since boot"`
BootTime string `json:"boot_time" example:"2026-06-19T12:08:00Z" doc:"Boot time (RFC3339, UTC)"`
Disks []DiskInfo `json:"disks" doc:"Mounted block-device filesystems"`
NetworkInterfaces []NetInterface `json:"network_interfaces" doc:"Network interfaces and their addresses"`
Temperatures []Temperature `json:"temperatures" doc:"Thermal sensor readings in Celsius"`
}
type OSInfo struct {
PrettyName string `json:"pretty_name" example:"Fedora Linux 44 (Workstation Edition)" doc:"Distro name from /etc/os-release PRETTY_NAME"`
Kernel string `json:"kernel" example:"7.0.12-201.fc44.x86_64" doc:"Running kernel release (uname -r)"`
Architecture string `json:"architecture" example:"x86_64" doc:"Machine hardware architecture (uname -m)"`
Hostname string `json:"hostname" example:"server01" doc:"System hostname"`
}
type CPUInfo struct {
Model string `json:"model" example:"AMD Ryzen 7 7840U" doc:"CPU model name"`
LogicalCPUs int `json:"logical_cpus" example:"16" doc:"Number of logical CPUs (cores × threads)"`
MinMHz int `json:"min_mhz" example:"400" doc:"Lowest frequency the scaling governor can select"`
MaxMHz int `json:"max_mhz" example:"5137" doc:"Highest frequency (boost ceiling)"`
CurrentMHz int `json:"current_mhz" example:"3157" doc:"Peak current clock across all cores (instantaneous snapshot; 0 if cpufreq unavailable)"`
}
type MemoryInfo struct {
TotalBytes uint64 `json:"total_bytes" example:"16384000000"`
AvailableBytes uint64 `json:"available_bytes" example:"8192000000" doc:"Memory available for new allocations without swapping"`
UsedBytes uint64 `json:"used_bytes" example:"8192000000" doc:"total - available"`
SwapTotalBytes uint64 `json:"swap_total_bytes" example:"8589934592"`
SwapFreeBytes uint64 `json:"swap_free_bytes" example:"8589934592"`
}
type LoadInfo struct {
Load1 float64 `json:"load1" example:"0.42"`
Load5 float64 `json:"load5" example:"0.55"`
Load15 float64 `json:"load15" example:"0.61"`
CPUUsage []CoreUsage `json:"cpu_usage" doc:"Per-core CPU usage percentage (sampled over ~1 s); empty until the first sample completes"`
}
// CoreUsage holds the usage percentage for a single logical core, computed as
// the delta of non-idle ticks over total ticks between two /proc/stat reads.
type CoreUsage struct {
Core int `json:"core" example:"0" doc:"Logical core index"`
UsagePct float64 `json:"usage_pct" example:"23.4" doc:"Usage percentage (0100)"`
}
type DiskInfo struct {
Mountpoint string `json:"mountpoint" example:"/"`
Filesystem string `json:"filesystem" example:"/dev/nvme0n1p2" doc:"Backing device"`
FSType string `json:"fstype" example:"btrfs"`
TotalBytes uint64 `json:"total_bytes" example:"512000000000"`
FreeBytes uint64 `json:"free_bytes" example:"256000000000" doc:"Space available to unprivileged users"`
UsedBytes uint64 `json:"used_bytes" example:"256000000000"`
}
type NetInterface struct {
Name string `json:"name" example:"eth0"`
MAC string `json:"mac" example:"aa:bb:cc:dd:ee:ff"`
Up bool `json:"up" doc:"Interface is administratively up"`
Addresses []string `json:"addresses" doc:"Assigned addresses in CIDR notation"`
}
type Temperature struct {
Chip string `json:"chip" example:"k10temp" doc:"hwmon chip name; identifies the source (k10temp/coretemp=CPU, amdgpu/nvidia=GPU, nvme=disk)"`
Label string `json:"label" example:"Tctl" doc:"Per-sensor label, or the chip name when the sensor is unlabelled"`
Celsius float64 `json:"celsius" example:"47.5"`
}
type GetInfoOutput struct{ Body SystemInfoBody }
func registerInfo(api huma.API) {
startCPUSampler()
huma.Register(api, huma.Operation{
OperationID: "system-get-info",
Method: "GET",
Path: "/api/system/info",
Summary: "Get system information",
Description: "Returns an overview for a dashboard: OS/kernel identity, CPU, " +
"memory and swap, mounted disks, load averages, uptime, network " +
"interfaces, and temperatures. All values come from cheap local reads " +
"(/proc, /sys, syscalls) with no D-Bus dependency; each section is " +
"best-effort.",
Tags: []string{tagSystem},
Metadata: op("read"),
Errors: readErrors,
}, func(ctx context.Context, _ *struct{}) (*GetInfoOutput, error) {
uptime, boot := uptimeAndBoot()
return &GetInfoOutput{Body: SystemInfoBody{
OS: osInfo(),
CPU: cpuInfo(),
Memory: memInfo(),
Load: loadInfo(),
UptimeSec: uptime,
BootTime: boot.Format(time.RFC3339),
Disks: diskInfo(),
NetworkInterfaces: netInfo(),
Temperatures: tempInfo(),
}}, nil
})
}
func osInfo() OSInfo {
host, _ := os.Hostname()
return OSInfo{
PrettyName: osReleasePretty(),
Kernel: firstLine(oscmd.Run("uname", "-r")),
Architecture: firstLine(oscmd.Run("uname", "-m")),
Hostname: host,
}
}
// firstLine discards a command error and returns its (already trimmed) output,
// used where a missing value is acceptable.
func firstLine(out string, _ error) string { return out }
func osReleasePretty() string {
data, err := os.ReadFile("/etc/os-release")
if err != nil {
return ""
}
for line := range strings.SplitSeq(string(data), "\n") {
if v, ok := strings.CutPrefix(line, "PRETTY_NAME="); ok {
return strings.Trim(v, `"`)
}
}
return ""
}
func cpuInfo() CPUInfo {
data, _ := os.ReadFile("/proc/cpuinfo")
c := CPUInfo{Model: cpuModel(string(data)), LogicalCPUs: runtime.NumCPU()}
c.MinMHz, c.MaxMHz, c.CurrentMHz = cpuFreqMHz("/sys/devices/system/cpu")
// ponytail: cpufreq sysfs is absent on many VMs and stock Ubuntu server
// kernels; fall back to /proc/cpuinfo "cpu MHz" so CurrentMHz isn't 0.
if c.CurrentMHz == 0 {
c.CurrentMHz = cpuinfoMaxMHz(string(data))
}
return c
}
// cpuinfoMaxMHz returns the highest "cpu MHz" value across all cores in
// /proc/cpuinfo, rounded to an int. Returns 0 when no such line exists.
func cpuinfoMaxMHz(cpuinfo string) int {
var max float64
for line := range strings.SplitSeq(cpuinfo, "\n") {
k, v, ok := strings.Cut(line, ":")
if !ok || strings.TrimSpace(k) != "cpu MHz" {
continue
}
if f, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil && f > max {
max = f
}
}
return int(math.Round(max))
}
// cpuFreqMHz reads cpufreq sysfs: min/max are stable hardware limits (from
// cpu0); current is the highest scaling_cur_freq across all cores — the "is it
// boosting" figure. Values are kHz in sysfs. Returns zeros when cpufreq is
// absent (e.g. some VMs).
func cpuFreqMHz(root string) (min, max, cur int) {
min = readKHzAsMHz(filepath.Join(root, "cpu0/cpufreq/cpuinfo_min_freq"))
max = readKHzAsMHz(filepath.Join(root, "cpu0/cpufreq/cpuinfo_max_freq"))
cores, _ := filepath.Glob(filepath.Join(root, "cpu[0-9]*/cpufreq/scaling_cur_freq"))
for _, f := range cores {
if v := readKHzAsMHz(f); v > cur {
cur = v
}
}
return min, max, cur
}
func readKHzAsMHz(path string) int {
khz, err := strconv.Atoi(readTrim(path))
if err != nil {
return 0
}
return khz / 1000
}
// cpuModel extracts the processor model from /proc/cpuinfo. x86 uses "model
// name"; many ARM boards use "Model" instead, so fall back to it.
func cpuModel(cpuinfo string) string {
var fallback string
for line := range strings.SplitSeq(cpuinfo, "\n") {
k, v, ok := strings.Cut(line, ":")
if !ok {
continue
}
switch strings.TrimSpace(k) {
case "model name":
return strings.TrimSpace(v)
case "Model":
fallback = strings.TrimSpace(v)
}
}
return fallback
}
func memInfo() MemoryInfo {
data, _ := os.ReadFile("/proc/meminfo")
return parseMeminfo(data)
}
// parseMeminfo reads the kB values in /proc/meminfo and converts them to bytes.
func parseMeminfo(data []byte) MemoryInfo {
kv := map[string]uint64{}
for line := range strings.SplitSeq(string(data), "\n") {
k, v, ok := strings.Cut(line, ":")
if !ok {
continue
}
fields := strings.Fields(v) // e.g. "16384000 kB"
if len(fields) == 0 {
continue
}
if n, err := strconv.ParseUint(fields[0], 10, 64); err == nil {
kv[k] = n * 1024 // values are in kB
}
}
return MemoryInfo{
TotalBytes: kv["MemTotal"],
AvailableBytes: kv["MemAvailable"],
UsedBytes: kv["MemTotal"] - kv["MemAvailable"],
SwapTotalBytes: kv["SwapTotal"],
SwapFreeBytes: kv["SwapFree"],
}
}
func loadInfo() LoadInfo {
data, _ := os.ReadFile("/proc/loadavg")
l := parseLoadavg(string(data))
l.CPUUsage = cachedCPUUsage()
return l
}
// parseLoadavg reads the three load averages from /proc/loadavg.
func parseLoadavg(loadavg string) LoadInfo {
f := strings.Fields(loadavg)
if len(f) < 3 {
return LoadInfo{}
}
at := func(i int) float64 { v, _ := strconv.ParseFloat(f[i], 64); return v }
return LoadInfo{Load1: at(0), Load5: at(1), Load15: at(2)}
}
// ---------------------------------------------------------------------------
// Per-core CPU usage sampler
// ---------------------------------------------------------------------------
//
// /proc/stat exposes cumulative jiffies per core:
//
// cpuN user nice system idle iowait irq softirq steal guest guest_nice
//
// We sample every second, compute the delta, and derive:
//
// usage% = (totalΔ idleΔ) / totalΔ × 100
//
// The result is cached behind a RWMutex so the HTTP handler never blocks.
var (
usageMu sync.RWMutex
usageCache []CoreUsage
)
func cachedCPUUsage() []CoreUsage {
usageMu.RLock()
defer usageMu.RUnlock()
// Return a copy so callers can't mutate the cache.
if usageCache == nil {
return nil
}
out := make([]CoreUsage, len(usageCache))
copy(out, usageCache)
return out
}
// startCPUSampler launches a goroutine that samples /proc/stat once per second
// for the lifetime of the process. Safe to call multiple times (only the first
// call starts the goroutine).
var samplerOnce sync.Once
func startCPUSampler() {
samplerOnce.Do(func() {
go cpuSamplerLoop("/proc/stat", 1*time.Second)
})
}
func cpuSamplerLoop(statPath string, interval time.Duration) {
prev := readProcStat(statPath)
for {
time.Sleep(interval)
cur := readProcStat(statPath)
usage := computeUsage(prev, cur)
usageMu.Lock()
usageCache = usage
usageMu.Unlock()
prev = cur
}
}
// cpuCoreTicks holds the cumulative jiffies for one "cpuN" line.
type cpuCoreTicks struct {
core int
total uint64
idle uint64
}
// readProcStat reads /proc/stat and returns per-core tick totals. The
// aggregate "cpu" line (no digit suffix) is skipped.
func readProcStat(path string) []cpuCoreTicks {
data, _ := os.ReadFile(path)
var cores []cpuCoreTicks
for line := range strings.SplitSeq(string(data), "\n") {
if !strings.HasPrefix(line, "cpu") {
continue
}
fields := strings.Fields(line)
if len(fields) < 5 {
continue
}
// Skip the aggregate "cpu" line; we only want "cpu0", "cpu1", …
name := fields[0]
if name == "cpu" {
continue
}
coreIdx, err := strconv.Atoi(strings.TrimPrefix(name, "cpu"))
if err != nil {
continue
}
// Fields: user(1) nice(2) system(3) idle(4) iowait(5) irq(6) softirq(7) steal(8) …
var total, idle uint64
for _, f := range fields[1:] {
v, _ := strconv.ParseUint(f, 10, 64)
total += v
}
// idle = idle + iowait (indices 4 and 5 in the original line).
if len(fields) > 5 {
v4, _ := strconv.ParseUint(fields[4], 10, 64)
v5, _ := strconv.ParseUint(fields[5], 10, 64)
idle = v4 + v5
} else {
v4, _ := strconv.ParseUint(fields[4], 10, 64)
idle = v4
}
cores = append(cores, cpuCoreTicks{core: coreIdx, total: total, idle: idle})
}
return cores
}
func computeUsage(prev, cur []cpuCoreTicks) []CoreUsage {
prevMap := make(map[int]cpuCoreTicks, len(prev))
for _, c := range prev {
prevMap[c.core] = c
}
usage := make([]CoreUsage, 0, len(cur))
for _, c := range cur {
p, ok := prevMap[c.core]
if !ok {
continue
}
dTotal := c.total - p.total
dIdle := c.idle - p.idle
var pct float64
if dTotal > 0 {
pct = float64(dTotal-dIdle) / float64(dTotal) * 100
// Round to one decimal.
pct = math.Round(pct*10) / 10
}
usage = append(usage, CoreUsage{Core: c.core, UsagePct: pct})
}
return usage
}
// uptimeAndBoot reads /proc/uptime (seconds since boot) and derives boot time.
// On any read error it returns zero values rather than failing the request.
func uptimeAndBoot() (int64, time.Time) {
data, err := os.ReadFile("/proc/uptime")
if err != nil {
return 0, time.Time{}
}
fields := strings.Fields(string(data))
if len(fields) == 0 {
return 0, time.Time{}
}
secs, err := strconv.ParseFloat(fields[0], 64)
if err != nil {
return 0, time.Time{}
}
boot := time.Now().Add(-time.Duration(secs * float64(time.Second))).UTC()
return int64(secs), boot
}
func diskInfo() []DiskInfo {
entries, err := mounts.Proc()
if err != nil {
return nil
}
disks := []DiskInfo{}
seen := map[string]bool{}
for _, e := range entries {
// Only real block devices; skip pseudo filesystems and snap's squashfs
// loop mounts that would otherwise clutter the list.
if !strings.HasPrefix(e.Device, "/dev/") || e.FSType == "squashfs" || seen[e.Mountpoint] {
continue
}
var st syscall.Statfs_t
if syscall.Statfs(e.Mountpoint, &st) != nil || st.Blocks == 0 {
continue
}
seen[e.Mountpoint] = true
bs := uint64(st.Bsize)
disks = append(disks, DiskInfo{
Mountpoint: e.Mountpoint,
Filesystem: e.Device,
FSType: e.FSType,
TotalBytes: st.Blocks * bs,
FreeBytes: st.Bavail * bs,
UsedBytes: (st.Blocks - st.Bfree) * bs,
})
}
return disks
}
func netInfo() []NetInterface {
ifaces, err := net.Interfaces()
if err != nil {
return nil
}
out := []NetInterface{}
for _, ifi := range ifaces {
addrs, _ := ifi.Addrs()
strs := []string{}
for _, a := range addrs {
strs = append(strs, a.String())
}
out = append(out, NetInterface{
Name: ifi.Name,
MAC: ifi.HardwareAddr.String(),
Up: ifi.Flags&net.FlagUp != 0,
Addresses: strs,
})
}
return out
}
func tempInfo() []Temperature {
if t := readHwmonTemps("/sys/class/hwmon"); len(t) > 0 {
return t
}
// ponytail: stock Ubuntu server has no coretemp/k10temp loaded, so hwmon
// is empty; thermal_zone exposes ACPI sensors (coarser, no chip name).
return readThermalZones("/sys/class/thermal")
}
// readThermalZones reads /sys/class/thermal/thermal_zone*/temp as a fallback
// for hosts without hwmon chip drivers. "type" names the zone (e.g. acpitz,
// x86_pkg_temp); used as both chip and label.
func readThermalZones(root string) []Temperature {
zones, _ := filepath.Glob(filepath.Join(root, "thermal_zone*"))
temps := []Temperature{}
for _, dir := range zones {
milli, err := strconv.Atoi(readTrim(filepath.Join(dir, "temp")))
if err != nil {
continue
}
c := float64(milli) / 1000
if c <= 0 || c >= 150 {
continue
}
name := readTrim(filepath.Join(dir, "type"))
if name == "" {
name = filepath.Base(dir)
}
temps = append(temps, Temperature{Chip: name, Label: name, Celsius: c})
}
return temps
}
// readHwmonTemps walks /sys/class/hwmon, which (unlike /sys/class/thermal, that
// only exposes generic ACPI zones like "acpitz") names each chip — so callers
// can split CPU (k10temp/coretemp) from GPU (amdgpu/nvidia) from disk (nvme).
// Each tempN_input may carry a tempN_label; when absent we fall back to the
// chip name. Best-effort: unreadable, empty, or implausible sensors are skipped.
func readHwmonTemps(root string) []Temperature {
chips, _ := filepath.Glob(filepath.Join(root, "hwmon*"))
temps := []Temperature{}
for _, dir := range chips {
chip := readTrim(filepath.Join(dir, "name"))
inputs, _ := filepath.Glob(filepath.Join(dir, "temp*_input"))
for _, in := range inputs {
milli, err := strconv.Atoi(readTrim(in))
if err != nil {
continue
}
c := float64(milli) / 1000
// Disabled/placeholder sensors report absurd values (e.g. -0.15 or
// 179.8 °C). Drop anything outside a plausible band.
if c <= 0 || c >= 150 {
continue
}
label := readTrim(strings.TrimSuffix(in, "_input") + "_label")
if label == "" {
label = chip
}
temps = append(temps, Temperature{Chip: chip, Label: label, Celsius: c})
}
}
return temps
}
// readTrim reads a sysfs file and trims it; a missing file yields "".
func readTrim(path string) string {
b, _ := os.ReadFile(path)
return strings.TrimSpace(string(b))
}
+177
View File
@@ -0,0 +1,177 @@
package system
import (
"os"
"path/filepath"
"testing"
)
func TestReadHwmonTemps(t *testing.T) {
root := t.TempDir()
// k10temp: CPU, labelled Tctl.
write(t, root, "hwmon0", "name", "k10temp")
write(t, root, "hwmon0", "temp1_input", "62125")
write(t, root, "hwmon0", "temp1_label", "Tctl")
// amdgpu: GPU, no label -> falls back to chip name. Also a sensor reading
// an implausible value that must be dropped.
write(t, root, "hwmon1", "name", "amdgpu")
write(t, root, "hwmon1", "temp1_input", "56000")
write(t, root, "hwmon1", "temp2_input", "-150") // disabled placeholder
// empty input -> skipped without error.
write(t, root, "hwmon2", "name", "cros_ec")
write(t, root, "hwmon2", "temp1_input", "")
got := readHwmonTemps(root)
if len(got) != 2 {
t.Fatalf("want 2 temps, got %d: %+v", len(got), got)
}
if got[0].Chip != "k10temp" || got[0].Label != "Tctl" || got[0].Celsius != 62.125 {
t.Errorf("cpu sensor wrong: %+v", got[0])
}
if got[1].Chip != "amdgpu" || got[1].Label != "amdgpu" || got[1].Celsius != 56 {
t.Errorf("gpu sensor wrong (label should fall back to chip): %+v", got[1])
}
}
func write(t *testing.T, root, chip, file, val string) {
t.Helper()
dir := filepath.Join(root, chip)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, file), []byte(val), 0o644); err != nil {
t.Fatal(err)
}
}
func TestParseMeminfo(t *testing.T) {
data := []byte(`MemTotal: 16000 kB
MemFree: 2000 kB
MemAvailable: 8000 kB
SwapTotal: 4000 kB
SwapFree: 3000 kB
`)
m := parseMeminfo(data)
const kb = 1024
if m.TotalBytes != 16000*kb {
t.Errorf("TotalBytes = %d", m.TotalBytes)
}
if m.AvailableBytes != 8000*kb {
t.Errorf("AvailableBytes = %d", m.AvailableBytes)
}
if m.UsedBytes != (16000-8000)*kb {
t.Errorf("UsedBytes = %d, want %d", m.UsedBytes, (16000-8000)*kb)
}
if m.SwapTotalBytes != 4000*kb || m.SwapFreeBytes != 3000*kb {
t.Errorf("swap parsed wrong: %+v", m)
}
}
func TestParseLoadavg(t *testing.T) {
l := parseLoadavg("0.42 0.55 0.61 1/234 5678")
if l.Load1 != 0.42 || l.Load5 != 0.55 || l.Load15 != 0.61 {
t.Errorf("got %+v", l)
}
bad := parseLoadavg("garbage")
if bad.Load1 != 0 || bad.Load5 != 0 || bad.Load15 != 0 {
t.Error("short input should yield zero LoadInfo")
}
}
func TestCpuFreqMHz(t *testing.T) {
root := t.TempDir()
// Hardware limits live under cpu0 only.
write(t, root, "cpu0/cpufreq", "cpuinfo_min_freq", "400000") // 400 MHz
write(t, root, "cpu0/cpufreq", "cpuinfo_max_freq", "5137000") // 5137 MHz
// Per-core current frequencies (kHz). Core 2 has the peak.
write(t, root, "cpu0/cpufreq", "scaling_cur_freq", "2500000")
write(t, root, "cpu1/cpufreq", "scaling_cur_freq", "1100000")
write(t, root, "cpu2/cpufreq", "scaling_cur_freq", "3200000")
write(t, root, "cpu3/cpufreq", "scaling_cur_freq", "2800000")
min, max, cur := cpuFreqMHz(root)
if min != 400 {
t.Errorf("min = %d, want 400", min)
}
if max != 5137 {
t.Errorf("max = %d, want 5137", max)
}
if cur != 3200 {
t.Errorf("cur = %d, want 3200 (peak across cores)", cur)
}
// Absent cpufreq → all zeros.
emptyRoot := t.TempDir()
min, max, cur = cpuFreqMHz(emptyRoot)
if min != 0 || max != 0 || cur != 0 {
t.Errorf("absent cpufreq: got min=%d max=%d cur=%d, want all 0", min, max, cur)
}
}
func TestCPUModel(t *testing.T) {
x86 := "processor\t: 0\nmodel name\t: AMD Ryzen 7 7840U\ncpu MHz\t: 3000\n"
if got := cpuModel(x86); got != "AMD Ryzen 7 7840U" {
t.Errorf("x86: got %q", got)
}
arm := "processor\t: 0\nModel\t: Raspberry Pi 5 Model B\n"
if got := cpuModel(arm); got != "Raspberry Pi 5 Model B" {
t.Errorf("arm fallback: got %q", got)
}
if got := cpuModel("processor\t: 0\n"); got != "" {
t.Errorf("no model: got %q", got)
}
}
func TestReadProcStat(t *testing.T) {
content := `cpu 100 20 30 400 10 5 3 2 0 0
cpu0 50 10 15 200 5 3 1 1 0 0
cpu1 50 10 15 200 5 2 2 1 0 0
intr 12345
`
f := filepath.Join(t.TempDir(), "stat")
if err := os.WriteFile(f, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
cores := readProcStat(f)
if len(cores) != 2 {
t.Fatalf("want 2 cores, got %d", len(cores))
}
// cpu0: user=50 nice=10 sys=15 idle=200 iowait=5 irq=3 softirq=1 steal=1 guest=0 guest_nice=0
// total = 50+10+15+200+5+3+1+1+0+0 = 285
// idle = 200 + 5 = 205
if cores[0].core != 0 {
t.Errorf("core index = %d, want 0", cores[0].core)
}
if cores[0].total != 285 {
t.Errorf("core0 total = %d, want 285", cores[0].total)
}
if cores[0].idle != 205 {
t.Errorf("core0 idle = %d, want 205", cores[0].idle)
}
}
func TestComputeUsage(t *testing.T) {
prev := []cpuCoreTicks{
{core: 0, total: 1000, idle: 800},
{core: 1, total: 1000, idle: 900},
}
cur := []cpuCoreTicks{
{core: 0, total: 1100, idle: 850}, // 100 total delta, 50 idle delta → 50% usage
{core: 1, total: 1200, idle: 950}, // 200 total delta, 50 idle delta → 75% usage
}
usage := computeUsage(prev, cur)
if len(usage) != 2 {
t.Fatalf("want 2 entries, got %d", len(usage))
}
if usage[0].Core != 0 || usage[0].UsagePct != 50.0 {
t.Errorf("core0: got %+v, want 50%%", usage[0])
}
if usage[1].Core != 1 || usage[1].UsagePct != 75.0 {
t.Errorf("core1: got %+v, want 75%%", usage[1])
}
// Empty prev → empty result.
if got := computeUsage(nil, cur); len(got) != 0 {
t.Errorf("nil prev should yield empty, got %d", len(got))
}
}
+186
View File
@@ -0,0 +1,186 @@
package system
import (
"context"
"slices"
"strings"
"nadir/internal/oscmd"
"github.com/danielgtaylor/huma/v2"
)
type LocaleStatusBody struct {
Lang string `json:"lang" example:"it_IT.UTF-8" doc:"System locale (LANG)"`
VCKeymap string `json:"vc_keymap" example:"it" doc:"Virtual console keymap"`
X11Layout string `json:"x11_layout" example:"it" doc:"X11 keyboard layout"`
}
type GetLocaleOutput struct{ Body LocaleStatusBody }
type LocalesOutput struct {
Body struct {
Locales []string `json:"locales" doc:"Available locales"`
}
}
type KeymapsOutput struct {
Body struct {
Keymaps []string `json:"keymaps" doc:"Available virtual console keymaps"`
}
}
type SetLocaleInput struct {
Body struct {
Lang string `json:"lang" example:"it_IT.UTF-8" doc:"Locale to set as LANG"`
}
}
type SetKeymapInput struct {
Body struct {
Keymap string `json:"keymap" example:"it" doc:"Virtual console keymap"`
}
}
func localeStatus() (LocaleStatusBody, error) {
lines, err := oscmd.RunLines("localectl", "status")
if err != nil {
return LocaleStatusBody{}, err
}
var b LocaleStatusBody
for _, line := range lines {
label, val, ok := strings.Cut(line, ":")
if !ok {
continue
}
switch strings.TrimSpace(label) {
case "System Locale":
for kv := range strings.FieldsSeq(val) {
if k, v, ok := strings.Cut(kv, "="); ok && k == "LANG" {
b.Lang = v
}
}
case "VC Keymap":
b.VCKeymap = strings.TrimSpace(val)
case "X11 Layout":
b.X11Layout = strings.TrimSpace(val)
}
}
return b, nil
}
func registerLocale(api huma.API) {
huma.Register(api, huma.Operation{
OperationID: "system-get-locale",
Method: "GET",
Path: "/api/system/locale",
Summary: "Get locale and keyboard layout",
Description: "Returns the system locale (LANG), virtual console keymap, " +
"and X11 layout from localectl.",
Tags: []string{tagSystem},
Metadata: op("read"),
Errors: readErrors,
}, func(ctx context.Context, _ *struct{}) (*GetLocaleOutput, error) {
b, err := localeStatus()
if err != nil {
return nil, huma.Error500InternalServerError("localectl failed", err)
}
return &GetLocaleOutput{Body: b}, nil
})
huma.Register(api, huma.Operation{
OperationID: "system-list-locales",
Method: "GET",
Path: "/api/system/locales",
Summary: "List available locales",
Description: "Returns every locale the host can be configured to use, " +
"suitable for populating a selector.",
Tags: []string{tagSystem},
Metadata: op("read"),
Errors: readErrors,
}, func(ctx context.Context, _ *struct{}) (*LocalesOutput, error) {
locales, err := oscmd.RunLines("localectl", "list-locales")
if err != nil {
return nil, huma.Error500InternalServerError("localectl failed", err)
}
out := &LocalesOutput{}
out.Body.Locales = locales
return out, nil
})
huma.Register(api, huma.Operation{
OperationID: "system-set-locale",
Method: "POST",
Path: "/api/system/locale",
Summary: "Set system locale (LANG)",
Description: "Sets LANG via localectl. The value is validated against the " +
"host's locale list, so an unknown locale returns 400.",
Tags: []string{tagSystem},
Metadata: op("write"),
Errors: writeErrors,
}, func(ctx context.Context, in *SetLocaleInput) (*oscmd.StatusOutput, error) {
lang := strings.TrimSpace(in.Body.Lang)
if lang == "" {
return nil, huma.Error400BadRequest("empty locale")
}
locales, err := oscmd.RunLines("localectl", "list-locales")
if err != nil {
return nil, huma.Error500InternalServerError("localectl failed", err)
}
if !slices.Contains(locales, lang) {
return nil, huma.Error400BadRequest("unknown locale: " + lang)
}
if _, err := oscmd.Run("localectl", "set-locale", "LANG="+lang); err != nil {
return nil, huma.Error500InternalServerError("set-locale failed", err)
}
return oscmd.OK(), nil
})
huma.Register(api, huma.Operation{
OperationID: "system-list-keymaps",
Method: "GET",
Path: "/api/system/keymaps",
Summary: "List available console keymaps",
Description: "Returns every virtual console keymap known to the host.",
Tags: []string{tagSystem},
Metadata: op("read"),
Errors: readErrors,
}, func(ctx context.Context, _ *struct{}) (*KeymapsOutput, error) {
keymaps, err := oscmd.RunLines("localectl", "list-keymaps")
if err != nil {
return nil, huma.Error500InternalServerError("localectl failed", err)
}
out := &KeymapsOutput{}
out.Body.Keymaps = keymaps
return out, nil
})
huma.Register(api, huma.Operation{
OperationID: "system-set-keymap",
Method: "POST",
Path: "/api/system/keymap",
Summary: "Set virtual console keymap",
Description: "Sets the virtual console keymap via localectl. The value is " +
"validated against the host's keymap list, so an unknown keymap " +
"returns 400.",
Tags: []string{tagSystem},
Metadata: op("write"),
Errors: writeErrors,
}, func(ctx context.Context, in *SetKeymapInput) (*oscmd.StatusOutput, error) {
km := strings.TrimSpace(in.Body.Keymap)
if km == "" {
return nil, huma.Error400BadRequest("empty keymap")
}
keymaps, err := oscmd.RunLines("localectl", "list-keymaps")
if err != nil {
return nil, huma.Error500InternalServerError("localectl failed", err)
}
if !slices.Contains(keymaps, km) {
return nil, huma.Error400BadRequest("unknown keymap: " + km)
}
if _, err := oscmd.Run("localectl", "set-keymap", km); err != nil {
return nil, huma.Error500InternalServerError("set-keymap failed", err)
}
return oscmd.OK(), nil
})
}
+35
View File
@@ -0,0 +1,35 @@
package system
import (
"nadir/internal/rbac"
"github.com/danielgtaylor/huma/v2"
)
const ModuleID = "system"
type Module struct{}
func New() *Module { return &Module{} }
func (m *Module) ID() string { return ModuleID }
func (m *Module) Name() string { return "System" }
func (m *Module) Permissions() []rbac.Permission {
return []rbac.Permission{rbac.Read, rbac.Write, rbac.Root}
}
func (m *Module) Register(api huma.API) {
registerInfo(api)
registerHostname(api)
registerTimedate(api)
registerLocale(api)
registerPower(api)
}
func op(permission string) map[string]any {
return map[string]any{
"module": ModuleID,
"permission": permission,
}
}
+73
View File
@@ -0,0 +1,73 @@
package system
import (
"context"
"regexp"
"strings"
"nadir/internal/oscmd"
"github.com/danielgtaylor/huma/v2"
)
// whenRe matches the shutdown(8) TIME forms we accept: "now", "+<minutes>", or
// "hh:mm". Validating before exec keeps a value like "-r" or "--help" from
// being read as a flag (shutdown does not honor a "--" separator), per the
// repo's input-validation rule.
var whenRe = regexp.MustCompile(`^(now|\+\d+|([01]?\d|2[0-3]):[0-5]\d)$`)
type PowerInput struct {
Body struct {
When string `json:"when,omitempty" example:"+1" doc:"When to act, as a shutdown(8) TIME (e.g. \"+5\" minutes, \"23:00\"). Empty or 'now' = immediate."`
}
}
const powerDescription = "Requires the `root` permission. The response is sent once `shutdown` " +
"accepts the request; for the immediate form it returns before the machine " +
"actually goes down, so a 200 does not guarantee a clean shutdown completed."
func registerPower(api huma.API) {
huma.Register(api, huma.Operation{
OperationID: "system-reboot",
Method: "POST",
Path: "/api/system/reboot",
Summary: "Reboot the system",
Description: powerDescription,
Tags: []string{tagSystem},
Metadata: op("root"),
Errors: []int{400, 401, 403, 500},
}, func(ctx context.Context, in *PowerInput) (*oscmd.StatusOutput, error) {
return schedulePower("reboot", in.Body.When)
})
huma.Register(api, huma.Operation{
OperationID: "system-poweroff",
Method: "POST",
Path: "/api/system/poweroff",
Summary: "Power off the system",
Description: powerDescription,
Tags: []string{tagSystem},
Metadata: op("root"),
Errors: []int{400, 401, 403, 500},
}, func(ctx context.Context, in *PowerInput) (*oscmd.StatusOutput, error) {
return schedulePower("poweroff", in.Body.When)
})
}
func schedulePower(action, when string) (*oscmd.StatusOutput, error) {
w := strings.TrimSpace(when)
if w == "" {
w = "now"
}
if !whenRe.MatchString(w) {
return nil, huma.Error400BadRequest("invalid when: " + w)
}
flag := "-h"
if action == "reboot" {
flag = "-r"
}
if _, err := oscmd.Run("shutdown", flag, w); err != nil {
return nil, huma.Error500InternalServerError("shutdown failed", err)
}
return oscmd.OK(), nil
}
+18
View File
@@ -0,0 +1,18 @@
package system
import "testing"
func TestWhenRe(t *testing.T) {
valid := []string{"now", "+0", "+5", "+120", "0:00", "9:30", "23:59", "07:05"}
for _, w := range valid {
if !whenRe.MatchString(w) {
t.Errorf("whenRe.MatchString(%q) = false, want true", w)
}
}
invalid := []string{"", "-r", "-h", "--help", "24:00", "9:60", "+5; reboot", "now ", "5", "1:2"}
for _, w := range invalid {
if whenRe.MatchString(w) {
t.Errorf("whenRe.MatchString(%q) = true, want false", w)
}
}
}
@@ -0,0 +1,235 @@
package system
import (
"encoding/json"
"net/http"
"os"
"reflect"
"testing"
"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())
}
func TestSystemHandlers(t *testing.T) {
mux := http.NewServeMux()
api := humatest.Wrap(t, humago.New(mux, huma.DefaultConfig("Test", "1.0.0")))
registerHostname(api)
registerTimedate(api)
registerLocale(api)
registerPower(api)
registerInfo(api)
// Mock uname for GET /api/system/info
oscmd.SetMock("uname", func(args []string) oscmd.MockCommand {
if reflect.DeepEqual(args, []string{"-r"}) {
return oscmd.MockCommand{Stdout: "6.9.1-1-test\n", ExitCode: 0}
}
if reflect.DeepEqual(args, []string{"-m"}) {
return oscmd.MockCommand{Stdout: "x86_64\n", ExitCode: 0}
}
return oscmd.MockCommand{ExitCode: 1}
})
defer oscmd.ClearMocks()
// 1. Test GET /api/system/info
resp := api.Get("/api/system/info")
if resp.Code != http.StatusOK {
t.Errorf("get info: got %d, want %d", resp.Code, http.StatusOK)
}
// 2. Test GET & POST /api/system/hostname
oscmd.SetMock("hostnamectl", func(args []string) oscmd.MockCommand {
if reflect.DeepEqual(args, []string{"hostname"}) {
return oscmd.MockCommand{Stdout: "server01\n", ExitCode: 0}
}
if reflect.DeepEqual(args, []string{"set-hostname", "server02"}) {
return oscmd.MockCommand{ExitCode: 0}
}
return oscmd.MockCommand{ExitCode: 1}
})
resp = api.Get("/api/system/hostname")
if resp.Code != http.StatusOK {
t.Errorf("get hostname: got %d, want %d", resp.Code, http.StatusOK)
}
var hostnameRes GetHostnameOutput
if err := json.Unmarshal(resp.Body.Bytes(), &hostnameRes.Body); err != nil {
t.Fatal(err)
}
if hostnameRes.Body.Hostname != "server01" {
t.Errorf("got hostname %q, want %q", hostnameRes.Body.Hostname, "server01")
}
resp = api.Post("/api/system/hostname", struct {
Hostname string `json:"hostname"`
}{
Hostname: "server02",
})
if resp.Code != http.StatusOK {
t.Errorf("set hostname: got %d, want %d", resp.Code, http.StatusOK)
}
// 3. Test GET & POST /api/system/time
oscmd.SetMock("timedatectl", func(args []string) oscmd.MockCommand {
if reflect.DeepEqual(args, []string{"show"}) {
showOut := "Timezone=Europe/Rome\nLocalRTC=no\nNTP=yes\nNTPSynchronized=yes\nCanNTP=yes\n"
return oscmd.MockCommand{Stdout: showOut, ExitCode: 0}
}
if reflect.DeepEqual(args, []string{"list-timezones"}) {
return oscmd.MockCommand{Stdout: "Europe/Rome\nUTC\n", ExitCode: 0}
}
if reflect.DeepEqual(args, []string{"set-timezone", "Europe/Rome"}) {
return oscmd.MockCommand{ExitCode: 0}
}
if reflect.DeepEqual(args, []string{"set-ntp", "true"}) {
return oscmd.MockCommand{ExitCode: 0}
}
if len(args) == 2 && args[0] == "set-time" {
return oscmd.MockCommand{ExitCode: 0}
}
return oscmd.MockCommand{ExitCode: 1}
})
resp = api.Get("/api/system/time")
if resp.Code != http.StatusOK {
t.Errorf("get time: got %d, want %d", resp.Code, http.StatusOK)
}
var timeRes GetTimeOutput
if err := json.Unmarshal(resp.Body.Bytes(), &timeRes.Body); err != nil {
t.Fatal(err)
}
if timeRes.Body.Timezone != "Europe/Rome" || !timeRes.Body.NTP {
t.Errorf("got time settings: %+v", timeRes.Body)
}
resp = api.Get("/api/system/timezones")
if resp.Code != http.StatusOK {
t.Errorf("list timezones: got %d, want %d", resp.Code, http.StatusOK)
}
resp = api.Post("/api/system/timezone", struct {
Timezone string `json:"timezone"`
}{
Timezone: "Europe/Rome",
})
if resp.Code != http.StatusOK {
t.Errorf("set timezone: got %d, want %d", resp.Code, http.StatusOK)
}
resp = api.Post("/api/system/ntp", struct {
Enabled bool `json:"enabled"`
}{
Enabled: true,
})
if resp.Code != http.StatusOK {
t.Errorf("set ntp: got %d, want %d", resp.Code, http.StatusOK)
}
resp = api.Post("/api/system/time", struct {
Time string `json:"time"`
}{
Time: "2026-06-20T12:00:00Z",
})
if resp.Code != http.StatusOK {
t.Errorf("set time: got %d, want %d", resp.Code, http.StatusOK)
}
// 4. Test GET & POST /api/system/locale
oscmd.SetMock("localectl", func(args []string) oscmd.MockCommand {
if reflect.DeepEqual(args, []string{"status"}) {
statusOut := " System Locale: LANG=it_IT.UTF-8\n VC Keymap: it\n X11 Layout: it\n"
return oscmd.MockCommand{Stdout: statusOut, ExitCode: 0}
}
if reflect.DeepEqual(args, []string{"list-locales"}) {
return oscmd.MockCommand{Stdout: "it_IT.UTF-8\nen_US.UTF-8\n", ExitCode: 0}
}
if reflect.DeepEqual(args, []string{"set-locale", "LANG=it_IT.UTF-8"}) {
return oscmd.MockCommand{ExitCode: 0}
}
if reflect.DeepEqual(args, []string{"list-keymaps"}) {
return oscmd.MockCommand{Stdout: "it\nus\n", ExitCode: 0}
}
if reflect.DeepEqual(args, []string{"set-keymap", "it"}) {
return oscmd.MockCommand{ExitCode: 0}
}
return oscmd.MockCommand{ExitCode: 1}
})
resp = api.Get("/api/system/locale")
if resp.Code != http.StatusOK {
t.Errorf("get locale: got %d, want %d", resp.Code, http.StatusOK)
}
var localeRes GetLocaleOutput
if err := json.Unmarshal(resp.Body.Bytes(), &localeRes.Body); err != nil {
t.Fatal(err)
}
if localeRes.Body.Lang != "it_IT.UTF-8" || localeRes.Body.VCKeymap != "it" {
t.Errorf("got locale status: %+v", localeRes.Body)
}
resp = api.Get("/api/system/locales")
if resp.Code != http.StatusOK {
t.Errorf("list locales: got %d, want %d", resp.Code, http.StatusOK)
}
resp = api.Post("/api/system/locale", struct {
Lang string `json:"lang"`
}{
Lang: "it_IT.UTF-8",
})
if resp.Code != http.StatusOK {
t.Errorf("set locale: got %d, want %d", resp.Code, http.StatusOK)
}
resp = api.Get("/api/system/keymaps")
if resp.Code != http.StatusOK {
t.Errorf("list keymaps: got %d, want %d", resp.Code, http.StatusOK)
}
resp = api.Post("/api/system/keymap", struct {
Keymap string `json:"keymap"`
}{
Keymap: "it",
})
if resp.Code != http.StatusOK {
t.Errorf("set keymap: got %d, want %d", resp.Code, http.StatusOK)
}
// 5. Test POST /api/system/reboot and /api/system/poweroff
oscmd.SetMock("shutdown", func(args []string) oscmd.MockCommand {
if reflect.DeepEqual(args, []string{"-r", "now"}) || reflect.DeepEqual(args, []string{"-h", "now"}) {
return oscmd.MockCommand{ExitCode: 0}
}
return oscmd.MockCommand{ExitCode: 1}
})
resp = api.Post("/api/system/reboot", struct {
When string `json:"when"`
}{
When: "now",
})
if resp.Code != http.StatusOK {
t.Errorf("reboot: got %d, want %d", resp.Code, http.StatusOK)
}
resp = api.Post("/api/system/poweroff", struct {
When string `json:"when"`
}{
When: "now",
})
if resp.Code != http.StatusOK {
t.Errorf("poweroff: got %d, want %d", resp.Code, http.StatusOK)
}
}
+200
View File
@@ -0,0 +1,200 @@
package system
import (
"context"
"slices"
"strconv"
"strings"
"time"
"nadir/internal/oscmd"
"github.com/danielgtaylor/huma/v2"
)
type TimeStatusBody struct {
Timezone string `json:"timezone" example:"Europe/Rome" doc:"IANA timezone name"`
LocalRTC bool `json:"local_rtc" doc:"Hardware clock kept in local time instead of UTC"`
NTP bool `json:"ntp" doc:"Network time synchronization enabled"`
NTPSynchronized bool `json:"ntp_synchronized" doc:"Clock is currently synchronized"`
CanNTP bool `json:"can_ntp" doc:"An NTP service is available on this host"`
Time string `json:"time" example:"2026-06-19T13:36:31Z" doc:"Current system time (RFC3339, UTC)"`
}
type GetTimeOutput struct{ Body TimeStatusBody }
type TimezonesOutput struct {
Body struct {
Timezones []string `json:"timezones" doc:"Available IANA timezone names"`
}
}
type SetTimezoneInput struct {
Body struct {
Timezone string `json:"timezone" example:"Europe/Rome" doc:"IANA timezone name"`
}
}
type SetNTPInput struct {
Body struct {
Enabled bool `json:"enabled" doc:"Enable network time synchronization"`
}
}
type SetTimeInput struct {
Body struct {
Time string `json:"time" example:"2026-06-19T13:36:31Z" doc:"New time (RFC3339). Requires NTP disabled."`
}
}
func timedatectlShow() (map[string]string, error) {
lines, err := oscmd.RunLines("timedatectl", "show")
if err != nil {
return nil, err
}
return oscmd.ParseKV(lines), nil
}
// readTimeStatus builds the current time/timezone/sync status. Shared by the
// GET endpoint and the write endpoints, which return the resulting state so
// clients render ground truth instead of guessing after an opaque "ok". Note
// NTPSynchronized lags NTP by seconds-to-minutes while the NTP daemon converges.
func readTimeStatus() (TimeStatusBody, error) {
m, err := timedatectlShow()
if err != nil {
return TimeStatusBody{}, err
}
return TimeStatusBody{
Timezone: m["Timezone"],
LocalRTC: m["LocalRTC"] == "yes",
NTP: m["NTP"] == "yes",
NTPSynchronized: m["NTPSynchronized"] == "yes",
CanNTP: m["CanNTP"] == "yes",
Time: time.Now().UTC().Format(time.RFC3339),
}, nil
}
func registerTimedate(api huma.API) {
huma.Register(api, huma.Operation{
OperationID: "system-get-time",
Method: "GET",
Path: "/api/system/time",
Summary: "Get time, timezone and sync status",
Description: "Returns the current UTC time plus timezone and " +
"synchronization state from timedatectl.",
Tags: []string{tagSystem},
Metadata: op("read"),
Errors: readErrors,
}, func(ctx context.Context, _ *struct{}) (*GetTimeOutput, error) {
body, err := readTimeStatus()
if err != nil {
return nil, huma.Error500InternalServerError("timedatectl failed", err)
}
return &GetTimeOutput{Body: body}, nil
})
huma.Register(api, huma.Operation{
OperationID: "system-list-timezones",
Method: "GET",
Path: "/api/system/timezones",
Summary: "List available timezones",
Description: "Returns every IANA timezone name known to the host, " +
"suitable for populating a selector.",
Tags: []string{tagSystem},
Metadata: op("read"),
Errors: readErrors,
}, func(ctx context.Context, _ *struct{}) (*TimezonesOutput, error) {
zones, err := oscmd.RunLines("timedatectl", "list-timezones")
if err != nil {
return nil, huma.Error500InternalServerError("timedatectl failed", err)
}
out := &TimezonesOutput{}
out.Body.Timezones = zones
return out, nil
})
huma.Register(api, huma.Operation{
OperationID: "system-set-timezone",
Method: "POST",
Path: "/api/system/timezone",
Summary: "Set system timezone",
Description: "Sets the timezone via timedatectl. The value is validated " +
"against the host's timezone list, so an unknown name returns 400.",
Tags: []string{tagSystem},
Metadata: op("write"),
Errors: writeErrors,
}, func(ctx context.Context, in *SetTimezoneInput) (*oscmd.StatusOutput, error) {
tz := strings.TrimSpace(in.Body.Timezone)
if tz == "" {
return nil, huma.Error400BadRequest("empty timezone")
}
zones, err := oscmd.RunLines("timedatectl", "list-timezones")
if err != nil {
return nil, huma.Error500InternalServerError("timedatectl failed", err)
}
if !slices.Contains(zones, tz) {
return nil, huma.Error400BadRequest("unknown timezone: " + tz)
}
if _, err := oscmd.Run("timedatectl", "set-timezone", tz); err != nil {
return nil, huma.Error500InternalServerError("set-timezone failed", err)
}
return oscmd.OK(), nil
})
huma.Register(api, huma.Operation{
OperationID: "system-set-ntp",
Method: "POST",
Path: "/api/system/ntp",
Summary: "Enable or disable time synchronization",
Description: "Toggles network time synchronization via " +
"`timedatectl set-ntp`. Selecting specific NTP servers is not yet " +
"supported. Returns the resulting time status: on enable, `ntp` is " +
"true immediately, but `ntp_synchronized` stays false until the NTP " +
"daemon converges (seconds to minutes).",
Tags: []string{tagSystem},
Metadata: op("write"),
Errors: []int{400, 401, 403, 409, 500},
}, func(ctx context.Context, in *SetNTPInput) (*GetTimeOutput, error) {
// Enabling is a no-op when no NTP daemon is installed (CanNTP=no):
// timedatectl reports success but nothing syncs. Reject it clearly.
if in.Body.Enabled {
if m, err := timedatectlShow(); err == nil && m["CanNTP"] != "yes" {
return nil, huma.Error409Conflict("cannot enable NTP: no NTP service available on this host")
}
}
val := strconv.FormatBool(in.Body.Enabled)
if _, err := oscmd.Run("timedatectl", "set-ntp", val); err != nil {
return nil, huma.Error500InternalServerError("set-ntp failed", err)
}
body, err := readTimeStatus()
if err != nil {
return nil, huma.Error500InternalServerError("set-ntp succeeded but reading status failed", err)
}
return &GetTimeOutput{Body: body}, nil
})
huma.Register(api, huma.Operation{
OperationID: "system-set-time",
Method: "POST",
Path: "/api/system/time",
Summary: "Set system time manually",
Description: "Sets the clock to an explicit RFC3339 time. This only works " +
"when NTP is disabled; otherwise timedatectl refuses and the call " +
"returns 409.",
Tags: []string{tagSystem},
Metadata: op("write"),
Errors: []int{400, 401, 403, 409, 500},
}, func(ctx context.Context, in *SetTimeInput) (*oscmd.StatusOutput, error) {
t, err := time.Parse(time.RFC3339, strings.TrimSpace(in.Body.Time))
if err != nil {
return nil, huma.Error400BadRequest("time must be RFC3339", err)
}
// timedatectl set-time interprets its argument as local wall-clock time.
stamp := t.Local().Format("2006-01-02 15:04:05")
if _, err := oscmd.Run("timedatectl", "set-time", stamp); err != nil {
// timedatectl refuses while NTP is active; make that actionable.
return nil, huma.Error409Conflict("set-time failed (disable NTP first?)", err)
}
return oscmd.OK(), nil
})
}
+210
View File
@@ -0,0 +1,210 @@
package terminal
import (
"context"
"encoding/json"
"net/http"
"os/exec"
"strings"
"sync/atomic"
"time"
"nadir/internal/auth"
"nadir/internal/module"
"nadir/internal/rbac"
"github.com/coder/websocket"
"github.com/creack/pty"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humago"
)
// tagTerminal is the OpenAPI tag for this module (registered in server.go),
// keeping tags 1:1 with modules per the project convention.
const tagTerminal = "Terminal"
const (
// maxTerminals caps concurrent shells so a buggy frontend or careless admin
// can't pile up PTYs (guideline: limit everything). Raise if it's ever a real
// limit in practice.
maxTerminals = 10
// idleTimeout closes a session after this long with no I/O in either
// direction, reclaiming abandoned shells.
idleTimeout = 15 * time.Minute
)
// terminalSem is the concurrency limiter; a slot is held for the life of a session.
var terminalSem = make(chan struct{}, maxTerminals)
type terminalModule struct {
sessions *auth.SessionStore
}
// New creates a new Terminal module that allows interactive shell access.
func New(sessions *auth.SessionStore) module.Module {
return &terminalModule{sessions: sessions}
}
func (m *terminalModule) ID() string { return "terminal" }
func (m *terminalModule) Name() string { return "Terminal" }
func (m *terminalModule) Permissions() []rbac.Permission {
return []rbac.Permission{rbac.Root}
}
type TerminalInput struct {
Ctx huma.Context `json:"-"`
}
// Resolve extracts the huma.Context into the input struct.
func (i *TerminalInput) Resolve(ctx huma.Context) []error {
i.Ctx = ctx
return nil
}
type resizeMessage struct {
Cols uint16 `json:"cols"`
Rows uint16 `json:"rows"`
}
func (m *terminalModule) Register(api huma.API) {
huma.Register(api, huma.Operation{
OperationID: "terminal-connect",
Method: "GET",
Path: "/api/terminal",
Summary: "Connect to an interactive terminal",
Description: "Upgrades the connection to a WebSocket and spawns a PTY shell as the logged-in user. Send JSON `{cols, rows}` text messages to resize, and raw binary/text messages for stdin. This is a raw WebSocket endpoint — it cannot be exercised from the API docs \"Try it\" panel; use a WebSocket client.",
Tags: []string{tagTerminal},
Metadata: map[string]any{"module": m.ID(), "permission": string(rbac.Root)},
Errors: []int{401, 403, 426, 500},
}, func(ctx context.Context, in *TerminalInput) (*struct{}, error) {
// The RBAC middleware already authenticated this request and enforced the
// "root" permission before we got here. We re-read the session only to get
// the username for `su`; the 401 below is the fallback for when the module
// is mounted without the middleware (e.g. in unit tests).
cookie, err := huma.ReadCookie(in.Ctx, "nadir_session_id")
if err != nil || cookie == nil {
return nil, huma.Error401Unauthorized("unauthorized")
}
sess, ok := m.sessions.GetByToken(cookie.Value)
if !ok {
return nil, huma.Error401Unauthorized("unauthorized")
}
req, res := humago.Unwrap(in.Ctx)
if req == nil || res == nil {
return nil, huma.Error500InternalServerError("missing http context")
}
// Reject plain GETs (e.g. the docs "Try it" button) with a clear 426 rather
// than letting websocket.Accept emit a raw protocol-violation error.
if !strings.EqualFold(req.Header.Get("Upgrade"), "websocket") {
return nil, huma.NewError(http.StatusUpgradeRequired,
"this endpoint requires a WebSocket connection; connect with a WebSocket client")
}
// InsecureSkipVerify is deliberately NOT set: coder/websocket then enforces
// that the Origin host matches the request Host, rejecting cross-site upgrade
// attempts. Defense-in-depth on top of the SameSite=Strict session cookie —
// important because this endpoint hands out an interactive shell.
conn, err := websocket.Accept(res, req, nil)
if err != nil {
// websocket.Accept already wrote the error response (e.g. 403 on an
// Origin mismatch). Just stop; writing again would corrupt the response.
return nil, nil
}
defer conn.CloseNow()
// Bound concurrent shells: take a slot or reject (don't pile up PTYs).
select {
case terminalSem <- struct{}{}:
defer func() { <-terminalSem }()
default:
conn.Close(websocket.StatusTryAgainLater, "too many terminal sessions")
return nil, nil
}
// Launch the user's login shell via su.
// "su - <username>" ensures we get their actual environment and shell.
cmd := exec.CommandContext(req.Context(), "su", "-", sess.Username)
// Start the command with a PTY.
ptmx, err := pty.Start(cmd)
if err != nil {
conn.Close(websocket.StatusInternalError, "failed to start pty")
return nil, nil
}
defer ptmx.Close()
// lastActive is bumped by both pumps; the watchdog uses it to close idle
// sessions. Output activity (e.g. `top`) counts, so it isn't killed.
var lastActive atomic.Int64
lastActive.Store(time.Now().UnixNano())
go func() {
tick := time.NewTicker(idleTimeout / 4)
defer tick.Stop()
for {
select {
case <-req.Context().Done():
return
case <-tick.C:
if time.Since(time.Unix(0, lastActive.Load())) > idleTimeout {
conn.Close(websocket.StatusGoingAway, "idle timeout")
return
}
}
}
}()
// Pump stdout/stderr from PTY to WebSocket.
go func() {
buf := make([]byte, 8192)
for {
n, err := ptmx.Read(buf)
if err != nil {
break
}
lastActive.Store(time.Now().UnixNano())
// We write PTY output as binary messages. The frontend (e.g., xterm.js)
// can handle UTF-8 binary or text transparently.
err = conn.Write(req.Context(), websocket.MessageBinary, buf[:n])
if err != nil {
break
}
}
conn.Close(websocket.StatusNormalClosure, "")
}()
// Pump stdin and resize commands from WebSocket to PTY.
for {
typ, b, err := conn.Read(req.Context())
if err != nil {
break
}
lastActive.Store(time.Now().UnixNano())
if typ == websocket.MessageText {
var resize resizeMessage
if err := json.Unmarshal(b, &resize); err == nil && resize.Cols > 0 && resize.Rows > 0 {
// Handle resize
_ = pty.Setsize(ptmx, &pty.Winsize{
Cols: resize.Cols,
Rows: resize.Rows,
})
continue
}
}
// If not a valid resize message, or if it's MessageBinary, pass to PTY stdin.
_, _ = ptmx.Write(b)
}
// The read loop has ended (client gone or PTY closed). Tear down the shell
// and reap it: closing the PTY sends EOF, Kill covers shells that ignore it.
_ = ptmx.Close()
_ = cmd.Process.Kill()
_ = cmd.Wait()
return nil, nil
})
}
+124
View File
@@ -0,0 +1,124 @@
package terminal
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"nadir/internal/auth"
"github.com/coder/websocket"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humago"
)
func TestTerminalConnectUnauthorized(t *testing.T) {
sessions, err := auth.NewSessionStore("file::memory:?cache=shared")
if err != nil {
t.Fatalf("session db: %v", err)
}
mux := http.NewServeMux()
api := humago.New(mux, huma.DefaultConfig("Test", "1.0.0"))
mod := New(sessions)
mod.Register(api)
srv := httptest.NewServer(mux)
defer srv.Close()
wsURL := strings.Replace(srv.URL, "http://", "ws://", 1) + "/api/terminal"
// Try without cookie
ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second)
defer cancel()
_, resp, err := websocket.Dial(ctx, wsURL, nil)
if err == nil {
t.Fatal("expected error, got success")
}
if resp != nil && resp.StatusCode != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", resp.StatusCode)
}
}
func TestTerminalConnectPlainGET(t *testing.T) {
// An authenticated but non-WebSocket GET (e.g. the docs "Try it" button) must
// get a clean 426 Upgrade Required, not a raw websocket protocol error.
sessions, err := auth.NewSessionStore("file::memory:?cache=shared")
if err != nil {
t.Fatalf("session db: %v", err)
}
token, err := sessions.Create("root")
if err != nil {
t.Fatalf("create session: %v", err)
}
mux := http.NewServeMux()
api := humago.New(mux, huma.DefaultConfig("Test", "1.0.0"))
New(sessions).Register(api)
srv := httptest.NewServer(mux)
defer srv.Close()
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/api/terminal", nil)
req.Header.Set("Cookie", "nadir_session_id="+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("GET failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUpgradeRequired {
t.Errorf("expected 426, got %d", resp.StatusCode)
}
}
func TestTerminalConnectAuthorized(t *testing.T) {
// Root or specific system configs might cause PTY or su to fail if run in constrained environments.
// But we expect the websocket upgrade to succeed and then maybe close with an error if PTY fails.
sessions, err := auth.NewSessionStore("file::memory:?cache=shared")
if err != nil {
t.Fatalf("session db: %v", err)
}
token, err := sessions.Create("root")
if err != nil {
t.Fatalf("create session: %v", err)
}
mux := http.NewServeMux()
api := humago.New(mux, huma.DefaultConfig("Test", "1.0.0"))
mod := New(sessions)
mod.Register(api)
srv := httptest.NewServer(mux)
defer srv.Close()
wsURL := strings.Replace(srv.URL, "http://", "ws://", 1) + "/api/terminal"
ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second)
defer cancel()
opts := &websocket.DialOptions{
HTTPHeader: http.Header{},
}
opts.HTTPHeader.Set("Cookie", "nadir_session_id="+token)
conn, resp, err := websocket.Dial(ctx, wsURL, opts)
if err != nil {
// Depending on the test environment, "su" or "pty" might fail, but
// the websocket upgrade itself should succeed before it drops.
// If it fails to upgrade, that's a real error.
t.Fatalf("dial failed: %v (status %d)", err, resp.StatusCode)
}
defer conn.CloseNow()
// The connection was upgraded successfully.
// If PTY allocation failed or su failed, the server might close the connection immediately.
// We just verify the upgrade succeeded.
if resp.StatusCode != http.StatusSwitchingProtocols {
t.Errorf("expected 101, got %d", resp.StatusCode)
}
}
+30
View File
@@ -0,0 +1,30 @@
package users
import (
"nadir/internal/rbac"
"github.com/danielgtaylor/huma/v2"
)
const ModuleID = "users"
type Module struct{}
func New() *Module { return &Module{} }
func (m *Module) ID() string { return ModuleID }
func (m *Module) Name() string { return "Users" }
// Permissions: read to list/inspect accounts; write to create and change
// passwords; root to delete (irreversible).
func (m *Module) Permissions() []rbac.Permission {
return []rbac.Permission{rbac.Read, rbac.Write, rbac.Root}
}
func (m *Module) Register(api huma.API) {
registerUsers(api)
}
func op(permission string) map[string]any {
return map[string]any{"module": ModuleID, "permission": permission}
}
+373
View File
@@ -0,0 +1,373 @@
package users
import (
"context"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"nadir/internal/oscmd"
"github.com/danielgtaylor/huma/v2"
)
const tagUsers = "Users"
var passwdPath = "/etc/passwd"
// systemUIDMax is the conventional upper bound for system (non-login) accounts;
// regular users start at 1000 on Debian/Fedora. Used only to flag accounts for
// the client, not to filter them out.
const systemUIDMax = 1000
var (
readErrors = []int{401, 403, 500}
writeErrors = []int{400, 401, 403, 404, 409, 500}
)
// userNameRe matches valid Linux usernames (the useradd default NAME_REGEX).
// Starting with a letter/underscore also rejects leading-dash flag injection.
var userNameRe = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}\$?$`)
// User mirrors one /etc/passwd entry.
type User struct {
Username string `json:"username" example:"alice" doc:"Login name"`
UID int `json:"uid" example:"1000" doc:"User ID"`
GID int `json:"gid" example:"1000" doc:"Primary group ID"`
Comment string `json:"comment" example:"Alice Smith" doc:"GECOS comment (often the full name)"`
Home string `json:"home" example:"/home/alice" doc:"Home directory"`
Shell string `json:"shell" example:"/bin/bash" doc:"Login shell"`
System bool `json:"system" doc:"True for system accounts (uid < 1000)"`
}
type ListUsersOutput struct {
Body struct {
Users []User `json:"users" doc:"All accounts from /etc/passwd"`
}
}
type GetUserOutput struct{ Body User }
type UserPath struct {
Username string `path:"username" example:"alice" doc:"Login name"`
}
type CreateUserInput struct {
Body struct {
Username string `json:"username" example:"alice" doc:"Login name"`
Comment string `json:"comment,omitempty" example:"Alice Smith" doc:"GECOS comment"`
Shell string `json:"shell,omitempty" example:"/bin/bash" doc:"Login shell"`
Home string `json:"home,omitempty" example:"/home/alice" doc:"Home directory (defaults to /home/<username>)"`
CreateHome bool `json:"create_home,omitempty" doc:"Create the home directory (useradd -m)"`
System bool `json:"system,omitempty" doc:"Create a system account (useradd --system)"`
}
}
type DeleteUserInput struct {
Username string `path:"username" example:"alice" doc:"Login name"`
RemoveHome bool `query:"remove_home" doc:"Also remove the home directory and mail spool (userdel -r)"`
}
type SetPasswordInput struct {
Username string `path:"username" example:"alice" doc:"Login name"`
Body struct {
Password string `json:"password" doc:"New password (sent to chpasswd over stdin, never argv)"`
}
}
type SetGroupsInput struct {
Username string `path:"username" example:"alice" doc:"Login name"`
Body struct {
Groups []string `json:"groups" doc:"Supplementary groups; replaces the user's full supplementary set"`
}
}
type UserGroupsOutput struct {
Body struct {
Username string `json:"username" example:"alice"`
Groups []string `json:"groups" doc:"All groups the user belongs to (primary + supplementary)"`
}
}
func registerUsers(api huma.API) {
huma.Register(api, huma.Operation{
OperationID: "users-list",
Method: "GET",
Path: "/api/users",
Summary: "List user accounts",
Description: "Returns every account in /etc/passwd, including system " +
"accounts (flagged via `system`).",
Tags: []string{tagUsers},
Metadata: op("read"),
Errors: readErrors,
}, func(ctx context.Context, _ *struct{}) (*ListUsersOutput, error) {
list, err := listUsers()
if err != nil {
return nil, huma.Error500InternalServerError("read "+passwdPath+" failed", err)
}
out := &ListUsersOutput{}
out.Body.Users = list
return out, nil
})
huma.Register(api, huma.Operation{
OperationID: "users-get",
Method: "GET",
Path: "/api/users/{username}",
Summary: "Get a single user",
Description: "Returns one account by login name. 404 if it does not exist.",
Tags: []string{tagUsers},
Metadata: op("read"),
Errors: []int{400, 401, 403, 404, 500},
}, func(ctx context.Context, in *UserPath) (*GetUserOutput, error) {
if err := validateUsername(in.Username); err != nil {
return nil, err
}
u, ok, err := lookupUser(in.Username)
if err != nil {
return nil, huma.Error500InternalServerError("read "+passwdPath+" failed", err)
}
if !ok {
return nil, huma.Error404NotFound("user not found: " + in.Username)
}
return &GetUserOutput{Body: u}, nil
})
huma.Register(api, huma.Operation{
OperationID: "users-create",
Method: "POST",
Path: "/api/users",
Summary: "Create a user account",
Description: "Creates an account via useradd. The new account has a locked " +
"password until one is set via the password endpoint. 409 if the user " +
"already exists.",
Tags: []string{tagUsers},
Metadata: op("write"),
Errors: writeErrors,
}, func(ctx context.Context, in *CreateUserInput) (*GetUserOutput, error) {
if err := validateUsername(in.Body.Username); err != nil {
return nil, err
}
// -c/-s/-d are option *arguments*, so the "--" separator doesn't shield
// them. Validate at the boundary: a ':' or newline in the GECOS field
// would corrupt /etc/passwd; shell/home must be absolute paths.
if strings.ContainsAny(in.Body.Comment, ":\n") {
return nil, huma.Error400BadRequest("comment may not contain ':' or newlines")
}
if in.Body.Shell != "" && !filepath.IsAbs(in.Body.Shell) {
return nil, huma.Error400BadRequest("shell must be an absolute path")
}
if in.Body.Home != "" && !filepath.IsAbs(in.Body.Home) {
return nil, huma.Error400BadRequest("home must be an absolute path")
}
if _, ok, err := lookupUser(in.Body.Username); err != nil {
return nil, huma.Error500InternalServerError("read "+passwdPath+" failed", err)
} else if ok {
return nil, huma.Error409Conflict("user already exists: " + in.Body.Username)
}
args := []string{}
if in.Body.System {
args = append(args, "--system")
}
if in.Body.CreateHome {
args = append(args, "-m")
}
if in.Body.Comment != "" {
args = append(args, "-c", in.Body.Comment)
}
if in.Body.Shell != "" {
args = append(args, "-s", in.Body.Shell)
}
if in.Body.Home != "" {
args = append(args, "-d", in.Body.Home)
}
args = append(args, "--", in.Body.Username)
if _, err := oscmd.Run("useradd", args...); err != nil {
return nil, huma.Error500InternalServerError("useradd failed", err)
}
u, ok, err := lookupUser(in.Body.Username)
if err != nil || !ok {
return nil, huma.Error500InternalServerError("user created but could not be read back", err)
}
return &GetUserOutput{Body: u}, nil
})
huma.Register(api, huma.Operation{
OperationID: "users-delete",
Method: "DELETE",
Path: "/api/users/{username}",
Summary: "Delete a user account",
Description: "Removes an account via userdel. Pass ?remove_home=true to " +
"also delete the home directory. 404 if the user does not exist.",
Tags: []string{tagUsers},
// Deleting an account is irreversible - gated behind root, not write.
Metadata: op("root"),
Errors: []int{400, 401, 403, 404, 500},
}, func(ctx context.Context, in *DeleteUserInput) (*oscmd.StatusOutput, error) {
if err := validateUsername(in.Username); err != nil {
return nil, err
}
if _, ok, err := lookupUser(in.Username); err != nil {
return nil, huma.Error500InternalServerError("read "+passwdPath+" failed", err)
} else if !ok {
return nil, huma.Error404NotFound("user not found: " + in.Username)
}
args := []string{}
if in.RemoveHome {
args = append(args, "-r")
}
args = append(args, "--", in.Username)
if _, err := oscmd.Run("userdel", args...); err != nil {
return nil, huma.Error500InternalServerError("userdel failed", err)
}
return oscmd.OK(), nil
})
huma.Register(api, huma.Operation{
OperationID: "users-set-password",
Method: "POST",
Path: "/api/users/{username}/password",
Summary: "Set a user's password",
Description: "Sets the password via chpasswd (fed over stdin, so the secret " +
"never appears in the process list). 404 if the user does not exist. " +
"Requires the `root` permission: resetting a privileged account's " +
"password (e.g. root) is a full-system action, not a routine write.",
Tags: []string{tagUsers},
Metadata: op("root"),
Errors: []int{400, 401, 403, 404, 500},
}, func(ctx context.Context, in *SetPasswordInput) (*oscmd.StatusOutput, error) {
if err := validateUsername(in.Username); err != nil {
return nil, err
}
if in.Body.Password == "" {
return nil, huma.Error400BadRequest("empty password")
}
// chpasswd reads one "name:password" line per stdin line, so a newline in
// the password would inject a second line and set another account's password.
if strings.ContainsAny(in.Body.Password, "\n\r") {
return nil, huma.Error400BadRequest("password may not contain newlines")
}
if _, ok, err := lookupUser(in.Username); err != nil {
return nil, huma.Error500InternalServerError("read "+passwdPath+" failed", err)
} else if !ok {
return nil, huma.Error404NotFound("user not found: " + in.Username)
}
// chpasswd reads "name:password" lines from stdin.
if _, err := oscmd.RunStdin(in.Username+":"+in.Body.Password+"\n", "chpasswd"); err != nil {
return nil, huma.Error500InternalServerError("chpasswd failed", err)
}
return oscmd.OK(), nil
})
huma.Register(api, huma.Operation{
OperationID: "users-set-groups",
Method: "PUT",
Path: "/api/users/{username}/groups",
Summary: "Set a user's supplementary groups",
Description: "Replaces the user's full supplementary group set via " +
"`usermod -G` (an empty list removes them from all supplementary " +
"groups). Returns the resulting group membership. 404 if the user " +
"does not exist; 400 if any named group is missing. Requires the " +
"`root` permission: adding an account to wheel/sudo/docker is a " +
"privilege grant, not a routine write.",
Tags: []string{tagUsers},
Metadata: op("root"),
Errors: writeErrors,
}, func(ctx context.Context, in *SetGroupsInput) (*UserGroupsOutput, error) {
if err := validateUsername(in.Username); err != nil {
return nil, err
}
for _, g := range in.Body.Groups {
// Group names follow the same rule as usernames; this also blocks
// flag injection and a stray comma turning one group into two.
if !userNameRe.MatchString(g) {
return nil, huma.Error400BadRequest("invalid group name: " + g)
}
}
if _, ok, err := lookupUser(in.Username); err != nil {
return nil, huma.Error500InternalServerError("read "+passwdPath+" failed", err)
} else if !ok {
return nil, huma.Error404NotFound("user not found: " + in.Username)
}
if _, err := oscmd.Run("usermod", "-G", strings.Join(in.Body.Groups, ","), "--", in.Username); err != nil {
return nil, huma.Error400BadRequest("usermod failed (does every named group exist?)", err)
}
// id -nG lists all groups (primary + supplementary) the user now has.
out, err := oscmd.Run("id", "-nG", in.Username)
if err != nil {
return nil, huma.Error500InternalServerError("groups set but read-back failed", err)
}
res := &UserGroupsOutput{}
res.Body.Username = in.Username
res.Body.Groups = strings.Fields(out)
return res, nil
})
}
// validateUsername rejects empty, flag-like, or malformed names before exec.
func validateUsername(name string) error {
if !userNameRe.MatchString(name) {
return huma.Error400BadRequest("invalid username: " + name)
}
return nil
}
func listUsers() ([]User, error) {
data, err := os.ReadFile(passwdPath)
if err != nil {
return nil, err
}
return parsePasswd(data), nil
}
func lookupUser(name string) (User, bool, error) {
list, err := listUsers()
if err != nil {
return User{}, false, err
}
for _, u := range list {
if u.Username == name {
return u, true, nil
}
}
return User{}, false, nil
}
// parsePasswd parses /etc/passwd content. Lines that are blank, commented, or
// malformed (fewer than 7 fields, non-numeric ids) are skipped.
func parsePasswd(data []byte) []User {
var users []User
for line := range strings.SplitSeq(string(data), "\n") {
if line == "" || strings.HasPrefix(line, "#") {
continue
}
f := strings.Split(line, ":")
if len(f) < 7 {
continue
}
uid, err := strconv.Atoi(f[2])
if err != nil {
continue
}
gid, err := strconv.Atoi(f[3])
if err != nil {
continue
}
users = append(users, User{
Username: f[0],
UID: uid,
GID: gid,
Comment: f[4],
Home: f[5],
Shell: f[6],
System: uid < systemUIDMax,
})
}
return users
}
@@ -0,0 +1,152 @@
package users
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"reflect"
"testing"
"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())
}
func TestUsersHandlers(t *testing.T) {
tempPasswd := filepath.Join(t.TempDir(), "passwd")
initialContent := "root:x:0:0:root:/root:/bin/bash\nalice:x:1000:1000:Alice Smith:/home/alice:/bin/bash\n"
if err := os.WriteFile(tempPasswd, []byte(initialContent), 0644); err != nil {
t.Fatal(err)
}
oldPasswd := passwdPath
passwdPath = tempPasswd
defer func() { passwdPath = oldPasswd }()
mux := http.NewServeMux()
api := humatest.Wrap(t, humago.New(mux, huma.DefaultConfig("Test", "1.0.0")))
registerUsers(api)
// 1. Test GET /api/users
resp := api.Get("/api/users")
if resp.Code != http.StatusOK {
t.Errorf("list users: got %d, want %d", resp.Code, http.StatusOK)
}
var listRes ListUsersOutput
if err := json.Unmarshal(resp.Body.Bytes(), &listRes.Body); err != nil {
t.Fatal(err)
}
if len(listRes.Body.Users) != 2 {
t.Errorf("got %d users, want 2", len(listRes.Body.Users))
}
// 2. Test GET /api/users/{username}
resp = api.Get("/api/users/alice")
if resp.Code != http.StatusOK {
t.Errorf("get user: got %d, want %d", resp.Code, http.StatusOK)
}
var getRes GetUserOutput
if err := json.Unmarshal(resp.Body.Bytes(), &getRes.Body); err != nil {
t.Fatal(err)
}
if getRes.Body.Username != "alice" || getRes.Body.UID != 1000 {
t.Errorf("get user: got %+v", getRes.Body)
}
resp = api.Get("/api/users/bob")
if resp.Code != http.StatusNotFound {
t.Errorf("get non-existent user: got %d, want %d", resp.Code, http.StatusNotFound)
}
// 3. Test POST /api/users
oscmd.SetMock("useradd", func(args []string) oscmd.MockCommand {
wantArgs := []string{"-m", "-c", "Bob Jones", "-s", "/bin/sh", "--", "bob"}
if !reflect.DeepEqual(args, wantArgs) {
t.Errorf("useradd args: got %v, want %v", args, wantArgs)
}
bobContent := initialContent + "bob:x:1001:1001:Bob Jones:/home/bob:/bin/sh\n"
os.WriteFile(tempPasswd, []byte(bobContent), 0644)
return oscmd.MockCommand{ExitCode: 0}
})
defer oscmd.ClearMocks()
resp = api.Post("/api/users", struct {
Username string `json:"username"`
Comment string `json:"comment"`
Shell string `json:"shell"`
CreateHome bool `json:"create_home"`
}{
Username: "bob",
Comment: "Bob Jones",
Shell: "/bin/sh",
CreateHome: true,
})
if resp.Code != http.StatusOK {
t.Errorf("create user: got %d, want %d", resp.Code, http.StatusOK)
}
// 4. Test DELETE /api/users/{username}
oscmd.SetMock("userdel", func(args []string) oscmd.MockCommand {
wantArgs := []string{"-r", "--", "bob"}
if !reflect.DeepEqual(args, wantArgs) {
t.Errorf("userdel args: got %v, want %v", args, wantArgs)
}
os.WriteFile(tempPasswd, []byte(initialContent), 0644)
return oscmd.MockCommand{ExitCode: 0}
})
resp = api.Delete("/api/users/bob?remove_home=true")
if resp.Code != http.StatusOK {
t.Errorf("delete user: got %d, want %d", resp.Code, http.StatusOK)
}
// 5. Test POST /api/users/{username}/password
oscmd.SetMock("chpasswd", func(args []string) oscmd.MockCommand {
return oscmd.MockCommand{ExitCode: 0}
})
resp = api.Post("/api/users/alice/password", struct {
Password string `json:"password"`
}{
Password: "newsecretpwd",
})
if resp.Code != http.StatusOK {
t.Errorf("set password: got %d, want %d", resp.Code, http.StatusOK)
}
// 6. Test PUT /api/users/{username}/groups
oscmd.SetMock("usermod", func(args []string) oscmd.MockCommand {
wantArgs := []string{"-G", "wheel,dev", "--", "alice"}
if !reflect.DeepEqual(args, wantArgs) {
t.Errorf("usermod args: got %v, want %v", args, wantArgs)
}
return oscmd.MockCommand{ExitCode: 0}
})
oscmd.SetMock("id", func(args []string) oscmd.MockCommand {
wantArgs := []string{"-nG", "alice"}
if !reflect.DeepEqual(args, wantArgs) {
t.Errorf("id args: got %v, want %v", args, wantArgs)
}
return oscmd.MockCommand{Stdout: "alice wheel dev\n", ExitCode: 0}
})
resp = api.Put("/api/users/alice/groups", struct {
Groups []string `json:"groups"`
}{
Groups: []string{"wheel", "dev"},
})
if resp.Code != http.StatusOK {
t.Errorf("set groups: got %d, want %d", resp.Code, http.StatusOK)
}
}
+53
View File
@@ -0,0 +1,53 @@
package users
import "testing"
func TestParsePasswd(t *testing.T) {
data := []byte(`root:x:0:0:root:/root:/bin/bash
# a comment
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
alice:x:1000:1000:Alice Smith:/home/alice:/bin/bash
broken:x:notanumber:5:::
short:x:2:2
`)
got := parsePasswd(data)
if len(got) != 3 {
t.Fatalf("expected 3 valid users, got %d: %+v", len(got), got)
}
alice := got[2]
if alice.Username != "alice" || alice.UID != 1000 || alice.GID != 1000 ||
alice.Comment != "Alice Smith" || alice.Home != "/home/alice" ||
alice.Shell != "/bin/bash" || alice.System {
t.Errorf("alice parsed wrong: %+v", alice)
}
if !got[0].System || !got[1].System {
t.Error("root/daemon should be flagged as system accounts")
}
}
func TestValidateUsername(t *testing.T) {
valid := []string{"alice", "_svc", "user-1", "a", "machine$", "abc_def"}
for _, n := range valid {
if err := validateUsername(n); err != nil {
t.Errorf("validateUsername(%q) = %v, want nil", n, err)
}
}
invalid := []string{
"", // empty
"-rf", // leading dash (flag injection)
"Alice", // uppercase
"1user", // leading digit
"a b", // space
"foo;rm", // shell metachar
"root:x", // colon (passwd separator)
"waytoolongusernamethatexceedsthirtytwochars", // >32
}
for _, n := range invalid {
if err := validateUsername(n); err == nil {
t.Errorf("validateUsername(%q) = nil, want error", n)
}
}
}
+68
View File
@@ -0,0 +1,68 @@
// Package mounts parses the kernel mount table (/proc/mounts). Both the system
// dashboard (disk usage) and the storage module (mount management) need it, so
// it lives here rather than being duplicated. It also exposes the octal
// unescaping that /proc/mounts and /etc/fstab use for spaces/tabs in paths.
package mounts
import (
"os"
"strings"
)
// procMounts is a var so tests can point it at a fixture.
var procMounts = "/proc/mounts"
// Mount is one mount-table line: the backing device, where it's mounted, the
// filesystem type, and the comma-separated mount options.
type Mount struct {
Device string `json:"device" example:"/dev/sda1"`
Mountpoint string `json:"mountpoint" example:"/mnt/data"`
FSType string `json:"fstype" example:"ext4"`
Options string `json:"options" example:"rw,relatime"`
}
// Proc reads and parses /proc/mounts.
func Proc() ([]Mount, error) {
data, err := os.ReadFile(procMounts)
if err != nil {
return nil, err
}
return parseProc(string(data)), nil
}
func parseProc(data string) []Mount {
entries := []Mount{}
for line := range strings.SplitSeq(data, "\n") {
f := strings.Fields(line)
if len(f) < 4 { // device mountpoint fstype options [dump pass]
continue
}
entries = append(entries, Mount{
Device: Unescape(f[0]),
Mountpoint: Unescape(f[1]),
FSType: f[2],
Options: f[3],
})
}
return entries
}
// Unescape decodes the octal escapes (\040 space, \011 tab, \012 newline,
// \134 backslash) that mount tables and fstab use for whitespace in fields.
func Unescape(s string) string {
if !strings.ContainsRune(s, '\\') {
return s
}
var b strings.Builder
for i := 0; i < len(s); i++ {
if s[i] == '\\' && i+3 < len(s) && isOctal(s[i+1]) && isOctal(s[i+2]) && isOctal(s[i+3]) {
b.WriteByte((s[i+1]-'0')*64 + (s[i+2]-'0')*8 + (s[i+3] - '0'))
i += 3
continue
}
b.WriteByte(s[i])
}
return b.String()
}
func isOctal(c byte) bool { return c >= '0' && c <= '7' }
+58
View File
@@ -0,0 +1,58 @@
package mounts
import (
"os"
"path/filepath"
"reflect"
"testing"
)
func TestParseProc(t *testing.T) {
// Real /proc/mounts shape, including a pseudo fs and an octal-escaped space.
data := `proc /proc proc rw,nosuid,nodev,noexec 0 0
/dev/sda1 / ext4 rw,relatime 0 0
/dev/sdb1 /mnt/my\040disk ext4 rw 0 0
`
got := parseProc(data)
want := []Mount{
{Device: "proc", Mountpoint: "/proc", FSType: "proc", Options: "rw,nosuid,nodev,noexec"},
{Device: "/dev/sda1", Mountpoint: "/", FSType: "ext4", Options: "rw,relatime"},
{Device: "/dev/sdb1", Mountpoint: "/mnt/my disk", FSType: "ext4", Options: "rw"},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("parseProc:\n got %+v\nwant %+v", got, want)
}
}
func TestUnescape(t *testing.T) {
cases := map[string]string{
`/mnt/my\040disk`: "/mnt/my disk",
`/no/escapes`: "/no/escapes",
`tab\011here`: "tab\there",
`back\134slash`: `back\slash`,
}
for in, want := range cases {
if got := Unescape(in); got != want {
t.Errorf("Unescape(%q) = %q, want %q", in, got, want)
}
}
}
func TestProc(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "mounts")
if err := os.WriteFile(f, []byte("/dev/sda1 / ext4 rw 0 0\n"), 0644); err != nil {
t.Fatal(err)
}
old := procMounts
procMounts = f
defer func() { procMounts = old }()
got, err := Proc()
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].Mountpoint != "/" {
t.Errorf("Proc() = %+v", got)
}
}
+71
View File
@@ -0,0 +1,71 @@
// Package openapitest holds a cross-module guard: it registers every module
// into one Huma API and generates the spec. Huma names OpenAPI schemas by Go
// type name alone (not package-qualified), so two modules declaring an
// identically named response type (e.g. two "ListOutput"s) collide only when
// registered together - a failure invisible to per-package `go build`/`go test`.
package openapitest
import (
"net/http"
"path/filepath"
"testing"
"nadir/internal/auditlog"
"nadir/internal/auth"
"nadir/internal/meta"
"nadir/internal/module"
"nadir/internal/modules/audit"
"nadir/internal/modules/groups"
"nadir/internal/modules/networking"
"nadir/internal/modules/packages"
"nadir/internal/modules/services"
"nadir/internal/modules/storage"
"nadir/internal/modules/system"
"nadir/internal/modules/terminal"
"nadir/internal/modules/users"
"nadir/internal/rbac"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humago"
)
func TestOpenAPISchemaNoCollisions(t *testing.T) {
auditStore, err := auditlog.New(filepath.Join(t.TempDir(), "audit.db"))
if err != nil {
t.Fatal(err)
}
sessions, err := auth.NewSessionStore(filepath.Join(t.TempDir(), "sessions.db"))
if err != nil {
t.Fatal(err)
}
roles := rbac.New()
// Mirror cmd/server.go's registration so this catches real collisions.
mods := []module.Module{
system.New(),
services.New(nil),
users.New(),
groups.New(),
packages.New(),
networking.New(),
storage.New(),
audit.New(auditStore),
terminal.New(sessions),
}
mux := http.NewServeMux()
api := humago.New(mux, huma.DefaultConfig("test", "1.0.0"))
for _, m := range mods {
m.Register(api) // huma panics here on a duplicate schema name
}
meta.Register(api, mods)
meta.RegisterHealth(api, sessions)
meta.RegisterWhoami(api, sessions, roles, mods)
auth.RegisterLogin(api, sessions, auditStore, true)
auth.RegisterLogout(api, sessions, true)
// Force full schema resolution.
if _, err := api.OpenAPI().YAML(); err != nil {
t.Fatalf("OpenAPI generation failed: %v", err)
}
}
+354
View File
@@ -0,0 +1,354 @@
// Package oscmd holds small helpers shared by modules that shell out to system
// tools (hostnamectl, timedatectl, systemctl, …): a command runner that
// surfaces stderr, and the common "status: ok" HTTP response.
package oscmd
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log"
"os"
"os/exec"
"strings"
"sync"
"time"
)
// cmdTimeout caps how long a synchronous system command may run before it is
// killed, so a wedged tool can't tie up a request indefinitely.
//
// It bounds hangs for the plain Run path (which uses context.Background()).
// RunContext additionally propagates client cancellation. Long ops (package
// install/upgrade) use the streaming runners below, which take a ctx and are
// uncapped.
const cmdTimeout = 60 * time.Second
// CommandRunner allows overriding the command execution function for testing.
var CommandRunner = func(ctx context.Context, name string, args ...string) *exec.Cmd {
mockMu.Lock()
handler, ok := mockCmds[name]
mockMu.Unlock()
if ok {
mockRes := handler(args)
tmpFile, err := os.CreateTemp("", "nadir-mock-*.json")
if err != nil {
log.Printf("oscmd mock: failed to create temp file: %v", err)
return exec.CommandContext(ctx, name, args...)
}
encoder := json.NewEncoder(tmpFile)
if err := encoder.Encode(mockRes); err != nil {
log.Printf("oscmd mock: failed to write json: %v", err)
tmpFile.Close()
return exec.CommandContext(ctx, name, args...)
}
tmpPath := tmpFile.Name()
tmpFile.Close()
cmd := exec.CommandContext(ctx, os.Args[0])
cmd.Env = append(os.Environ(),
"GO_WANT_HELPER_PROCESS=1",
"NADIR_MOCK_FILE="+tmpPath,
)
return cmd
}
return exec.CommandContext(ctx, name, args...)
}
// Run executes name with args and returns trimmed stdout. On failure it wraps
// the command's stderr (falling back to the exec error) so handlers can surface
// a meaningful message instead of a bare "exit status 1".
//
// Run uses context.Background(): it is bounded by cmdTimeout but not tied to any
// request. Callers running a slow command on behalf of a request (so a client
// disconnect should kill it) should use RunContext instead.
func Run(name string, args ...string) (string, error) {
return RunContext(context.Background(), name, args...)
}
// RunContext is Run with a caller-supplied context. The command is killed when
// ctx is cancelled (e.g. the client disconnected) or after cmdTimeout, whichever
// comes first.
func RunContext(ctx context.Context, name string, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, cmdTimeout)
defer cancel()
cmd := CommandRunner(ctx, name, args...)
var stderr strings.Builder
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
if msg := strings.TrimSpace(stderr.String()); msg != "" {
return "", errors.New(msg)
}
return "", err
}
return strings.TrimSpace(string(out)), nil
}
// RunStdin is Run with data fed to the command's stdin. Use it for secrets
// (e.g. piping "user:password" to chpasswd) so they never appear in argv/ps.
func RunStdin(stdin, name string, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), cmdTimeout)
defer cancel()
cmd := CommandRunner(ctx, name, args...)
cmd.Stdin = strings.NewReader(stdin)
var stderr strings.Builder
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
if msg := strings.TrimSpace(stderr.String()); msg != "" {
return "", errors.New(msg)
}
return "", err
}
return strings.TrimSpace(string(out)), nil
}
// RunLines runs the command and splits stdout into non-empty lines.
func RunLines(name string, args ...string) ([]string, error) {
out, err := Run(name, args...)
if err != nil {
return nil, err
}
if out == "" {
return []string{}, nil
}
return strings.Split(out, "\n"), nil
}
// RunStatus runs name and returns trimmed stdout plus the process exit code. It
// does NOT treat a non-zero exit as an error, because some tools signal state
// that way (dnf `check-update` exits 100 when updates exist; pacman `-Qu` exits
// 1 when there are none). err is set only when the command could not be run.
func RunStatus(name string, args ...string) (string, int, error) {
ctx, cancel := context.WithTimeout(context.Background(), cmdTimeout)
defer cancel()
cmd := CommandRunner(ctx, name, args...)
out, err := cmd.Output()
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
return strings.TrimSpace(string(out)), ee.ExitCode(), nil
}
return "", -1, err
}
return strings.TrimSpace(string(out)), 0, nil
}
// RunStream runs a long-lived command (e.g. `journalctl -f`) and pushes its
// stdout lines onto the returned channel until the process exits or ctx is
// cancelled. Cancelling ctx kills the process (exec.CommandContext) and closes
// the channel, so an SSE handler can stop simply by cancelling its request
// context (client disconnect).
func RunStream(ctx context.Context, name string, args ...string) (<-chan string, error) {
cmd := CommandRunner(ctx, name, args...)
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
if err := cmd.Start(); err != nil {
return nil, err
}
ch := make(chan string)
go func() {
defer close(ch)
defer cmd.Wait() // reap; process is already killed on ctx cancel
sc := bufio.NewScanner(stdout)
sc.Buffer(make([]byte, 64*1024), 1024*1024) // tolerate long log lines
for sc.Scan() {
select {
case ch <- sc.Text():
case <-ctx.Done():
return
}
}
// A scan error (e.g. a line over the buffer cap) ends the loop silently
// otherwise; surface it so a truncated stream is diagnosable. Context
// cancellation returns above, so this only fires on a real read error.
if err := sc.Err(); err != nil && ctx.Err() == nil {
log.Printf("oscmd: stream %s: %v", name, err)
}
}()
return ch, nil
}
// RunStreamCombined runs a command and streams its merged stdout+stderr line by
// line, the way the command's own terminal output looks. Lines are split on \r
// as well as \n so progress redraws (apt/dnf) stream instead of buffering until
// a newline. extraEnv is appended to the process environment.
//
// It returns a lines channel and a one-shot error channel that delivers the
// process's exit status after the lines channel closes (nil = success).
// Cancelling ctx kills the process.
func RunStreamCombined(ctx context.Context, extraEnv []string, name string, args ...string) (<-chan string, <-chan error, error) {
cmd := CommandRunner(ctx, name, args...)
if len(extraEnv) > 0 {
if cmd.Env == nil {
cmd.Env = append(os.Environ(), extraEnv...)
} else {
cmd.Env = append(cmd.Env, extraEnv...)
}
}
// One OS pipe for both streams gives the natural interleaving; passing an
// *os.File hands the fd straight to the child (no copy goroutine).
pr, pw, err := os.Pipe()
if err != nil {
return nil, nil, err
}
cmd.Stdout, cmd.Stderr = pw, pw
if err := cmd.Start(); err != nil {
pr.Close()
pw.Close()
return nil, nil, err
}
pw.Close() // the child holds its own dup; the reader sees EOF when it exits
lines := make(chan string)
errc := make(chan error, 1)
go func() {
defer close(lines)
defer func() { errc <- cmd.Wait() }()
defer pr.Close()
sc := bufio.NewScanner(pr)
sc.Buffer(make([]byte, 64*1024), 1024*1024)
sc.Split(scanLinesCR)
for sc.Scan() {
if line := sc.Text(); line != "" {
select {
case lines <- line:
case <-ctx.Done():
return
}
}
}
// Surface a read error (e.g. a line over the buffer cap) so a truncated
// stream is diagnosable. ctx cancellation returns above, so this only
// fires on a real error; the exit status still travels via errc.
if err := sc.Err(); err != nil && ctx.Err() == nil {
log.Printf("oscmd: stream %s: %v", name, err)
}
}()
return lines, errc, nil
}
// scanLinesCR is a bufio.SplitFunc that breaks on either \r or \n, so terminal
// progress redraws (carriage returns) surface as they happen.
func scanLinesCR(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF && len(data) == 0 {
return 0, nil, nil
}
if i := bytes.IndexAny(data, "\r\n"); i >= 0 {
return i + 1, data[:i], nil
}
if atEOF {
return len(data), data, nil
}
return 0, nil, nil // request more data
}
// ParseKV parses key=value lines (e.g. from timedatectl/systemctl show) into a map.
func ParseKV(lines []string) map[string]string {
m := make(map[string]string, len(lines))
for _, line := range lines {
if k, v, ok := strings.Cut(line, "="); ok {
m[k] = v
}
}
return m
}
// StatusOutput is the shared response for write operations that just report
// success. Reusing one type means all such endpoints share a single OpenAPI
// schema.
type StatusOutput struct {
Body struct {
Status string `json:"status" example:"ok" doc:"Always \"ok\" on success"`
}
}
// OK returns a populated StatusOutput.
func OK() *StatusOutput {
out := &StatusOutput{}
out.Body.Status = "ok"
return out
}
// --- Mocking helpers for testing ---------------------------------------------
// MockCommand holds the behavior for a mocked command.
type MockCommand struct {
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
ExitCode int `json:"exit_code"`
Lines []string `json:"lines,omitempty"`
DelayMs int `json:"delay_ms,omitempty"`
}
var (
mockMu sync.Mutex
mockCmds = make(map[string]func(args []string) MockCommand)
)
// SetMock registers a mock handler function for the given command name.
func SetMock(name string, handler func(args []string) MockCommand) {
mockMu.Lock()
defer mockMu.Unlock()
mockCmds[name] = handler
}
// ClearMocks removes all registered mock command handlers.
func ClearMocks() {
mockMu.Lock()
defer mockMu.Unlock()
clear(mockCmds)
}
// RunHelperProcess executes the mock helper process logic if GO_WANT_HELPER_PROCESS is set.
// It returns true if it ran (and exits the process), false otherwise.
func RunHelperProcess() bool {
if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
return false
}
mockFile := os.Getenv("NADIR_MOCK_FILE")
if mockFile == "" {
os.Exit(1)
}
defer os.Remove(mockFile)
data, err := os.ReadFile(mockFile)
if err != nil {
fmt.Fprintf(os.Stderr, "mock helper: read failed: %v\n", err)
os.Exit(1)
}
var mock MockCommand
if err := json.Unmarshal(data, &mock); err != nil {
fmt.Fprintf(os.Stderr, "mock helper: unmarshal failed: %v\n", err)
os.Exit(1)
}
if len(mock.Lines) > 0 {
for _, line := range mock.Lines {
fmt.Fprintln(os.Stdout, line)
if mock.DelayMs > 0 {
time.Sleep(time.Duration(mock.DelayMs) * time.Millisecond)
}
}
} else {
if mock.Stdout != "" {
fmt.Fprint(os.Stdout, mock.Stdout)
}
if mock.Stderr != "" {
fmt.Fprint(os.Stderr, mock.Stderr)
}
}
os.Exit(mock.ExitCode)
return true
}
+143
View File
@@ -0,0 +1,143 @@
package oscmd
import (
"os"
"reflect"
"strings"
"testing"
)
func TestMain(m *testing.M) {
if RunHelperProcess() {
return
}
os.Exit(m.Run())
}
func TestRunTrimsStdout(t *testing.T) {
out, err := Run("echo", "hello")
if err != nil {
t.Fatal(err)
}
if out != "hello" {
t.Errorf("got %q, want %q", out, "hello")
}
}
func TestRunSurfacesStderr(t *testing.T) {
_, err := Run("sh", "-c", "echo boom >&2; exit 1")
if err == nil || !strings.Contains(err.Error(), "boom") {
t.Fatalf("expected stderr 'boom' in error, got %v", err)
}
}
func TestRunMissingBinary(t *testing.T) {
if _, err := Run("definitely-not-a-real-binary-xyz"); err == nil {
t.Fatal("expected error for missing binary")
}
}
func TestRunLines(t *testing.T) {
got, err := RunLines("printf", `a\nb\nc\n`)
if err != nil {
t.Fatal(err)
}
want := []string{"a", "b", "c"}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %v, want %v", got, want)
}
}
func TestRunLinesEmpty(t *testing.T) {
got, err := RunLines("true")
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Errorf("empty output should yield no lines, got %v", got)
}
}
func TestOK(t *testing.T) {
if OK().Body.Status != "ok" {
t.Error("OK() should report status ok")
}
}
func TestRunStdin(t *testing.T) {
SetMock("cat", func(args []string) MockCommand {
return MockCommand{Stdout: "piped input data", ExitCode: 0}
})
defer ClearMocks()
got, err := RunStdin("some input", "cat")
if err != nil {
t.Fatal(err)
}
if got != "piped input data" {
t.Errorf("got %q, want %q", got, "piped input data")
}
}
func TestRunStatus(t *testing.T) {
SetMock("check-update", func(args []string) MockCommand {
return MockCommand{Stdout: "updates available", ExitCode: 100}
})
defer ClearMocks()
out, code, err := RunStatus("check-update")
if err != nil {
t.Fatal(err)
}
if out != "updates available" || code != 100 {
t.Errorf("got out=%q code=%d, want out=%q code=100", out, code, "updates available")
}
}
func TestRunStream(t *testing.T) {
SetMock("stream-cmd", func(args []string) MockCommand {
return MockCommand{Lines: []string{"line1", "line2"}, DelayMs: 1}
})
defer ClearMocks()
ch, err := RunStream(t.Context(), "stream-cmd")
if err != nil {
t.Fatal(err)
}
var got []string
for line := range ch {
got = append(got, line)
}
want := []string{"line1", "line2"}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %v, want %v", got, want)
}
}
func TestRunStreamCombined(t *testing.T) {
SetMock("combined-cmd", func(args []string) MockCommand {
return MockCommand{Lines: []string{"output1", "output2"}, ExitCode: 0}
})
defer ClearMocks()
lines, errc, err := RunStreamCombined(t.Context(), nil, "combined-cmd")
if err != nil {
t.Fatal(err)
}
var got []string
for line := range lines {
got = append(got, line)
}
if err := <-errc; err != nil {
t.Errorf("stream failed with error: %v", err)
}
want := []string{"output1", "output2"}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %v, want %v", got, want)
}
}
+32
View File
@@ -0,0 +1,32 @@
package rbac
import (
"testing"
)
func BenchmarkCan(b *testing.B) {
r := New()
r.DefineRole(Role{Name: "admin", ModuleGrants: map[string][]Permission{"*": {"*"}}})
r.DefineRole(Role{Name: "viewer", ModuleGrants: map[string][]Permission{
"system": {"read"},
"services": {"read"},
"audit": {"read"},
}})
r.DefineRole(Role{Name: "operator", ModuleGrants: map[string][]Permission{
"system": {"read", "root"},
"services": {"read", "write"},
}})
r.AssignRole("alice", "admin")
r.AssignRole("bob", "viewer")
r.AssignRole("charlie", "viewer")
r.AssignRole("charlie", "operator")
// b.Loop automatically excludes the setup above from timing (no ResetTimer
// needed) and keeps the calls from being optimised away.
for b.Loop() {
_ = r.Can("charlie", "services", "write")
_ = r.Can("bob", "system", "root")
_ = r.Can("alice", "audit", "write")
}
}
+121
View File
@@ -0,0 +1,121 @@
package rbac
import (
"net/http"
"net/url"
"nadir/internal/auditlog"
"nadir/internal/auth"
"github.com/danielgtaylor/huma/v2"
)
func RbacMiddleware(api huma.API, sessions *auth.SessionStore, tokens *auth.TokenAuth, roles *RBAC, auditor *auditlog.Store) func(huma.Context, func(huma.Context)) {
return func(ctx huma.Context, next func(huma.Context)) {
// CSRF defense-in-depth (beyond the SameSite=Strict cookie): reject any
// state-changing request whose Origin doesn't match our Host. Runs before
// the permission check so it also covers the public login/logout writes.
//
// Bearer tokens are exempt by construction: browsers don't auto-attach an
// Authorization header, so a token request carries no ambient authority for
// CSRF to exploit. Such requests also send no Origin, so they pass here too.
if !sameOriginOK(ctx) {
huma.WriteErr(api, ctx, http.StatusForbidden, "cross-origin request blocked")
return
}
op := ctx.Operation()
perm, _ := op.Metadata["permission"].(string)
if perm == "" {
next(ctx)
return
}
moduleID, _ := op.Metadata["module"].(string)
// Machine-to-machine: a Bearer token authenticates as its token name (a
// dashboard managing N nodes needs no PAM session). Checked before the
// cookie. The token name is the RBAC subject - assign it a role in
// config.yaml's `assignments`, same as a username.
if raw, isBearer := auth.BearerToken(ctx.Header("Authorization")); isBearer {
if tokens == nil {
huma.WriteErr(api, ctx, http.StatusUnauthorized, "unauthorized")
return
}
name, ok, throttled := tokens.Verify(auth.ClientIP(ctx.Context()), raw)
if throttled {
huma.WriteErr(api, ctx, http.StatusTooManyRequests, "too many failed token attempts; wait a minute")
return
}
if !ok {
huma.WriteErr(api, ctx, http.StatusUnauthorized, "unauthorized")
return
}
// Audit actor is prefixed "token:" so the trail distinguishes a token
// from a human with the same name; RBAC still checks the bare name.
if !roles.Can(name, moduleID, Permission(perm)) {
huma.WriteErr(api, ctx, http.StatusForbidden, "forbidden")
record(auditor, "token:"+name, op, ctx.URL().Path, http.StatusForbidden)
return
}
next(ctx)
record(auditor, "token:"+name, op, ctx.URL().Path, ctx.Status())
return
}
cookie, err := huma.ReadCookie(ctx, "nadir_session_id")
if err != nil || cookie == nil {
huma.WriteErr(api, ctx, http.StatusUnauthorized, "unauthorized")
return
}
sess, ok := sessions.GetByToken(cookie.Value)
if !ok {
huma.WriteErr(api, ctx, http.StatusUnauthorized, "unauthorized")
return
}
if !roles.Can(sess.Username, moduleID, Permission(perm)) {
huma.WriteErr(api, ctx, http.StatusForbidden, "forbidden")
record(auditor, sess.Username, op, ctx.URL().Path, http.StatusForbidden)
return
}
next(ctx)
record(auditor, sess.Username, op, ctx.URL().Path, ctx.Status())
}
}
// sameOriginOK allows safe methods and any request whose Origin header matches
// the request Host. A missing Origin (non-browser client, or a same-origin
// navigation) is allowed - CSRF is a browser-only concern. Only browser-issued
// cross-origin writes, which always carry an Origin, are rejected.
func sameOriginOK(ctx huma.Context) bool {
switch ctx.Method() {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return true
}
origin := ctx.Header("Origin")
if origin == "" {
return true
}
u, err := url.Parse(origin)
if err != nil {
return false
}
return u.Host == ctx.Host()
}
// record logs a mutation (anything but a read) to the audit trail. Reads are
// skipped to keep the trail to "who changed what". Best-effort: a logging
// failure is reported to the server log, never to the caller.
func record(auditor *auditlog.Store, username string, op *huma.Operation, path string, status int) {
if op.Method == http.MethodGet || op.Method == http.MethodHead || op.Method == http.MethodOptions {
return
}
// SSE handlers stream via BodyWriter and never call SetStatus, so huma's
// context reports status 0 even though net/http has implicitly sent a 200.
// A streamed response that reached here passed RBAC and started, so record
// it as 200 (stream-level failures surface as `error` events, not codes).
if status == 0 {
status = http.StatusOK
}
moduleID, _ := op.Metadata["module"].(string)
auditor.Record(username, op.Method, path, moduleID, status)
}
+165
View File
@@ -0,0 +1,165 @@
package rbac
import (
"context"
"net/http"
"os"
"path/filepath"
"testing"
"nadir/internal/auditlog"
"nadir/internal/auth"
"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())
}
func TestRbacMiddleware(t *testing.T) {
tempDir := t.TempDir()
auditStore, err := auditlog.New(filepath.Join(tempDir, "audit.db"))
if err != nil {
t.Fatal(err)
}
defer auditStore.Close()
sessions, err := auth.NewSessionStore(filepath.Join(tempDir, "sessions.db"))
if err != nil {
t.Fatal(err)
}
tokenStore, err := auth.NewTokenStore(filepath.Join(tempDir, "tokens.db"))
if err != nil {
t.Fatal(err)
}
tokenAuth := auth.NewTokenAuth(tokenStore)
r := New()
r.DefineRole(Role{
Name: "test-role",
ModuleGrants: map[string][]Permission{
"test-mod": {Read, Write},
},
})
r.AssignRole("alice", "test-role")
// A machine credential is just another RBAC subject: the token name is
// assigned a role exactly like a username.
r.AssignRole("dash", "test-role")
mux := http.NewServeMux()
api := humatest.Wrap(t, humago.New(mux, huma.DefaultConfig("Test", "1.0.0")))
api.UseMiddleware(RbacMiddleware(api, sessions, tokenAuth, r, auditStore))
huma.Register(api, huma.Operation{
OperationID: "public-get",
Method: "GET",
Path: "/public",
}, func(ctx context.Context, _ *struct{}) (*struct{ Body string }, error) {
return &struct{ Body string }{Body: "public"}, nil
})
huma.Register(api, huma.Operation{
OperationID: "gated-get",
Method: "GET",
Path: "/gated-read",
Metadata: map[string]any{"module": "test-mod", "permission": "read"},
}, func(ctx context.Context, _ *struct{}) (*struct{ Body string }, error) {
return &struct{ Body string }{Body: "gated-read"}, nil
})
huma.Register(api, huma.Operation{
OperationID: "gated-post",
Method: "POST",
Path: "/gated-write",
Metadata: map[string]any{"module": "test-mod", "permission": "write"},
}, func(ctx context.Context, _ *struct{}) (*struct{ Body string }, error) {
return &struct{ Body string }{Body: "gated-write"}, nil
})
// 1. Test public route
resp := api.Get("/public")
if resp.Code != http.StatusOK {
t.Errorf("public GET: got status %d, want %d", resp.Code, http.StatusOK)
}
// 2. Test gated route without cookie -> 401 Unauthorized
resp = api.Get("/gated-read")
if resp.Code != http.StatusUnauthorized {
t.Errorf("gated GET no cookie: got status %d, want %d", resp.Code, http.StatusUnauthorized)
}
// 3. Test gated route with invalid cookie -> 401 Unauthorized
resp = api.Get("/gated-read", "Cookie: nadir_session_id=invalid")
if resp.Code != http.StatusUnauthorized {
t.Errorf("gated GET invalid cookie: got status %d, want %d", resp.Code, http.StatusUnauthorized)
}
// Create valid session
token, err := sessions.Create("alice")
if err != nil {
t.Fatal(err)
}
// 4. Test gated route with valid cookie -> 200 OK
resp = api.Get("/gated-read", "Cookie: nadir_session_id="+token)
if resp.Code != http.StatusOK {
t.Errorf("gated GET valid cookie: got status %d, want %d", resp.Code, http.StatusOK)
}
// 5. Test CSRF violation: POST with mismatched Origin header -> 403 Forbidden
resp = api.Post("/gated-write", "Cookie: nadir_session_id="+token, "Origin: http://evil.com", "Host: example.com", struct{}{})
if resp.Code != http.StatusForbidden {
t.Errorf("CSRF mismatched Origin: got status %d, want %d", resp.Code, http.StatusForbidden)
}
// 6. Test CSRF success: POST with matching Origin header -> 200 OK
resp = api.Post("/gated-write", "Cookie: nadir_session_id="+token, "Origin: http://example.com", "Host: example.com", struct{}{})
if resp.Code != http.StatusOK {
t.Errorf("CSRF matching Origin: got status %d, want %d", resp.Code, http.StatusOK)
}
// 7. Test gated route with unauthorized user -> 403 Forbidden
tokenBob, err := sessions.Create("bob")
if err != nil {
t.Fatal(err)
}
resp = api.Get("/gated-read", "Cookie: nadir_session_id="+tokenBob)
if resp.Code != http.StatusForbidden {
t.Errorf("bob unauthorized GET: got status %d, want %d", resp.Code, http.StatusForbidden)
}
// 8. Bearer token for an assigned name -> 200 OK
rawToken, err := tokenStore.Create("dash")
if err != nil {
t.Fatal(err)
}
resp = api.Get("/gated-read", "Authorization: Bearer "+rawToken)
if resp.Code != http.StatusOK {
t.Errorf("valid bearer GET: got status %d, want %d", resp.Code, http.StatusOK)
}
// 9. Bogus bearer token -> 401 Unauthorized
resp = api.Get("/gated-read", "Authorization: Bearer nad_deadbeef")
if resp.Code != http.StatusUnauthorized {
t.Errorf("bogus bearer GET: got status %d, want %d", resp.Code, http.StatusUnauthorized)
}
// 10. Bearer token with no role assignment -> 403 Forbidden
rawUnassigned, err := tokenStore.Create("orphan")
if err != nil {
t.Fatal(err)
}
resp = api.Get("/gated-read", "Authorization: Bearer "+rawUnassigned)
if resp.Code != http.StatusForbidden {
t.Errorf("unassigned bearer GET: got status %d, want %d", resp.Code, http.StatusForbidden)
}
}
+71
View File
@@ -0,0 +1,71 @@
package rbac
type Permission string
const (
Read Permission = "read"
Write Permission = "write"
// Root is the high-impact tier: destructive or irreversible operations
// (reboot, shutdown, account deletion, firewall flush, …) that callers
// should be able to grant separately from routine writes.
Root Permission = "root"
All Permission = "*" // wildcard: matches any permission
)
// Wildcard is the module-key value that matches all modules in a Role's grants.
const Wildcard = "*"
type Role struct {
Name string
ModuleGrants map[string][]Permission // module ID (or "*") -> permissions (each may be "*")
}
type RBAC struct {
roles map[string]Role
userRoles map[string][]string
}
func New() *RBAC {
return &RBAC{
roles: make(map[string]Role),
userRoles: make(map[string][]string),
}
}
func (r *RBAC) DefineRole(role Role) {
r.roles[role.Name] = role
}
// RoleExists reports whether a role with the given name has been defined.
func (r *RBAC) RoleExists(name string) bool {
_, ok := r.roles[name]
return ok
}
func (r *RBAC) AssignRole(username, roleName string) {
r.userRoles[username] = append(r.userRoles[username], roleName)
}
// Can checks whether the user holds any role granting (module, perm),
// honoring "*" wildcards on both the module key and inside the permission list.
func (r *RBAC) Can(username, module string, perm Permission) bool {
for _, roleName := range r.userRoles[username] {
role, ok := r.roles[roleName]
if !ok {
continue
}
// Check the exact module key AND the wildcard module key.
for _, key := range []string{module, Wildcard} {
grants, ok := role.ModuleGrants[key]
if !ok {
continue
}
for _, p := range grants {
if p == perm || p == All {
return true
}
}
}
}
return false
}
+84
View File
@@ -0,0 +1,84 @@
package rbac
import "testing"
// build sets up an RBAC store with the given roles, then assigns them all to
// user "u".
func build(t *testing.T, roles ...Role) *RBAC {
t.Helper()
r := New()
for _, role := range roles {
r.DefineRole(role)
r.AssignRole("u", role.Name)
}
return r
}
func TestCan(t *testing.T) {
admin := Role{Name: "admin", ModuleGrants: map[string][]Permission{Wildcard: {All}}}
reader := Role{Name: "reader", ModuleGrants: map[string][]Permission{Wildcard: {Read}}}
sysWrite := Role{Name: "sysw", ModuleGrants: map[string][]Permission{"system": {Read, Write}}}
tests := []struct {
name string
role Role
module string
perm Permission
want bool
}{
{"wildcard module + wildcard perm", admin, "system", Root, true},
{"wildcard module + wildcard perm, other module", admin, "services", Write, true},
{"wildcard module, read only, read", reader, "anything", Read, true},
{"wildcard module, read only, write denied", reader, "anything", Write, false},
{"exact module exact perm", sysWrite, "system", Write, true},
{"exact module missing perm", sysWrite, "system", Root, false},
{"exact module, different module denied", sysWrite, "services", Read, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := build(t, tt.role)
if got := r.Can("u", tt.module, tt.perm); got != tt.want {
t.Errorf("Can(u, %q, %q) = %v, want %v", tt.module, tt.perm, got, tt.want)
}
})
}
}
func TestCanNoRolesDenied(t *testing.T) {
r := New()
if r.Can("nobody", "system", Read) {
t.Fatal("user with no roles was granted access")
}
}
func TestCanAssignedUndefinedRoleSkipped(t *testing.T) {
r := New()
r.AssignRole("u", "ghost") // never defined
if r.Can("u", "system", Read) {
t.Fatal("undefined role granted access")
}
}
func TestCanUnionOfRoles(t *testing.T) {
r := build(t,
Role{Name: "a", ModuleGrants: map[string][]Permission{"system": {Read}}},
Role{Name: "b", ModuleGrants: map[string][]Permission{"services": {Write}}},
)
if !r.Can("u", "system", Read) || !r.Can("u", "services", Write) {
t.Fatal("union of roles not honored")
}
if r.Can("u", "system", Write) {
t.Fatal("permission leaked across modules")
}
}
func TestRoleExists(t *testing.T) {
r := New()
r.DefineRole(Role{Name: "x"})
if !r.RoleExists("x") {
t.Error("defined role reported missing")
}
if r.RoleExists("y") {
t.Error("undefined role reported present")
}
}