Nadir
Nadir is a lightweight, modular Linux system-administration backend - a modern, FOSS system admin panels. It exposes a typed REST API for the everyday tasks you'd otherwise SSH in to do: inspect the host, manage systemd services, edit local users and groups, install packages, and read logs - all behind role-based access control and a tamper-evident audit trail.
The API is generated with Huma (OpenAPI 3.1) and ships
interactive docs at /docs. The backend is Go and self-contained: no external
database, no agent, no runtime dependencies beyond the standard system tools it
drives (systemctl, hostnamectl, useradd, the host package manager, …).
What it does
Functionality is organized into modules. Each module owns a slice of the API and declares its own permission vocabulary.
- System - Dashboard overview (OS/kernel, CPU, memory, disks, load, uptime, network interfaces, GPU, temperatures); get/set hostname; time, timezone, and NTP; locale and console keymap; reboot and power off.
- Services - List and inspect systemd units; start / stop / restart / enable / disable; read service logs from the journal or an allowlisted file, as a snapshot or a live Server-Sent-Events stream.
- Users - List, inspect, create, and delete local accounts; set a password; set supplementary groups.
- Groups - List, inspect, create, and delete local groups.
- Packages - List installed packages and available updates; install, remove,
and upgrade - streamed live over SSE. Auto-detects
dnf,apt, orpacman. - Networking - List network interfaces, routing tables, and DNS settings; configure IPv4 settings with temporary applying and safety auto-rollback; bring interfaces up or down; edit
/etc/hosts. - Storage - List active mounts and
/etc/fstabentries; add, edit, and delete fstab entries; mount and unmount filesystems. - Audit - Read-only trail of every privileged write (who, what, when, result).
- Meta - Self-description for clients:
/api/_modules,/api/whoami,/api/health; trigger a self-update viaPOST /api/update.
Security model at a glance
- Authentication is delegated to PAM (
pam_unix), so logins use real system credentials. A successful login sets anHttpOnly,SameSite=Strictsession cookie; sessions are stored in SQLite and survive restarts. - Machine credentials for non-interactive callers (e.g. a central dashboard
managing many nodes) authenticate with a static
Authorization: Bearer nad_…token instead of a PAM session. Mint withnadir token add <name>(shown once, only its SHA-256 is stored); revoke withnadir token rm <name>(immediate, no restart). A token is an ordinary RBAC subject - its name is assigned a role inconfig.yamlassignments, so a leaked token is scoped, not implicitly admin. The audit trail records the actor astoken:<name>to distinguish it from a human. CSRF does not apply: browsers never auto-attach a Bearer header, so the same-origin cookie defense is irrelevant for token auth. Bad-token guesses are throttled per source IP. - Authorization is RBAC driven entirely by
config.yaml. Every protected operation declares amoduleand one of three permission tiers:read- inspect (list users, read status, view logs…)write- routine changes (create a user, restart a service, set the hostname…)root- high-impact or irreversible actions (reboot, delete an account, reset a password, change group membership). Password and group- membership changes arerootprecisely because they can hand someone root.
- Brute-force throttling on login (per username + source IP cooldown).
- CSRF defense via
SameSite=Strictplus a same-origin check on writes. - Audit of every mutation, written off the request path to SQLite.
- The server must run as root - PAM reads
/etc/shadow, and the system tools it drives (hostnamectl,systemctl,useradd,shutdown, …) require it.
Installing
Prerequisites
- Linux with systemd (the Services module and the
nadirservice wrapper use it). - Root access (see above).
- Go (recent) to build from source.
Build
The entry point is the main package under cmd/server:
go build -o nadir ./cmd/server
This produces a single static-ish binary, nadir.
Run directly
On first start, nadir requires a configuration file to exist. If the configuration is missing, the server will fail to start and ask you to run nadir install (to install the systemd service) or use --save-config.
To generate a default configuration file (assigning the admin role to your current user) without installing the systemd service:
./nadir --save-config
To save it for the root user (who runs the server):
sudo ./nadir --save-config
You can also specify a custom path using -f/--config:
./nadir --save-config -f ./config.yaml
Once the configuration file is created, start the server directly:
sudo ./nadir # same as: sudo ./nadir run
By default it reads ~/.config/config.yaml (resolving to the running user's home, i.e., /root/.config/config.yaml when run as root); override with the -f/--config flag or CONFIG_PATH env var:
sudo ./nadir -f /etc/nadir/config.yaml
# or: sudo CONFIG_PATH=/etc/nadir/config.yaml ./nadir
By default it serves HTTPS with a self-signed certificate (see
Deployment note 2) on the hostname:port from the config,
and exposes interactive docs at https://<host>:<port>/docs and the raw spec at
/openapi.json.
Run in the background (-d)
Like docker run -d, this detaches from the terminal and returns your shell:
sudo ./nadir run -d
# nadir running in background (pid 12345); logs: /var/lib/nadir/server.log
# follow with: nadir logs
Output goes to /var/lib/nadir/server.log.
Install as a systemd service (start on boot)
For a real deployment, register nadir as a service so it starts on boot and is managed with the usual tooling:
sudo ./nadir install # writes the unit, enables it, and starts it now
sudo ./nadir status
sudo ./nadir logs # follow the journal live
install writes /etc/systemd/system/nadir.service pinning the absolute
binary and the absolute config file path (so it doesn't depend on the working directory at
boot), runs systemctl daemon-reload, and enable --now. If no configuration file
exists at the target path, install automatically creates a default config file and
assigns the admin role to the installing user.
CLI reference
| Command | Effect |
|---|---|
nadir [run] [-d] |
Start the server. -d / --detach runs it in the background. |
nadir --save-config |
Save the default configuration template to the target path and exit. |
nadir install |
Install + enable the systemd service (starts now and on boot). |
nadir uninstall |
Stop, disable, and remove the systemd service. |
nadir start | stop | restart | status |
Control the running service. |
nadir enable | disable |
Toggle start-on-boot without removing the unit. |
nadir logs |
Follow logs - journald if installed as a service, otherwise the detach log. |
nadir update [--check] [--force] |
Download and install the latest release (requires server.release_repo in config). --check reports the available version without downloading; --force re-downloads even when already current. |
nadir token add <name> |
Mint a machine API token (shown once, not stored in plain text). |
nadir token rm <name> |
Revoke a token immediately (no restart needed). |
nadir token ls |
List token names (not the raw keys). |
nadir help |
Show usage. |
Most commands need root.
Configuration (config.yaml)
config.yaml is the single source of truth for runtime configuration: server
and TLS settings, which roles exist, what each role can do, and who holds which
role. By default, it reads ~/.config/config.yaml. The path can be overridden using
the -f / --config CLI flags or the CONFIG_PATH environment variable.
server:
secure_tls: true # Secure flag on the session cookie (keep true behind TLS)
trust_proxy: true # a reverse proxy terminates TLS; see Deployment note 3
# tls_cert: /etc/nadir/tls/cert.pem # or terminate TLS in nadir yourself
# tls_key: /etc/nadir/tls/key.pem
hostname: 100.64.0.189
port: 9999
# release_repo: https://gitea.example.com/owner/nadir # enables `nadir update`
# Quote "*" - bare * is YAML alias syntax and fails to parse.
roles:
admin:
"*": ["*"] # every permission on every module (including future ones)
auditor:
"*": ["read"] # read-only everywhere
system_ops:
system: ["read", "write"]
assignments:
urania: [admin]
# Optional: per-unit allowlist of log files the Services module may read.
log_files:
nginx:
- /var/log/nginx/access.log
- /var/log/nginx/error.log
server
| Key | Default | Meaning |
|---|---|---|
secure_tls |
true |
Sets the Secure flag on the session cookie. Keep true whenever the browser reaches nadir over HTTPS (direct or via proxy); false only for local plain-HTTP dev. |
trust_proxy |
false |
When true, nadir serves plaintext HTTP and trusts X-Forwarded-For / forwarded Host from the proxy. See Deployment note 3. |
tls_cert, tls_key |
- | PEM paths. When both are set (and trust_proxy is off), nadir terminates TLS with this pair. |
hostname |
- | Address to bind. Use 127.0.0.1 for local-only, or an overlay/VPN address to expose nadir only on that interface. |
port |
- | TCP port to listen on. |
release_repo |
- | Gitea repo URL (https://host/owner/repo). When set, enables nadir update and POST /api/update. Must be https://. |
TLS selection is covered in Deployment note 2.
roles / assignments
rolesmaps a role name tomodule → [permissions]."*"as the module key means "all modules";"*"in the permission list means "all permissions".assignmentsmaps a username to the roles they hold; effective grants are the union."*"must be quoted - bare*is YAML alias syntax and fails to parse.- Module keys and permissions are validated at startup against the modules actually compiled in. An unknown module, an unexported permission, or an assignment to an undefined role aborts startup with a clear message rather than silently granting or denying access.
- Each module owns its permission vocabulary via
Permissions(), so adding a module automatically makes it available to wildcard roles and validatable for restricted ones. Clients discover the live module/permission set atGET /api/_modules, and a user's own grants atGET /api/whoami.
log_files
An allowlist, keyed by unit, of log file paths the Services module is allowed to
read via the source=file log endpoints. The caller can only read paths an
admin has listed here - never an arbitrary file.
Deployment notes
These notes capture the non-obvious operational decisions. They'll seed the formal installation guide.
1. PAM service
Nadir authenticates against its own PAM service, /etc/pam.d/nadir, and the
server creates that file on startup if it is missing (see
internal/auth/pamservice.go). Here is why.
What went wrong with stock services
Originally we authenticated against the "login" service. On a Framework
laptop (and many other machines) /etc/pam.d/login pulls in system-auth,
whose auth stack lists pam_fprintd.so as sufficient before
pam_unix.so:
auth sufficient pam_fprintd.so # fingerprint, tried first
auth sufficient pam_unix.so nullok # password, only reached if fprintd fails
Our PAM conversation callback only answers the password prompt; it can't swipe
a finger. So pam_fprintd would start a fingerprint scan and block until its
~30-second timeout before falling through to the password check. Every login
took 30s. (It was never a network, D-Bus, systemd, or NSS problem —
hostnamectl was instant and there is no SSSD/LDAP on the box.)
Switching to "passwd" is not a fix either: /etc/pam.d/passwd has only a
password stack and no auth stack, so it can't verify a login.
The fix
Ship a dedicated, minimal service - exactly what sshd, cockpit, and
polkit do. /etc/pam.d/nadir contains only:
#%PAM-1.0
auth required pam_unix.so
account required pam_unix.so
That is a straight /etc/shadow password check plus an account-validity check
— no fingerprint, no systemd, no env loading, no DNS. Authentication drops from
~30s to milliseconds, and we stop inheriting whatever the distro's login stack
happens to do.
Notes:
- We omit
nullokon purpose: this service is reachable over the network, andnullokwould let passwordless accounts log in. EnsurePAMService()only writes the file when it is absent - a missing service falls through to/etc/pam.d/other(pam_deny), which looks identical to "wrong credentials". If an admin customizes the file, nadir leaves it untouched.pam_unixreads/etc/shadow, so the server must run as root.
2. TLS: three modes
Credentials and session cookies must never travel in cleartext. Nadir picks how
the connection is secured from config.yaml, in priority order:
- Behind a reverse proxy (
trust_proxy: true) - a proxy such as Traefik terminates TLS and forwards plaintext to nadir on a trusted network. Keepsecure_tls: true(the browser↔proxy leg is HTTPS). This is the deployment covered in note 3. - Nadir terminates TLS (
tls_cert+tls_key) - point both at a PEM certificate/key pair and nadir serves HTTPS directly. Use this when there is no proxy. - Self-signed (dev only) - when none of the above is configured, nadir
generates a fresh in-memory self-signed certificate (valid for
localhostand the loopback addresses, one year). Browsers will warn; that's expected. Never rely on this in production.
To create a persistent self-signed pair for mode 2 in development:
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout key.pem -out cert.pem -days 365 \
-subj "/O=nadir-dev-local/CN=localhost" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1,IP:::1"
…then set tls_cert/tls_key to those paths.
3. Reverse proxy + VPN
When nadir runs behind a TLS-terminating reverse proxy (e.g. Traefik) on a
private overlay network, set trust_proxy: true. Nadir then serves plaintext
HTTP and trusts X-Forwarded-For (used by the login throttle) and the forwarded
Host (used by the CSRF same-origin check). That trust is only safe if
nothing but the proxy can reach the app's port - otherwise any client that
reaches it directly can forge those headers.
The recommended shape: the proxy and the app each sit on a WireGuard-based overlay, and nadir binds to its overlay address so the public/LAN interfaces never answer.
server:
trust_proxy: true
secure_tls: true # browser↔proxy leg is HTTPS, so keep the cookie Secure
hostname: 100.64.0.189 # the app's overlay IP - only the VPN interface listens
port: 9999
Netbird / Tailscale assign peers out of 100.64.0.0/10 (RFC 6598 CGNAT),
which is not publicly routable - binding there means only VPN peers can connect.
Plain WireGuard is the same idea with a private range you pick (e.g.
10.0.0.0/24); bind to the app's address on the wg0 interface.
Two things make the header trust airtight:
-
Restrict the port to the proxy peer only. Binding to the overlay limits reachability to all VPN peers, not just the proxy. Tighten it so only the proxy can reach
:9999:- Netbird: an access-control policy allowing the proxy peer/group → the app peer on tcp/9999, denying others.
- Tailscale: an ACL rule (
"src": ["tag:proxy"], "dst": ["tag:nadir:9999"]). - Plain WireGuard: a host firewall rule on the app, e.g.
iptables -A INPUT -i wg0 ! -s <proxy-wg-ip> -p tcp --dport 9999 -j DROP.
-
Make the proxy overwrite client-supplied forwarded headers. Otherwise a client sending its own
X-Forwarded-For/X-Forwarded-Hostcan have it passed through. In Traefik, mark the overlay as trusted on the entrypoint:# traefik static config entryPoints: websecure: address: ":443" forwardedHeaders: trustedIPs: - 100.64.0.0/10 # or your wg subnet, e.g. 10.0.0.0/24And ensure it forwards the original host (Traefik does by default; nginx needs
proxy_set_header Host $host;), since the CSRF check comparesOriginagainstHost.
With both in place, the only path to the app is proxy → overlay → app, and the forwarded headers are trustworthy. Without step 1 you're trusting every peer on the overlay - fine for a single-tenant network you fully control, risky on a shared one.
4. Self-update
When server.release_repo points at a Gitea repo, nadir can update itself:
sudo nadir update # download + install latest, restart service
sudo nadir update --check # report available version, do nothing
sudo nadir update --force # re-download even if already at latest
The updater:
- Fetches the latest release from the Gitea API.
- Downloads the binary for the host's architecture (
linux-amd64,linux-arm64, …). - Verifies the release: checks the minisign signature on
sha256sums.txt, then checks the binary's SHA-256 against it. Refuses to install if either check fails. - Atomically replaces the running binary (
os.Renameon the same filesystem) and runssystemctl restart nadir.
The same flow is also reachable via POST /api/update (requires the admin wildcard role), which runs the updater detached and returns 202 immediately. Poll GET /api/health to confirm the new version is running after the restart drops in-flight connections.
release_repo must use https:// — the update downloads and executes the binary, and a plaintext URL would expose the host to on-path replacement.
5. Connecting a dashboard (machine clients)
To manage one or more Nadir instances via a central dashboard or non-interactive client, authenticate requests using a static Bearer token rather than interactive PAM credentials.
Here is how to authorize and connect a dashboard:
Step 1: Mint a token
Run nadir token add <name> (for example, dashboard) to generate a unique API key:
sudo nadir token add dashboard
This generates a secure token starting with nad_. Copy this token immediately; only its SHA-256 hash is stored in /var/lib/nadir/tokens.db (shared via SQLite WAL between server and CLI), and the raw key cannot be retrieved again.
Step 2: Authorize the token in config.yaml
Minting and authorizing are deliberately separate steps (safe default). A newly minted token does not grant any access.
To grant the token a role, edit the assignments map in your config.yaml:
assignments:
dashboard: [admin] # or another role like [system_ops] or [auditor]
The audit log will record mutations performed by this token as token:<name> (e.g., token:dashboard), distinguishing it from human logins.
Step 3: Restart Nadir
While token creation and revocation (nadir token rm) are written to the database and take effect immediately, policy assignments live in config.yaml. To reload the configuration and authorize the new token name, you must restart the Nadir server:
sudo systemctl restart nadir
Step 4: Configure the dashboard client
Configure your client to include the token in the HTTP Authorization header of every API request:
Authorization: Bearer nad_your_secret_token_here
Note on CORS / Cross-Origin requests
If your dashboard runs as a web application directly in the user's browser (cross-origin relative to the Nadir instance) and makes state-changing write requests (POST, PUT, DELETE), the browser will include an Origin header.
To defend against CSRF, Nadir's middleware rejects state-changing requests if an Origin header is present and does not match the request's Host header.
To connect a browser-based dashboard hosted on a different origin, choose one of these patterns:
- Server-to-Server Calls (Recommended): Build the dashboard with a backend that calls Nadir's API. Because the backend is not a browser, it does not send an
Originheader, allowing the requests to pass. - Reverse Proxy: Terminate the dashboard and the Nadir instance under the same origin (e.g., dashboard at
https://control.example.com/and Nadir athttps://control.example.com/api/nadir-node-1/), letting a reverse proxy route the requests. - Header Rewriting: Have a proxy in front of Nadir rewrite/strip the
Originheader for authorized token requests before forwarding them to Nadir.
Layout
cmd/ process entry point + CLI (run / install / update / token / logs …), TLS, service wiring
internal/auth PAM auth, sessions, login/logout, login throttle, bearer tokens, PAM service install
internal/auditlog SQLite-backed audit log writer
internal/config config.yaml loader + startup validation
internal/meta /api/_modules, /api/whoami, /api/health, /api/update discovery + update endpoints
internal/module the Module interface
internal/modules concrete modules:
system - info, hostname, time/timezone/NTP, locale/keymap, power
services - systemd unit control + journal/file logs (snapshot + SSE)
users - local accounts
groups - local groups
packages - dnf/apt/pacman install/remove/upgrade (streamed)
networking - interfaces, routing tables, DNS, IP config, /etc/hosts
storage - active mounts, /etc/fstab read/write, mount/unmount
internal/mounts /proc/mounts parser (used by storage module)
internal/oscmd shared command runner (timeouts, stderr surfacing) + helpers
internal/rbac roles, permissions ("*" wildcards), HTTP middleware (RBAC + CSRF)
API docs
With the server running, browse https://<host>:<port>/docs for the Scalar UI,
or fetch the raw OpenAPI document from /openapi.json.
Built with LLM assistance
This project was built with the help of large language models - but every architectural choice, security decision, and operational trade-off is the author's. The LLM never drove; it was a power tool, not a co-pilot with the wheel.
In practice, the workflow looks like this: the author designs the feature, decides how it should fit into the existing module structure, specifies the API surface, and defines the security and permission semantics. The LLM then accelerates the mechanical side - scaffolding boilerplate, drafting implementations from precise instructions, generating documentation, and proposing test cases. Every line of output is reviewed, corrected where needed, and integrated only when it meets the project's standards.
What the LLM provides is commodity leverage: it collapses the time between "I know exactly what I want" and "it's written, tested, and documented." What it does not provide is judgment - that stays with the person who understands the system, its threat model, and its users.
License
Credits
Favicon: Orbit from Lucide, recolored. Lucide icons are licensed under the ISC License.