feat: first release
build-and-release / release (push) Failing after 17m7s

This commit is contained in:
2026-06-22 16:51:18 +02:00
parent fe485dd86d
commit 2bf11dda91
29 changed files with 182 additions and 191 deletions
+2 -3
View File
@@ -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
}
-8
View File
@@ -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
+1 -6
View File
@@ -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)
}
+5
View File
@@ -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
-1
View File
@@ -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) {}
+1 -1
View File
@@ -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) })
+13 -1
View File
@@ -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:]
}
-1
View File
@@ -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"
-1
View File
@@ -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.
-1
View File
@@ -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
-1
View File
@@ -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"}
-1
View File
@@ -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")))
-1
View File
@@ -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.
-9
View File
@@ -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
-1
View File
@@ -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")))
-1
View File
@@ -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}
-1
View File
@@ -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
View File
@@ -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
}
-8
View File
@@ -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 {
-9
View File
@@ -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"))