4 Commits

Author SHA1 Message Date
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
6 changed files with 124 additions and 5 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 *)"
]
}
}
+9
View File
@@ -50,6 +50,8 @@ func main() {
configFlag := fs.String("f", "", "config file path")
fs.StringVar(configFlag, "config", "", "alias for -f")
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:]
var args []string
@@ -63,6 +65,11 @@ func main() {
rest = rest[1:]
}
if *showVersion {
fmt.Println(nadir.Version)
os.Exit(0)
}
if *configFlag != "" {
os.Setenv("CONFIG_PATH", *configFlag)
}
@@ -232,6 +239,8 @@ func runServer() {
meta.Register(api, mods)
meta.RegisterHealth(api, sessions)
meta.RegisterWhoami(api, sessions, roles, mods)
meta.ConfigPath = configPath
meta.RegisterUpdate(api)
auth.RegisterLogin(api, sessions, auditStore, cfg.SecureCookie())
auth.RegisterLogout(api, sessions, cfg.SecureCookie())
+2 -1
View File
@@ -425,7 +425,8 @@ Usage:
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 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
Most commands need root. Config path is specified via -f/--config or CONFIG_PATH (default ~/.config/config.yaml).
+23 -1
View File
@@ -2,6 +2,7 @@ package main
import (
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
@@ -12,6 +13,7 @@ import (
"strings"
"time"
"nadir"
"nadir/internal/config"
)
@@ -19,7 +21,12 @@ import (
// releases/latest, pick the asset for the host's GOARCH, atomically replace
// /usr/local/bin/nadir (or wherever the running binary lives), and restart
// 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()
if err != nil {
return err
@@ -44,6 +51,21 @@ func updateCmd(_ []string) error {
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
}
var assetURL, assetName string
for _, a := range rel.Assets {
if strings.HasSuffix(a.Name, suffix) {
+63
View File
@@ -0,0 +1,63 @@
package meta
import (
"context"
"os"
"os/exec"
"syscall"
"nadir/internal/config"
"nadir/internal/oscmd"
"github.com/danielgtaylor/huma/v2"
)
// ConfigPath is set at startup so the update handler can re-load config and
// surface release_repo / parse errors to the caller instead of only stderr.
var ConfigPath string
// RegisterUpdate wires POST /api/meta/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.
//
// 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) {
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
})
}
+12 -3
View File
@@ -159,9 +159,18 @@ func cpuInfo() CPUInfo {
c := CPUInfo{Model: cpuModel(string(data)), LogicalCPUs: runtime.NumCPU()}
c.MinMHz, c.MaxMHz, c.CurrentMHz = cpuFreqMHz("/sys/devices/system/cpu")
// ponytail: cpufreq sysfs is absent on many VMs and stock Ubuntu server
// kernels; fall back to /proc/cpuinfo "cpu MHz" so CurrentMHz isn't 0.
if c.CurrentMHz == 0 {
c.CurrentMHz = cpuinfoMaxMHz(string(data))
// kernels; fall back to /proc/cpuinfo "cpu MHz" — VMs have a fixed clock,
// so min == max == cur is the honest answer.
if mhz := cpuinfoMaxMHz(string(data)); 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
}