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 /watch for live state and metric streaming.
  • Optional Unix socket at socket for loopback operators.

nanook ctl and nanook tui reuse nanook.toml to find the endpoint, or override with --addr / --socket.

Endpoints

PathMethodWhat
/stateGETCurrent snapshot: collectors, rules, silences.
/metrics/{since}GETMetrics emitted since the given unix timestamp.
/watchGET (WS)Live stream: init snapshot, deltas, metrics, logs.
/reloadPOSTRe-read nanook.toml from disk and apply the diff.
/silencePOSTMute a rule for a duration. JSON body: { "expr": "...", "duration_secs": 3600 }.
/unsilencePOSTLift a silence. JSON body: { "expr": "..." }.
/collectors/{name}/pausePOSTStop a collector from polling.
/collectors/{name}/resumePOSTResume a paused collector.
/collectors/{name}/triggerPOSTFire one read manually, regardless of interval.
/rules/{name}/firePOSTForce 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

[admin]
enabled         = true
addr            = "127.0.0.1:9091"        # HTTP/WS bind addr
socket          = "/run/nanook.sock"       # optional Unix socket
cert            = "/etc/nanook/tls.crt"    # optional TLS chain (PEM)
key             = "/etc/nanook/tls.key"    # optional TLS private key (PEM)
auth            = "required"              # "required" (default) or "none"
authorized      = [                       # inline ssh-ed25519 lines
  "ssh-ed25519 AAAAC3Nz... alice@laptop",
]
authorized_keys = "/etc/nanook/authorized_keys"  # optional file path

Field reference:

FieldMeaning
enabledMaster switch. Server is off when false.
addrhost:port for HTTP/WS. Empty means HTTP is disabled (Unix socket only).
socketPath to a Unix socket. Permissions on the parent directory are your gate.
certPath to a PEM-encoded certificate (chain). Pair with key to enable TLS.
keyPath 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.
authorizedInline allowlist. Each entry is a full ssh-ed25519 <base64> [comment] line.
authorized_keysPath 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 CERTIFICATE blocks. Use fullchain.pem on 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 with openssl 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:

nanook ctl --addr https://agent.internal:9091 --ca /etc/nanook/cert.pem state
nanook tui --addr wss://agent.internal:9091  --ca /etc/nanook/cert.pem

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:

HeaderValue
x-nanook-keyssh-ed25519 <base64-pubkey> [comment]
x-nanook-timestampUnix seconds when the request was signed.
x-nanook-nonce32 random bytes, base64 encoded.
x-nanook-signature64-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:

  1. Parses the timestamp; rejects skew over 60 seconds (default).
  2. Decodes the nonce; rejects if seen inside the skew window (replay).
  3. Verifies the Ed25519 signature against the claimed public key.
  4. Looks the key up in the configured AuthorizedKeys set; unknown keys get unauthorized.

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:

nanook keygen
# wrote        ~/.nanook/admin/id_ed25519
# public       ~/.nanook/admin/id_ed25519.pub
# fingerprint  SHA256:...

Defaults:

  • Path: ~/.nanook/admin/id_ed25519 (and id_ed25519.pub).
  • Comment: $USER@$HOSTNAME.
  • Mode: 0600 on the private file, 0644 on the public, 0700 on ~/.nanook/.

Flags:

FlagWhat
--path <file>Write somewhere other than ~/.nanook/admin/id_ed25519.
--comment <text> (-m)Override the recorded comment.
--forceOverwrite 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:

[admin]
enabled    = true
addr       = "127.0.0.1:9091"
authorized = [
  "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH...wsd alice@laptop",
  "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH...xyz bob@workstation",
]

Then pick it up with:

nanook ctl reload

(Or restart the agent.)

Via a file. Append to an SSH-style authorized_keys:

cat alice.pub >> /etc/nanook/authorized_keys
chmod 600 /etc/nanook/authorized_keys
[admin]
enabled         = true
addr            = "127.0.0.1:9091"
authorized_keys = "/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:

[admin]
enabled = true
addr    = "127.0.0.1:9091"
auth    = "none"

Auth rejection codes

When auth rejects a request, the response carries a stable code:

CodeHTTPCause
missing_auth401One of the four x-nanook-* headers is absent.
clock_skew401Client and server clocks differ by more than 60s.
replay401The same nonce was reused inside the skew window.
bad_signature401Signature didn't match the canonical pre-image. The body, path, or method changed in flight.
unauthorized403Signature was valid, but the public key isn't on the allowlist.
bad_auth400Malformed 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.toml schema.