urania 60b9fbc42c
build-and-release / release (push) Successful in 2m4s
fix: cpu info
2026-06-22 18:54:50 +02:00
2026-06-22 18:24:59 +02:00
2026-06-22 17:24:01 +02:00
2026-06-22 18:24:59 +02:00
2026-06-22 18:54:50 +02:00
2026-06-22 16:06:57 +02:00
2026-06-22 17:20:33 +02:00
2026-06-22 16:51:18 +02:00
2026-06-22 17:10:09 +02:00
2026-06-22 16:06:57 +02:00
2026-06-22 16:06:57 +02:00
2026-06-22 16:06:57 +02:00
2026-06-22 16:51:18 +02:00
2026-06-22 16:06:57 +02:00
2026-06-22 18:54:50 +02:00
2026-06-22 16:06:57 +02:00

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, 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, or pacman.
  • Networking - List network interfaces, routing tables, and DNS settings; configure IPv4 settings with temporary applying and safety auto-rollback; bring interfaces up or down.
  • Audit - Read-only trail of every privileged write (who, what, when, result).
  • Terminal - Interactive shell access. Upgrades connection to a WebSocket and spawns a PTY shell as the logged-in user (requires root permission).
  • Meta - Self-description for clients: /api/_modules, /api/whoami, /api/health.

Security model at a glance

  • Authentication is delegated to PAM (pam_unix), so logins use real system credentials. A successful login sets an HttpOnly, SameSite=Strict session 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 with nadir token add <name> (shown once, only its SHA-256 is stored); revoke with nadir token rm <name> (immediate, no restart). A token is an ordinary RBAC subject - its name is assigned a role in config.yaml assignments, so a leaked token is scoped, not implicitly admin. The audit trail records the actor as token:<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 a module and 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 are root precisely because they can hand someone root.
  • Brute-force throttling on login (per username + source IP cooldown).
  • CSRF defense via SameSite=Strict plus 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 nadir service 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 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

# 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.

TLS selection is covered in Deployment note 2.

roles / assignments

  • roles maps a role name to module → [permissions]. "*" as the module key means "all modules"; "*" in the permission list means "all permissions".
  • assignments maps 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 at GET /api/_modules, and a user's own grants at GET /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 nullok on purpose: this service is reachable over the network, and nullok would 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_unix reads /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:

  1. Behind a reverse proxy (trust_proxy: true) - a proxy such as Traefik terminates TLS and forwards plaintext to nadir on a trusted network. Keep secure_tls: true (the browser↔proxy leg is HTTPS). This is the deployment covered in note 3.
  2. 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.
  3. Self-signed (dev only) - when none of the above is configured, nadir generates a fresh in-memory self-signed certificate (valid for localhost and 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:

  1. 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.
  2. Make the proxy overwrite client-supplied forwarded headers. Otherwise a client sending its own X-Forwarded-For / X-Forwarded-Host can 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/24
    

    And ensure it forwards the original host (Traefik does by default; nginx needs proxy_set_header Host $host;), since the CSRF check compares Origin against Host.

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. 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:

  1. 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 Origin header, allowing the requests to pass.
  2. Reverse Proxy: Terminate the dashboard and the Nadir instance under the same origin (e.g., dashboard at https://control.example.com/ and Nadir at https://control.example.com/api/nadir-node-1/), letting a reverse proxy route the requests.
  3. Header Rewriting: Have a proxy in front of Nadir rewrite/strip the Origin header for authorized token requests before forwarding them to Nadir.

Layout

cmd/                  process entry point + CLI (run / install / logs …), TLS, service wiring
internal/auth         PAM auth, sessions, login/logout, login throttle, PAM service install
internal/config       config.yaml loader + startup validation
internal/meta         /api/_modules, /api/whoami, /api/health discovery 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)
                        audit    - read-only audit trail
                        networking - network interfaces, routing tables, DNS, and IP configurations
                        terminal - interactive PTY shell over WebSocket
internal/oscmd        shared command runner (timeouts, stderr surfacing) + helpers
internal/rbac         roles, permissions ("*" wildcards), HTTP middleware (RBAC + CSRF)
internal/audit        SQLite-backed audit log writer

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

MIT

Credits

Favicon: Orbit from Lucide, recolored. Lucide icons are licensed under the ISC License.

S
Description
No description provided
Readme MIT 6.7 MiB
v0.6.0 Latest
2026-06-25 20:44:56 +02:00
Languages
Go 99.1%
Go Template 0.9%