13 Commits

Author SHA1 Message Date
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
24 changed files with 985 additions and 55 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
+43 -2
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)
} }
@@ -232,6 +239,7 @@ 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, 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
})
}
+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).
+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)
+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)
}