Drafted from the public code and docs, so names and roles only, never values. Blank cells are for the operator to fill in. Marks the credentials held by more than one app, which are the ones most likely to break on rotation. Offered on co-op/support#17.
203 lines
12 KiB
Markdown
203 lines
12 KiB
Markdown
# Operator runbook — Cloudron tokens
|
|
|
|
How the co-op's Cloudron credentials are organized, and how to rotate them
|
|
without breaking the stack.
|
|
|
|
## The two-token split
|
|
|
|
There are **two separate** Cloudron API tokens, deliberately decoupled so
|
|
rotating one doesn't break the other:
|
|
|
|
| Token | Purpose | Where it lives |
|
|
|-------|---------|----------------|
|
|
| **Agent token** | Inferencebot's own Cloudron access | The agent's private `.env` (gitignored) |
|
|
| **Portal token** | The member portal's Cloudron access (create users, set groups, invite links) | Cloudron's env store, under the portal app's `CLOUDRON_TOKEN` |
|
|
|
|
The portal needs only a handful of Cloudron capabilities (user/group management,
|
|
invite links). It is the most exposed app (public webhook + broker endpoints),
|
|
so it must **not** hold the agent's token — a portal compromise shouldn't reach
|
|
the agent's wider Cloudron access.
|
|
|
|
## Rotating a token
|
|
|
|
### Portal token
|
|
|
|
1. In `my.inference.coop` → **Settings → API Tokens**, create a **"Read and Write"** token.
|
|
2. Set it as the portal's `CLOUDRON_TOKEN` env var (custom apps have no env-var
|
|
UI — use the Cloudron CLI or the API; see the next section).
|
|
3. Verify: the portal can reach `GET /api/v1/users` with the new token (HTTP 200).
|
|
|
|
### Agent token
|
|
|
|
1. Create a fresh token in `Settings → API Tokens`.
|
|
2. Put it in the agent's `.env` as `CLOUDRON_TOKEN`.
|
|
3. Verify against `/api/v1/cloudron/status`.
|
|
|
|
**The trap this split exists to prevent:** the portal keeps its *own copy* of a
|
|
Cloudron token. If the agent's token is rotated without also updating the portal
|
|
copy, the portal's Cloudron calls start returning `401` — which breaks
|
|
provisioning (new members can't be created) and the member dashboard (its
|
|
usage/keys data is resolved through the portal's Cloudron calls).
|
|
|
|
## Chat image: spaCy model for document parsing (EPUB/DOCX)
|
|
|
|
Open WebUI's document parser (`unstructured`) lazily downloads the spaCy model
|
|
`en_core_web_sm` on first EPUB/DOCX parse — but the app directory is read-only
|
|
under Cloudron, so that install always fails ("Errno 30 Read-only file
|
|
system"). Fixed 2026-10-14 (issue #20) by pre-installing the model into
|
|
persistent storage:
|
|
|
|
- `/app/data/spacy_model/` holds the unpacked wheel
|
|
(SHA-verified `en_core_web_sm-3.8.0-py3-none-any.whl` from the pinned URL in
|
|
`unstructured/nlp/tokenize.py`).
|
|
- App env has `PYTHONPATH=/app/data/spacy_model` so `import en_core_web_sm`
|
|
resolves and the lazy download never triggers.
|
|
|
|
**Restore caveat:** a backup restore from before 2026-10-14 will NOT contain
|
|
the model dir — re-create it (download the pinned wheel, unzip into
|
|
`/app/data/spacy_model/`, verify `spacy.load("en_core_web_sm")` works in the
|
|
container). The `PYTHONPATH` env survives restores (it's in app config).
|
|
|
|
## Scheduled tasks (scheduler addon)
|
|
|
|
The portal and gateway run their own housekeeping via the Cloudron scheduler
|
|
addon, declared in each app's `CloudronManifest.json` under `addons.scheduler`:
|
|
|
|
- **portal**: `membership_sweep` (nightly 3am, `/app/code/sweep.sh` →
|
|
`POST /admin/sweep` with the `WEBHOOK_TOKEN` header) and `rag_refresh`
|
|
(nightly 3:30am, `/app/code/rag_refresh.py` — pulls docs from the Gitea raw
|
|
API and refreshes the chat knowledge base; needs `OWUI_API_KEY` +
|
|
`DOCS_KNOWLEDGE_ID` in the app env).
|
|
- **gateway**: `audio_keepalive` (every 10 minutes, `/app/code/audio_keepalive.py`
|
|
— pings the Tinfoil audio models locally to avoid the ~24s TEE cold-start;
|
|
reads the master key from `/app/data/.master_key`, which `start.sh` writes —
|
|
the env var `LITELLM_MASTER_KEY` is only exported into the litellm process).
|
|
|
|
**After any manifest change, verify the scheduler survived the deploy:** the
|
|
build log must show `setupAddons: setting up addon scheduler with options
|
|
{...}` listing your task(s), and each script must succeed when run manually
|
|
inside the container (`cloudron exec --app <id> -- <command>`). The scheduler
|
|
has historically gone missing from the manifest during edits — a deploy without
|
|
the setupAddons line means nightly reconciliation is NOT running.
|
|
|
|
## Custom apps have no env-var UI
|
|
|
|
The member portal is a *custom* app (packaged by us), not an App Store app.
|
|
Custom apps do **not** expose environment variables in the Cloudron dashboard.
|
|
Env vars for custom apps are set via:
|
|
|
|
- **Cloudron CLI** (on your workstation, *not* the server): `cloudron env set --app portal.inference.coop CLOUDRON_TOKEN=...`
|
|
- **Cloudron API**: `POST /apps/:appId/configure/env`
|
|
|
|
## `configure/env` is a full replace
|
|
|
|
`POST /apps/:appId/configure/env` with body `{"env": {KEY: value, ...}}`
|
|
**replaces the entire environment** — it does not merge. Sending a partial map
|
|
(e.g. a single test variable) wipes every other variable.
|
|
|
|
- To change **one** variable, read the current env first (`GET /api/v1/apps/:id`
|
|
→ `.env`), merge in memory, and POST the full map back.
|
|
- Never probe the endpoint with a throwaway body against a live app.
|
|
- If the env is wiped, it is recoverable from a Cloudron app backup:
|
|
`GET /api/v1/apps/:appId/backups` → newest entry → `GET .../backups/:backupId/download`
|
|
(a `.tar.gz` containing `config.json`, whose `.env` field holds the original map).
|
|
|
|
## Reference
|
|
|
|
- Cloudron CLI: `npm install -g cloudron` (install on your workstation, not the server), then `cloudron login my.inference.coop`.
|
|
- Cloudron API docs: `docs.cloudron.io/api/`.
|
|
|
|
## Open Collective: the bot token needs the `email` scope (Sept 2026)
|
|
|
|
The portal identifies members by email, read via the bot's OC personal token
|
|
(`OC_PERSONAL_TOKEN`) as `collective.members.nodes[].account.email` with the
|
|
`... on Individual { email }` inline fragment.
|
|
|
|
**The `email` scope is load-bearing.** The bot's personal token must be minted
|
|
with the `email` scope. Without it, the GraphQL `email` field returns `null` for
|
|
*every* member — including the bot's own `me { email }` — even though:
|
|
|
|
- the bot still authenticates and reports `isAdmin: true`, and
|
|
- the OC **admin CSV export still shows emails** (it's a separate, non-GraphQL
|
|
path; its `contributorEmail` column is not a GraphQL field).
|
|
|
|
**Symptoms when the scope is missing:** `fetch_members()` filters out empty
|
|
emails and returns `[]`, so the webhook can't provision anyone by email and the
|
|
nightly sweep goes into its empty-list fail-safe (skips, doesn't deactivate).
|
|
A new member's webhook fires cleanly but no "Sent email to …" log line follows,
|
|
and no Cloudron user is created. (This is easy to misread as a "guest with no
|
|
email" problem — it is not; verify the scope first.)
|
|
|
|
**Diagnosis:** run `me { email }` with the bot token. `null` here while
|
|
`isAdmin: true` → the token is missing the `email` scope (or the scope was
|
|
revoked). Fix: re-add the `email` scope to the token on Open Collective
|
|
(Settings → Developers → Personal Tokens). No token rotation, no re-granting of
|
|
admin — just the scope.
|
|
|
|
## Credential inventory (draft, 2026-10-26)
|
|
|
|
Every credential the agent and the member-facing apps hold, compiled from the
|
|
public code and docs. **No secret values appear here, only names and roles.**
|
|
"Where it lives" and "Who rotates" are filled in where the docs say; blank
|
|
cells are for the operator to complete. When rotating anything, check every
|
|
row that names the same credential: several are held by more than one app.
|
|
|
|
### Member portal (`portal.inference.coop`)
|
|
|
|
| Credential | Purpose | Where it lives | Who rotates | Notes |
|
|
|---|---|---|---|---|
|
|
| `CLOUDRON_TOKEN` (portal token) | Create users and groups, invite links | Portal app env | GM | See "The two-token split" above |
|
|
| `OC_PERSONAL_TOKEN` | Read members and orders from Open Collective GraphQL | Portal app env | The Open Collective account that minted it | Needs the `email` scope (see below). Currently a personal token; a co-op-owned OC account would make it a co-op credential |
|
|
| `WEBHOOK_TOKEN` | Secret in the Open Collective webhook URL | Portal app env, and the webhook URL on Open Collective | GM | Rotating means re-creating the webhook on OC as well |
|
|
| `LITELLM_MASTER_KEY` | Create gateway teams and keys for members | Portal app env (copy of the gateway's) | GM | Also held by the gateway; rotate both together |
|
|
| `LITELLM_DB_*` | Read spend logs from the gateway database | Portal app env | Cloudron (addon) | Postgres credentials for the gateway's database |
|
|
| `MATRIX_BOT_TOKEN` | `@inference-bot:inference.coop` admin account: invites, space membership | Portal app env | GM | The account has no password; the token is the only credential |
|
|
| `LOOMIO_API_KEY`, `LOOMIO_B3_KEY` | Forum (Loomio) membership sync | Portal app env | GM | |
|
|
| `LISTMONK_API_USER`, `LISTMONK_API_TOKEN` | Newsletter list sync | Portal app env | GM | |
|
|
| `OWUI_API_KEY`, `OWUI_JWT_SECRET` | Open WebUI knowledge refresh and SSO | Portal app env | GM | Chat moved to LibreChat in October; confirm whether still needed, and retire if not |
|
|
| `BROKER_SECRET` | Shared secret between the portal and the member dashboard | Portal and dashboard app env | GM | Held by both; rotate together |
|
|
| `PORTAL_SECRET` | Session signing | Portal app env | GM | |
|
|
| `CLOUDRON_MAIL_SMTP_*` | Outbound mail | Cloudron mail addon | Cloudron | Managed by the platform |
|
|
|
|
### Gateway (`gateway.inference.coop`)
|
|
|
|
| Credential | Purpose | Where it lives | Who rotates | Notes |
|
|
|---|---|---|---|---|
|
|
| `LITELLM_MASTER_KEY` | Gateway admin | `/app/data/.master_key`, exported into the litellm process by `start.sh` | GM | Also held by the portal (above) and the audio keep-alive job |
|
|
| Salt key (`SALT_KEY_FILE`) | Encrypts provider keys stored in the gateway database | `/app/data` | GM | Losing it means re-entering every provider key |
|
|
| `TINFOIL_API_KEY` | Provider account | Gateway app env | Provider account owner | Billing lives at the provider |
|
|
| `GREENPT_API_KEY` | Provider account | Gateway app env | Provider account owner | Billing lives at the provider |
|
|
| `REGOLO_API_KEY` | Provider account | Gateway app env | Provider account owner | Billing lives at the provider |
|
|
| `CLOUDRON_POSTGRESQL_*`, `CLOUDRON_REDIS_*` | Database and cache | Cloudron addons | Cloudron | Managed by the platform |
|
|
|
|
### Agent (inferencebot, on the agent VPS)
|
|
|
|
| Credential | Purpose | Where it lives | Who rotates | Notes |
|
|
|---|---|---|---|---|
|
|
| Cloudron agent token | Cloudron API | Agent `.env` | GM | See "The two-token split" above |
|
|
| Gitea token | Commits, issues, PRs as `inference-bot` | Agent `.env` | GM | |
|
|
| Matrix access token (`@inferencebot:inference.coop`) | Reading and posting in rooms | Agent host, with its E2EE key store | GM | The off switch in [Working with the agent](working-with-the-agent.md) revokes this second |
|
|
| Gateway virtual key | The agent's own inference | Agent `.env` | GM | Model allowlist and budget live on this key; the off switch revokes it first |
|
|
| Direct provider key (fallback) | Inference during a gateway outage | Agent `.env` | Provider account owner | Planned on co-op/support#17 |
|
|
| LibreChat admin credential | Per-member key sync job | Agent `.env` | GM | Inferred from the job list on #17 |
|
|
| Restic repository passphrase | Nightly encrypted backup of the agent's state | Agent host and the co-op vault | GM | Per #17 |
|
|
| Infomaniak Swiss Backup S3 keys (agent) | Backup target | Agent host | Infomaniak account owner | Per #17 |
|
|
|
|
### Accounts (not tokens, but the things tokens come from)
|
|
|
|
| Account | Purpose | Owner today | Notes |
|
|
|---|---|---|---|
|
|
| Cloudron admin (`my.inference.coop`) | Everything on the main server | | |
|
|
| Hostinger | Hosts the main Cloudron server | Personal account (co-op/support#2) | Moving to Infomaniak |
|
|
| Infomaniak | Agent VPS, Swiss Backup S3 for Cloudron and the agent | | S3 keys for Cloudron backups rotated after setup (co-op/support#12) |
|
|
| Open Collective (collective admins) | Dues, webhooks, the personal token above | | |
|
|
| Provider accounts (Tinfoil, GreenPT, Regolo) | Billing and API keys | | |
|
|
| Domain registrar and DNS for `inference.coop` | | | |
|
|
| Gitea, Matrix, Loomio, Listmonk admin accounts | Service administration | | Cloudron SSO where applicable |
|
|
|
|
### Keeping this current
|
|
|
|
Add a row when a new integration lands, and strike one when a service is
|
|
retired. A credential held by two apps is the thing most likely to break on
|
|
rotation; the "Notes" column marks each one.
|