Server and API reference
Environment variables, authentication, HTTP endpoints, and device storage.
Node.js + TypeScript control plane that relays terminal sessions between browsers and agents.
Configuration
The server loads the nearest .env file automatically (searching upward from its working directory), so a repo-root .env works whether you start from the root or from server/. Values already set in the environment take precedence over the file.
| Variable | Default | Description |
|---|---|---|
ADMIN_PASSWORD | (none) | Required. Web UI password, min 12 chars in production. Setting it turns authentication on; the server refuses to start without it unless SPECTRE_DEV_NO_AUTH=1 |
SPECTRE_DEV_NO_AUTH | Set to 1 to let the server start with no password (auth off) for local development. Ignored when ADMIN_PASSWORD is set — a password always means auth is on. Fatal when NODE_ENV=production | |
SPECTRE_PUBLIC_HOST | (auto) | Address agents should dial, shown in the UI's enrollment command, e.g. wss://spectre.example.com. When unset and TRUST_PROXY=1, the UI advertises the origin the browser reached the proxy on; otherwise the server's detected LAN address and PORT |
PORT | 8080 | HTTP/API port. Everything is served under /api |
DATA_DIR | ./data | SQLite database location (spectre.db, written 0600) |
CORS_ORIGIN | (empty) | Comma-separated allowed origins. Empty = no cross-origin access |
TRUST_PROXY | Set to 1 only behind a proxy that sets X-Forwarded-For and X-Forwarded-Proto. The supplied proxy container does both | |
SPECTRE_DEBUG_TERMINAL | Set to 1 to log terminal output summaries. Off by default: output contains what the user typed |
Authentication is on exactly when ADMIN_PASSWORD is set, so its state is consistent across page loads. SPECTRE_DEV_NO_AUTH only permits running without a password; it never overrides one.
Authentication
- Browser → server:
POST /api/auth/loginwith the password returns a session token, sent asAuthorization: Bearer <token>. Sessions idle out after 24h and expire absolutely after 7 days. Repeated failed logins lock out the client. - Browser → WebSocket: browsers can't set headers on a WebSocket handshake, so the session is exchanged via
POST /api/auth/ws-ticketfor a single-use ticket valid for 30 seconds, passed as?ticket=. Session tokens are never accepted in a URL. - Agent → server:
Authorization: Bearer <device key | auth key>on the handshake. Agents are authenticated before the WebSocket is established.
HTTP API
Public:
| Endpoint | Description |
|---|---|
GET /api/healthz | Liveness probe |
GET /api/version | Server version |
GET /api/auth/status | { authEnabled: true/false } |
POST /api/auth/login | { "password": "..." } → { "token": "..." }. Rate limited |
POST /api/devices/approval-request | Agent asks to be approved → { userCode, pollToken, expiresAt }. Rate limited |
POST /api/devices/approval-poll | Agent polls → { status: "pending" | "approved" | "expired", deviceKey? }. Rate limited |
Requires Authorization: Bearer <session token>:
| Endpoint | Description |
|---|---|
POST /api/auth/logout | Invalidate the current session |
POST /api/auth/ws-ticket | → { ticket } for a WebSocket upgrade |
GET /api/agents | List agents with status, system info, Docker containers |
POST /api/agents/:id/command | Push keystrokes. { "data": "ls\n" } |
POST /api/agents/refresh-docker | -system | -network | Re-fetch info from all agents |
POST /api/agents/:id/update | Ask a connected machine to upgrade itself. { version? }, defaulting to the latest release. 409 when the machine is offline |
POST /api/authkeys | Create an auth key. { reusable?, expiresInMs?, description? } → { key, ... }. The plaintext key is returned only here |
GET /api/authkeys | List auth keys (hints only, never the key) |
DELETE /api/authkeys/:id | Revoke an auth key |
GET /api/devices | List enrolled devices (one per physical machine). Never includes key material |
GET /api/devices/:id/connections | Connection history for a device |
DELETE /api/devices/:id | Revoke a device and drop its live connections |
GET /api/devices/pending | Machines waiting for approval |
POST /api/devices/pending/:userCode/approve | Approve a machine. { name? } |
POST /api/devices/pending/:userCode/deny | Deny and discard the request |
WebSocket:
| Path | Auth | Description |
|---|---|---|
WS /api/terminal?id=<agentId>&ticket=<t> | Single-use ticket | Browser terminal I/O |
WS /api/agents/events?ticket=<t> | Single-use ticket | Live agent status stream |
WS /api/agents/register | Authorization: Bearer <device key | auth key> | Agent registration |
Device store
State lives in a SQLite database (spectre.db in DATA_DIR, via Node's built-in node:sqlite, written 0600): enrolled devices and their last-known state, auth keys, pending approvals, and a connection history. All credentials are stored as SHA-256 hashes — reading the file is not enough to impersonate a device or enrol a new one. These are high-entropy random tokens rather than passwords, so a fast hash is appropriate; there is nothing to brute-force. A pre-existing store.json from an earlier version is imported once on first start and renamed to store.json.imported.
Device identity. A device is keyed by a stable hardware identity derived from what the agent reports — the Linux machine-id if present, otherwise its set of MAC addresses, otherwise the agent's persistent device id. This is why a machine that disconnects and reconnects — or is re-enrolled with a new key — stays a single row that flips between connected and disconnected, rather than appearing twice.