17 Commits

Author SHA1 Message Date
urania 8fc4b236ac fix: whomai with bearer
build-and-release / release (push) Successful in 2m39s
2026-06-23 18:02:52 +02:00
urania 37f03816e1 fix: networking/interfaces
build-and-release / release (push) Successful in 2m29s
2026-06-23 17:40:44 +02:00
urania 541260e65e fix: networking/interfaces
build-and-release / release (push) Has been cancelled
2026-06-23 17:40:17 +02:00
urania c4180bada1 fix: networking / services / and testing
build-and-release / release (push) Successful in 2m40s
2026-06-23 17:16:01 +02:00
urania 088880f584 fix: updates
build-and-release / release (push) Successful in 2m39s
2026-06-23 14:27:06 +02:00
urania 67d95475ee fix: added package update endpoint
build-and-release / release (push) Successful in 3m1s
2026-06-23 13:15:39 +02:00
urania aec04bfe02 fix: storages and mounts
build-and-release / release (push) Successful in 2m52s
2026-06-23 11:50:55 +02:00
urania a54c42271c minor: test
build-and-release / release (push) Successful in 1m9s
2026-06-23 01:23:19 +02:00
urania b10abb24e3 fix: various
build-and-release / release (push) Successful in 2m49s
2026-06-22 22:40:34 +02:00
urania 9587d11e21 fix: localectl
build-and-release / release (push) Successful in 2m38s
2026-06-22 22:22:36 +02:00
urania ac196e720b fix: add locale
build-and-release / release (push) Successful in 2m38s
2026-06-22 21:58:01 +02:00
urania a106b7413f fix: workflow
build-and-release / release (push) Successful in 2m37s
2026-06-22 20:12:54 +02:00
urania 0e041fac5e fix: .minisign for signed releases
build-and-release / release (push) Failing after 2m1s
2026-06-22 20:03:27 +02:00
urania eba478471f fix: --v
build-and-release / release (push) Successful in 2m3s
2026-06-22 19:29:04 +02:00
urania dbce9aa56e fix: minor changes on update
build-and-release / release (push) Successful in 2m5s
2026-06-22 19:15:06 +02:00
urania 60b9fbc42c fix: cpu info
build-and-release / release (push) Successful in 2m4s
2026-06-22 18:54:50 +02:00
urania fff43a5ab6 fix: auto-updates
build-and-release / release (push) Successful in 2m6s
2026-06-22 18:24:59 +02:00
33 changed files with 1440 additions and 74 deletions
+15
View File
@@ -0,0 +1,15 @@
{
"permissions": {
"allow": [
"Bash(go get *)",
"Bash(go build *)",
"Bash(go vet *)",
"Read(//usr/lib/**)",
"Read(//proc/**)",
"Bash(systemctl show *)",
"Bash(echo \"exit=$?\")",
"Bash(systemctl list-units *)",
"Bash(go test *)"
]
}
}
+24 -2
View File
@@ -10,13 +10,22 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 # svu needs full history + tags fetch-depth: 0
- uses: actions/setup-go@v5 - uses: actions/setup-go@v5
with: with:
go-version: '1.26' go-version: "1.26"
cache: false cache: false
- name: Install PAM headers (needed by go test)
run: sudo apt-get update && sudo apt-get install -y libpam0g-dev
- name: Run tests
run: |
set -ex
go vet ./...
go test ./...
- name: Install svu - name: Install svu
timeout-minutes: 3 timeout-minutes: 3
run: | run: |
@@ -87,6 +96,19 @@ jobs:
go build -ldflags="$LDFLAGS" -o dist/nadir-$VERSION-linux-arm64 ./cmd/server go build -ldflags="$LDFLAGS" -o dist/nadir-$VERSION-linux-arm64 ./cmd/server
upx --best --lzma dist/nadir-$VERSION-linux-amd64 dist/nadir-$VERSION-linux-arm64 upx --best --lzma dist/nadir-$VERSION-linux-amd64 dist/nadir-$VERSION-linux-arm64
- name: Sign checksums
if: steps.ver.outputs.release == 'true'
env:
MINISIGN_SECRET_KEY: ${{ secrets.MINISIGN_SECRET_KEY }}
MINISIGN_PASSWORD: ${{ secrets.MINISIGN_PASSWORD }}
run: |
set -ex
cd dist
sha256sum nadir-* > sha256sums.txt
cat sha256sums.txt
go run ../tools/sign-checksums sha256sums.txt
ls -la sha256sums.txt sha256sums.txt.minisig
- name: Tag and release - name: Tag and release
if: steps.ver.outputs.release == 'true' if: steps.ver.outputs.release == 'true'
env: env:
+3 -1
View File
@@ -11,4 +11,6 @@ config.yml
# Editor # Editor
*.swp *.swp
CLAUDE.md CLAUDE.md
minisign.key
+11
View File
@@ -28,3 +28,14 @@ var InstallScriptTemplate string
// go build -ldflags "-X nadir.Version=v1.2.3" // go build -ldflags "-X nadir.Version=v1.2.3"
// Local dev builds leave it as "dev". // Local dev builds leave it as "dev".
var Version = "dev" var Version = "dev"
// ReleasePublicKey is the minisign public key whose signature on a release's
// sha256sums.txt is required by the auto-updater. Replacing the binary is the
// most dangerous thing nadir does, so it gets the strongest verification: the
// updater downloads sha256sums.txt + .minisig from the configured Gitea repo,
// verifies the signature against this embedded key, then verifies the binary's
// sha256 against the file. Rotation requires a rebuild — intentional, so a
// compromised Gitea instance cannot also rotate the trust anchor.
//
//go:embed minisign.pub
var ReleasePublicKey string
+44 -3
View File
@@ -50,6 +50,8 @@ func main() {
configFlag := fs.String("f", "", "config file path") configFlag := fs.String("f", "", "config file path")
fs.StringVar(configFlag, "config", "", "alias for -f") fs.StringVar(configFlag, "config", "", "alias for -f")
saveConfig := fs.Bool("save-config", false, "write default config and exit") saveConfig := fs.Bool("save-config", false, "write default config and exit")
showVersion := fs.Bool("v", false, "print version and exit")
fs.BoolVar(showVersion, "version", false, "alias for -v")
rest := os.Args[1:] rest := os.Args[1:]
var args []string var args []string
@@ -63,6 +65,11 @@ func main() {
rest = rest[1:] rest = rest[1:]
} }
if *showVersion {
fmt.Println(nadir.Version)
os.Exit(0)
}
if *configFlag != "" { if *configFlag != "" {
os.Setenv("CONFIG_PATH", *configFlag) os.Setenv("CONFIG_PATH", *configFlag)
} }
@@ -231,7 +238,8 @@ func runServer() {
meta.Register(api, mods) meta.Register(api, mods)
meta.RegisterHealth(api, sessions) meta.RegisterHealth(api, sessions)
meta.RegisterWhoami(api, sessions, roles, mods) meta.RegisterWhoami(api, sessions, tokenAuth, roles, mods)
meta.RegisterUpdate(api, configPath)
auth.RegisterLogin(api, sessions, auditStore, cfg.SecureCookie()) auth.RegisterLogin(api, sessions, auditStore, cfg.SecureCookie())
auth.RegisterLogout(api, sessions, cfg.SecureCookie()) auth.RegisterLogout(api, sessions, cfg.SecureCookie())
@@ -263,12 +271,24 @@ func runServer() {
}) })
mux.HandleFunc("GET /docs", func(w http.ResponseWriter, _ *http.Request) { mux.HandleFunc("GET /docs", func(w http.ResponseWriter, _ *http.Request) {
// /docs needs to execute the Scalar bundle, so loosen the strict CSP set
// by secHeaders for this one page: allow scripts/styles from the jsdelivr
// CDN plus inline (Scalar uses inline <script> + inline styles). The CDN
// host is the supply-chain trust boundary; pinning a version + SRI here
// is the follow-up (M5 partial).
w.Header().Set("Content-Security-Policy",
"default-src 'self'; "+
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net; "+
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "+
"img-src 'self' data: https:; "+
"connect-src 'self'; "+
"font-src 'self' data: https://cdn.jsdelivr.net https://fonts.scalar.com")
w.Header().Set("Content-Type", "text/html") w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`<!doctype html><html><head><title>API</title> w.Write([]byte(`<!doctype html><html><head><title>API</title>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="icon" type="image/svg+xml" href="/favicon.svg"></head> <link rel="icon" type="image/svg+xml" href="/favicon.svg"></head>
<body><script id="api-reference" data-url="/openapi.json" data-configuration='{"layout":"classic"}'></script> <body><script id="api-reference" data-url="/openapi.json" data-configuration='{"layout":"classic"}'></script>
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script></body></html>`)) <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference" crossorigin="anonymous"></script></body></html>`))
}) })
addr := cfg.Server.Hostname + ":" + cfg.Server.Port addr := cfg.Server.Hostname + ":" + cfg.Server.Port
@@ -277,7 +297,7 @@ func runServer() {
Addr: addr, Addr: addr,
// WithClientIP records the source IP for the login throttle (H1); behind a // WithClientIP records the source IP for the login throttle (H1); behind a
// trusted proxy it reads X-Forwarded-For instead of the proxy's address. // trusted proxy it reads X-Forwarded-For instead of the proxy's address.
Handler: auth.WithClientIP(cfg.Server.TrustProxy, mux), Handler: secHeaders(auth.WithClientIP(cfg.Server.TrustProxy, mux)),
ReadHeaderTimeout: 10 * time.Second, ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second, ReadTimeout: 30 * time.Second,
WriteTimeout: 0, // unset: SSE endpoints stream indefinitely WriteTimeout: 0, // unset: SSE endpoints stream indefinitely
@@ -334,6 +354,27 @@ func runServer() {
} }
} }
// secHeaders sets defensive response headers on every HTTP response. The
// default Content-Security-Policy denies everything (`default-src 'none'`) —
// correct for the JSON API and the tiny landing/favicon endpoints. /docs
// overrides it with a CDN-permissive policy because the Scalar bundle needs
// to execute.
//
// HSTS is set unconditionally: nadir always serves TLS directly or sits behind
// a TLS-terminating proxy (config rejects any other shape), so a browser
// receiving this header is on an HTTPS connection.
func secHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "no-referrer")
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
h.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
next.ServeHTTP(w, r)
})
}
func ensureRoot() { func ensureRoot() {
if os.Getuid() != 0 { if os.Getuid() != 0 {
fatalIf(fmt.Errorf("nadir must run as root")) fatalIf(fmt.Errorf("nadir must run as root"))
+2 -1
View File
@@ -425,7 +425,8 @@ Usage:
nadir token add <name> Mint a machine credential (Bearer token), shown once nadir token add <name> Mint a machine credential (Bearer token), shown once
nadir token rm <name> Revoke a token (effective immediately, no restart) nadir token rm <name> Revoke a token (effective immediately, no restart)
nadir token ls List token names and when they were created nadir token ls List token names and when they were created
nadir update Fetch the latest release from server.release_repo and restart nadir update [--check|--force] Fetch the latest release from server.release_repo and restart
(--check: report only; --force: re-download when already current)
nadir help Show this help nadir help Show this help
Most commands need root. Config path is specified via -f/--config or CONFIG_PATH (default ~/.config/config.yaml). Most commands need root. Config path is specified via -f/--config or CONFIG_PATH (default ~/.config/config.yaml).
+3 -2
View File
@@ -1,8 +1,9 @@
package main package main
import ( import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand" "crypto/rand"
"crypto/rsa"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"crypto/x509/pkix" "crypto/x509/pkix"
@@ -24,7 +25,7 @@ func serverCert(certPath, keyPath string) (tls.Certificate, error) {
} }
func generateSelfSignedCert() (tls.Certificate, error) { func generateSelfSignedCert() (tls.Certificate, error) {
priv, err := rsa.GenerateKey(rand.Reader, 2048) priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil { if err != nil {
return tls.Certificate{}, err return tls.Certificate{}, err
} }
+159 -4
View File
@@ -1,7 +1,10 @@
package main package main
import ( import (
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"flag"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -12,14 +15,23 @@ import (
"strings" "strings"
"time" "time"
"nadir"
"nadir/internal/config" "nadir/internal/config"
"github.com/jedisct1/go-minisign"
) )
// updateCmd implements `nadir update`: hit the configured Gitea repo's // updateCmd implements `nadir update`: hit the configured Gitea repo's
// releases/latest, pick the asset for the host's GOARCH, atomically replace // releases/latest, pick the asset for the host's GOARCH, verify the release
// signature (minisign) and checksum (SHA-256), atomically replace
// /usr/local/bin/nadir (or wherever the running binary lives), and restart // /usr/local/bin/nadir (or wherever the running binary lives), and restart
// the systemd unit so the new code takes effect. // the systemd unit so the new code takes effect.
func updateCmd(_ []string) error { func updateCmd(args []string) error {
fs := flag.NewFlagSet("update", flag.ExitOnError)
check := fs.Bool("check", false, "report the latest version without downloading")
force := fs.Bool("force", false, "re-download even when already at the latest version")
fs.Parse(args)
configPath, err := resolveConfigPath() configPath, err := resolveConfigPath()
if err != nil { if err != nil {
return err return err
@@ -44,16 +56,40 @@ func updateCmd(_ []string) error {
return err return err
} }
fmt.Printf("current: %s\nlatest: %s\n", nadir.Version, rel.TagName)
upToDate := nadir.Version == rel.TagName
switch {
case *check:
if upToDate {
fmt.Println("already up to date.")
} else {
fmt.Println("update available; run `nadir update` to install.")
}
return nil
case upToDate && !*force:
fmt.Println("already up to date; pass --force to re-download.")
return nil
}
// Locate the binary asset and the two verification assets in the release.
var assetURL, assetName string var assetURL, assetName string
var sumsURL, sigURL string
for _, a := range rel.Assets { for _, a := range rel.Assets {
if strings.HasSuffix(a.Name, suffix) { switch {
case strings.HasSuffix(a.Name, suffix):
assetURL, assetName = a.URL, a.Name assetURL, assetName = a.URL, a.Name
break case a.Name == "sha256sums.txt":
sumsURL = a.URL
case a.Name == "sha256sums.txt.minisig":
sigURL = a.URL
} }
} }
if assetURL == "" { if assetURL == "" {
return fmt.Errorf("no %s asset in release %s", suffix, rel.TagName) return fmt.Errorf("no %s asset in release %s", suffix, rel.TagName)
} }
if sumsURL == "" || sigURL == "" {
return fmt.Errorf("release %s is missing sha256sums.txt or sha256sums.txt.minisig — cannot verify; refusing to install an unverified binary", rel.TagName)
}
exe, err := os.Executable() exe, err := os.Executable()
if err != nil { if err != nil {
@@ -66,6 +102,15 @@ func updateCmd(_ []string) error {
os.Remove(tmp) os.Remove(tmp)
return err return err
} }
// Verify: download checksums + signature, check minisign, check SHA-256.
fmt.Println("verifying release signature ...")
if err := verifyRelease(tmp, assetName, sumsURL, sigURL); err != nil {
os.Remove(tmp)
return fmt.Errorf("verification failed: %w", err)
}
fmt.Println("signature and checksum OK.")
// Atomic on the same filesystem; replaces the on-disk file without // Atomic on the same filesystem; replaces the on-disk file without
// disturbing the still-running process (its inode stays alive). // disturbing the still-running process (its inode stays alive).
if err := os.Rename(tmp, exe); err != nil { if err := os.Rename(tmp, exe); err != nil {
@@ -81,6 +126,113 @@ func updateCmd(_ []string) error {
return nil return nil
} }
// verifyRelease downloads sha256sums.txt + its minisig from the release,
// verifies the signature against the embedded public key, then checks the
// downloaded binary's SHA-256 against the matching line in the checksums file.
func verifyRelease(binaryPath, assetName, sumsURL, sigURL string) error {
// Parse the embedded public key. The placeholder file will fail here
// (no valid base64 line), which is the desired behavior: updates are
// disabled until a real key is committed.
pubKey, err := minisign.DecodePublicKey(nadir.ReleasePublicKey)
if err != nil {
return fmt.Errorf("embedded minisign public key is invalid (is minisign.pub still the placeholder?): %w", err)
}
// Download the checksums file and its signature.
sumsBody, err := downloadBytes(sumsURL)
if err != nil {
return fmt.Errorf("download sha256sums.txt: %w", err)
}
sigBody, err := downloadBytes(sigURL)
if err != nil {
return fmt.Errorf("download sha256sums.txt.minisig: %w", err)
}
// Verify the minisign signature on the checksums file.
sig, err := minisign.DecodeSignature(string(sigBody))
if err != nil {
return fmt.Errorf("decode minisig: %w", err)
}
ok, err := pubKey.Verify(sumsBody, sig)
if err != nil {
return fmt.Errorf("minisign verify: %w", err)
}
if !ok {
return fmt.Errorf("minisign signature is not valid for this sha256sums.txt")
}
// Find the expected hash for our binary in the signed checksums file.
expectedHash, err := findChecksum(string(sumsBody), assetName)
if err != nil {
return err
}
// Hash the downloaded binary and compare.
actualHash, err := sha256File(binaryPath)
if err != nil {
return fmt.Errorf("hash downloaded binary: %w", err)
}
if actualHash != expectedHash {
return fmt.Errorf("SHA-256 mismatch for %s: expected %s, got %s", assetName, expectedHash, actualHash)
}
return nil
}
// findChecksum parses a sha256sums.txt body (one "hash filename\n" per line)
// and returns the hex hash for the named asset.
func findChecksum(sums, assetName string) (string, error) {
for _, line := range strings.Split(sums, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Standard sha256sum output: "<hex> <filename>" (two spaces).
parts := strings.SplitN(line, " ", 2)
if len(parts) != 2 {
// Also accept single-space separation.
parts = strings.SplitN(line, " ", 2)
}
if len(parts) == 2 && strings.TrimSpace(parts[1]) == assetName {
h := strings.TrimSpace(parts[0])
if len(h) != 64 {
return "", fmt.Errorf("sha256sums.txt: invalid hash length for %s: %q", assetName, h)
}
return strings.ToLower(h), nil
}
}
return "", fmt.Errorf("sha256sums.txt does not contain a hash for %s", assetName)
}
// sha256File returns the lowercase hex SHA-256 of the file at path.
func sha256File(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// downloadBytes fetches a URL and returns its body as a byte slice (for small
// files like sha256sums.txt and .minisig).
func downloadBytes(srcURL string) ([]byte, error) {
c := &http.Client{Timeout: 30 * time.Second}
resp, err := c.Get(srcURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GET %s: %s", srcURL, resp.Status)
}
// Cap read to 1 MB — sha256sums.txt and .minisig are tiny.
return io.ReadAll(io.LimitReader(resp.Body, 1<<20))
}
type giteaRelease struct { type giteaRelease struct {
TagName string `json:"tag_name"` TagName string `json:"tag_name"`
Assets []struct { Assets []struct {
@@ -114,6 +266,9 @@ func releaseAPIURL(repoURL string) (string, error) {
if err != nil { if err != nil {
return "", fmt.Errorf("release_repo: %w", err) return "", fmt.Errorf("release_repo: %w", err)
} }
if u.Scheme != "https" {
return "", fmt.Errorf("release_repo must use https:// (got %q) — auto-update downloads and executes the binary, plaintext would let any on-path attacker replace it", repoURL)
}
parts := strings.Split(strings.Trim(u.Path, "/"), "/") parts := strings.Split(strings.Trim(u.Path, "/"), "/")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" { if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", fmt.Errorf("release_repo must look like https://host/owner/repo, got %q", repoURL) return "", fmt.Errorf("release_repo must look like https://host/owner/repo, got %q", repoURL)
+72
View File
@@ -0,0 +1,72 @@
package main
import (
"strings"
"testing"
)
// TestReleaseAPIURL pins the contract that downstream code (the updater and
// /install.sh) relies on: only https://host/owner/repo produces an API URL,
// everything else errors. Plain HTTP must be rejected — M7 makes auto-update
// only trust TLS-protected release feeds, since the binary it downloads is
// exec'd as root.
func TestReleaseAPIURL(t *testing.T) {
tests := []struct {
name string
in string
want string
wantErr string // substring expected in the error message; "" means success
}{
{
name: "valid https repo",
in: "https://tea.example.com/owner/repo",
want: "https://tea.example.com/api/v1/repos/owner/repo/releases/latest",
},
{
name: "http rejected",
in: "http://tea.example.com/owner/repo",
wantErr: "https",
},
{
name: "ssh scheme rejected",
in: "ssh://tea.example.com/owner/repo",
wantErr: "https",
},
{
name: "missing repo segment",
in: "https://tea.example.com/owner",
wantErr: "owner/repo",
},
{
name: "extra path segments",
in: "https://tea.example.com/owner/repo/extra",
wantErr: "owner/repo",
},
{
name: "empty",
in: "",
wantErr: "https",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := releaseAPIURL(tt.in)
if tt.wantErr == "" {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
return
}
if err == nil {
t.Fatalf("expected error containing %q, got nil (result %q)", tt.wantErr, got)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("error %q does not contain %q", err.Error(), tt.wantErr)
}
})
}
}
+6 -3
View File
@@ -7,19 +7,22 @@ require github.com/msteinert/pam v1.2.0
require github.com/danielgtaylor/huma/v2 v2.38.0 require github.com/danielgtaylor/huma/v2 v2.38.0
require ( require (
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.52.0
github.com/coder/websocket v1.8.15 github.com/coder/websocket v1.8.15
github.com/creack/pty v1.1.24 github.com/creack/pty v1.1.24
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.52.0
) )
require ( require (
aead.dev/minisign v0.3.0
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/jedisct1/go-minisign v0.0.0-20260527172527-a09352b57a22
github.com/mattn/go-isatty v0.0.21 // indirect github.com/mattn/go-isatty v0.0.21 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.43.0 // indirect golang.org/x/crypto v0.52.0 // indirect
golang.org/x/sys v0.45.0 // indirect
modernc.org/libc v1.72.3 // indirect modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
+10 -4
View File
@@ -1,3 +1,5 @@
aead.dev/minisign v0.3.0 h1:8Xafzy5PEVZqYDNP60yJHARlW1eOQtsKNp/Ph2c0vRA=
aead.dev/minisign v0.3.0/go.mod h1:NLvG3Uoq3skkRMDuc3YHpWUTMTrSExqm+Ij73W13F6Y=
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
@@ -16,6 +18,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jedisct1/go-minisign v0.0.0-20260527172527-a09352b57a22 h1:C68TAi+k12EKJCAmsdaERzQ22ZxVE6n+CuB3kOkhQ7c=
github.com/jedisct1/go-minisign v0.0.0-20260527172527-a09352b57a22/go.mod h1:vYVVh81Lqe/TP0sPLjiNYcX9Hxy/YSfkUx96lYJeyKo=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/msteinert/pam v1.2.0 h1:mYfjlvN2KYs2Pb9G6nb/1f/nPfAttT/Jee5Sq9r3bGE= github.com/msteinert/pam v1.2.0 h1:mYfjlvN2KYs2Pb9G6nb/1f/nPfAttT/Jee5Sq9r3bGE=
@@ -30,14 +34,16 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+34
View File
@@ -60,6 +60,40 @@ do_install() {
echo "downloading $asset_url ..." echo "downloading $asset_url ..."
curl -f --progress-bar -L "$asset_url" -o /usr/local/bin/nadir.tmp curl -f --progress-bar -L "$asset_url" -o /usr/local/bin/nadir.tmp
asset_name=$(basename "$asset_url")
# Verify SHA-256: download the checksums file published alongside the binary
# and confirm the hash matches. This catches CDN corruption and (together with
# the HTTPS transport) makes tampered binaries detectable.
sums_url="$host/api/v1/repos/$path/releases/latest"
sums_asset_url=$(curl -fsSL "$sums_url" \
| grep -o '"browser_download_url":"[^"]*sha256sums\.txt"' \
| head -n1 \
| cut -d'"' -f4)
if [ -n "$sums_asset_url" ]; then
echo "verifying checksum ..."
curl -fsSL "$sums_asset_url" -o /tmp/nadir-sha256sums.txt
# Extract the expected hash for our asset and compare.
expected=$(grep "$asset_name" /tmp/nadir-sha256sums.txt | awk '{print $1}')
actual=$(sha256sum /usr/local/bin/nadir.tmp | awk '{print $1}')
rm -f /tmp/nadir-sha256sums.txt
if [ -z "$expected" ]; then
echo "warning: sha256sums.txt does not contain a hash for $asset_name" >&2
echo "proceeding without verification" >&2
elif [ "$expected" != "$actual" ]; then
echo "SHA-256 MISMATCH: expected $expected, got $actual" >&2
echo "the downloaded binary may be corrupted or tampered with — aborting" >&2
rm -f /usr/local/bin/nadir.tmp
exit 1
else
echo "checksum OK ($actual)"
fi
else
echo "warning: no sha256sums.txt in release — skipping verification" >&2
fi
mv /usr/local/bin/nadir.tmp /usr/local/bin/nadir mv /usr/local/bin/nadir.tmp /usr/local/bin/nadir
chmod +x /usr/local/bin/nadir chmod +x /usr/local/bin/nadir
+11
View File
@@ -3,6 +3,7 @@ package auth
import ( import (
"context" "context"
"net/http" "net/http"
"regexp"
"time" "time"
"nadir/internal/auditlog" "nadir/internal/auditlog"
@@ -10,6 +11,11 @@ import (
"github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2"
) )
// loginNameRe is the useradd default NAME_REGEX. Validating at this trust
// boundary keeps a flag-like name (e.g. "-c", "--help") from reaching `su` in
// the terminal handler or showing up verbatim in audit logs / throttle keys.
var loginNameRe = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}\$?$`)
// authenticator verifies a username/password (PAM in production). It's a field // 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 // of the login handler rather than a package global so tests can inject a stub
// without mutating shared state. // without mutating shared state.
@@ -53,6 +59,11 @@ func registerLogin(api huma.API, sessions *SessionStore, auditor *auditlog.Store
Tags: []string{"Authentication"}, Tags: []string{"Authentication"},
Errors: []int{401, 429}, Errors: []int{401, 429},
}, func(ctx context.Context, in *LoginInput) (*LoginOutput, error) { }, func(ctx context.Context, in *LoginInput) (*LoginOutput, error) {
// Reject malformed usernames at the trust boundary so PAM, su, and the
// audit log never see flag-like or shell-metacharacter input.
if !loginNameRe.MatchString(in.Body.Username) {
return nil, huma.Error401Unauthorized("invalid credentials")
}
// Throttle brute force: too many recent failures for this account/source // Throttle brute force: too many recent failures for this account/source
// put it in a short cooldown before the password is even checked. // put it in a short cooldown before the password is even checked.
throttleKey := in.Body.Username + "|" + ClientIP(ctx) throttleKey := in.Body.Username + "|" + ClientIP(ctx)
+22
View File
@@ -2,6 +2,7 @@ package config
import ( import (
"fmt" "fmt"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -108,6 +109,27 @@ func Load(path string) (*File, error) {
if err := yaml.Unmarshal(data, &f); err != nil { if err := yaml.Unmarshal(data, &f); err != nil {
return nil, fmt.Errorf("parse config %s: %w", path, err) return nil, fmt.Errorf("parse config %s: %w", path, err)
} }
// release_repo, when set, is downloaded over the wire and (for /api/update)
// executed. Validate shape + scheme once here so /install.sh and the updater
// can use the string directly. Trim any trailing slash so downstream string
// concatenation produces a clean URL.
if f.Server.ReleaseRepo != "" {
f.Server.ReleaseRepo = strings.TrimRight(f.Server.ReleaseRepo, "/")
u, err := url.Parse(f.Server.ReleaseRepo)
if err != nil {
return nil, fmt.Errorf("server.release_repo: %w", err)
}
if u.Scheme != "https" {
return nil, fmt.Errorf("server.release_repo must use https:// (got %q)", f.Server.ReleaseRepo)
}
if u.Host == "" {
return nil, fmt.Errorf("server.release_repo missing host: %q", f.Server.ReleaseRepo)
}
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return nil, fmt.Errorf("server.release_repo must be https://host/owner/repo, got %q", f.Server.ReleaseRepo)
}
}
return &f, nil return &f, nil
} }
+62
View File
@@ -0,0 +1,62 @@
package meta
import (
"context"
"os"
"os/exec"
"syscall"
"nadir/internal/config"
"nadir/internal/oscmd"
"github.com/danielgtaylor/huma/v2"
)
// RegisterUpdate wires POST /api/update. It runs the equivalent of
// `sudo nadir update` in a detached session and returns 202 immediately; the
// systemctl restart that ends the updater drops in-flight connections, so the
// caller should poll /api/health to confirm the new version is up.
//
// configPath is re-read by the handler so a missing release_repo (or any other
// config error) surfaces as 4xx/5xx to the caller, not as stderr only.
//
// Authorization: requires (meta, root). Only roles with a wildcard grant
// (the default admin role) match, since "meta" isn't a real module with a
// declared permission vocabulary.
func RegisterUpdate(api huma.API, configPath string) {
huma.Register(api, huma.Operation{
OperationID: "meta-update",
Method: "POST",
Path: "/api/update",
Summary: "Update nadir to the latest release",
Description: "Equivalent to running `sudo nadir update` on the host: queries server.release_repo for the latest release, downloads the binary matching the host's architecture, atomically replaces the running binary, and restarts the systemd unit. Returns 202 immediately; the service restart drops in-flight connections, so poll /api/health to confirm the new version is up. Requires the wildcard admin role.",
Tags: []string{"Meta"},
Metadata: map[string]any{"module": "meta", "permission": "root"},
Errors: []int{400, 401, 403, 500},
DefaultStatus: 202,
}, func(ctx context.Context, _ *struct{}) (*oscmd.StatusOutput, error) {
if configPath != "" {
cfg, err := config.Load(configPath)
if err != nil {
return nil, huma.Error500InternalServerError("config load failed", err)
}
if cfg.Server.ReleaseRepo == "" {
return nil, huma.Error400BadRequest("server.release_repo not set in " + configPath)
}
}
exe, err := os.Executable()
if err != nil {
return nil, huma.Error500InternalServerError("could not resolve own binary path", err)
}
cmd := exec.Command(exe, "update")
// Detach from the server's process group so `systemctl restart nadir`
// (the final step of `nadir update`) doesn't kill its own updater.
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
return nil, huma.Error500InternalServerError("could not start updater", err)
}
return oscmd.OK(), nil
})
}
+26 -7
View File
@@ -15,6 +15,7 @@ import (
// itself. // itself.
type WhoamiInput struct { type WhoamiInput struct {
SessionID string `cookie:"nadir_session_id"` SessionID string `cookie:"nadir_session_id"`
Auth string `header:"Authorization"`
} }
// WhoamiBody reports who the caller is and, per module, which permissions they // WhoamiBody reports who the caller is and, per module, which permissions they
@@ -30,7 +31,7 @@ type WhoamiOutput struct{ Body WhoamiBody }
// RegisterWhoami adds the current-user endpoint. It resolves the caller's // RegisterWhoami adds the current-user endpoint. It resolves the caller's
// concrete grants by asking the RBAC store about each module's permissions, // concrete grants by asking the RBAC store about each module's permissions,
// so "*" wildcards in roles are expanded for free. // so "*" wildcards in roles are expanded for free.
func RegisterWhoami(api huma.API, sessions *auth.SessionStore, roles *rbac.RBAC, mods []module.Module) { func RegisterWhoami(api huma.API, sessions *auth.SessionStore, tokens *auth.TokenAuth, roles *rbac.RBAC, mods []module.Module) {
huma.Register(api, huma.Operation{ huma.Register(api, huma.Operation{
OperationID: "whoami", OperationID: "whoami",
Method: "GET", Method: "GET",
@@ -40,18 +41,35 @@ func RegisterWhoami(api huma.API, sessions *auth.SessionStore, roles *rbac.RBAC,
"permissions the caller holds (wildcards resolved). Pair with " + "permissions the caller holds (wildcards resolved). Pair with " +
"/api/_modules to render the full permission matrix.", "/api/_modules to render the full permission matrix.",
Tags: []string{"Meta"}, Tags: []string{"Meta"},
Errors: []int{401}, Errors: []int{401, 429},
}, func(ctx context.Context, in *WhoamiInput) (*WhoamiOutput, error) { }, func(ctx context.Context, in *WhoamiInput) (*WhoamiOutput, error) {
sess, ok := sessions.GetByToken(in.SessionID) var username string
if !ok {
return nil, huma.Error401Unauthorized("unauthorized") if raw, isBearer := auth.BearerToken(in.Auth); isBearer {
if tokens == nil {
return nil, huma.Error401Unauthorized("unauthorized")
}
name, ok, throttled := tokens.Verify(auth.ClientIP(ctx), raw)
if throttled {
return nil, huma.Error429TooManyRequests("too many failed token attempts; wait a minute")
}
if !ok {
return nil, huma.Error401Unauthorized("unauthorized")
}
username = name
} else {
sess, ok := sessions.GetByToken(in.SessionID)
if !ok {
return nil, huma.Error401Unauthorized("unauthorized")
}
username = sess.Username
} }
held := make(map[string][]string) held := make(map[string][]string)
for _, m := range mods { for _, m := range mods {
var perms []string var perms []string
for _, p := range m.Permissions() { for _, p := range m.Permissions() {
if roles.Can(sess.Username, m.ID(), p) { if roles.Can(username, m.ID(), p) {
perms = append(perms, string(p)) perms = append(perms, string(p))
} }
} }
@@ -61,7 +79,8 @@ func RegisterWhoami(api huma.API, sessions *auth.SessionStore, roles *rbac.RBAC,
} }
out := &WhoamiOutput{} out := &WhoamiOutput{}
out.Body = WhoamiBody{Username: sess.Username, Permissions: held} out.Body = WhoamiBody{Username: username, Permissions: held}
return out, nil return out, nil
}) })
} }
+115
View File
@@ -0,0 +1,115 @@
package meta
import (
"encoding/json"
"net/http"
"path/filepath"
"slices"
"testing"
"nadir/internal/auth"
"nadir/internal/module"
"nadir/internal/rbac"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humago"
"github.com/danielgtaylor/huma/v2/humatest"
)
type dummyModule struct {
id string
perms []rbac.Permission
}
func (m *dummyModule) ID() string { return m.id }
func (m *dummyModule) Name() string { return m.id }
func (m *dummyModule) Permissions() []rbac.Permission { return m.perms }
func (m *dummyModule) Register(api huma.API) {}
func TestWhoami(t *testing.T) {
tempDir := t.TempDir()
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)
}
defer tokenStore.Close()
tokenAuth := auth.NewTokenAuth(tokenStore)
roles := rbac.New()
roles.DefineRole(rbac.Role{
Name: "admin-role",
ModuleGrants: map[string][]rbac.Permission{
"system": {rbac.Read},
},
})
roles.AssignRole("admin", "admin-role")
mods := []module.Module{
&dummyModule{
id: "system",
perms: []rbac.Permission{rbac.Read, rbac.Write},
},
}
mux := http.NewServeMux()
api := humatest.Wrap(t, humago.New(mux, huma.DefaultConfig("Test", "1.0.0")))
RegisterWhoami(api, sessions, tokenAuth, roles, mods)
// 1. Unauthorized request (no token, no session)
resp := api.Get("/api/whoami")
if resp.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", resp.Code)
}
// 2. Cookie session request
sessToken, err := sessions.Create("admin")
if err != nil {
t.Fatal(err)
}
resp = api.Get("/api/whoami", "Cookie: nadir_session_id="+sessToken)
if resp.Code != http.StatusOK {
t.Errorf("expected 200, got %d", resp.Code)
}
var body WhoamiBody
if err := json.Unmarshal(resp.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Username != "admin" {
t.Errorf("expected username admin, got %q", body.Username)
}
if !slices.Contains(body.Permissions["system"], "read") {
t.Errorf("expected system read permission, got %v", body.Permissions["system"])
}
// 3. Token request
bearerToken, err := tokenStore.Create("api-user")
if err != nil {
t.Fatal(err)
}
roles.DefineRole(rbac.Role{
Name: "api-role",
ModuleGrants: map[string][]rbac.Permission{
"system": {rbac.Write},
},
})
roles.AssignRole("api-user", "api-role")
resp = api.Get("/api/whoami", "Authorization: Bearer "+bearerToken)
if resp.Code != http.StatusOK {
t.Errorf("expected 200, got %d", resp.Code)
}
if err := json.Unmarshal(resp.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Username != "api-user" {
t.Errorf("expected username api-user, got %q", body.Username)
}
if !slices.Contains(body.Permissions["system"], "write") {
t.Errorf("expected system write permission, got %v", body.Permissions["system"])
}
}
+1 -1
View File
@@ -32,7 +32,7 @@ func (m *Module) Permissions() []rbac.Permission {
} }
func (m *Module) Register(api huma.API) { func (m *Module) Register(api huma.API) {
registerReads(api) registerReads(api, m)
registerWrites(api, m) registerWrites(api, m)
registerHosts(api) registerHosts(api)
} }
@@ -87,6 +87,30 @@ func TestNetworkingHandlers(t *testing.T) {
t.Errorf("list interfaces: got %d, want %d", resp.Code, http.StatusOK) t.Errorf("list interfaces: got %d, want %d", resp.Code, http.StatusOK)
} }
// 1b. Test GET /api/networking/interfaces/{name} (used by edit-form prefill).
// Asserts the backend's Snapshot output is returned verbatim as the body, so
// the same shape can feed straight into PUT.
resp = api.Get("/api/networking/interfaces/eth0")
if resp.Code != http.StatusOK {
t.Errorf("get interface: got %d, want %d", resp.Code, http.StatusOK)
}
var ifaceRes GetInterfaceConfigOutput
if err := json.Unmarshal(resp.Body.Bytes(), &ifaceRes.Body); err != nil {
t.Fatal(err)
}
if ifaceRes.Body.Method != "dhcp" || ifaceRes.Body.Address != "192.168.1.10/24" {
t.Errorf("get interface: got %+v, want snapshot result", ifaceRes.Body)
}
// Same endpoint with no backend should return 501.
noBackend := &Module{}
noBackendMux := http.NewServeMux()
noBackendAPI := humatest.Wrap(t, humago.New(noBackendMux, huma.DefaultConfig("Test", "1.0.0")))
noBackend.Register(noBackendAPI)
if got := noBackendAPI.Get("/api/networking/interfaces/eth0").Code; got != http.StatusNotImplemented {
t.Errorf("get interface without backend: got %d, want 501", got)
}
// 2. Test GET /api/networking/routes // 2. Test GET /api/networking/routes
resp = api.Get("/api/networking/routes") resp = api.Get("/api/networking/routes")
if resp.Code != http.StatusOK { if resp.Code != http.StatusOK {
@@ -388,3 +412,69 @@ func TestBackendImplementations(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
} }
func TestGetInterfaceConfigAugment(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",
},
}
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 {
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.10", "prefixlen": 24}, {"family": "inet6", "local": "2001:db8::10", "prefixlen": 64}]}]`
return oscmd.MockCommand{Stdout: out, ExitCode: 0}
}
if strings.Contains(argStr, "route show") && !strings.Contains(argStr, "-6") {
out := `[{"dst": "default", "gateway": "192.168.1.1", "dev": "eth0"}]`
return oscmd.MockCommand{Stdout: out, ExitCode: 0}
}
if strings.Contains(argStr, "-6") && strings.Contains(argStr, "route show") {
out := `[{"dst": "default", "gateway": "2001:db8::1", "dev": "eth0"}]`
return oscmd.MockCommand{Stdout: out, ExitCode: 0}
}
return oscmd.MockCommand{ExitCode: 1}
})
defer oscmd.ClearMocks()
resp := api.Get("/api/networking/interfaces/eth0")
if resp.Code != http.StatusOK {
t.Errorf("get interface: got %d, want %d", resp.Code, http.StatusOK)
}
var ifaceRes GetInterfaceConfigOutput
if err := json.Unmarshal(resp.Body.Bytes(), &ifaceRes.Body); err != nil {
t.Fatal(err)
}
if ifaceRes.Body.Method != "dhcp" {
t.Errorf("expected Method to be dhcp, got %s", ifaceRes.Body.Method)
}
if ifaceRes.Body.Address != "192.168.1.10" || ifaceRes.Body.Prefix != 24 {
t.Errorf("expected augmented Address 192.168.1.10/24, got %s/%d", ifaceRes.Body.Address, ifaceRes.Body.Prefix)
}
if ifaceRes.Body.Gateway != "192.168.1.1" {
t.Errorf("expected augmented Gateway 192.168.1.1, got %s", ifaceRes.Body.Gateway)
}
if len(ifaceRes.Body.DNS) != 2 || ifaceRes.Body.DNS[0] != "1.1.1.1" || ifaceRes.Body.DNS[1] != "8.8.8.8" {
t.Errorf("expected augmented DNS [1.1.1.1, 8.8.8.8], got %v", ifaceRes.Body.DNS)
}
if ifaceRes.Body.IPv6 == nil || ifaceRes.Body.IPv6.Method != "auto" || ifaceRes.Body.IPv6.Address != "2001:db8::10" || ifaceRes.Body.IPv6.Prefix != 64 || ifaceRes.Body.IPv6.Gateway != "2001:db8::1" {
t.Errorf("expected augmented IPv6, got %+v", ifaceRes.Body.IPv6)
}
}
+16 -8
View File
@@ -46,11 +46,19 @@ func (b *nmcliBackend) Snapshot(ctx context.Context, iface string) (IfaceConfig,
return IfaceConfig{Method: "dhcp"}, nil return IfaceConfig{Method: "dhcp"}, nil
} }
// nmcli's `con show <NAME>` parser does NOT honor `--` as an end-of-options
// separator; passing it makes nmcli look for a connection literally named
// "--" and fail. `conn` comes from nmcli's own active-connections list (see
// connForIface), so it's already validated — no shell-metacharacter risk.
// Same applies to con up / con down / con modify below.
out, err := oscmd.RunContext(ctx, "nmcli", "-t", "-f", out, err := oscmd.RunContext(ctx, "nmcli", "-t", "-f",
"ipv4.method,ipv4.addresses,ipv4.gateway,ipv4.dns,ipv4.routes,ipv6.method,ipv6.addresses,ipv6.gateway", "ipv4.method,ipv4.addresses,ipv4.gateway,ipv4.dns,ipv4.routes,ipv6.method,ipv6.addresses,ipv6.gateway",
"con", "show", "--", conn) "con", "show", conn)
if err != nil { if err != nil {
return IfaceConfig{}, fmt.Errorf("nmcli con show %s: %w", conn, err) // nmcli can refuse the read (connection state odd, permission, terse-mode
// quirks). Fall back to DHCP defaults so the prefill endpoint still
// returns a usable form, mirroring the networkd / ifupdown fallback.
return IfaceConfig{Method: "dhcp"}, nil
} }
return parseNmcliSnapshot(out), nil return parseNmcliSnapshot(out), nil
@@ -159,9 +167,9 @@ func (b *nmcliBackend) Apply(ctx context.Context, iface string, cfg IfaceConfig)
return fmt.Errorf("cannot apply: %w", err) return fmt.Errorf("cannot apply: %w", err)
} }
// Build the nmcli con modify arguments. Note: conn is safe to place after // conn comes from nmcli's own active list (connForIface), not user input.
// -- since it comes from nmcli output, not directly from the user. // nmcli's con subcommands don't honor "--" as an end-of-options separator.
args := []string{"con", "modify", "--", conn} args := []string{"con", "modify", conn}
switch cfg.Method { switch cfg.Method {
case "static": case "static":
@@ -222,7 +230,7 @@ func (b *nmcliBackend) Apply(ctx context.Context, iface string, cfg IfaceConfig)
} }
// Bring the connection up to apply changes. // Bring the connection up to apply changes.
if _, err := oscmd.RunContext(ctx, "nmcli", "con", "up", "--", conn); err != nil { if _, err := oscmd.RunContext(ctx, "nmcli", "con", "up", conn); err != nil {
return fmt.Errorf("nmcli con up: %w", err) return fmt.Errorf("nmcli con up: %w", err)
} }
return nil return nil
@@ -235,7 +243,7 @@ func (b *nmcliBackend) SetLinkUp(ctx context.Context, iface string) error {
_, err = oscmd.RunContext(ctx, "ip", "link", "set", "--", iface, "up") _, err = oscmd.RunContext(ctx, "ip", "link", "set", "--", iface, "up")
return err return err
} }
_, err = oscmd.RunContext(ctx, "nmcli", "con", "up", "--", conn) _, err = oscmd.RunContext(ctx, "nmcli", "con", "up", conn)
return err return err
} }
@@ -245,7 +253,7 @@ func (b *nmcliBackend) SetLinkDown(ctx context.Context, iface string) error {
_, err = oscmd.RunContext(ctx, "ip", "link", "set", "--", iface, "down") _, err = oscmd.RunContext(ctx, "ip", "link", "set", "--", iface, "down")
return err return err
} }
_, err = oscmd.RunContext(ctx, "nmcli", "con", "down", "--", conn) _, err = oscmd.RunContext(ctx, "nmcli", "con", "down", conn)
return err return err
} }
+154 -1
View File
@@ -3,6 +3,8 @@ package networking
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"net/netip"
"os" "os"
"strconv" "strconv"
"strings" "strings"
@@ -53,7 +55,48 @@ type DNSOutput struct {
} }
} }
func registerReads(api huma.API) { // GetInterfaceConfigInput carries the path param; matches the PUT endpoint's
// IfacePathInput so the frontend can use the same path for both verbs.
type GetInterfaceConfigInput struct {
Name string `path:"name" example:"eth0" doc:"Interface name"`
}
// GetInterfaceConfigOutput returns the same IfaceConfig shape that PUT
// accepts, so the form can be pre-filled directly from this response.
type GetInterfaceConfigOutput struct {
Body IfaceConfig
}
func registerReads(api huma.API, m *Module) {
huma.Register(api, huma.Operation{
OperationID: "networking-get-interface",
Method: "GET",
Path: "/api/networking/interfaces/{name}",
Summary: "Get an interface's current configuration",
Description: "Returns the IfaceConfig the backend currently has for this " +
"interface (method, address/prefix, gateway, DNS, IPv6). Same schema as " +
"PUT /api/networking/interfaces/{name}, so the frontend can prefill an " +
"edit form from this response directly. Returns 501 when no backend was " +
"detected (nmcli / networkd / ifupdown).",
Tags: []string{tagNetworking},
Metadata: op("read"),
Errors: readErrors,
}, func(ctx context.Context, in *GetInterfaceConfigInput) (*GetInterfaceConfigOutput, error) {
if m.be == nil {
return nil, huma.Error501NotImplemented("", errNoBackend)
}
if err := validateIface(in.Name); err != nil {
return nil, err
}
cfg, err := m.be.Snapshot(ctx, in.Name)
if err != nil {
return nil, huma.Error500InternalServerError("snapshot failed", err)
}
augmentWithLiveState(ctx, in.Name, &cfg)
return &GetInterfaceConfigOutput{Body: cfg}, nil
})
huma.Register(api, huma.Operation{ huma.Register(api, huma.Operation{
OperationID: "networking-list-interfaces", OperationID: "networking-list-interfaces",
Method: "GET", Method: "GET",
@@ -209,3 +252,113 @@ func parseResolv(text string) []string {
} }
return servers return servers
} }
func getLiveInterface(ctx context.Context, iface string) (*Interface, error) {
out, err := oscmd.RunContext(ctx, "ip", "-j", "addr", "show", "--", iface)
if err != nil {
out, err = oscmd.RunContext(ctx, "ip", "-j", "addr")
if err != nil {
return nil, err
}
}
ifaces, err := parseInterfaces(out)
if err != nil {
return nil, err
}
for i := range ifaces {
if ifaces[i].Name == iface {
return &ifaces[i], nil
}
}
return nil, fmt.Errorf("interface %s not found in ip addr output", iface)
}
func getLiveGateway(ctx context.Context, iface string) string {
routeOut, err := oscmd.RunContext(ctx, "ip", "-j", "route", "show", "dev", "--", iface)
if err != nil {
routeOut, err = oscmd.RunContext(ctx, "ip", "-j", "route")
if err != nil {
return ""
}
}
routes, err := parseRoutes(routeOut)
if err != nil {
return ""
}
for _, r := range routes {
if r.Destination == "default" && (r.Interface == iface || r.Interface == "") && r.Gateway != "" {
return r.Gateway
}
}
return ""
}
func getLiveIPv6Gateway(ctx context.Context, iface string) string {
routeOut, err := oscmd.RunContext(ctx, "ip", "-6", "-j", "route", "show", "dev", "--", iface)
if err != nil {
routeOut, err = oscmd.RunContext(ctx, "ip", "-6", "-j", "route")
if err != nil {
return ""
}
}
routes, err := parseRoutes(routeOut)
if err != nil {
return ""
}
for _, r := range routes {
if r.Destination == "default" && (r.Interface == iface || r.Interface == "") && r.Gateway != "" {
return r.Gateway
}
}
return ""
}
func augmentWithLiveState(ctx context.Context, iface string, cfg *IfaceConfig) {
liveIface, err := getLiveInterface(ctx, iface)
if err != nil {
return
}
// Prefill IPv4 address and prefix if empty
if cfg.Address == "" && len(liveIface.IPv4) > 0 {
addr, prefix := splitCIDR(liveIface.IPv4[0])
if addr != "" {
cfg.Address = addr
cfg.Prefix = prefix
}
}
// Prefill Gateway if empty
if cfg.Gateway == "" {
cfg.Gateway = getLiveGateway(ctx, iface)
}
// Prefill DNS if empty
if len(cfg.DNS) == 0 {
if data, err := os.ReadFile(resolvConf); err == nil {
cfg.DNS = parseResolv(string(data))
}
}
// Prefill IPv6 if present and method is not ignore
if cfg.IPv6 == nil {
cfg.IPv6 = &IPv6Config{Method: "auto"}
}
if cfg.IPv6.Method != "ignore" {
// Capture first global IPv6 if address is empty
if cfg.IPv6.Address == "" {
for _, c := range liveIface.IPv6 {
addr, prefix := splitCIDR(c)
if ip, err := netip.ParseAddr(addr); err == nil && !ip.IsLinkLocalUnicast() {
cfg.IPv6.Address = addr
cfg.IPv6.Prefix = prefix
break
}
}
}
// Capture IPv6 default gateway if empty
if cfg.IPv6.Gateway == "" {
cfg.IPv6.Gateway = getLiveIPv6Gateway(ctx, iface)
}
}
}
+44 -6
View File
@@ -65,6 +65,10 @@ type RemoveInput struct {
Name string `path:"name" example:"htop" doc:"Package to remove"` Name string `path:"name" example:"htop" doc:"Package to remove"`
} }
type UpgradeOneInput struct {
Name string `path:"name" example:"htop" doc:"Package to upgrade"`
}
// SSE event types for streaming package operations. // SSE event types for streaming package operations.
type PkgOutputEvent struct { type PkgOutputEvent struct {
Line string `json:"line" doc:"One line of the package manager's terminal output"` Line string `json:"line" doc:"One line of the package manager's terminal output"`
@@ -162,6 +166,25 @@ func registerPackages(api huma.API, pm manager) {
bin, args := pm.upgradeArgs() bin, args := pm.upgradeArgs()
streamOp(ctx, send, bin, args) streamOp(ctx, send, bin, args)
}) })
sse.Register(api, huma.Operation{
OperationID: "packages-upgrade-one",
Method: "POST",
Path: "/api/packages/upgrade/{name}",
Summary: "Upgrade a single package (streamed)",
Description: "Upgrades the named package to its latest version, streaming the " +
"package manager's output live. apt uses `install --only-upgrade` so the " +
"package must already be installed; dnf/pacman handle this natively.",
Tags: []string{tagPackages},
Metadata: op("write"),
}, pkgEvents, func(ctx context.Context, in *UpgradeOneInput, send sse.Sender) {
if validateName(in.Name) != nil {
send.Data(PkgErrorEvent{Message: "invalid package name: " + in.Name})
return
}
bin, args := pm.upgradeOneArgs(in.Name)
streamOp(ctx, send, bin, args)
})
} }
// streamOp runs a package write and streams its combined output to the client. // streamOp runs a package write and streams its combined output to the client.
@@ -260,11 +283,11 @@ func result(pm manager, pkgs []Package) *ListOutput {
func (m manager) installArgs(name string) (string, []string) { func (m manager) installArgs(name string) (string, []string) {
switch m.name { switch m.name {
case "dnf": case "dnf":
return "dnf", []string{"install", "-y", "--", name} return "dnf", []string{"install", "-y", name}
case "apt": case "apt":
return "apt-get", []string{"install", "-y", "--", name} return "apt-get", []string{"install", "-y", name}
case "pacman": case "pacman":
return "pacman", []string{"-S", "--noconfirm", "--", name} return "pacman", []string{"-S", "--noconfirm", name}
} }
return "", nil return "", nil
} }
@@ -272,11 +295,11 @@ func (m manager) installArgs(name string) (string, []string) {
func (m manager) removeArgs(name string) (string, []string) { func (m manager) removeArgs(name string) (string, []string) {
switch m.name { switch m.name {
case "dnf": case "dnf":
return "dnf", []string{"remove", "-y", "--", name} return "dnf", []string{"remove", "-y", name}
case "apt": case "apt":
return "apt-get", []string{"remove", "-y", "--", name} return "apt-get", []string{"remove", "-y", name}
case "pacman": case "pacman":
return "pacman", []string{"-R", "--noconfirm", "--", name} return "pacman", []string{"-R", "--noconfirm", name}
} }
return "", nil return "", nil
} }
@@ -293,6 +316,21 @@ func (m manager) upgradeArgs() (string, []string) {
return "", nil return "", nil
} }
// upgradeOneArgs upgrades a single package to its latest version. apt's
// `install --only-upgrade` is the safe variant (won't install if absent);
// pacman -S re-syncs to latest; dnf upgrade is naturally scoped by name.
func (m manager) upgradeOneArgs(name string) (string, []string) {
switch m.name {
case "dnf":
return "dnf", []string{"upgrade", "-y", name}
case "apt":
return "apt-get", []string{"install", "--only-upgrade", "-y", name}
case "pacman":
return "pacman", []string{"-S", "--noconfirm", name}
}
return "", nil
}
// --- parsers (pure, tested) -------------------------------------------------- // --- parsers (pure, tested) --------------------------------------------------
// parseTabbed reads "name\tversion" lines (dpkg-query / rpm output). // parseTabbed reads "name\tversion" lines (dpkg-query / rpm output).
+32
View File
@@ -3,14 +3,22 @@ package services
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"os/exec"
"regexp" "regexp"
"strings" "strings"
"syscall"
"nadir/internal/oscmd" "nadir/internal/oscmd"
"github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2"
) )
// selfUnit is nadir's own systemd unit name. Acting on it via the normal
// synchronous path would have systemd SIGTERM the very process serving the
// request, so the client sees a dropped connection / 500 even though the
// action succeeded. We detach those calls into a Setsid subprocess instead.
const selfUnit = "nadir"
const tagServices = "Services" const tagServices = "Services"
var ( var (
@@ -136,6 +144,9 @@ func registerServices(api huma.API) {
if err := ensureExists(in.Unit); err != nil { if err := ensureExists(in.Unit); err != nil {
return nil, err return nil, err
} }
if isSelf(in.Unit) {
return runDetached(c.action, in.Unit)
}
if _, err := oscmd.Run("systemctl", c.action, "--", in.Unit); err != nil { if _, err := oscmd.Run("systemctl", c.action, "--", in.Unit); err != nil {
return nil, huma.Error500InternalServerError("systemctl "+c.action+" failed", err) return nil, huma.Error500InternalServerError("systemctl "+c.action+" failed", err)
} }
@@ -144,6 +155,27 @@ func registerServices(api huma.API) {
} }
} }
// isSelf reports whether unit names nadir's own service, with or without the
// .service suffix.
func isSelf(unit string) bool {
return unit == selfUnit || unit == selfUnit+".service"
}
// runDetached fires systemctl in a new session so a "systemctl restart nadir"
// (or stop) doesn't kill its own caller before the HTTP response is written.
// Returns success once the subprocess has *started* — the actual systemd
// operation may complete after the response is sent, which is the whole point.
func runDetached(action, unit string) (*oscmd.StatusOutput, error) {
cmd := exec.Command("systemctl", action, "--", unit)
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
if err := cmd.Start(); err != nil {
return nil, huma.Error500InternalServerError("could not start detached systemctl", err)
}
// Reap in the background so the child doesn't become a zombie.
go cmd.Wait()
return oscmd.OK(), nil
}
// validateUnit guards against empty, flag-like, or malformed unit names. // validateUnit guards against empty, flag-like, or malformed unit names.
func validateUnit(unit string) error { func validateUnit(unit string) error {
if unit == "" || strings.HasPrefix(unit, "-") || !unitNameRe.MatchString(unit) { if unit == "" || strings.HasPrefix(unit, "-") || !unitNameRe.MatchString(unit) {
@@ -19,3 +19,22 @@ func TestValidateUnit(t *testing.T) {
} }
} }
} }
// TestIsSelf pins the dispatch that detaches stop/restart-of-self into a
// Setsid subprocess. Both "nadir" and "nadir.service" must match; anything
// else (including substrings) must not, or unrelated services would also get
// detached and bypass the synchronous error path.
func TestIsSelf(t *testing.T) {
yes := []string{"nadir", "nadir.service"}
for _, u := range yes {
if !isSelf(u) {
t.Errorf("isSelf(%q) = false, want true", u)
}
}
no := []string{"", "sshd.service", "nadir-something.service", "nadir.timer", "not-nadir.service"}
for _, u := range no {
if isSelf(u) {
t.Errorf("isSelf(%q) = true, want false", u)
}
}
}
+10 -1
View File
@@ -2,6 +2,7 @@ package system
import ( import (
"context" "context"
"regexp"
"strings" "strings"
"nadir/internal/oscmd" "nadir/internal/oscmd"
@@ -9,6 +10,11 @@ import (
"github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2"
) )
// hostnameRe matches RFC-1123 labels joined by dots, max 253 chars total. Anchored
// so a leading "-" can't be read as a hostnamectl flag and shell metacharacters
// can't survive — same pattern the other modules use (CLAUDE.md §5).
var hostnameRe = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9-]{0,62})(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,62}))*$`)
type HostnameBody struct { type HostnameBody struct {
Hostname string `json:"hostname" example:"server01" doc:"System hostname"` Hostname string `json:"hostname" example:"server01" doc:"System hostname"`
} }
@@ -49,7 +55,10 @@ func registerHostname(api huma.API) {
if name == "" { if name == "" {
return nil, huma.Error400BadRequest("empty hostname") return nil, huma.Error400BadRequest("empty hostname")
} }
if _, err := oscmd.Run("hostnamectl", "set-hostname", name); err != nil { if len(name) > 253 || !hostnameRe.MatchString(name) {
return nil, huma.Error400BadRequest("invalid hostname: " + name)
}
if _, err := oscmd.Run("hostnamectl", "set-hostname", "--", name); err != nil {
return nil, huma.Error500InternalServerError("hostnamectl failed", err) return nil, huma.Error500InternalServerError("hostnamectl failed", err)
} }
return oscmd.OK(), nil return oscmd.OK(), nil
+114 -6
View File
@@ -5,6 +5,7 @@ import (
"math" "math"
"net" "net"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strconv" "strconv"
@@ -159,13 +160,84 @@ func cpuInfo() CPUInfo {
c := CPUInfo{Model: cpuModel(string(data)), LogicalCPUs: runtime.NumCPU()} c := CPUInfo{Model: cpuModel(string(data)), LogicalCPUs: runtime.NumCPU()}
c.MinMHz, c.MaxMHz, c.CurrentMHz = cpuFreqMHz("/sys/devices/system/cpu") c.MinMHz, c.MaxMHz, c.CurrentMHz = cpuFreqMHz("/sys/devices/system/cpu")
// ponytail: cpufreq sysfs is absent on many VMs and stock Ubuntu server // 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. // kernels; fall back to /proc/cpuinfo "cpu MHz" — VMs have a fixed clock,
if c.CurrentMHz == 0 { // so min == max == cur is the honest answer.
c.CurrentMHz = cpuinfoMaxMHz(string(data)) mhz := cpuinfoMaxMHz(string(data))
// ponytail: ARM /proc/cpuinfo has no "cpu MHz" and often no "model name";
// lscpu decodes the ARM part-id table and reads DMI, so use it as last resort.
if c.Model == "" || mhz == 0 {
model, lscpuMHz := lscpuFallback()
if c.Model == "" {
c.Model = model
}
if mhz == 0 {
mhz = lscpuMHz
}
}
if mhz > 0 {
if c.CurrentMHz == 0 {
c.CurrentMHz = mhz
}
if c.MaxMHz == 0 {
c.MaxMHz = mhz
}
if c.MinMHz == 0 {
c.MinMHz = mhz
}
} }
return c return c
} }
// lscpuFallback parses `lscpu` for "Model name" and any embedded "@ X.X GHz"
// or "CPU max MHz:" value. Returns zeros when lscpu is missing or silent.
func lscpuFallback() (model string, mhz int) {
out, err := exec.Command("lscpu").Output()
if err != nil {
return "", 0
}
for line := range strings.SplitSeq(string(out), "\n") {
k, v, ok := strings.Cut(line, ":")
if !ok {
continue
}
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
switch k {
case "Model name":
if model == "" {
model = v
}
case "BIOS Model name":
if model == "" {
model = v
}
case "CPU max MHz", "CPU MHz":
if f, err := strconv.ParseFloat(v, 64); err == nil && int(f) > mhz {
mhz = int(math.Round(f))
}
}
}
if mhz == 0 {
mhz = parseGHzSuffix(model)
}
return model, mhz
}
// parseGHzSuffix pulls "2.0GHz" / "@ 2.0 GHz" out of a model string.
func parseGHzSuffix(s string) int {
i := strings.LastIndex(s, "@")
if i < 0 {
return 0
}
rest := strings.TrimSpace(s[i+1:])
rest = strings.TrimSuffix(strings.TrimSuffix(rest, "GHz"), "Ghz")
rest = strings.TrimSpace(strings.TrimSuffix(rest, "G"))
f, err := strconv.ParseFloat(strings.TrimSpace(rest), 64)
if err != nil {
return 0
}
return int(math.Round(f * 1000))
}
// cpuinfoMaxMHz returns the highest "cpu MHz" value across all cores in // cpuinfoMaxMHz returns the highest "cpu MHz" value across all cores in
// /proc/cpuinfo, rounded to an int. Returns 0 when no such line exists. // /proc/cpuinfo, rounded to an int. Returns 0 when no such line exists.
func cpuinfoMaxMHz(cpuinfo string) int { func cpuinfoMaxMHz(cpuinfo string) int {
@@ -427,9 +499,12 @@ func diskInfo() []DiskInfo {
disks := []DiskInfo{} disks := []DiskInfo{}
seen := map[string]bool{} seen := map[string]bool{}
for _, e := range entries { for _, e := range entries {
// Only real block devices; skip pseudo filesystems and snap's squashfs // ponytail: filter by fstype, not device path. LXC/Docker containers
// loop mounts that would otherwise clutter the list. // expose their rootfs as a ZFS dataset name, an overlayfs, or a bind
if !strings.HasPrefix(e.Device, "/dev/") || e.FSType == "squashfs" || seen[e.Mountpoint] { // path — never /dev/* — so a "must start with /dev/" check silently
// returned no disks on those hosts. statfs + non-zero blocks already
// excludes mounts that aren't real storage.
if pseudoFS[e.FSType] || seen[e.Mountpoint] {
continue continue
} }
var st syscall.Statfs_t var st syscall.Statfs_t
@@ -450,6 +525,39 @@ func diskInfo() []DiskInfo {
return disks return disks
} }
// pseudoFS lists kernel-virtual filesystems that show up in /proc/mounts but
// aren't user-facing storage. squashfs covers snap loop mounts; fuse.lxcfs is
// LXC's per-container /proc/* shim. Anything not on this list and statfs-able
// with non-zero blocks is treated as real storage — covers ext*, btrfs, xfs,
// zfs, nfs, cifs, overlay, and the bind-mount cases inside Proxmox LXC.
var pseudoFS = map[string]bool{
"autofs": true,
"binfmt_misc": true,
"bpf": true,
"cgroup": true,
"cgroup2": true,
"configfs": true,
"debugfs": true,
"devpts": true,
"devtmpfs": true,
"fuse.gvfsd-fuse": true,
"fuse.lxcfs": true,
"fusectl": true,
"hugetlbfs": true,
"mqueue": true,
"nsfs": true,
"overlay": true,
"proc": true,
"pstore": true,
"ramfs": true,
"rpc_pipefs": true,
"securityfs": true,
"squashfs": true,
"sysfs": true,
"tmpfs": true,
"tracefs": true,
}
func netInfo() []NetInterface { func netInfo() []NetInterface {
ifaces, err := net.Interfaces() ifaces, err := net.Interfaces()
if err != nil { if err != nil {
+179 -19
View File
@@ -2,6 +2,10 @@ package system
import ( import (
"context" "context"
"fmt"
"os"
"os/exec"
"regexp"
"slices" "slices"
"strings" "strings"
@@ -12,6 +16,7 @@ import (
type LocaleStatusBody struct { type LocaleStatusBody struct {
Lang string `json:"lang" example:"it_IT.UTF-8" doc:"System locale (LANG)"` Lang string `json:"lang" example:"it_IT.UTF-8" doc:"System locale (LANG)"`
Language string `json:"language" example:"en_US:" doc:"Fallback language list (LANGUAGE)"`
VCKeymap string `json:"vc_keymap" example:"it" doc:"Virtual console keymap"` VCKeymap string `json:"vc_keymap" example:"it" doc:"Virtual console keymap"`
X11Layout string `json:"x11_layout" example:"it" doc:"X11 keyboard layout"` X11Layout string `json:"x11_layout" example:"it" doc:"X11 keyboard layout"`
} }
@@ -27,12 +32,14 @@ type LocalesOutput struct {
type KeymapsOutput struct { type KeymapsOutput struct {
Body struct { Body struct {
Keymaps []string `json:"keymaps" doc:"Available virtual console keymaps"` Keymaps []string `json:"keymaps" doc:"Available virtual console keymaps"`
Reason string `json:"reason,omitempty" doc:"When keymaps is empty, why: e.g. \"kbd not installed on this server\""`
} }
} }
type SetLocaleInput struct { type SetLocaleInput struct {
Body struct { Body struct {
Lang string `json:"lang" example:"it_IT.UTF-8" doc:"Locale to set as LANG"` Lang string `json:"lang" example:"it_IT.UTF-8" doc:"Locale to set as LANG"`
Language *string `json:"language,omitempty" example:"en_US:" doc:"Fallback language list (LANGUAGE)"`
} }
} }
@@ -42,6 +49,20 @@ type SetKeymapInput struct {
} }
} }
type GenerateLocaleInput struct {
Body struct {
Locale string `json:"locale" example:"fr_FR.UTF-8" doc:"Locale to generate (e.g. fr_FR.UTF-8)"`
}
}
// localeRe validates a locale identifier: language_TERRITORY with an optional
// .charmap suffix (e.g. fr_FR, fr_FR.UTF-8, en_US.ISO-8859-1).
var localeRe = regexp.MustCompile(`^[a-z]{2,3}_[A-Z]{2}(\.[A-Za-z0-9_-]+)?$`)
// languageRe validates the LANGUAGE fallback list: colon-separated locale names
// like "en_US:de_DE". Anchored so a leading "-" can't survive into argv.
var languageRe = regexp.MustCompile(`^[a-zA-Z0-9_.:-]*$`)
func localeStatus() (LocaleStatusBody, error) { func localeStatus() (LocaleStatusBody, error) {
lines, err := oscmd.RunLines("localectl", "status") lines, err := oscmd.RunLines("localectl", "status")
if err != nil { if err != nil {
@@ -49,21 +70,28 @@ func localeStatus() (LocaleStatusBody, error) {
} }
var b LocaleStatusBody var b LocaleStatusBody
for _, line := range lines { for _, line := range lines {
label, val, ok := strings.Cut(line, ":") trimmed := strings.TrimSpace(line)
if !ok { if strings.HasPrefix(trimmed, "VC Keymap:") {
b.VCKeymap = strings.TrimSpace(strings.TrimPrefix(trimmed, "VC Keymap:"))
continue continue
} }
switch strings.TrimSpace(label) { if strings.HasPrefix(trimmed, "X11 Layout:") {
case "System Locale": b.X11Layout = strings.TrimSpace(strings.TrimPrefix(trimmed, "X11 Layout:"))
for kv := range strings.FieldsSeq(val) { continue
if k, v, ok := strings.Cut(kv, "="); ok && k == "LANG" { }
// Parse k=v fields on any other line (like System Locale block).
parts := strings.Fields(trimmed)
for _, part := range parts {
part = strings.TrimPrefix(part, "System Locale:")
part = strings.TrimSpace(part)
if k, v, ok := strings.Cut(part, "="); ok {
switch k {
case "LANG":
b.Lang = v b.Lang = v
case "LANGUAGE":
b.Language = v
} }
} }
case "VC Keymap":
b.VCKeymap = strings.TrimSpace(val)
case "X11 Layout":
b.X11Layout = strings.TrimSpace(val)
} }
} }
return b, nil return b, nil
@@ -130,7 +158,17 @@ func registerLocale(api huma.API) {
if !slices.Contains(locales, lang) { if !slices.Contains(locales, lang) {
return nil, huma.Error400BadRequest("unknown locale: " + lang) return nil, huma.Error400BadRequest("unknown locale: " + lang)
} }
if _, err := oscmd.Run("localectl", "set-locale", "LANG="+lang); err != nil {
args := []string{"set-locale", "LANG=" + lang}
if in.Body.Language != nil {
langVal := strings.TrimSpace(*in.Body.Language)
if !languageRe.MatchString(langVal) {
return nil, huma.Error400BadRequest("invalid language format: " + langVal)
}
args = append(args, "LANGUAGE="+langVal)
}
if _, err := oscmd.Run("localectl", args...); err != nil {
return nil, huma.Error500InternalServerError("set-locale failed", err) return nil, huma.Error500InternalServerError("set-locale failed", err)
} }
return oscmd.OK(), nil return oscmd.OK(), nil
@@ -146,12 +184,15 @@ func registerLocale(api huma.API) {
Metadata: op("read"), Metadata: op("read"),
Errors: readErrors, Errors: readErrors,
}, func(ctx context.Context, _ *struct{}) (*KeymapsOutput, error) { }, func(ctx context.Context, _ *struct{}) (*KeymapsOutput, error) {
// ponytail: minimal servers ship without kbd / /usr/share/keymaps, so
// localectl errors instead of returning empty. Surface that as a `reason`
// the frontend can display ("kbd not installed") instead of an opaque N/A.
keymaps, err := oscmd.RunLines("localectl", "list-keymaps") keymaps, err := oscmd.RunLines("localectl", "list-keymaps")
if err != nil {
return nil, huma.Error500InternalServerError("localectl failed", err)
}
out := &KeymapsOutput{} out := &KeymapsOutput{}
out.Body.Keymaps = keymaps out.Body.Keymaps = keymaps
if err != nil || len(keymaps) == 0 {
out.Body.Reason = "kbd is not installed on this server (install the kbd / console-data package to enable keymap selection)"
}
return out, nil return out, nil
}) })
@@ -171,10 +212,9 @@ func registerLocale(api huma.API) {
if km == "" { if km == "" {
return nil, huma.Error400BadRequest("empty keymap") return nil, huma.Error400BadRequest("empty keymap")
} }
keymaps, err := oscmd.RunLines("localectl", "list-keymaps") // list-keymaps failure means no keymap allowlist on this host (kbd absent);
if err != nil { // fall through to unknown-keymap 400 instead of 500.
return nil, huma.Error500InternalServerError("localectl failed", err) keymaps, _ := oscmd.RunLines("localectl", "list-keymaps")
}
if !slices.Contains(keymaps, km) { if !slices.Contains(keymaps, km) {
return nil, huma.Error400BadRequest("unknown keymap: " + km) return nil, huma.Error400BadRequest("unknown keymap: " + km)
} }
@@ -183,4 +223,124 @@ func registerLocale(api huma.API) {
} }
return oscmd.OK(), nil return oscmd.OK(), nil
}) })
huma.Register(api, huma.Operation{
OperationID: "system-generate-locale",
Method: "POST",
Path: "/api/system/locale/generate",
Summary: "Generate (install) a new locale",
Description: "Generates a locale so it becomes available for use with set-locale. " +
"On Debian/Ubuntu/Arch this uncomments the entry in /etc/locale.gen and runs " +
"locale-gen; on RHEL/Fedora it uses localedef. Idempotent: if the locale is " +
"already generated, returns 200 immediately.",
Tags: []string{tagSystem},
Metadata: op("write"),
Errors: []int{400, 401, 403, 500, 501},
}, func(ctx context.Context, in *GenerateLocaleInput) (*oscmd.StatusOutput, error) {
locale := strings.TrimSpace(in.Body.Locale)
if locale == "" {
return nil, huma.Error400BadRequest("empty locale")
}
if !localeRe.MatchString(locale) {
return nil, huma.Error400BadRequest("invalid locale format: "+locale,
fmt.Errorf("expected language_TERRITORY[.charmap], e.g. fr_FR.UTF-8"))
}
// Idempotency: already generated → success.
existing, err := oscmd.RunLines("localectl", "list-locales")
if err != nil {
return nil, huma.Error500InternalServerError("localectl failed", err)
}
if slices.Contains(existing, locale) {
return oscmd.OK(), nil
}
// Detect which generation path the host supports.
localeGenFile := "/etc/locale.gen"
_, hasFile := os.Stat(localeGenFile)
_, hasLocaleGen := exec.LookPath("locale-gen")
_, hasLocaledef := exec.LookPath("localedef")
switch {
case hasFile == nil && hasLocaleGen == nil:
if err := enableLocaleGen(localeGenFile, locale); err != nil {
return nil, err
}
case hasLocaledef == nil:
if err := generateLocaledef(locale); err != nil {
return nil, err
}
default:
return nil, huma.Error501NotImplemented(
"locale generation not supported on this host (no locale-gen or localedef found)")
}
return oscmd.OK(), nil
})
}
// enableLocaleGen uncomments the locale in /etc/locale.gen and runs locale-gen.
// This is the Debian/Ubuntu/Arch path.
func enableLocaleGen(path, locale string) error {
data, err := os.ReadFile(path)
if err != nil {
return huma.Error500InternalServerError("reading locale.gen failed", err)
}
newContent, found := uncommentLocaleGen(string(data), locale)
if !found {
return huma.Error400BadRequest("locale not available for generation: " + locale)
}
// Write atomically: a crash mid-write would leave /etc/locale.gen truncated
// and break every subsequent locale-gen on the host.
tmp := path + ".nadir.tmp"
if err := os.WriteFile(tmp, []byte(newContent), 0644); err != nil {
return huma.Error500InternalServerError("writing locale.gen failed", err)
}
if err := os.Rename(tmp, path); err != nil {
os.Remove(tmp)
return huma.Error500InternalServerError("replacing locale.gen failed", err)
}
if _, err := oscmd.Run("locale-gen"); err != nil {
return huma.Error500InternalServerError("locale-gen failed", err)
}
return nil
}
// uncommentLocaleGen finds a commented-out line for the given locale in a
// locale.gen file and uncomments it. Returns the modified content and whether
// a matching line was found. Pure function for testability.
func uncommentLocaleGen(content, locale string) (string, bool) {
lines := strings.Split(content, "\n")
found := false
for i, line := range lines {
trimmed := strings.TrimSpace(line)
// Match lines like "# fr_FR.UTF-8 UTF-8" or "#fr_FR.UTF-8 UTF-8".
if !strings.HasPrefix(trimmed, "#") {
// Also check if already uncommented (idempotent at the file level).
if strings.HasPrefix(trimmed, locale+" ") || trimmed == locale {
found = true
}
continue
}
uncommented := strings.TrimSpace(strings.TrimPrefix(trimmed, "#"))
if strings.HasPrefix(uncommented, locale+" ") || uncommented == locale {
lines[i] = uncommented
found = true
}
}
return strings.Join(lines, "\n"), found
}
// generateLocaledef generates a locale using localedef. This is the RHEL/Fedora
// path where there is no /etc/locale.gen.
func generateLocaledef(locale string) error {
// Parse "fr_FR.UTF-8" into language_territory="fr_FR" and charmap="UTF-8".
// If there is no dot, default to UTF-8 (the common case on modern systems).
langTerritory, charmap, _ := strings.Cut(locale, ".")
if charmap == "" {
charmap = "UTF-8"
}
if _, err := oscmd.Run("localedef", "-i", langTerritory, "-f", charmap, locale); err != nil {
return huma.Error500InternalServerError("localedef failed", err)
}
return nil
} }
+104 -3
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"reflect" "reflect"
"strings"
"testing" "testing"
"nadir/internal/oscmd" "nadir/internal/oscmd"
@@ -46,7 +47,7 @@ func TestSystemHandlers(t *testing.T) {
if reflect.DeepEqual(args, []string{"hostname"}) { if reflect.DeepEqual(args, []string{"hostname"}) {
return oscmd.MockCommand{Stdout: "server01\n", ExitCode: 0} return oscmd.MockCommand{Stdout: "server01\n", ExitCode: 0}
} }
if reflect.DeepEqual(args, []string{"set-hostname", "server02"}) { if reflect.DeepEqual(args, []string{"set-hostname", "--", "server02"}) {
return oscmd.MockCommand{ExitCode: 0} return oscmd.MockCommand{ExitCode: 0}
} }
return oscmd.MockCommand{ExitCode: 1} return oscmd.MockCommand{ExitCode: 1}
@@ -141,7 +142,7 @@ func TestSystemHandlers(t *testing.T) {
// 4. Test GET & POST /api/system/locale // 4. Test GET & POST /api/system/locale
oscmd.SetMock("localectl", func(args []string) oscmd.MockCommand { oscmd.SetMock("localectl", func(args []string) oscmd.MockCommand {
if reflect.DeepEqual(args, []string{"status"}) { if reflect.DeepEqual(args, []string{"status"}) {
statusOut := " System Locale: LANG=it_IT.UTF-8\n VC Keymap: it\n X11 Layout: it\n" statusOut := " System Locale: LANG=it_IT.UTF-8\n LANGUAGE=en_US:\n VC Keymap: it\n X11 Layout: it\n"
return oscmd.MockCommand{Stdout: statusOut, ExitCode: 0} return oscmd.MockCommand{Stdout: statusOut, ExitCode: 0}
} }
if reflect.DeepEqual(args, []string{"list-locales"}) { if reflect.DeepEqual(args, []string{"list-locales"}) {
@@ -150,6 +151,9 @@ func TestSystemHandlers(t *testing.T) {
if reflect.DeepEqual(args, []string{"set-locale", "LANG=it_IT.UTF-8"}) { if reflect.DeepEqual(args, []string{"set-locale", "LANG=it_IT.UTF-8"}) {
return oscmd.MockCommand{ExitCode: 0} return oscmd.MockCommand{ExitCode: 0}
} }
if reflect.DeepEqual(args, []string{"set-locale", "LANG=it_IT.UTF-8", "LANGUAGE=en_US:"}) {
return oscmd.MockCommand{ExitCode: 0}
}
if reflect.DeepEqual(args, []string{"list-keymaps"}) { if reflect.DeepEqual(args, []string{"list-keymaps"}) {
return oscmd.MockCommand{Stdout: "it\nus\n", ExitCode: 0} return oscmd.MockCommand{Stdout: "it\nus\n", ExitCode: 0}
} }
@@ -167,7 +171,7 @@ func TestSystemHandlers(t *testing.T) {
if err := json.Unmarshal(resp.Body.Bytes(), &localeRes.Body); err != nil { if err := json.Unmarshal(resp.Body.Bytes(), &localeRes.Body); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if localeRes.Body.Lang != "it_IT.UTF-8" || localeRes.Body.VCKeymap != "it" { if localeRes.Body.Lang != "it_IT.UTF-8" || localeRes.Body.Language != "en_US:" || localeRes.Body.VCKeymap != "it" {
t.Errorf("got locale status: %+v", localeRes.Body) t.Errorf("got locale status: %+v", localeRes.Body)
} }
@@ -185,6 +189,17 @@ func TestSystemHandlers(t *testing.T) {
t.Errorf("set locale: got %d, want %d", resp.Code, http.StatusOK) t.Errorf("set locale: got %d, want %d", resp.Code, http.StatusOK)
} }
resp = api.Post("/api/system/locale", struct {
Lang string `json:"lang"`
Language string `json:"language"`
}{
Lang: "it_IT.UTF-8",
Language: "en_US:",
})
if resp.Code != http.StatusOK {
t.Errorf("set locale with language: got %d, want %d", resp.Code, http.StatusOK)
}
resp = api.Get("/api/system/keymaps") resp = api.Get("/api/system/keymaps")
if resp.Code != http.StatusOK { if resp.Code != http.StatusOK {
t.Errorf("list keymaps: got %d, want %d", resp.Code, http.StatusOK) t.Errorf("list keymaps: got %d, want %d", resp.Code, http.StatusOK)
@@ -199,6 +214,37 @@ func TestSystemHandlers(t *testing.T) {
t.Errorf("set keymap: got %d, want %d", resp.Code, http.StatusOK) t.Errorf("set keymap: got %d, want %d", resp.Code, http.StatusOK)
} }
// 4b. Test POST /api/system/locale/generate (validation & idempotent)
// Empty locale → 422 (huma validates non-empty before handler runs)
resp = api.Post("/api/system/locale/generate", struct {
Locale string `json:"locale"`
}{
Locale: "",
})
if resp.Code != http.StatusBadRequest && resp.Code != http.StatusUnprocessableEntity {
t.Errorf("generate empty locale: got %d, want 400 or 422", resp.Code)
}
// Invalid format → 400
resp = api.Post("/api/system/locale/generate", struct {
Locale string `json:"locale"`
}{
Locale: "not-a-locale!!",
})
if resp.Code != http.StatusBadRequest {
t.Errorf("generate invalid locale: got %d, want %d", resp.Code, http.StatusBadRequest)
}
// Already generated (it_IT.UTF-8 is in list-locales mock) → 200 (idempotent)
resp = api.Post("/api/system/locale/generate", struct {
Locale string `json:"locale"`
}{
Locale: "it_IT.UTF-8",
})
if resp.Code != http.StatusOK {
t.Errorf("generate existing locale (idempotent): got %d, want %d", resp.Code, http.StatusOK)
}
// 5. Test POST /api/system/reboot and /api/system/poweroff // 5. Test POST /api/system/reboot and /api/system/poweroff
oscmd.SetMock("shutdown", func(args []string) oscmd.MockCommand { oscmd.SetMock("shutdown", func(args []string) oscmd.MockCommand {
if reflect.DeepEqual(args, []string{"-r", "now"}) || reflect.DeepEqual(args, []string{"-h", "now"}) { if reflect.DeepEqual(args, []string{"-r", "now"}) || reflect.DeepEqual(args, []string{"-h", "now"}) {
@@ -225,3 +271,58 @@ func TestSystemHandlers(t *testing.T) {
t.Errorf("poweroff: got %d, want %d", resp.Code, http.StatusOK) t.Errorf("poweroff: got %d, want %d", resp.Code, http.StatusOK)
} }
} }
func TestUncommentLocaleGen(t *testing.T) {
const sampleLocaleGen = `# This file lists locales that you wish to have built.
#
# en_US.UTF-8 UTF-8
# fr_FR.UTF-8 UTF-8
# de_DE.UTF-8 UTF-8
it_IT.UTF-8 UTF-8
`
tests := []struct {
name string
locale string
wantFound bool
wantSubstr string // substring that should appear uncommented
}{
{
name: "uncomment commented locale",
locale: "fr_FR.UTF-8",
wantFound: true,
wantSubstr: "\nfr_FR.UTF-8 UTF-8\n",
},
{
name: "already uncommented",
locale: "it_IT.UTF-8",
wantFound: true,
},
{
name: "locale not in file",
locale: "ja_JP.UTF-8",
wantFound: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, found := uncommentLocaleGen(sampleLocaleGen, tt.locale)
if found != tt.wantFound {
t.Errorf("found = %v, want %v", found, tt.wantFound)
}
if tt.wantSubstr != "" && !contains(result, tt.wantSubstr) {
t.Errorf("result does not contain %q:\n%s", tt.wantSubstr, result)
}
// The commented versions of OTHER locales should remain commented.
if tt.locale == "fr_FR.UTF-8" && !contains(result, "# en_US.UTF-8 UTF-8") {
t.Errorf("other locales should stay commented:\n%s", result)
}
})
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
(len(s) > 0 && strings.Contains(s, substr)))
}
+1 -1
View File
@@ -126,7 +126,7 @@ func (m *terminalModule) Register(api huma.API) {
// Launch the user's login shell via su. // Launch the user's login shell via su.
// "su - <username>" ensures we get their actual environment and shell. // "su - <username>" ensures we get their actual environment and shell.
cmd := exec.CommandContext(req.Context(), "su", "-", sess.Username) cmd := exec.CommandContext(req.Context(), "su", "-", "--", sess.Username)
// Start the command with a PTY. // Start the command with a PTY.
ptmx, err := pty.Start(cmd) ptmx, err := pty.Start(cmd)
+1 -1
View File
@@ -60,7 +60,7 @@ func TestOpenAPISchemaNoCollisions(t *testing.T) {
} }
meta.Register(api, mods) meta.Register(api, mods)
meta.RegisterHealth(api, sessions) meta.RegisterHealth(api, sessions)
meta.RegisterWhoami(api, sessions, roles, mods) meta.RegisterWhoami(api, sessions, nil, roles, mods)
auth.RegisterLogin(api, sessions, auditStore, true) auth.RegisterLogin(api, sessions, auditStore, true)
auth.RegisterLogout(api, sessions, true) auth.RegisterLogout(api, sessions, true)
+2
View File
@@ -0,0 +1,2 @@
untrusted comment: minisign public key: 702ABD7F45200669
RWRpBiBFf70qcEXS0cOS+8tZ1hpoLj9mX0V5OiE8qYIIZsetU8hNA4Ou
Executable
BIN
View File
Binary file not shown.
+54
View File
@@ -0,0 +1,54 @@
// Command sign-checksums is a CI helper that signs a file using minisign.
//
// The minisign CLI reads passwords from /dev/tty, which doesn't exist in CI
// runners. This program uses the library directly: password and encrypted
// secret key come from environment variables, no terminal required.
//
// Usage (in CI):
//
// MINISIGN_SECRET_KEY=... MINISIGN_PASSWORD=... go run ./tools/sign-checksums dist/sha256sums.txt
//
// Produces dist/sha256sums.txt.minisig alongside the input.
package main
import (
"fmt"
"os"
"aead.dev/minisign"
)
func main() {
if len(os.Args) != 2 {
fmt.Fprintf(os.Stderr, "usage: sign-checksums <file>\n")
os.Exit(2)
}
filePath := os.Args[1]
password := os.Getenv("MINISIGN_PASSWORD")
keyBytes := []byte(os.Getenv("MINISIGN_SECRET_KEY"))
if len(keyBytes) == 0 {
fmt.Fprintln(os.Stderr, "MINISIGN_SECRET_KEY is not set")
os.Exit(1)
}
key, err := minisign.DecryptKey(password, keyBytes)
if err != nil {
fmt.Fprintf(os.Stderr, "decrypt key: %v\n", err)
os.Exit(1)
}
message, err := os.ReadFile(filePath)
if err != nil {
fmt.Fprintf(os.Stderr, "read %s: %v\n", filePath, err)
os.Exit(1)
}
sig := minisign.Sign(key, message)
sigPath := filePath + ".minisig"
if err := os.WriteFile(sigPath, sig, 0644); err != nil {
fmt.Fprintf(os.Stderr, "write %s: %v\n", sigPath, err)
os.Exit(1)
}
fmt.Printf("signed %s -> %s\n", filePath, sigPath)
}