# 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, 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`, 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; edit `/etc/hosts`.
- **Storage** - List active mounts and `/etc/fstab` entries; 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 via `POST /api/update`.

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

<!-- api-desc-end -->

---

## 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://<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:

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

```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
  # 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](#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.                                                                                                                                                |
| `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](#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 <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:

   ```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. Self-update

When `server.release_repo` points at a Gitea repo, nadir can update itself:

```bash
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:
1. Fetches the latest release from the Gitea API.
2. Downloads the binary for the host's architecture (`linux-amd64`, `linux-arm64`, …).
3. 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.
4. Atomically replaces the running binary (`os.Rename` on the same filesystem) and runs `systemctl 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:
```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:<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:
```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 / 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

[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).
