Admin server
HTTP, WebSocket, and Unix socket control plane for a running agent, with Ed25519 SSH-style auth.
The admin server is the control plane of a running agent. nanook ctl and nanook tui speak to it over HTTP, WebSocket, or a Unix socket. Disabled by default.
What you get
When [admin].enabled = true:
- HTTP listener on
addr(e.g.127.0.0.1:9091). - WebSocket at
/watchfor live state and metric streaming. - Optional Unix socket at
socketfor loopback operators.
nanook ctl and nanook tui reuse nanook.toml to find the endpoint, or override with --addr / --socket.
Endpoints
| Path | Method | What |
|---|---|---|
/state | GET | Current snapshot: collectors, rules, silences. |
/metrics/{since} | GET | Metrics emitted since the given unix timestamp. |
/watch | GET (WS) | Live stream: init snapshot, deltas, metrics, logs. |
/reload | POST | Re-read nanook.toml from disk and apply the diff. |
/silence | POST | Mute a rule for a duration. JSON body: { "expr": "...", "duration_secs": 3600 }. |
/unsilence | POST | Lift a silence. JSON body: { "expr": "..." }. |
/collectors/{name}/pause | POST | Stop a collector from polling. |
/collectors/{name}/resume | POST | Resume a paused collector. |
/collectors/{name}/trigger | POST | Fire one read manually, regardless of interval. |
/rules/{name}/fire | POST | Force a rule's action to run now. Bypasses eval, silences, cooldown. name matches the rule's name field, falling back to the raw expression. |
Responses are JSON. Successful mutations return {"ok":true}. Failures return {"error": {"code": "...", "message": "..."}}. Exact wire shapes live in crates/nanook-admin/src/wire.rs.
Configuration
[]
= true
= "127.0.0.1:9091" # HTTP/WS bind addr
= "/run/nanook.sock" # optional Unix socket
= "/etc/nanook/tls.crt" # optional TLS chain (PEM)
= "/etc/nanook/tls.key" # optional TLS private key (PEM)
= "required" # "required" (default) or "none"
= [ # inline ssh-ed25519 lines
"ssh-ed25519 AAAAC3Nz... alice@laptop",
]
= "/etc/nanook/authorized_keys" # optional file path
Field reference:
| Field | Meaning |
|---|---|
enabled | Master switch. Server is off when false. |
addr | host:port for HTTP/WS. Empty means HTTP is disabled (Unix socket only). |
socket | Path to a Unix socket. Permissions on the parent directory are your gate. |
cert | Path to a PEM-encoded certificate (chain). Pair with key to enable TLS. |
key | Path to a PEM-encoded private key (PKCS#8, RSA, or SEC1; unencrypted). |
auth | "required" (default) or "none". With required, the agent refuses to start when no keys are configured. |
authorized | Inline allowlist. Each entry is a full ssh-ed25519 <base64> [comment] line. |
authorized_keys | Path to an SSH-style authorized_keys file. Refused at load time when group- or world-writable. |
Auth activates as soon as any key is configured (inline or via file). With auth = "required" and zero keys, nanook check and the agent both refuse to start.
TLS
TLS support is gated behind the tls Cargo feature (on by default). A build with --no-default-features and the canonical pieces but no tls flag will not accept cert / key config keys and skips the --ca flag on ctl / tui.
Set both cert and key to serve HTTPS instead of plain HTTP. The Unix socket is unaffected (filesystem permissions are its trust boundary). Setting only one of the two is a config error caught by nanook check.
Supported PEM formats:
- Certificate: one or more
BEGIN CERTIFICATEblocks. Usefullchain.pemon Let's Encrypt deployments so intermediates ride along. - Key: PKCS#8 (
BEGIN PRIVATE KEY), traditional RSA (BEGIN RSA PRIVATE KEY), or SEC1 EC (BEGIN EC PRIVATE KEY). Encrypted keys are not supported. Decrypt first withopenssl pkcs8 -topk8 -nocrypt.
Once TLS is on, clients talk https:// and wss:// to that addr. With a public CA the system trust store is enough. For self-signed or private-CA deployments, point nanook ctl and nanook tui at the trust anchor:
The --ca file may carry one or more BEGIN CERTIFICATE blocks (concat them for mixed private + public trust). Programmatic clients reach the same path via Negotiator::root_ca_pem(bytes) or HttpControl::with_root_ca(bytes).
Common load failures: tls_cert_read, tls_parse_cert, tls_parse_key, tls_build (cert and key do not match).
How the auth scheme works
Ed25519 SSH-style keypairs, per-request signature in headers. Same shape as SSH's authorized_keys, no bearer tokens, no shared secrets.
The client sends four headers per request:
| Header | Value |
|---|---|
x-nanook-key | ssh-ed25519 <base64-pubkey> [comment] |
x-nanook-timestamp | Unix seconds when the request was signed. |
x-nanook-nonce | 32 random bytes, base64 encoded. |
x-nanook-signature | 64-byte Ed25519 signature, base64 encoded. |
The signature covers a canonical pre-image: protocol version, HTTP method, request path, timestamp, nonce, and SHA-256 of the body. Tamper with any of those and verification fails.
The server then:
- Parses the timestamp; rejects skew over 60 seconds (default).
- Decodes the nonce; rejects if seen inside the skew window (
replay). - Verifies the Ed25519 signature against the claimed public key.
- Looks the key up in the configured
AuthorizedKeysset; unknown keys getunauthorized.
The middleware buffers the body (capped at 1 MiB) so it can hash without streaming surprises.
Generating an identity
Use nanook keygen. It writes the SSH-style private key plus a .pub companion:
# wrote ~/.nanook/admin/id_ed25519
# public ~/.nanook/admin/id_ed25519.pub
# fingerprint SHA256:...
Defaults:
- Path:
~/.nanook/admin/id_ed25519(andid_ed25519.pub). - Comment:
$USER@$HOSTNAME. - Mode:
0600on the private file,0644on the public,0700on~/.nanook/.
Flags:
| Flag | What |
|---|---|
--path <file> | Write somewhere other than ~/.nanook/admin/id_ed25519. |
--comment <text> (-m) | Override the recorded comment. |
--force | Overwrite an existing identity. |
The private file is loaded on use (nanook ctl, nanook tui). If its mode is looser than 0600, the loader refuses with insecure_perms.
Authorizing a client
You need the contents of the operator's id_ed25519.pub:
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH...wsd alice@laptop
Two ways to trust it:
Inline. Drop the line into [admin].authorized:
[]
= true
= "127.0.0.1:9091"
= [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH...wsd alice@laptop",
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH...xyz bob@workstation",
]
Then pick it up with:
(Or restart the agent.)
Via a file. Append to an SSH-style authorized_keys:
[]
= true
= "127.0.0.1:9091"
= "/etc/nanook/authorized_keys"
Mode must have no group/world write bits set (e.g. 0600, 0640, 0644). nanook refuses to load a writable allowlist.
Inline and file entries can be mixed: they merge into one set, deduped by fingerprint.
Disabling auth
For a homelab agent on loopback:
[]
= true
= "127.0.0.1:9091"
= "none"
Auth rejection codes
When auth rejects a request, the response carries a stable code:
| Code | HTTP | Cause |
|---|---|---|
missing_auth | 401 | One of the four x-nanook-* headers is absent. |
clock_skew | 401 | Client and server clocks differ by more than 60s. |
replay | 401 | The same nonce was reused inside the skew window. |
bad_signature | 401 | Signature didn't match the canonical pre-image. The body, path, or method changed in flight. |
unauthorized | 403 | Signature was valid, but the public key isn't on the allowlist. |
bad_auth | 400 | Malformed header (bad base64, wrong length, unsupported algo, etc.). |
Full diagnostic codes:
See also
ctl: the operator CLI that talks to this server.- TUI: the live dashboard that subscribes to
/watch. - Configuration: full
nanook.tomlschema.