# 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](https://huma.rocks) (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 ` (shown once, only its SHA-256 is stored); revoke with `nadir token rm ` (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:` 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`: ```bash 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: ```bash ./nadir --save-config ``` To save it for the root user (who runs the server): ```bash sudo ./nadir --save-config ``` You can also specify a custom path using `-f`/`--config`: ```bash ./nadir --save-config -f ./config.yaml ``` Once the configuration file is created, start the server directly: ```bash 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: ```bash 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](#2-tls-three-modes)) on the `hostname:port` from the config, and exposes interactive docs at `https://:/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: ```bash 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: ```bash 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. ```yaml 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](#3-reverse-proxy--vpn). | | `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](#2-tls-three-modes). ### `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: ```bash 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. ```yaml 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 -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: ```yaml # 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 ` (for example, `dashboard`) to generate a unique API key: ```bash 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`: ```yaml assignments: dashboard: [admin] # or another role like [system_ops] or [auditor] ``` The audit log will record mutations performed by this token as `token:` (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: ```bash 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: ```http 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://:/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](./LICENSE) ## Credits Favicon: [Orbit](https://lucide.dev/icons/orbit) from [Lucide](https://lucide.dev), recolored. Lucide icons are licensed under the [ISC License](https://github.com/lucide-icons/lucide/blob/main/LICENSE).