Clients and Steward consoles¶
Tenantry is the control plane of an MSP that serves many clients. Two pages give the per-client view:
- Clients (
/clients): the client register, with live counts per client. - AD consoles (
/consoles): every Steward consoles Tenantry polls, globally or for one client (/consoles?client=<code>).
Administrators (password session, or a role listed in TELEPORT_JWT_ADMIN_ROLES) see every client. Other Teleport users are
scoped technicians: they read only the registered clients of their scope, and edit a client or one of its consoles (and
poll it) only with the operator level on that client; creating or deleting a client or a console, and changing a console's
URL or CA, stay with administrators
(see Technicians). Every change requires the CSRF token and is written to the audit log.
1. Client register¶
A client is identified by its code: the slug carried by the client_code label of its proxies and desktops
(lower case, digits and hyphens, acme, dupont-sa). The code cannot change after creation.
| Field | Content |
|---|---|
code |
Slug, 63 characters at most, same rule as the client_code label. |
name |
Display name (120 characters). |
notes |
Free text, multi-line (4,000 characters): VPN, escalation, contract. |
contacts |
Up to 20 contacts { name, email?, phone? } (shown as mailto: / tel: links). |
createdAt / updatedAt |
Set by the server. |
Counts are computed on each read, never stored:
| Count | Source |
|---|---|
| Proxies up / down | Fleet nodes whose client_code is the client's code; "up" = online, "down" = stale or offline (same rule as the Client proxies page). |
| Desktops | Sum of the desktops served by those proxies. |
| Consoles | Consoles registered for the code; "down" = unreachable or reporting down. |
When the fleet cannot be read (tctl error), the page still lists the register and shows the proxy and desktop counts as
unknown (—): it never shows "0 proxies" for a fleet it could not read.
Unregistered clients. A client code seen on a proxy or a console but absent from the register appears with the
Unregistered badge. Register opens the creation form prefilled with the code (and the client_name label of its
proxies, when there is one).
Client detail (/clients/<code>): name, notes, contacts, its proxies (each links to /proxies/<id>), its desktops
and its consoles. Deleting a client removes it from the register only: proxies, desktops and consoles are untouched (the
code then shows as unregistered again if something still carries it).
API: GET /api/clients, GET /api/clients/:code, POST /api/clients, PUT /api/clients/:code, DELETE /api/clients/:code.
Audit: client.create, client.update, client.delete (target = code; detail = number of contacts, never their content).
2. Steward consoles¶
A console is { clientCode, url, caPem?, token }:
| Field | Rule |
|---|---|
clientCode |
Client code (the client does not have to be registered). |
url |
Base URL of the console: https only, no user/password, no query or fragment; a path prefix is allowed (reverse proxy). Private addresses and internal names are accepted: consoles live on client networks. Two consoles cannot share a URL. |
caPem |
Optional PEM CA bundle (64 KiB at most, every block must be an X.509 certificate). When set, it replaces the system trust store for this console (pin the console's private CA). The UI shows its SHA-256 fingerprint. |
token |
Fleet token issued by the console, format stf_ + 20 to 200 characters [A-Za-z0-9_-]. Write-only: required on creation, left empty on edit to keep the current one; never returned by the API, never logged, never in the audit. Stored encrypted (see 4). |
The token is bound to the endpoint: only an administrator changes url or caPem (403 admin_required otherwise), and a
change of either without a new token drops the stored token, so it is never sent to a new host. The console then shows
token_missing and is not polled until you enter the token again. Scoped technicians see a console certificate reduced to
its validity dates and whether it is trusted.
2.1 Polling¶
Every 60 seconds (CONSOLE_POLL_INTERVAL_SECONDS, 10 to 3,600) Tenantry polls all consoles, 4 at a time:
GET <url>/api/fleet/v1/status
Authorization: Bearer stf_...
Accept: application/json
- TLS always verified, against the console's CA when one is set, otherwise the system store (plus
NODE_EXTRA_CA_CERTS). - Redirects are never followed (any 3xx is the error
redirect_refused). - 10 s timeout for the whole exchange; response capped at 256 KiB (
too_large, the connection is cut). Content-Typemust beapplication/json; the body is validated strictly (below).- The document's
instance.clientCode, when not empty, must equal the console's code (client_mismatch): this catches a URL pointing at another client's console. - A console is polled right after it is added or edited; Poll now (administrators, 10 per minute) forces one poll.
The latest result is kept in memory (it is rebuilt at the next round after a restart): last poll, last success, consecutive failures, latency, error code and the last valid status (kept after a failure, with its age).
2.2 Status contract (schema 1)¶
The console answers exactly this document. Any missing or extra key, other schema, bad enumeration, string too long
(title 200, version 64, FQDN 255…), more than 100 checks or DCs, or negative counter is refused (schema_invalid): a newer
console format must bump schema.
{
"schema": 1,
"time": "2026-10-18T10:00:00Z",
"instance": { "title": "Acme console", "clientCode": "acme", "version": "2.4.0" },
"health": { "status": "ok", "checks": [{ "code": "ldap", "status": "ok" }] },
"directory": { "domain": "acme.local", "dcs": [{ "fqdn": "dc1.acme.local", "status": "up" }] },
"audit": { "chain": "intact", "lastEventAt": "2026-10-18T09:58:00Z", "events24h": 120, "denied24h": 2, "failedLogins24h": 3 },
"helpdesk": { "enabled": true },
"setup": { "complete": true }
}
| Field | Values |
|---|---|
health.status |
ok, degraded, down |
directory.dcs[].status |
up, down, unknown |
audit.chain |
intact, broken, unknown |
audit.lastEventAt |
ISO date or null |
2.3 What the Consoles view shows¶
Client (link to its detail), URL, version, health, DCs up / total (list on demand), audit chain, last poll (relative time) and the translated error. Error codes:
| Code | Meaning / what to check |
|---|---|
timeout |
No complete answer within 10 s: route from the Tenantry host, firewall, console load. |
dns_error |
The name does not resolve from Tenantry container (--add-host of install.sh, internal DNS). |
connect_error |
Connection refused or reset: port, reverse proxy, console stopped. |
tls_error |
Certificate not trusted (set caPem), expired, or name mismatch with the URL. |
redirect_refused |
The console (or its proxy) answered a 3xx: use the final URL (often http → https or a missing path). |
unauthorized |
401/403: wrong or revoked token; issue a new one on the console and paste it. |
http_error |
Other HTTP status (shown): wrong path prefix, console version without the fleet API. |
too_large |
Answer over 256 KiB: not the fleet endpoint. |
invalid_json |
Not JSON (Content-Type): typically a login page or a proxy error page. |
schema_invalid |
JSON that does not follow schema 1 exactly (console version mismatch). |
client_mismatch |
The console declares another client code. |
token_unreadable |
The stored token cannot be decrypted (encryption key changed): enter the token again. |
token_missing |
No token: it was dropped when the URL or the CA changed. Enter the console's fleet token again (not polled until then). |
API: GET /api/consoles[?clientCode=], POST /api/consoles, PUT /api/consoles/:id, DELETE /api/consoles/:id,
POST /api/consoles/:id/poll. Audit: console.create, console.update (detail: url, clientCode=…, ca=set|cleared,
token=replaced|dropped), console.delete; target = client code + console host.
3. Alerts¶
Consoles and proxies feed the alert engine (console down, audit chain broken, DC down, failed logins spike, proxy unreachable, Tenantry identity expiring): see Alerts.
4. Storage¶
State lives in the data volume (DATA_DIR, /data in production; memory only outside production when unset):
| File | Content |
|---|---|
clients.json |
Client register (including the skipped onboarding steps). |
consoles.json |
Consoles; tokenEnc = AES-256-GCM ciphertext (v1.<iv>.<tag>.<data>). |
alerts-settings.json, alerts-state.json |
See Alerts. |
Each file is { "schema": 1, "savedAt": …, "data": … }, written atomically (temporary file 0600 in the same folder, fsync,
rename) and updates are serialised. A file that cannot be read (bad JSON, other schema, invalid record) is never overwritten:
it is renamed <name>.broken-<timestamp> and Tenantry starts with an empty document (error in the log). Restore it by
fixing or copying it back while the container is stopped.
Why JSON and not SQLite: no SQLite dependency exists in the project (a native module adds a build step and attack
surface to the image), the data is small and rarely written, and the root filesystem is read-only with /data the only
writable path.
Encryption key. SECRETS_ENCRYPTION_KEY (≥ 32 characters, different from SESSION_SECRET) is generated by
scripts/install.sh on first run and kept across re-runs (--reset-secrets replaces it: then enter every console token and
channel secret again). The AES-256-GCM key is derived from it with HKDF-SHA256; each value is bound to its record and field
(a ciphertext copied elsewhere does not decrypt). Without the variable in production, the pages work but adding a console or
a channel answers 503 encryption_unavailable. Outside production a throwaway key is generated at each start.
Backup: back up the /data volume and .env (the key) together, encrypted; one without the other does not restore
the tokens.
5. Onboarding a new client¶
The guided journey and the per-client checklist are described in Onboard a client.