This commit is contained in:
@@ -40,9 +40,8 @@ func (a *TokenAuth) Verify(ip, raw string) (name string, ok, throttled bool) {
|
||||
// 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) {
|
||||
if len(authHeader) <= 7 || !strings.EqualFold(authHeader[:7], "Bearer ") {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimSpace(authHeader[len(scheme):]), true
|
||||
return strings.TrimSpace(authHeader[7:]), true
|
||||
}
|
||||
|
||||
@@ -10,20 +10,12 @@ import (
|
||||
"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
|
||||
|
||||
@@ -89,11 +89,6 @@ func (s *SessionStore) GetByToken(token string) (Session, bool) {
|
||||
|
||||
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())
|
||||
}
|
||||
rand.Read(b) // never fails; rand.Read panics internally on misconfigured platforms.
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
@@ -60,6 +60,11 @@ type Server struct {
|
||||
TrustProxy bool `yaml:"trust_proxy"`
|
||||
TLSCert string `yaml:"tls_cert"`
|
||||
TLSKey string `yaml:"tls_key"`
|
||||
|
||||
// ReleaseRepo is the Gitea repo URL used by /install.sh to fetch the
|
||||
// latest binary. Example: https://gitea.example.com/urania/nadir.
|
||||
// Empty disables the install.sh endpoint.
|
||||
ReleaseRepo string `yaml:"release_repo"`
|
||||
}
|
||||
|
||||
// SecureCookie reports whether the session cookie should carry the Secure
|
||||
|
||||
@@ -20,7 +20,6 @@ type fakeModule struct {
|
||||
}
|
||||
|
||||
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) {}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ func Register(api huma.API, mods []module.Module) {
|
||||
for i, p := range perms {
|
||||
ps[i] = string(p)
|
||||
}
|
||||
infos = append(infos, ModuleInfo{ID: m.ID(), Name: m.Name(), Permissions: ps})
|
||||
infos = append(infos, ModuleInfo{ID: m.ID(), Name: module.Title(m.ID()), Permissions: ps})
|
||||
}
|
||||
slices.SortFunc(infos, func(a, b ModuleInfo) int { return cmp.Compare(a.ID, b.ID) })
|
||||
|
||||
|
||||
@@ -8,7 +8,19 @@ import (
|
||||
|
||||
type Module interface {
|
||||
ID() string
|
||||
Name() string
|
||||
Permissions() []rbac.Permission // permissions this module exposes (no "*")
|
||||
Register(api huma.API)
|
||||
}
|
||||
|
||||
// Title returns the display name for a module ID, capitalizing the first
|
||||
// letter (modules are single lowercase words: "system" -> "System").
|
||||
func Title(id string) string {
|
||||
if id == "" {
|
||||
return ""
|
||||
}
|
||||
b := id[0]
|
||||
if b >= 'a' && b <= 'z' {
|
||||
b -= 'a' - 'A'
|
||||
}
|
||||
return string(b) + id[1:]
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ type Module struct {
|
||||
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.
|
||||
|
||||
@@ -15,13 +15,6 @@ import (
|
||||
"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"
|
||||
|
||||
@@ -13,7 +13,6 @@ 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.
|
||||
|
||||
@@ -24,7 +24,6 @@ type Module struct {
|
||||
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).
|
||||
|
||||
@@ -19,13 +19,6 @@ import (
|
||||
"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
|
||||
|
||||
@@ -16,7 +16,6 @@ type Module struct {
|
||||
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.
|
||||
|
||||
@@ -3,7 +3,6 @@ package packages
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -15,13 +14,6 @@ import (
|
||||
"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"}
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ type Module struct {
|
||||
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).
|
||||
|
||||
@@ -16,13 +16,6 @@ import (
|
||||
"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")))
|
||||
|
||||
@@ -13,7 +13,6 @@ 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.
|
||||
|
||||
@@ -6,17 +6,8 @@ import (
|
||||
"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
|
||||
|
||||
@@ -13,7 +13,6 @@ 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}
|
||||
|
||||
@@ -3,7 +3,6 @@ package system
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
@@ -14,13 +13,6 @@ import (
|
||||
"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")))
|
||||
|
||||
@@ -46,7 +46,6 @@ func New(sessions *auth.SessionStore) module.Module {
|
||||
}
|
||||
|
||||
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}
|
||||
|
||||
@@ -13,7 +13,6 @@ 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).
|
||||
|
||||
@@ -15,13 +15,6 @@ import (
|
||||
"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"
|
||||
|
||||
+44
-74
@@ -7,7 +7,6 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -28,37 +27,51 @@ import (
|
||||
const cmdTimeout = 60 * time.Second
|
||||
|
||||
// CommandRunner allows overriding the command execution function for testing.
|
||||
// In tests, register a handler with SetMock; the runner translates the
|
||||
// MockCommand into a /bin/sh script that reproduces the stdout/stderr/exit
|
||||
// behavior — no helper process, no temp file.
|
||||
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
|
||||
if !ok {
|
||||
return exec.CommandContext(ctx, name, args...)
|
||||
}
|
||||
return exec.CommandContext(ctx, "/bin/sh", "-c", mockScript(handler(args)))
|
||||
}
|
||||
|
||||
return exec.CommandContext(ctx, name, args...)
|
||||
// mockScript builds the shell script that emits a MockCommand's behavior.
|
||||
// Uses printf so backslashes and percent signs in output pass through verbatim.
|
||||
func mockScript(m MockCommand) string {
|
||||
var b strings.Builder
|
||||
if len(m.Lines) > 0 {
|
||||
for _, line := range m.Lines {
|
||||
b.WriteString("printf '%s\\n' ")
|
||||
b.WriteString(shellQuote(line))
|
||||
b.WriteByte('\n')
|
||||
if m.DelayMs > 0 {
|
||||
fmt.Fprintf(&b, "sleep %g\n", float64(m.DelayMs)/1000)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if m.Stdout != "" {
|
||||
b.WriteString("printf '%s' ")
|
||||
b.WriteString(shellQuote(m.Stdout))
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
if m.Stderr != "" {
|
||||
b.WriteString("printf '%s' ")
|
||||
b.WriteString(shellQuote(m.Stderr))
|
||||
b.WriteString(" 1>&2\n")
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&b, "exit %d\n", m.ExitCode)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// shellQuote returns s wrapped in single quotes, with embedded single quotes escaped.
|
||||
func shellQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||
}
|
||||
|
||||
// Run executes name with args and returns trimmed stdout. On failure it wraps
|
||||
@@ -283,11 +296,11 @@ func OK() *StatusOutput {
|
||||
|
||||
// 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"`
|
||||
Stdout string
|
||||
Stderr string
|
||||
ExitCode int
|
||||
Lines []string
|
||||
DelayMs int
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -309,46 +322,3 @@ func ClearMocks() {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
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 {
|
||||
|
||||
@@ -3,26 +3,17 @@ 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"))
|
||||
|
||||
Reference in New Issue
Block a user