API
The panel exposes a JSON HTTP API under /api. Every route documented here exists in
dashboard/src/app/api/** — methods and auth were read from the handlers, not inferred.
Authentication#
Three ways in, checked by lib/auth.ts / middleware.ts:
| Method | How | Notes |
|---|---|---|
| Session cookie | conduit_s — set by POST /api/auth/login, passkey login, or SSO | HMAC-signed, 7-day TTL, per-device session id |
| Personal API token | Authorization: Bearer cu_<secret> | Created on the account page; sha256-stored, shown once. Supports readOnly, expiry, and area scopes; scoped tokens can never touch /api/account* |
| Machine token | Authorization: Bearer <token> equal to CONDUIT_AGENT_TOKEN | Full access at the middleware, for ops scripts and the Discord bot. The connector's own token opens connector paths only. Machine paths require a token by default; CONDUIT_ENFORCE_MACHINE_AUTH=0 opts out |
| Fleet grant | Authorization: Bearer <grant secret> from a joined cluster | Only the /api/fleet/* paths listed under Joined clusters; each grant carries the paths it may open |
Roles: admin, operator, developer (scoped to assigned groups), viewer
(read-only). Sensitive vaults (mail, domains, system credentials) additionally require
sudo mode — a 5-minute elevation from POST /api/auth/reauth.
Who a route answers to#
The middleware lets the machine token through everywhere, but a handler decides who it is talking to in one of two ways, and the tables below say which:
requester()(lib/requester.ts): a session, a personal token, or the machine token, which counts as an admin named "machine". A request the relay executes for a joined cluster carries the machine token plus a signed hop naming a person, and is answered as that person.currentUser()only: a session or a personal token. The machine token is refused with 401 on these routes, marked session only below. Routes that act on "your own account" need an account id, so they refuse the machine token too.- Grant-gated: public at the middleware (it runs on the edge, with no store to read a grant from), verified in the handler against the grant's own paths, constant-time.
Two query parameters address another cluster (lib/cluster-params.ts). ?cluster=<id> on an
instance action or anything under /api/services/<vmid>/ acts on that cluster's nodes directly
over the mesh, with the credentials it issued, so it works while its panel is down; it is
refused for a scoped account, for any role but admin or operator, and while the cluster has
issued no reach. ?estate=<id> on an edit of a store record forwards the request to the panel
that owns the record through its relay, as the person asking; only a signed-in, unscoped account
may cross. Both take the cluster's own id. Sending either empty is a 400, never "local".
The same request, three ways — a session login followed by a read of the fleet overview:
# Session login: the cookie jar holds conduit_s afterwards.
curl -s -X POST http://panel:3001/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"name":"admin","password":"…"}' -c cookies.txt
# Or skip the session entirely and use a personal token.
curl -s http://panel:3001/api/overview -H 'Authorization: Bearer cu_…'# One-time: name the panel, then approve the login from the panel's /approve page.
conduit context add production --url http://panel:3001
conduit auth login
# --json prints the route's response verbatim, so anything below is scriptable.
conduit status --jsonconst panel = "http://panel:3001";
const token = process.env.CONDUIT_TOKEN; // cu_… — a personal token, never a machine token
const res = await fetch(`${panel}/api/overview`, {
headers: { authorization: `Bearer ${token}` },
});
// A panel error arrives as 200 + {ok:false}, not as a 5xx — see "5xx does not carry a
// message anybody will read" below. Checking res.ok alone will miss it.
const body = await res.json();
if (!res.ok || body.ok === false) throw new Error(body.error ?? `HTTP ${res.status}`);Conventions#
- Requests and responses are JSON (uploads use multipart).
- Errors:
{ "error": "message" }with a 4xx/5xx status. - Mutations (
POST/PATCH/PUT/DELETE) are recorded in the operator audit log with actor, method, and path. - Failed logins answer after a uniform 400ms delay — no timing or existence oracle.
5xx does not carry a message anybody will read#
A panel behind Cloudflare cannot use a 5xx status to deliver an explanation. Cloudflare treats an
origin 5xx as its own concern: it discards the body and serves a branded error page instead. Measured
on this cluster — a 90-byte JSON explanation replaced by 6419 bytes of 502: Bad gateway HTML — and
the browser then reports SyntaxError: unexpected character at line 1 column 1, which names the JSON
parser and nothing that actually happened.
So the rule for handlers:
- 4xx for a request that could not be served as asked (unknown id, wrong software, not running, forbidden). These reach the client untouched, body included.
- 200 with
ok: falseand anerrorstring for an operation that ran and did not succeed. The request was served; the outcome is the failure, and an outcome belongs in the body. - 5xx only for genuinely unhandled faults, where nobody is relying on the text.
Swept across every handler on 2026-08-25: 26 sites moved off 5xx, and lib/api-fail.test.ts now
fails the build if a new one appears. failed(e) in lib/api-fail.ts is the one implementation —
409 rather than 400 because these are almost never malformed requests: the request was fine and the
cluster could not honour it, and 400 would blame the caller for the cluster's state.
/api/metrics/prometheus is the deliberate exception, for the opposite reason to everything
else: nobody reads its text, a scraper reads the status. A 200 carrying an error comment would be
recorded as a successful scrape of zero metrics — indistinguishable from a healthy idle cluster — so
the 500 is the signal, and it is scraped inside the network rather than through the CDN.
services/[vmid]/hytale-auth is the worked example. On the client, lib/read-json.ts (readJson /
fetchJson) reads a reply without letting a non-JSON one masquerade as a parser bug, and names the
intermediary when a reply was replaced in transit. Prefer it to fetch(...).then(r => r.json()).
Route families#
Method lists are exact; per-field shapes live in the handlers.
118 von 118 Endpunkten
Überwachung
Server
Einstellungen
Netzwerk
Spieler
Plattform
Knoten
Dateien
Benutzer
Backups
Datenbanken
Speicher
Auth & account#
| Route | Methods | Access | Purpose |
|---|---|---|---|
/api/auth/login · logout · me · setup | POST/GET | public / session | Password + TOTP login, session teardown, identity, first-admin bootstrap |
/api/auth/passkeys (+ /register, /login) | GET/POST/PUT/DELETE | session · login public | WebAuthn: list/remove, registration ceremony, usernameless sign-in |
/api/auth/reauth | POST | session | Sudo-mode elevation (5 min) |
/api/auth/reset-request · reset-complete | POST | public | Mail-token password reset |
/api/auth/sso/* | GET | public | OAuth (Google / Discord / GitHub) start, callback, provider list |
/api/account | GET/PATCH | session only | Your profile, tokens, live sessions and sign-in history, with cluster (which one keeps the account) and foreign. PATCH sets the avatar, alert opt-ins, or starts a verified recovery-email change (password re-checked); refused with 409 for an account another cluster keeps |
/api/account/password · tokens · ssh-keys | POST/PATCH/DELETE (GET for keys) | a signed-in account; the machine token is nobody and is refused | Change your password; mint (shown once), edit the lock of, or revoke a personal token (readOnly, expiresInDays, scopes); paste or generate an ssh key for the CLI gateway (private key returned once). Each runs on the cluster that keeps the account: ?estate=<id> relays it there as you, and a request for a replicated account without it answers 409 naming that cluster. The result replicates within a tick, so a token works on every joined panel |
/api/account/sessions · logout-all · totp · verify-email · console-sessions | POST/GET/DELETE | session only; never relayed | Revoke one device, sign out everywhere (bumps the session epoch, which replicates), 2FA, confirm a recovery email, ssh console sessions. A session is the panel's that minted it; a foreign account's sessions on this panel live beside it, not on its home cluster |
/api/users (+ [id], [id]/tokens) | GET/POST/PATCH/DELETE | admin (session or machine token); users.view / users.manage | GET lists the whole fleet's accounts, each with cluster and sameNameOn (two accounts with one name on two clusters are two accounts). A name is unique across the fleet. Create, edit (role, custom roleId, extras, scope, disabled, password) and delete run on the cluster that keeps the account: ?estate=<id> relays them there as the admin asking, otherwise a foreign account answers 409. Last-admin, no-self-delete and self-lockout guards |
/api/roles | GET/POST/PATCH/DELETE | users.view / users.roles; requester | Every role the fleet has, built-ins first, each with its expanded capabilities, holders across every cluster and the cluster that keeps it. A grant beyond what the editor holds is refused (cannot grant what you do not hold: ...); a built-in cannot be edited or deleted; a role in use anywhere cannot be deleted; ?estate= relays an edit to the cluster that keeps the role. The icon is local and settable on any role |
Fleet & infrastructure#
| Route | Methods | Access | Purpose |
|---|---|---|---|
/api/groups (+ [id], subgroups, broadcast) | GET/POST/PATCH/DELETE | role-gated | Groups, subgroups, slot limits, maintenance |
/api/tasks (+ [id], update, resync, motd, sharding, env) | POST/PATCH/DELETE | role + group scope | Services: scale, software, MOTD, world sharding, shared time/weather |
/api/tasks/[id]/shards · timemachine · replay · portals | GET/POST/DELETE | role + group scope | Per-strip claims and the live split (Elastic World), Time Machine captures/rewind, Ghost Replay scrubber data, cross-server portal regions |
/api/tasks/[id]/canary · prewarm · builds · reachability | GET/POST | session | Canary rollout state, predictive pre-warm, source-build pipeline, public-path reachability check |
/api/containers (+ [vmid], action, migrate) | GET/POST/DELETE | session | Raw LXC view, power actions, node migration |
/api/instances/bulk | POST | session | Bulk instance operations |
/api/nodes · /api/overview · /api/conduit/state · /api/conduit/reconcile | GET/POST | session | Cluster snapshot, full state read, manual reconcile kick |
/api/blueprints (+ [id], bundle, import-bundle) · /api/templates · /api/global-templates | GET/POST/PATCH/DELETE | session | Blueprints and file-overlay templates |
/api/firewall | GET/POST | session · admin mutations | Toggle, per-service ingress, manual rules, access lists, node protection |
/api/domains | GET/POST | session only; network.domains; POST admin + sudo | GET: the zone and whether a token and an Origin CA key are stored, shareWithFleet, every hostname row with verified, the certificate, the per-CT rollout, the primary, and the public address with its source (override / env / detected / unknown). POST takes one action per body: {zone, cfToken?, cfOriginKey?} saves credentials and resolves the zone id; {entry:{hostname, target, customTarget?, proxied, failover?}} upserts a row and its A record (an ssh row is always DNS-only; an ssh, packs or cluster row replaces the existing one of that kind, DNS record included; failover is accepted on panel rows and needs a connected zone); {removeEntry: id}; {issueCert: true}; `{setPrimary: hostname |
/api/domains/check · /api/domains/challenge | POST · GET | check: admin session; challenge: public | check {hostname} mints a one-time challenge and returns DNS diagnostics plus the URL the browser should fetch; challenge?token= is fetched through the hostname itself and stamps the row verified only when the token and the Host header both match |
/api/backups (+ jobs, restore, coverage, capture, staging) | GET/POST/PATCH/DELETE | session · restore admin | Proxmox vzdump storages, schedules, restore; coverage is the per-world data-safety report (vzdump + strip snapshots + Time Machine + db dumps + shared-volume capture, 60s cache); POST capture takes a shared-volume capture now; POST staging reclaims shard-transfer leftovers |
/api/drills | GET/POST | session · run admin | Restore drills — prove a backup restores; verdicts per world, scratch containers destroyed after |
/api/offsite | GET/POST | admin | Encrypted off-site copies of the cluster store to any S3-compatible bucket. GET never returns the secret key or the encryption key — only a fingerprint of the latter. POST {action:"save"|"run"|"verify"}: save writes config (a blank secret leaves the stored one alone), run sends one copy now, verify downloads the newest and decrypts it |
/api/logs/search | GET | session | Fleet-wide log search. ?q= (≥2 chars) &since= ®ex=1 &vmid=. Fans out to every running container's agent in parallel; each greps its own log files and journal. Returns hits grouped by service plus unsearched[] — containers with no agent, or that failed, named with the reason |
/api/jobs | GET | session | Health of the leader's 30 recurring background jobs — last outcome, duration, consecutive failures, plus pending for known jobs with no run since this boot (the union of live and persisted state, so the list never shrinks after a restart). Served from the leader's memory, or the shared store on a passive replica. 24 run on the ten-second reconcile chain; the six that do real I/O when they fire have their own minute interval |
/api/diagnostics/memory | GET | admin | What this replica is holding: V8 heap used against its own reported ceiling, RSS, and every in-process registry by size. Samples a few entries per collection rather than serializing them, so it stays safe to call on a process that is already near its limit. Per-replica — call it against a replica's own address. heap.prior carries what the previous process peaked at and which jobs were in flight then, read back from disk after a restart, so the reading is not lost with the process that took it |
/api/network/managed | GET/POST | session · admin | Conduit's own container subnet: current config, each node's /24, addresses assigned, and any overlap with a route a node already has. POST {enabled, base?, bridge?} — refused with 409 if the range collides |
/api/fleet and /api/fleet/* | The joined clusters: see Joined clusters below | ||
/api/wireguard | GET/POST | session · admin to POST | The node mesh: one row per node with its mesh address, a truncated public key, how long ago another node last heard from it, and rx/tx throughput plus cumulative bytes read from wg show dump. totals sums the fleet and counts how many nodes are actually linked. Rates are null rather than 0 when they cannot yet be known — a first sample, or a counter reset by an interface restart. POST {enabled} turns the mesh on or off |
/api/dev-workspaces (+ /:id) | GET/POST/PATCH/DELETE | session (developer = own only) | Temporary personal server copies: GET lists them with live instances and the scope-filtered source picker; POST clones a source task; PATCH {ttlHours} extends the TTL measured from now (capped 168h); DELETE tears one down early |
/api/databases | GET/POST/PATCH/DELETE | session only; databases.view / databases.manage; writes admin | Managed databases with their connection strings, masked (***) unless the caller's sudo re-auth is fresh (revealed), plus live connection usage per system engine. POST {name, engine} provisions a database and a login scoped to it; PATCH {id, exposePublic} opens or closes the engine port on its node through the firewall; DELETE ?id= drops it. See databases.md |
/api/databases/{id} | GET/POST/DELETE | session only; POST needs sudo; DELETE admin | GET: size on disk, table count and open connections, read as the tenant login. POST {rotate: true} replaces the login's password on the engine and in the store and returns the new strings revealed. DELETE drops the database and its login |
/api/db/pg · /api/db/mysql | GET/POST | databases.query (admin in the built-in roles); machine token accepted | The editor. Without dbId it opens the system database as the panel's own login (the LuckPerms PostgreSQL, the gamestats MariaDB); with dbId a managed database as its own login. GET: the table list with row counts, ?table=&limit=&offset= one page of rows inside a read-only transaction (200 rows at most), ?meta=1&table= columns and primary key. POST {action}: update (exactly one row must match), insert, delete, exec (one statement, 8 s timeout, 500 rows, audited), and addColumn, dropColumn, createTable, dropTable, which answer 409 on a system engine because its schema is shared with the servers |
/api/db/redis | GET | databases.query | Read-only Valkey browser: no parameters gives the overview (keyspace, role, live pub/sub channels, instances); ?pattern=&cursor=&count= scans keys with type and TTL; ?key= reads one value by type. ?target=ip:port picks a replica, ?db= a logical database |
/api/system-credentials | GET/POST | admin + sudo | Derived service credentials, rotation |
Joined clusters#
Capabilities from the manifest: /api/fleet reads with network.view and writes with
network.edit (the handlers add admin); /api/fleet/grants is settings.secrets;
/api/fleet/front-door and /api/clusters are network.view. The grant-gated routes are public
at the middleware and verify the grant in the handler.
| Route | Methods | Access | Purpose |
|---|---|---|---|
/api/fleet | GET/POST/PATCH/DELETE | requester; admin to write; GET also answers a peer grant | The joined clusters: each peer with health, node count, inClear (an http:// address carries its credential in the clear), mesh link state, manage (what this cluster allows that one), canManage (what it allows us, learned from its answers, never set here), reach, and errors kept apart per purpose; the merged node list and provider groups. POST {url, code, label?, provider?} redeems an invite at url and links both sides, or {url, token} pastes that panel's machine token; a bare host becomes https://; probed before it is saved; a new peer starts with the mesh offered and manage: true. PATCH {id, manage?, meshLink?, label?, provider?} recomputes every grant issued to that estate from the two switches. DELETE ?id= detaches: reach withdrawn at once, replica and mesh entry dropped, grants narrowed to the node list. Peer tokens are never returned |
/api/fleet/invite | GET/POST · PUT | GET/POST admin (session or machine token); PUT public | POST {label} mints a code, shown once, valid one hour, single use, stored as a hash. GET lists outstanding invites with valid / expired / spent. PUT {code, label?, url?, token?} is redeemed by a panel that holds nothing here yet: answers 200 with {ok, token, mutual} or {ok:false, error} naming expired, spent or unknown |
/api/fleet/invite/check | GET | public | Whether a code is worth spending; spends nothing |
/api/fleet/peer | GET | any grant, or the machine token | This cluster's node list for a joined panel (the same shape as /api/nodes?detail=1), plus publicUrl: the https cluster address once one is proven, which moves the join off a bare address |
/api/fleet/mesh | GET | grant with the mesh cap, or the machine token | Mesh identity: the segment, and per node its public key, mesh address and candidate endpoints |
/api/fleet/node | GET | grant with the mesh cap | One node's hardware detail |
/api/fleet/remote-node | GET | requester | The asking half of the row above: ?estate=&node= fetches a far node's detail with the stored peer token, which never reaches a browser |
/api/fleet/partition | GET | grant with the partition cap | This store's logical partition (groups, tasks, labels, blueprints, schedules, rules, the front door, the identity projection; never sessions, grants or anything under network), with an ETag so an unchanged poll is a 304. 200 with ok:false while the store has not assigned itself an id |
/api/fleet/services | GET | grant with the manage caps | This cluster's groups, servers and instances for a cluster allowed to manage it, without container addresses. A 401 here is how the far side learns it is not allowed |
/api/fleet/relay | POST | grant with the relay cap and role: "admin" | Run one request here for a joined cluster, as the person named in the signed hop: method and path from headers, body byte for byte. An explicit allow list of method and route, a deny list that wins (auth, sessions, secrets, update, every fleet route, the connector's own channel), a hop counter so a relay never relays, a 1 MiB body cap, a 120 s timeout except for streams. The person is resolved over the replicated accounts (409 while not replicated yet) and re-checked against this panel's manifest and roles; writes are audited under their name with the asking cluster in the detail, reads once per five minutes |
/api/fleet/grants | GET | admin (session or machine token) | Every credential this cluster issued to another panel: label, url, role, caps, created, lastUsedAt ("never used" marks a leftover) and a fingerprint. Never the secret |
/api/fleet/grants/{id} | DELETE | admin session plus sudo, or the machine token | Revoke one grant; the far panel loses it on its next request |
/api/fleet/front-door | GET | requester | Every hostname the fleet publishes for failover: owner, address, who serves it now and since when, and for a foreign door whether this cluster could stand in (holds the credentials, holds a certificate, how long the owner has been silent or answering, the last probe error). sharing and sharedWith for this cluster's own zone |
/api/clusters | GET | requester | This cluster first, then every mesh-linked joined cluster: id, label, local, reachable (it has issued us reach) and its panel url. Cheap enough for a page to poll instead of the whole state tree |
Services & consoles#
| Route | Methods | Access | Purpose |
|---|---|---|---|
/api/services/[vmid]/console | GET/POST | session | Read recent lines / send a command |
/api/services/[vmid]/console/stream | GET (SSE) | session | Live console stream |
/api/services/[vmid]/install-log | GET (SSE) | session | Provisioning log during install |
/api/services/[vmid]/commands | GET | session | The server's real command list (feeds completion) |
/api/services/[vmid]/complete | POST | session | Live tab completion — answered by the running server |
/api/services/[vmid]/config · files · tps · agent · share | GET/POST | session | Config drift, file browser, TPS history, console sharing |
/api/services/[vmid]/stats | GET | session | Live vitals from inside the container (cpu, memory, disk, net, load, procs) plus history — the last half hour of heartbeats, so a graph has shape before its first poll. Memory excludes reclaimable page cache. 404 where the container has no agent — the RRD graphs cover that case |
/api/services/[vmid]/logs | GET (SSE) | session | The service's systemd journal, relayed from the container agent. ?unit= is OPTIONAL and defaults to whatever that container's own agent runs — naming one means knowing whether it is a Paper server, a Postgres or an nginx. 404 without an agent |
/api/ctagent | GET | session | Container-agent coverage across the fleet, answered from heartbeats already in memory — no container is touched. Names the silent, stale and outdated rather than averaging them |
/api/ctagent/heartbeat | POST | per-container token | Inbound from a container agent. The token is derived from the vmid, so a container can only speak for itself |
/api/agents | GET | admin | Every node and container agent, probed live: what answers, in how many ms, what version and what source hash against what this panel ships, and why a container has no agent. ?refresh=1 skips the 20-second cache |
/api/agents | POST | admin | {op:"converge"|"restart", vmid}. Converge writes the bundled agent and restarts it, always forced — the button exists for when the version agrees and something is still wrong. Restart goes through pct, since the agent's unit allowlist excludes itself. Node agents are not drivable here: install/ owns those |
Players & gameplay#
| Route | Methods | Access | Purpose |
|---|---|---|---|
/api/players (+ [id]) | GET | session | Live network players; Player 360 profile (rank, balance, punishments, friends, session history) |
/api/bans · /api/friends · /api/parties · /api/logs · /api/blossoms | GET/POST | machine + session | Punishments, social graph, action logs, currency |
/api/reports · /api/report | GET/PATCH | session | In-game report inbox + operator report export |
/api/moderation surfaces via /api/bans | — | — | Moderation UI is backed by the bans API |
/api/luckperms/status · groups · groups/[name] · tracks · users · users/[uuid] · permissions | GET/POST/DELETE | players.view / players.permissions; writes refuse an account scoped to particular servers; ?estate=<id> relays to the cluster whose PostgreSQL holds the tables | Storage state, groups and their nodes, tracks, player search (?q=&limit=&offset=), a player's nodes and primary group, known permissions for completion. Every write is followed by lp networksync on a live server and answers synced |
/api/luckperms/install | GET/POST | platform.install; unscoped; never relayed | The managed LuckPerms set as targets, and its install: restarts every Paper and Velocity server in the set |
/api/luckperms/effective | GET | machine path (connector token) | Effective prefix and suffix for a uuid, for servers that run the connector without the plugin |
/api/packs (+ [id], latest) | GET/POST/PATCH/DELETE | session · download public | Resource packs: upload, scope/targets, GitHub auto-pull, stable client URL |
/api/voice | GET/POST | session | Managed voice: routing mode, proxies, halls, endpoints |
/api/worlds (+ coverage, pull) | GET/POST | session · pull machine | Pregeneration control, on-disk coverage map, shard world transfer |
/api/maps | GET/PATCH/DELETE | session · delete non-viewer | The map library — worlds authored in-game with /conduit map; toggle rotation, de-list (the S3 tarball stays) |
/api/languages (+ [id], lang-data) | GET/POST/PATCH | session · lang-data machine | Localization catalogs and the connector feed |
/api/plugins (+ [id]) · /api/plugin-bundles | GET/POST/PATCH/DELETE | session | Managed plugins (Modrinth/GitHub/URL) and config bundles |
/api/leaderboards · /api/minigames/sync | GET/POST | session | Game stats surfaces |
Observability & automation#
| Route | Methods | Access | Purpose |
|---|---|---|---|
/api/uptime · /api/monitor | GET/POST/PATCH/DELETE | session | Health history + custom monitors (tcp/udp/http/ping) |
/api/alerts · /api/incidents | GET | session | Alert feed (open per entry + openCount; see below); incidents with postmortem markdown export |
/api/status (+ subscribe) · /api/badge/[slug] | GET/POST | public (opt-in) | Public status page, email subscriptions, embeddable badges |
/api/metrics (+ history) · /api/metrics/prometheus | GET | session · bearer | Live metrics; Prometheus scrape endpoint |
/api/analytics · /api/forecast · /api/pulse · /api/rightsizing · /api/digest · /api/profiler | GET/POST | session | Analytics, capacity forecast, fleet pulse, rightsizing, digests, spark profiler |
/api/activity · /api/op-audit · /api/audit (+ erase) · /api/siem | GET/POST | session · admin | Activity feed, operator audit, player audit + GDPR erasure, SIEM export |
/api/schedules (+ [id]) · /api/automation | GET/POST/PATCH/DELETE | session | Cron actions with warn countdowns; self-healing rules |
/api/maintenance-windows · /api/event-windows | GET/POST/PATCH/DELETE | session | Planned downtime and event windows (countdowns, MOTD swaps) |
/api/hooks/[token] | POST | token in URL | Inbound webhooks (restart/broadcast/scale/reconcile), HMAC-verifiable |
/api/outbound-hooks · /api/webhooks | GET/POST/PATCH/DELETE | admin | Signed outbound event subscriptions + inbound hook management |
/api/mail (+ announce) | GET/POST | admin + sudo | SMTP settings, DKIM, announce mail |
/api/push | GET/POST | session | Web-push subscriptions + test |
/api/search · /api/live | GET | session · SSE | Global entity search; live update bus |
/api/ask | GET/POST/PUT | session · key PUT admin | Ask Conduit — one question answered from a bundle of the panel's own evidence (read-only); PUT stores the Anthropic key after validating it |
/api/hub | GET/PUT | session · PUT admin | Which build this panel runs and what the hub offers (?fresh=1 skips the 15-minute memo); PUT sets the hub URL — validated by reading its feed — and the telemetry switch |
/api/update | GET/POST | session · POST admin | The panel updating itself: GET merges the leader's preflight with the run persisted in the store. POST {action:"install"} is the button — it runs the checks and rolls out automatically once they pass, so the rollout cannot be reached without them. preflight and run remain for scripts; a bare run still needs an all-green preflight under five minutes old for the version the feed currently offers |
/api/store-history (+ [rev], revert) | GET/POST | session · revert admin | Config history: per-revision snapshots with human diffs and actors (secrets masked to sentinels at capture); revert goes through the CAS write path and re-resolves sentinels to live values |
/api/drift | GET | session | The drift report — reconcile actions normalised to their kind and counted per day; a fix that recurs daily is a bug wearing a bandage |
/api/secrets | GET/POST | session only; settings.secrets | The register: every credential by store path with presence, count, an eight-character fingerprint, what it protects, its blast radius and rotation class, never a value. posture.machineAuth is enforced or opted-out; sharedWith names the joined clusters whose reach token can read all of it. Answers 409 instead of a list of absences when the store read is stale or empty. POST {id} records that an external secret was rotated; a self-service one is refused |
/api/services/[vmid]/regions | GET | session | Folia region view: per-player placement (world, chunk, region id) inferred from scheduler threads, grouped per region with chunk bounds |
/api/nodes/[node] | GET/PATCH | GET requester; ?cluster=<id> reads a joined cluster's node through its own Proxmox over the mesh (admin or operator, unscoped, only while that cluster has issued reach); PATCH admin | Node detail as {ok, detail}, the same envelope /api/fleet/node and /api/fleet/remote-node use. PATCH arms or ends maintenance (an object arms a window, null ends it, anything else is a 400 — truthiness once re-armed a window on false) or sets {provider}; both answer 409 for a joined cluster's node, which is set on its own panel |
/api/replica-coverage | GET | requester; nodes.view | Whether every node has a panel and a fallback exists, as the reconciler sees it right now, plus what it would do about it. 200 with ok:false when the node list could not be read |
/api/alerts: readopen, notresolved.resolvedis a property of one ENTRY, not of the condition. A "service is DOWN" record keepsresolved: falsefor ever — it is a true record of something that happened — and its recovery arrives as a separate entry carrying the same key. Openness is therefore "is the newest entry for this key unresolved", which is what theopenflag on each entry and theopenCounton the response answer, both judged over the whole ring rather than the returned window. Filtering on!resolvedreports a healthy cluster as a wall of red; that is not hypothetical, it is how this note came to be written.
Streams (SSE)#
| Stream | Emits |
|---|---|
/api/live?topics=… | Version bumps per topic (state, activity, bans, logs, audit, opaudit, alerts, ssh-approvals, hub, conn) — clients refetch on change. conn fires when a heartbeat's player geometry (region/chunk) actually changes, which is what makes the regions view pushed rather than polled. One multiplexed EventSource per browser — six streams once starved every fetch on the page |
/api/services/[vmid]/console/stream | Base64 console frames (agent WebSocket bridge, SSH-poll fallback) |
/api/services/[vmid]/install-log | Live provisioning output |
Machine API#
Paths intended for the connector and infrastructure (bearer-token auth; see
lib/connector-auth.ts): /api/connector/* (heartbeat, register, unregister, event,
announce, broadcasts, tabresult, transfer, pending, report, maintenance, rounds,
servers, move-targets, selection — the in-game wand posts its box here — and
maps/groups|upload|commit, the map studio's upload path: the connector asks for the
game groups, gets a presigned S3 PUT for the world tarball, and commits only after the
panel has verified the object exists), plus
/api/luckperms/effective, /api/worlds/pull,
/api/languages/lang-data, /api/friends|parties|bans|logs ops, and
/api/metrics/prometheus.
The hub speaks its own small protocol, documented in hub/README.md: GET /v1/feed,
GET /v1/artifacts/<version>.tgz, PUT /v1/releases/<version> (bearer publish token),
POST /v1/ping, POST /v1/crash, GET /v1/crashes, GET /v1/stats, and
GET /v1/diagnostics (a cluster reading back its own private crash detail, bearer
cluster secret). The full protocol is described in
architecture.md → Connector protocol.
One machine path authenticates differently: POST /api/auth/cli-session (the SSH
gateway's token exchange) accepts only the CT-local secret at
/etc/conduit/cli-gateway.secret, compared constant-time — useless from anywhere the
reconcile leader hasn't provisioned that file.
Rate limiting — there is no global limiter. Login enforces a uniform 400ms failure delay, inbound hooks have per-hook cooldowns, and report filing is rate-limited; other routes rely on the auth gate. Front the panel with your own limiter if it faces the open internet.