Reference
admin-cli commands
| Command | Description |
|---|---|
init |
Generates the panel's signing key and OPRF key. Stores them in the gitignored data directory. Prints the panel public key and the service pairing code. |
export-escrow [--out <file>] |
Exports DATA_DIR/keys/ encrypted under a passphrase you type. This is the supported way to move the identity off the box. The file verifies itself before it is written. See key escrow. |
verify-escrow <file> [--restore-to <empty-dir>] |
Proves an escrow file decrypts and holds the identity its fingerprint names. Needs no panel and no DATA_DIR — run it wherever the copy lives. --restore-to extracts the key files into an empty directory. Only one panel may ever run with an identity, so you move them from there by hand. |
create-user <u> / set-password <u> |
Creates a user, or rotates a password. Stores an Argon2id verifier. |
set-status <u> <active\|disabled> |
Disables or re-enables an account. Disabling also revokes the account's sessions. |
delete-user <u> |
Deletes the account record. Tokens already issued keep working offline until they expire. |
grant <u> <stream> / revoke <u> <stream> |
Grants or revokes a user's access to a stream. |
add-stream <id> [--title --category --feed --key] |
Registers a stream and generates its encryption key. |
delete-stream <id> |
Full purge: removes the catalog entry, the private key, every grant, and the art. See the caveat below. |
set-meta <id> [--order <n\|null> --featured … --epg-url <https> --epg-id <id>] |
Updates catalog metadata, including curation fields. --epg-url/--epg-id attach a program guide; an empty value ('') clears it. |
upload-art <id> <poster\|backdrop\|logo> <file> |
Adds art to the assets drive. |
set-max-devices <u> <n> |
Sets the concurrent device limit. |
list-devices <u> |
Shows a user's enrolled devices. |
logout-device <u> <deviceId> |
Drops one device enrollment. This is cooperative — it does not bump tokenVersion. |
logout-all <u> |
Revokes all of a user's sessions by bumping tokenVersion. |
list |
Lists users and streams. |
add-admin <name> / remove-admin <name> |
Adds or removes an admin account for the HTTP admin API. |
set-admin-password <name> / list-admins |
Rotates an admin password, which revokes their sessions, or lists admins. |
add-publisher <name> [--scopes "east-*,sports-1"] |
Enrolls a broadcaster site: generates a per-site keypair — the secret prints once, and becomes that site's PUBLISHER_KEY/PUBLISHER_NAME — plus streamId-glob channel scopes. |
list-publishers / remove-publisher <name> |
Lists enrollments, or hard-deletes one. Revoking a publisher instead keeps the audit trail. |
set-publisher-scopes <name> <globs> |
Replaces a site's channel scopes (comma-separated). Takes effect from its next register. |
set-publisher-status <name> <active\|revoked> |
Revokes or re-accepts a site's key. This flips its status only — it does not re-key other sites. |
add-source <name> <url> --category <label> [--prefix --interval-hours --auto-grant false --disabled] |
Registers a remote channel feed (provider JSON). Imports it as a category of redirect channels. |
list-sources / set-source <name> [--url --category … --exclude "id1,id2"] |
Lists sources with their sync state, or edits one. This only touches the registry, so it's safe beside a running panel. --exclude deselects feed entries; an empty value ("") re-includes all of them. |
sync-source <name> |
Pulls, diffs, and grants now. Needs direct store access — stop the panel first, or use the dashboard/API on a live panel. |
vod-config / vod-config-set [--enabled --api-base --service --movies-source --series-source --params "hm=1,hs=2" --param hs=2] |
Shows or configures the external VOD provider that the apps call directly. --params replaces the whole parameter map; --param merges in one key. Each --*-source flag merges its own kind of source, and an empty value ("") clears it — no series source means the apps show movies only. Needs direct store access: stop the panel first, or use the dashboard/API. |
remove-source <name> [--keep-channels] |
Removes a source. This purges its channels, unless --keep-channels detaches them instead. |
add-package <name> [--label L --members "news-24,sports-*,category:Deportes,source:anime" --default] |
Defines a channel package (bouquet). Members can be stream ids, id globs, or category:/source: selectors, resolved at reconcile time. --default auto-assigns the package to new users. |
set-package <name> [--label --members "…" --default true\|false] / remove-package <name> |
Edits a package — member edits apply immediately to every holder, and an empty value ("") clears the members — or removes it. Removing a package removes only the grants it covered. |
list-packages / show-package <name> |
Lists packages with their resolved-channel and holder counts, or shows one package with the channels it resolves to right now. |
set-user-packages <u> <p1,p2\|""> |
Replaces a user's package list. Seals or removes grants immediately. Needs direct store access: stop the panel first, or use the dashboard/API. |
list-reports [--status --channel --category --limit] |
Lists viewer problem reports. Reporters are 16-hex pseudonyms — no username or device id is stored anywhere. |
ack-report <id> / resolve-report <id> [note] |
Acknowledges or closes one report. |
list-alerts [--status open\|ack\|resolved] |
Lists correlation alerts. Read-only here — a running panel holds alerts in memory and flushes them lazily, so acknowledge or resolve them in the dashboard/API instead. |
test-notify |
Sends a synthetic ops notification through the configured webhook or Telegram targets. Needs the panel's REPORTS_* env in the current shell. |
The report commands only touch
DATA_DIR/reports/, re-read on every operation, so they work beside a running panel — triaging reports never means stopping the service. The knobs, the ntfy/Slack/Discord/Telegram recipes, and the pseudonymity limits are in Viewer problem reports.Stream deletion caveat: the purge removes everything the panel can remove. But a client that already unsealed the stream key may have it cached — full revocation of live content needs a stream-key rotation. Re-adding a deleted id mints a fresh key.
CLI and HTTP API share one implementation (panel/src/ops.js), so they cannot drift.
Admin HTTP API + dashboard (ADMIN_ENABLED=1)
The panel process serves this API (default 127.0.0.1:3210). Put TLS in front of
it if you expose it beyond loopback.
Opening the address in a browser loads the admin dashboard (panel/admin-ui/,
plain HTML/JS). Sign in with an admin account to manage:
- Users — create, prefix-search with cursor-paged "Load more", set password, disable, delete, manage grants and devices (including a per-device revoke ✕), and set device limits. The grants cell splits package chips, manual chips, and source auto-grant chips, so you can see where each grant comes from. The grant dialog offers whole packages alongside single streams.
- Streams — add a stream (the encryption key shows once), edit metadata, set curation (order and the featured hero hint), upload art with a preview, and run a permanent purge behind a type-the-id confirmation.
- Packages tab — define bouquets of channels (members, a resolved-channel preview, holder counts). A package grants as one unit and materializes into per-user sealed keys.
- Admins tab — add, remove, and rotate admin passwords. Rotating your own password signs you out.
- Publishers tab — enroll broadcaster sites with their own keys and channel scopes, edit scopes live, revoke or re-activate a site, and remove one. The site secret shows once, at enrollment.
- Sources tab — register provider channel feeds, imported as categories of redirect channels. Adding a source auto-syncs it; each row also offers sync now, edit, pause, and remove (with an option to detach its channels instead of purging them). The last-sync report and any error show inline.
- Overview tab — uptime, memory, peers, and storage chips, plus the live activity feed, polled every 10 seconds while the tab is open.
Destructive flows state their caveats inline: key rotation for a purge, offline-token validity for a user delete, and cooperative semantics for a device revoke. The dashboard consumes only the API below.
Log in with an admin account (add-admin) to get a panel-signed session token.
Send it as Authorization: Bearer <token>. Admin credentials are Argon2id
verifiers stored in the panel-private DATA_DIR/secrets/admins.json, never in
the replicated database. Login attempts are rate-limited
(LOCKOUT_THRESHOLD/LOCKOUT_SECONDS).
| Endpoint | Description |
|---|---|
GET /healthz |
Unauthenticated liveness check → {up, uptimeSec, swarmConnections}. It's cheap and synchronous, and served before the auth gate. Point uptime checks here. |
GET /metrics |
Unauthenticated Prometheus text: uptime, RSS/heap, swarm connections, plus analytics counters (aliran_panel_logins_{ok,failed}_total, aliran_panel_sessions_issued_total, aliran_panel_catalog_channels{class}). |
POST /api/login {username,password} |
→ {token, expiresAt} |
GET /api/status |
Counts: users, streams, live, admins. Also panelKey, serviceName, and pairingCode — the service pairing code the panel derived at boot (null when the panel started without one) — plus escrowExport, which says whether the route below exists. |
POST /api/identity/escrow {password,passphrase} |
Exports the panel identity, encrypted server-side → {filename, fingerprint, kdf, verified, escrow}. escrow is the sealed envelope; no key material crosses the wire in the clear. The route does not exist unless ESCROW_EXPORT=1 — it lowers identity theft from "shell access on the box" to "an admin session", so it is off by default. It re-checks the caller's password, permits 3 attempts per hour, records every attempt in the activity ring as a security event, and decrypts and verifies its own output before answering. See key escrow. |
GET /api/observability |
Uptime, memory, swarm peers, data size and disk free, plus the last-200 activity ring. The ring is in-memory, so a restart clears it. |
GET /api/analytics?days=N |
Aggregate-only analytics → {enabled, retentionDays, days:[{date, hours:{H:{logins:{ok,failed}, sessions, onlineApps:{min,max,mean,samples}, catalog?}}, day:{uniqueViewers}}], current}. UTC day rollups, default 7 and capped at the retention setting, plus the reduced in-progress hour. Counts only — never an identity. |
GET /api/reports?status&channel&category&since&limit |
Pseudonymous viewer problem reports → {enabled, reports:[{id, at, lastAt, count, reporter, category, text, channel, appVersion, platform, peers, events, status, ackAt, resolvedAt, note}]}. reporter is a 16-hex HMAC pseudonym. It is never a username or device id. |
GET /api/reports/summary |
Badge and chart source → {enabled, retentionDays, total, new, ack, resolved, openAlerts, shed, collapsed, byChannel, byCategory, byHour[24]}. Counts only. |
POST /api/reports/:id/ack · POST /api/reports/:id/resolve {note?} |
Acknowledges or closes one report. The note is operator text; the panel strips control characters from it and caps its length. |
POST /api/reports/test-notify |
Sends a synthetic notification through the real configured targets → {enabled, targets, results:[{target, ok, status?, attempts, error?}]}. |
GET /api/alerts?status |
Correlation alerts → {enabled, alerts:[{id, kind:'channel'\|'login', channel, categories, reporters, openedAt, lastAt, status, shedCount, sampled}]}. The panel keeps one alert per channel per window, and extends it rather than firing a new one. |
POST /api/alerts/:id/ack · POST /api/alerts/:id/resolve |
Acknowledges or closes an alert. Resolving lets the next storm on that channel open a new alert. |
GET /api/users?prefix&after&limit |
→ {users, next}. Supports prefix search and cursor paging — next is the after value for the following page. |
POST /api/users |
Creates a user ({username,password}). |
GET /api/users/:u · DELETE /api/users/:u |
Gets one user, or deletes the account record. |
GET /api/users/:u/devices |
Enrolled devices |
DELETE /api/users/:u/devices/:deviceId |
Drops one device enrollment. This is cooperative — it does not bump tokenVersion. |
POST /api/users/:u/password |
Rotates the password. This re-seals the user's grants. |
POST /api/users/:u/status {status} |
active or disabled |
POST /api/users/:u/logout-all · POST /api/users/:u/max-devices |
Session/device controls |
POST /api/users/:u/grants {streamId} · DELETE /api/users/:u/grants/:id |
Grants or revokes access. A revoke removes the manual grant only — if a package still covers the same stream id, the panel re-seals it in the same request. |
POST /api/users/:u/packages {packages:['basic',…]} |
Replaces the user's package list. Materializes sealed grants immediately. User summaries carry packages and manualGrants as provenance fields. |
GET/POST /api/streams |
Lists streams, or adds one. Add takes the add-stream fields plus order/featured and url — an https url creates a redirect channel. The response returns the encryption key once. |
PATCH /api/streams/:id |
Updates catalog metadata: order (0–9999 or null), featured (bool), url (an https value sets the redirect class, an empty value clears it), and epgUrl/epgId (https program-guide pointers the app fetches; empty clears them). |
DELETE /api/streams/:id |
Full purge: removes the catalog entry, the private key, every grant, and the art. See the deletion caveat above. |
POST /api/streams/:id/art/:kind |
Upload poster/backdrop/logo (raw image body) |
GET /api/assets/:id/:file |
Art bytes from the assets drive (for previews) |
GET/POST /api/admins · DELETE /api/admins/:name |
Manage admin accounts |
POST /api/admins/:name/password |
Rotates an admin password. This bumps tokenVersion, so their sessions end. |
GET/POST /api/publishers · DELETE /api/publishers/:name |
Enrolled broadcaster identities: list, enroll ({name, scopes?} — returns the site's secretKey once), or hard-delete. |
POST /api/publishers/:name/status {status} |
active or revoked. A revoked site's registrations bounce until it is re-activated. |
POST /api/publishers/:name/scopes {scopes} |
Replaces the site's streamId-glob scopes. Applies from its next register. |
GET/POST /api/sources |
Remote channel sources: list (with owned-channel counts and the last sync or error), or add ({name,url,category,prefix?,autoGrant?,enabled?,intervalMs?}). |
PATCH /api/sources/:name |
Edits any source field. enabled:false pauses the schedule. Changing url or exclude resets the ETag, so the next sync applies the change. |
GET /api/sources/:name/channels |
Imported and excluded entries — the channels-dialog data ({feedId,id,title,order,excluded}). |
DELETE /api/sources/:name |
Removes a source. Purges its channels, unless ?keepChannels=1 detaches them instead as manual redirect channels. |
POST /api/sources/:name/sync |
Pulls, diffs, and grants now → the sync report (added/updated/removed/skipped/conflicts/granted/notModified). |
GET/PATCH /api/vod-config |
External VOD provider: gets the replicated svcmeta/vod record (null when never configured), or partially merges {enabled,apiBase,service,sources,params}. apiBase must be https, with no query string and no embedded credentials. sources (movies and/or series) and params each replace their whole map. Setting enabled:true is refused unless apiBase and service are also set. Viewers pick up a change at their next login. |
GET/POST /api/packages |
Channel packages (bouquets): list (with resolved-channel and holder counts), or add ({name,label?,members?,default?} — members can be stream ids, id globs, category:<slug>, or source:<name>). |
GET /api/packages/:name · PATCH /api/packages/:name |
Gets one package with the ids it resolves to right now, or edits its label, members, or default flag. Member edits materialize immediately for every holder. |
DELETE /api/packages/:name |
Removes a package and strips it from users. Only the grants it covered are removed — manual grants and auto-grant source channels survive. |
Config snapshots, templates and the backup listing (all four dashboards)
The panel, the broadcaster (:3310), the library (:3320) and the reseller
(:3330) serve these routes from one shared module, so the rules cannot drift
between services. Two differences: the reseller requires an admin-tier
principal and is export-only (restoreSupported:false — its account map and
principal file are unsafe to write back), and a library restore puts back
ingest settings only (a title's media is not configuration). The
runbook explains the artifacts
these routes serve.
| Route | What it does |
|---|---|
GET /api/config |
The section map: what a snapshot holds, which sections a restore refuses, and why → {service, sections, restoreSupported, snapshotDir, notes}. |
GET /api/config/snapshots · POST {note?} |
List / take an on-box config snapshot. A snapshot holds this service's secrets. It is stored 0600 under DATA_DIR/config-snapshots/ and is never served over HTTP. The newest CONFIG_SNAPSHOT_KEEP (default 20) are kept; the service also takes one by itself before a destructive change and before every restore. |
GET /api/config/snapshots/:id |
Metadata and section sizes only — never the contents. |
POST /api/config/snapshots/:id/plan |
Dry run → exactly what a restore changes (added / changed / left alone / skipped, plus warnings). Nothing is applied. |
POST /api/config/snapshots/:id/restore {confirm:true, removeExtra?} |
Applies the snapshot. Additive by default: entries the snapshot does not name are left alone and reported. It never overwrites a live per-stream key, never writes admin or publisher files back, and takes a fresh snapshot first so the restore itself can be undone. Refused without confirm:true (400). |
DELETE /api/config/snapshots/:id |
Removes one snapshot. |
GET /api/config/template |
The config template — the same structure with every secret removed, and the only artifact here that downloads. The envelope carries contains:"no-secrets" and an omitted list that names each removal and its reason. |
POST /api/config/template/plan {template} · …/import {template, confirm:true} |
Dry-run / apply a template. An import recreates structure, not entitlements — grants seal the per-stream keys a template leaves out — and each push channel gets a new stream key. A template from another service is refused (409). |
GET /api/backups |
The recovery archives this service can see through its read-only BACKUP_DIR mount → {available, archives:[{name, ageHours, freshness, newest, …}], commands, note}. canRunHere is always false: a cold backup stops the service that would answer, so the response carries the exact commands to run on the box instead. |
Broadcaster control API + UI (CONTROL_ENABLED=1)
The broadcaster process serves this API (default 127.0.0.1:3310). Put TLS in
front of it if you expose it beyond loopback.
Opening the address in a browser loads the control UI
(broadcaster/control-ui/, plain HTML/JS). Sign in with a control admin to:
- Add or edit channels. The ingest-kind picker hides push kinds the host ffmpeg lacks. Per-channel transcode disables unusable encoders and shows the probe error as a tooltip.
- Start or stop a channel.
- Copy the push URL for a push channel straight off its card.
- Read the ffmpeg log ring in a dialog that refreshes every 2 seconds. The last lines also show inline on an unhealthy card.
- Watch live status.
State badges: ON AIR, WAITING FOR PUBLISHER (the push listener is idle —
normal), or RETRYING (exit N) (watchdog backoff). A channel whose source has
failed past SLATE_AFTER shows the offline slate. It still reports ON AIR —
it genuinely is, since the slate bars are flowing — but sets slate.slated.
Check that flag, not the state, to tell "showing the source" from "showing
bars." See KB.
Channel art is a panel admin operation, since the register RPC carries no art. Upload it in the panel dashboard instead. The UI consumes only the API below.
Channels can be started and stopped at runtime; each has its own persisted feed
identity (feedKey plus an encryption key). Create admins with node
src/control-cli.js add-admin <name> — this stores Argon2id verifiers in the
local DATA_DIR/secrets/admins.json. Login returns a session token signed with
a broadcaster-local keypair, and login attempts are rate-limited. Starting a
channel spawns its ffmpeg pipeline, seeds the encrypted feed, and
auto-registers with the panel using publisher-key auth. When PUBLISHER_NAME
is set, the payload carries the enrolled identity and is subject to that site's
channel scopes. The env-configured channel (STREAM_ID) keeps the legacy
DATA_DIR-root store, so its existing feed identity is preserved.
| Endpoint | Description |
|---|---|
GET /healthz |
Unauthenticated liveness and boot-resume progress → {up, uptimeSec, resuming, resumed, total, failed, resumeSec}. It's cheap and served before the auth gate, so monitoring can tell "up, resuming 45/83" from "dead" even while a mass resume keeps the rest of the API busy. Point uptime checks here, not at /api/status, which needs a token and does real work. |
POST /api/login {username,password} |
→ {token, expiresAt} |
GET /metrics |
Unauthenticated Prometheus text: process stats, channel count, boot-resume progress, an incidents gauge, plus per-channel analytics lines from the last 5-minute sample — aliran_broadcaster_channel_peers{stream_id} (a lower bound on audience) and aliran_broadcaster_channel_egress_bytes_total{stream_id}. |
GET /api/status |
Channels, running count, panel configured |
GET /api/analytics?days=N |
Aggregate-only analytics → {enabled, retentionDays, days:[{date, hours:{H:{channels:{id:{peers:{min,max,mean,samples}, egressBytes, respawns}}, incidents}}}], current}. Per-channel UTC day rollups plus the in-progress hour. Stream ids and counts only — never a peer key or IP. |
GET /api/capabilities |
ffmpeg probe: input protocols plus deep-verified encoders ({listed,verified,error?}). |
GET/POST /api/channels |
Lists channels with live status, or adds one ({id,title,category,input,transcode,buffer,…}). |
GET /api/channels/:id |
Status: state (stopped·starting·up·waiting-input·backoff), running, ffmpegUp, peers, registered, playlist, watchdog, and slate ({slated,file,since,failures} — slated:true means viewers see the offline slate, not the source, even when state is up). Also detectedProfile ({codec,width,height}, the profile the slate matches against) and ingest.pushUrl (push kinds; uses PUBLIC_HOST). |
PATCH /api/channels/:id |
Edits metadata, input, or transcode settings. Changes apply on the next start; a source change also rotates the feed identity. |
DELETE /api/channels/:id |
Removes the channel from the registry. It must be stopped first; its data is kept. |
POST /api/channels/:id/start · …/stop |
Spawn / tear down the pipeline |
POST /api/channels/:id/rotate |
Disk mode only: mints a fresh feed generation now, which bounds merkle-tree growth. ffmpeg keeps running, watching viewers follow the new feedKey live, and the retired generation's cores are purged after a grace window. See feed buffer. |
GET /api/channels/:id/logs?lines=N |
ffmpeg stderr ring → {lines:[{t,line}], running, restarts, state} (at most 400 lines). An operator start clears the ring; watchdog respawns don't. |
GET /api/incidents |
Correlated incident log: fleet-wide respawn bursts and per-source outage windows detected across channels — the pattern a lone per-channel restart counter can't show. |
GET/POST /api/admins · DELETE /api/admins/:name |
Manage control admin accounts |
POST /api/admins/:name/password |
Rotates an admin password. This revokes their sessions. |
It also serves the shared config-snapshot and backup routes.
Library control API + UI (CONTROL_ENABLED=1)
The library process — the standalone VOD service — serves this API (default
127.0.0.1:3320). Put TLS in front of it if you expose it beyond loopback.
Opening the address loads the minimal control UI (library/control-ui/). Sign
in with a control admin — created with node src/library-cli.js add-admin
<name>, the same auth skeleton as the broadcaster's — to add titles, watch
ingest progress, read logs, re-ingest, and delete.
A title is a one-shot ingest. The library probes the input, then either
remuxes it with -c copy when the codecs are HLS-compatible, or transcodes it
to h264/aac. The result is a finished HLS VOD rendition in its own encrypted
Hyperdrive, with all segments kept. The title then seeds persistently and
registers with the panel as type:'vod' plus durationSec, under the
library's own enrolled publisher.
Inputs must have a finite duration — files, not live streams. Disk use equals the sum of title sizes, and only a delete reclaims it.
| Endpoint | Description |
|---|---|
GET /healthz |
Unauthenticated liveness → {ok, titles, ready, ingesting, queued, error, panelLink:{connected,pendingOps,…}}. It's cheap and synchronous, and answers even mid-transcode. |
POST /api/login {username,password} |
→ {token, expiresAt} |
GET /metrics |
Unauthenticated Prometheus text: process stats, title-state counters, panel-link connected/pending. |
GET /api/status |
Titles summary, publisher, panel key, swarm connections |
GET/POST /api/titles |
Lists titles (with ingest progress, peers, and registered status), or adds one and queues its ingest ({id, input, title?, description?, category?, protection?, mode?, hlsTime?}). mode is auto (default), copy, or transcode. input is a path on the library box, or any URL ffmpeg can read. |
GET /api/titles/:id |
Registry view: state (queued·ingesting·ready·error), ingest:{phase,pct}, feedKey, durationSec, segments, bytes, peers, registered, registerError. |
PATCH /api/titles/:id |
Edits input, mode, or hlsTime only. Descriptive metadata is panel-owned after creation. |
POST /api/titles/:id/ingest |
Re-ingests the title (optional {input}). Mints the next feed generation — a fresh feedKey, with old cores purged — and viewers pick it up at their next tune-in. |
DELETE /api/titles/:id |
Stops seeding and purges the title's cores and key from this box. Refused mid-ingest. Registers status:'unavailable' — remove the catalog record and grants in the panel separately. |
GET /api/titles/:id/logs?lines=N |
The ingest's ffmpeg/log ring → {lines, state, ingest}. |
GET/POST /api/admins · DELETE /api/admins/:name · POST /api/admins/:name/password |
Manage control admins (same shapes as the broadcaster's) |
Env config (library/.env): DATA_DIR, PANEL_PUBKEY, PUBLISHER_NAME +
PUBLISHER_KEY (enroll the library as its own publisher, scoped to its title
ids), HLS_TIME (VOD segment length, default 4 s), INGEST_CONCURRENCY
(default 1 — transcodes are 0.5–1 core each), SWARM_MAX_PEERS (default 256),
SWARM_RCVBUF_MB/SWARM_SNDBUF_MB (default 4/4 — a seeder is send-dominant),
CONTROL_ENABLED/CONTROL_HOST/CONTROL_PORT/CONTROL_SESSION_TTL_HOURS,
LOCKOUT_*, ARGON2_*, BOOTSTRAP.
It also serves the shared config-snapshot and backup routes.
Reseller panel API
The reseller process — the standalone role-hierarchy and credit panel that
fronts the panel admin API — serves this API (default 127.0.0.1:3330). Put
TLS in front of it if you expose it, and add an IP allowlist, since third
parties use this service.
Opening the address loads the control UI (reseller/control-ui/). Sign in as a
principal — the root admin is seeded with node src/reseller-cli.js add-admin
<name>. Every account mutation becomes a call to the panel admin API, gated by
the signed-in principal's role and credit balance. Concepts, topologies, and
the bootstrap walkthrough are in Reseller panel.
Roles, from highest to lowest: admin (root — mints credits, the sole
co-admin manager), co-admin (an admin clone), super, reseller. Errors:
403 for a capability or scope denial, 402 for insufficient credits,
404/409 as the panel returns them, and panel failures surface with a
PANEL: prefix (502 when the panel is unreachable).
| Endpoint | Description |
|---|---|
GET /healthz |
Unauthenticated liveness → {ok, principals, accounts, panel:{reachable,lastOkAt}, sweep, ledger:{seq,invariantOk}}. |
GET /branding.json · GET /branding.css · GET /branding/logo\|favicon\|login-bg |
Public white-label surface: {name, accent, logo, favicon, loginBg, loginStyle}, the operator's theme-token overrides (layered after the shared theme block), and the logo/favicon/login-backdrop images. Configured with BRAND_* env vars, including BRAND_LOGIN_BG_FILE and BRAND_LOGIN_STYLE (glow, plain, grid, dots, or stripes). See the manual. |
POST /api/webhooks/credits |
HMAC-authenticated (no Bearer token) automated top-up: {id, to, amount, note?}. Sign it as x-topup-signature = hex HMAC-SHA256(WEBHOOK_SECRET, "<ts>.<raw body>"), plus x-topup-timestamp (within ±300 seconds). It's idempotent by id — a retry returns {duplicate:true}. A successful call mints a MINT ledger line with actor webhook. Returns 404 when no secret is configured. |
POST /api/login {username,password} |
→ {token, expiresAt, role}. Rate-limited, and single-flight. |
GET /api/me · POST /api/me/password |
Gets your own record, balance, and trials used today, or rotates your own password. |
GET /metrics |
Unauthenticated Prometheus text: process stats, principals/accounts, panel reachability, and a ledger seq plus invariant gauge. |
GET /api/status |
Role-scoped KPIs: balance, and active/expiring/trial counts. Admins also get principals, outstanding credits, panel reachability, and the last reconcile. |
GET /api/panel/status · GET /api/streams · GET /api/packages |
Passthrough of the panel status (admins only), the catalog, or channel packages, including resolved-channel and holder counts. Any authenticated role can call these; the catalog and packages responses are cached for 60 seconds, for the activate pickers. |
GET /api/system |
Admin tiers only. Operator diagnostics for the System section of the UI's Overview: {service:{node,pid,uptimeSec,rssBytes,heapUsedBytes,dataDir,sweeps,ledger}, host:{hostname,platform,release,arch,cpuModel,cpuCount,loadavg,totalMemBytes,freeMemBytes,uptimeSec,disk:{totalBytes,freeBytes}}, panel:{url,reachable,lastOkAt,lastError,latencyMs,stats:{panelKey,users,streams,live,admins},error}}. The panel block is a live timed probe — when the panel is down, it fills error instead of failing the whole request. |
GET/POST /api/principals |
Lists principals (scoped to what you can see), or creates one ({username,password,role,maxDevicesLimit?,trialDailyCap?,note?}). The new principal's parent is you. |
GET/DELETE /api/principals/:name |
Views or deletes a principal. Delete is refused while the principal still has child principals or accounts. Its remaining balance is reclaimed to you. |
POST /api/principals/:name/password\|status\|limits |
Rotates a principal's password; suspends or resumes it ({status, mode:'panel-only'\|'with-accounts'}); or sets its limits ({maxDevicesLimit,trialDailyCap}). maxDevicesLimit is the admin-set device policy — only admin tiers can set it, null means inherit the parent chain, and supers may only tune trialDailyCap. Views report the effective value plus maxDevicesLimitInherited. |
POST /api/credits/mint\|transfer\|reclaim\|adjust |
Mints credits (admin tiers only), funds a child principal, pulls credits back, or makes a correction (a note is required). Returns 402 when a debit would exceed the balance. |
GET /api/ledger?principal&account&type&before&limit |
Append-only credit ledger, newest first. before is a seq cursor. Non-admins see only themselves and their subtree. |
GET/POST /api/accounts |
Lists accounts, using a server-side query engine built for large registries: ?q (case-insensitive substring over name and owner), &filter=active\|disabled\|expiring\|trial, &owner, &sort=name\|expires\|created\|status\|owner, &dir, &offset, &limit (default 50, capped at 500) → {items, total, offset, limit}. Or activates an account ({name,password,months,maxDevices?,grants?,packages?}) — a plain panel username, first come first served. Only admin tiers may pass maxDevices (403 otherwise); accounts receive the creator's inherited device policy. packages (bouquet names, no credit impact) replace the panel's default packages when passed. |
GET/DELETE /api/accounts/:acct |
Views an account (with live panel state, including packages and manualGrants provenance), or deletes one. Deleting refunds floor(remaining months) to the owner — unless an admin deletes it, which refunds nothing. |
POST /api/accounts/:acct/renew\|status\|password\|max-devices\|grants\|packages\|logout-all |
Renews an account from max(now,expiry), which converts a trial to paid; suspends or resumes it; sets its password; sets its device limit (admin tiers only — a per-account policy override); adds a grant; replaces its bouquets ({packages:[names]}, panel-validated, and re-asserted by the reconcile sweep if it drifts); or drops all its sessions. |
DELETE /api/accounts/:acct/grants/:streamId · GET/DELETE /api/accounts/:acct/devices[/:id] |
Removes a one-off grant — the response carries stillGranted: true when a covering package re-seals the channel in the same request — or lists and revokes devices. |
POST /api/trials |
{name,password,maxDevices?} → a free, time-boxed trial, subject to a per-reseller daily cap. |
POST /api/ops/sweep · GET/POST /api/ops/reconcile |
Runs the expiry sweep now, or reads/runs the reconcile (admin tiers only). |
Env config (reseller/.env): DATA_DIR, PANEL_ADMIN_URL + PANEL_ADMIN_USER/
PANEL_ADMIN_PASS (the dedicated panel admin) + PANEL_TIMEOUT_MS,
DAYS_PER_MONTH, TRIAL_HOURS, TRIAL_DAILY_CAP_DEFAULT,
MAX_DEVICES_LIMIT_DEFAULT, SWEEP_INTERVAL_SEC, RECONCILE_INTERVAL_SEC,
RECONCILE_REPAIR, CONTROL_HOST/CONTROL_PORT/CONTROL_SESSION_TTL_HOURS,
LOCKOUT_*, TRUST_PROXY_HEADER (use only behind a trusted proxy or tunnel —
for example cf-connecting-ip for Cloudflare Tunnel, or x-forwarded-for for
Caddy/nginx. This keys the login lockout on the proxied client IP instead of
the proxy's own socket), BRAND_NAME/BRAND_LOGO_FILE/BRAND_FAVICON_FILE/
BRAND_LOGIN_BG_FILE/BRAND_LOGIN_STYLE/BRAND_THEME_FILE (white-label — see
the manual),
WEBHOOK_SECRET (enables the top-up webhook), ARGON2_*.
It also serves the shared config-snapshot and backup routes.
MCP server tool catalog
The MCP server (@aliran/mcp, local stdio) registers one tool per
admin operation. R marks readOnlyHint; D marks destructiveHint — a
well-behaved client confirms with you before running a D tool. Only the
groups whose backend the config enables get registered.
| Group | Tools |
|---|---|
panel_* (reads, R) |
panel_status, panel_observability, panel_analytics, panel_list_users, panel_get_user, panel_list_devices, panel_list_streams, panel_list_packages, panel_get_package, panel_list_sources, panel_source_channels, panel_list_categories, panel_vod_config, panel_list_publishers, panel_list_reports, panel_list_alerts, panel_list_admins |
panel_* (writes) |
panel_create_user, panel_set_user_password, panel_set_user_status, panel_set_max_devices, panel_logout_all, panel_grant, panel_set_user_packages, panel_add_stream, panel_set_stream_meta, panel_set_stream_art, panel_add_package, panel_set_package, panel_add_source, panel_set_source, panel_sync_source, panel_set_category, panel_rename_category, panel_set_vod_config, panel_add_publisher, panel_set_publisher_scopes, panel_set_publisher_status, panel_ack_report, panel_resolve_report, panel_test_notify, panel_add_admin, panel_set_admin_password |
panel_* (purges, D) |
panel_delete_user, panel_revoke_device, panel_revoke_grant, panel_delete_stream, panel_delete_package, panel_delete_source, panel_merge_categories, panel_delete_category, panel_remove_publisher, panel_remove_admin |
broadcaster_* |
broadcaster_health R, broadcaster_status R, broadcaster_capabilities R, broadcaster_list_channels R, broadcaster_get_channel R, broadcaster_channel_logs R, broadcaster_incidents R, broadcaster_analytics R, broadcaster_list_admins R, broadcaster_add_channel, broadcaster_update_channel, broadcaster_start_channel, broadcaster_add_admin, broadcaster_set_admin_password, broadcaster_stop_channel D, broadcaster_rotate_channel D, broadcaster_remove_channel D, broadcaster_remove_admin D |
reseller_* (optional) |
reseller_status R, reseller_system R, reseller_list_principals R, reseller_get_principal R, reseller_ledger R, reseller_list_accounts R, reseller_get_account R, reseller_trials R, reseller_ops_status R, reseller_add_principal, reseller_set_principal_password, reseller_set_principal_limits, reseller_grant_credits, reseller_set_principal_status D |
library_* (optional) |
library_status R, library_list_titles R, library_get_title R, library_title_logs R, library_add_title, library_set_title, library_reingest_title D, library_delete_title D |
server_* (SSH executor) |
server_preflight R, server_status R, server_logs R, server_disk R, server_list_backups R, server_backup, server_set_env D, server_restart D, server_restore D, server_sysctl D, server_update D (dryRun:true previews), server_install — every tool (except server_install) takes host:"<name>" for a box named in ssh.hosts |
repeater_* |
repeater_status R — SSH-shaped (the repeater has no admin API by design): compose state + logs + the opt-in loopback /metrics when STATUS_PORT is set on the box |
diagnose_* |
diagnose_healthz R, diagnose_symptom R |
| resources | docs_search R + every docs/+docs/kb/ file as mcp://aliran/docs/<path>, plus mcp://aliran/guide |
The server also registers MCP prompts — guided runbooks that name the exact tools for a multi-step job:
| Prompt | Runbook |
|---|---|
new-site-install |
preflight → install → verify → first channel → first viewer |
onboard-a-reseller |
enroll a principal → mint credits → verify the ledger → the oversight boundary |
migrate-a-channel-source |
remote-source path (add → curate → sync → verify) and broadcaster-pull path (update → stop/start → verify) |
monthly-maintenance |
update dry-run → backup → update → disk + analytics review → admin hygiene → viewer-report triage + a notification test |
incident-triage |
healthz sweep → what viewers reported → localize (logs/incidents) → symptom → KB → fix or escalate (takes an optional symptom argument) |
expose-dashboards |
publish the dashboards behind Caddy TLS per the KB, then repoint the MCP config at the https urls |
The control API is off unless CONTROL_ENABLED=1. A broadcaster_* tool that
can't reach it says so (server_install sets this flag for you). Secrets
minted server-side, such as PUBLISHER_KEY, are written into the box's .env
file and never returned to the model.
server_set_env only upserts documented, allowlisted env knobs. It refuses
secret or identity keys — PUBLISHER_KEY, PANEL_PUBKEY, WEBHOOK_SECRET,
REPORTS_TELEGRAM_BOT_TOKEN, and REPORTS_WEBHOOK_URL (an ntfy/Slack/Discord
webhook url carries its credential in the path, so it counts as a secret too —
see Viewer problem reports). Before
anything restarts, the tool dry-runs the new .env through node
src/config.js --check in the built image, since every service config is
fail-fast at boot. A validation failure reverts the file and surfaces the
exact problem list. On success, the change applies via a plain docker compose
up -d <service> — a compose restart does not re-read env files, which is
also why server_restart (the server_sysctl follow-up) documents itself as a
process bounce only. server_restore wraps deploy/restore.sh: it refuses a
non-empty volume or a name-mismatched archive unless you pass force, and it
echoes exactly what it overwrote and from which archive. Rotating or removing
the admin account the MCP itself logs in with requires updating the operator's
local mcp config afterward.
Multi-host and ergonomics behaviors worth knowing: multi-host — ssh.hosts names extra boxes
(repeaters, scale-out broadcasters; each entry may carry its own keyPath/
port/repoDir), the host parameter routes a tool there, and
panel_add_publisher {host} writes the minted PUBLISHER_KEY into that
box's broadcaster/.env (the key still never transits the model). List
ergonomics — panel_list_streams gained client-side category/prefix/
idsOnly/limit filters (the no-argument call still returns the raw
catalog), and every user-shaped result summarizes grant lists longer than 12
ids to {count, sample}, with full:true restoring every id
(panel_revoke_grant additionally reports stillGranted when a package
re-sealed the stream). Schema gaps closed — broadcaster_add_channel/
_update_channel take hlsTime (1-30) / hlsListSize (2-64); panel_add_stream
takes feedKey + key for pre-seeded feeds (a supplied key is stored
panel-side and redacted from the result; an omitted one is generated and
returned once), and panel_set_stream_meta takes feedKey.
Content-curation behaviors worth knowing: panel_rename_category / panel_merge_categories
rewrite the category tag across every catalog record (a package's category:
member selector is a string, re-resolved after the move — update it to the new
slug), and panel_delete_category drops only the registry entry, keeping
membership. panel_set_source's exclude change resets the source ETag so
the next sync re-diffs the full feed. panel_set_stream_art reads the image
from the operator's machine and posts raw bytes (at most 10 MiB, image
extensions only) — never base64 through the model. reseller_grant_credits
echoes the ledger line it appended (seq/actor/principal/amount/new balance);
reseller daily driving (activate/renew) is deliberately unwrapped — that
lives in the resellers' own panel. library_add_title's input is a path
on the library box; library_delete_title purges the box but only marks
the panel record unavailable (purge that separately with
panel_delete_stream).
broadcaster_add_channel / broadcaster_update_channel take input as either
a shorthand string ("test", "rtmp", a pull url, a file path) or a typed
object ({kind:"pull",url,fallbacks?}, {kind:"file",path}, {kind:"test"},
{kind:"rtmp"|"srt"|"udp",port?,…}). They take transcode as an object, or
null to clear it. Pass both as real objects, not as quoted JSON strings: a
stringified object is parsed back where possible, and rejected with a 400
where it can't be — it is never stored as a literal file path (that fallback
used to leave a channel with no working source behind an HTTP 200).
Panel RPC (over Hyperswarm)
hello→ a proof-of-work challenge plus its difficulty (pre-login).login(username, blinded, powNonce)→ a blinded OPRF evaluation. This is throttled; the panel never sees the password or the result.session(username, deviceId, signature, …)→ device enrollment plus a panel-signed session token. This enforcesmaxDevicesand evicts the oldest device when needed; revocation happens viatokenVersion.register(payload, sig)→ a broadcaster publishes or updates a catalog record, authenticated with Ed25519. The encryption key is stored panel-private — never in the catalog. A payload carryingpublisher: "<name>"verifies against that enrolled site's own public key (add-publisher), and itsstreamIdmust match the site's channel scopes before anything is written. Rejections areunknown-publisher,revoked, orout-of-scope(orunauthorizedfor a bad signature), and they surface verbatim as the channel'sregisterErrorin the broadcaster control UI. An accepted named register stampsorigin: "<name>"on the record. A payload withoutpublisherverifies instead against the legacy shared key frominit(implicit scope*), as long asLEGACY_PUBLISHER=1(the default) — set it to0once every site is enrolled. Descriptive metadata is panel-authoritative: a register only setsfeedKeyplusisLive(live) orfeedKeyplusdurationSec(vod) on an existing record. It seedstitle/description/categoryonly when it first creates the record, and never overwrites them after — the admin owns them, the same as art, EPG, curation, and the redirect class. To rename or recategorize a P2P channel or a title, edit it in the panel, not the broadcaster/library config.
Schemas
Catalog record (catalog/<streamId>)
{
"title": "News 24",
"description": "...",
"category": ["news"],
"type": "live", // live | vod (record class — see the vod note below)
"protection": "self", // reserved — only 'self' exists (no DRM, by design)
"isLive": true, // live records ONLY — a vod record omits the field entirely
"durationSec": null, // vod records ONLY — title runtime in seconds
"viewerCount": null, // derived, not durable
"order": 0, // curation: rail sort 0-9999, or null (unordered)
"featured": false, // curation: hero-pick hint for client UIs
"poster": "assets/<hash>.jpg",
"backdrop": "assets/<hash>.jpg",
"logo": "assets/<hash>.png",
"feedKey": "<hex>",
"blobsKey": "<hex>", // the feed drive's blobs-core key (or null) — see below
"redirect": false, // redirect channel class — see below
"url": null, // redirect channels: https HLS the client plays directly
"origin": null, // enrolled publisher that made the LAST register (audit), or null
"source": "anime", // imported by this channel source, absent on manual channels
"epgUrl": "https://…", // source imports: the feed URL carrying this channel's schedule
"epgId": "demotv.es.629…", // source imports: this channel's id INSIDE that feed
"status": "live"
}
VOD titles: a record with
type:'vod'is a library title — a finished HLS VOD rendition in its own encrypted drive, registered over the sameregisterRPC withdurationSecin the payload. The class differs in exactly two fields:durationSec(payload-owned, likefeedKey— the library measures it at ingest), and noisLive(liveness is not a property a title has; clients must not read liveness into vod records). Thestatusvocabulary is'available'(seeding) or'unavailable'(the library deleted the title; the record stays admin-owned until removed in the panel). Grants, sealing,blobsKeyenrichment, art, curation, and categories work identically for both classes.Redirect channels: a record with
{redirect: true, url: "https://…"}is a different class of entry. Viewers play the operator's URL directly, instead of a P2P feed —feedKeystaysnull, and the panel rejects mixing the two. Set or clear it via theurlfield onPOST/PATCH /api/streams, or the dashboard's "Redirect URL" input (the CLI does not expose it). A broadcaster re-register never erases the class. Details: content-management.md.
source/epgUrl/epgId: stamped on records imported by a remote channel source.sourceis the ownership mark — a sync may only touch records carrying its name, and detaching or removing the source strips or purges them. The epg fields point back to the feed, so a client can fetch the schedule over https on demand. The apps render it as the Info panel's Now/Next guide, using the shared EPG layer in@aliran/react-native. The registry (nothing secret) lives inDATA_DIR/sources.json; see content-management.md.The stream's content encryption key is not in the catalog. It is kept in a panel-private, non-replicated secrets file (
DATA_DIR/secrets/streams.json), and delivered per-user viauser.wrapped[streamId].
origin: which enrolled publisher's key signed the record's most recent register — the audit trail behind the origin chip in the panel dashboard. A legacy (shared-key) register writesnull, since attribution never guesses. Clients ignore the field. Publisher enrollments themselves live panel-private, inDATA_DIR/secrets/publishers.json(public keys and scopes only — the site keeps its own secret). See security-model.md.
blobsKey: the feed drive's blobs-core key, published so keyless repeater or seed nodes can mirror the encrypted video blocks. The blobs core is a named core whose key lives inside the drive's encrypted header, so it is not derivable fromfeedKeyalone. The panel fills this field asynchronously after a register: it opens the drive with its stored encryption key, reads the header, and writes the key back (panel/src/blobs-key.js) — the register RPC never waits on this. The field is cleared and re-filled whenever a register rotatesfeedKey. Publishing it is safe: it only enables ciphertext replication, and watching still requires a per-user sealed grant key.
User record (user/<username>)
{
"salt": "<hex>",
"verifier": "<hex>", // Argon2id(rwd, salt); rwd = OPRF output
"argon": { "opslimit": 2, "memlimit": 67108864 },
"pub": "<hex>", // user X25519 public key
"encPriv": "<nonce||cipher hex>", // private key sealed under a key derived from rwd
"wrapped": { "<streamId>": "<stream key sealed to pub, hex>" },
"devices": [ { "deviceId": "<pubkey>", "label": "Pixel 8", "expiresAt": 0, "tokenVersion": 1, "status": "active" } ],
"tokenVersion": 1,
"maxDevices": 2,
"status": "active"
}