184 lines
11 KiB
Markdown
184 lines
11 KiB
Markdown
# Account control plane
|
||
|
||
Run `python -m control` with FastAPI and uvicorn installed. Defaults to
|
||
`127.0.0.1:8092`; configure `CONTROL_HOST` / `CONTROL_PORT` explicitly if needed.
|
||
Run one control process against a database (lifecycle scheduling is process-local).
|
||
No Docker logic lives in the account API: production imports
|
||
`control.provisioner.Provisioner()` (locally aliased HermesProvisioner) lazily
|
||
on startup.
|
||
|
||
## Runtime settings
|
||
|
||
- `CONTROL_DB_PATH`: SQLite path, default `/data/control/accounts.sqlite3`.
|
||
Mount persistent private storage. The DB contains hashes and Hermes keys;
|
||
protect the directory and its WAL/SHM files, backups, and host access.
|
||
- `CONTROL_ADMIN_USERNAME`: initial administrator lowercase slug, default `admin`.
|
||
- `CONTROL_ADMIN_PASSWORD`: **required when DB is empty**, 12–256 characters;
|
||
no built-in password. Ignored once existing accounts are present. Do not log it.
|
||
- `CONTROL_SERVICE_SECRET`: >=32-character secret for trusted internal callers.
|
||
Missing/short secret disables all internal access; send as `X-Control-Secret`.
|
||
- `CONTROL_ALLOWED_ORIGINS`: exact comma-separated HTTPS browser origins.
|
||
**Set this behind nginx**; no forwarded host/proto headers are trusted.
|
||
|
||
Native login sends `Content-Type: application/json` and no browser Origin.
|
||
Browser login requires its actual Origin to match. Cookie mutations require a
|
||
matching Origin; bearer-authenticated requests are exempt. Login is throttled
|
||
per validated client IP and username (10 failed attempts / 5 minutes; successes
|
||
release their reservation). Only a direct loopback peer may supply `X-Real-IP`,
|
||
which nginx must overwrite with `$remote_addr`. Do not expose the control port
|
||
or preserve untrusted client-supplied identity headers in another proxy.
|
||
Opaque sessions last 24 hours, are hashed at rest and revoked by logout,
|
||
password reset or deletion. Cookies are Secure, HttpOnly, SameSite=Strict.
|
||
|
||
## Public API
|
||
|
||
- `POST /api/auth/login` JSON `{username,password}` →
|
||
`{token,user,expires_at}` (expiry Unix seconds) plus `hope_session` cookie.
|
||
- `POST /api/auth/logout` → `{ok:true}`; invalid sessions return 401.
|
||
- `GET /api/auth/me` → `{user}`.
|
||
- Administrator-only `GET /api/admin/users` → `{users:[...]}`.
|
||
- Administrator-only `POST /api/admin/users` JSON
|
||
`{username,display_name,password,role?:"user"}` → 201 `{user}`.
|
||
- Administrator-only `DELETE /api/admin/users/{id}` → 202 `{user}` in
|
||
deleting state. Sessions/rooms revoked synchronously before return; container
|
||
deletion is background, durable and retriable. Last administrator is protected.
|
||
- Administrator-only `POST /api/admin/users/{id}/password` JSON `{password}` →
|
||
`{ok:true}`; revokes all sessions and room grants.
|
||
- Administrator-only `POST /api/admin/users/{id}/retry` → 202 `{user}`;
|
||
retries failed provisioning or removal, preserving intended lifecycle.
|
||
- `GET /health` → `{status:"ok"}` (control HTTP liveness, not Hermes readiness).
|
||
|
||
Public user is exactly `{id,username,display_name,role,status,container_name,error}`.
|
||
IDs are stable UUID hex. Usernames match `[a-z][a-z0-9]*(?:-[a-z0-9]+)*`,
|
||
1–48 characters. Status is `provisioning`, `ready`, `error` or `deleting`.
|
||
Deleting failures remain deleting with a sanitized visible error, never become
|
||
login-eligible. Background reconcile runs on startup and every 30 seconds.
|
||
|
||
## Trusted speech boundary
|
||
|
||
Every internal route requires `X-Control-Secret`, including room lookups.
|
||
|
||
- `POST /internal/resolve-session` JSON `{token}` validates the exact cookie or
|
||
bearer token; returns `{user,hermes_url,hermes_api_key}` only for ready user.
|
||
- `GET /internal/users/{id}` returns the same ready-user metadata.
|
||
- `POST /internal/rooms` JSON `{user_id,room_name,session_token,ttl?:3600}`
|
||
validates that session belongs to the user and persists a unique server-minted
|
||
room grant, returning 201 `{room_name,user_id}`. TTL 1–86400 sec, capped at
|
||
original session expiry.
|
||
- `GET /internal/rooms/{room_name}` resolves that grant only while unexpired
|
||
and user still active/ready. Returns the same metadata. Deletion/reset revoke
|
||
grants immediately; speech agents should poll to stop active conversations.
|
||
|
||
401 means absent/invalid auth; 403 forbidden role/Origin; 404 missing/deleted
|
||
user or invalid room; 503 means not yet ready. Metadata is never in public APIs.
|
||
Do not proxy internal endpoints publicly. Token service resolves client sessions,
|
||
mints an opaque room name, persists its grant before issuing LiveKit credentials,
|
||
and never accepts browser-supplied account IDs, room ownership or Hermes URLs.
|
||
For colocated FastAPI integration use `Depends(control.current_user)` and
|
||
`Depends(control.administrator)`; these return public user dicts and require
|
||
this application's `app.state.accounts`/settings. Separate services use the
|
||
secret-authenticated internal endpoints instead.
|
||
|
||
## Hermes LAN access and migration
|
||
|
||
User containers use Docker **host networking** for outbound access to the same
|
||
LAN resources as Unraid. The control service must also run with host networking
|
||
(as in the deployment script). Each Hermes API binds **127.0.0.1 only**, on its
|
||
own persistent port; no LAN-facing API publication or Docker port bindings.
|
||
|
||
`HERMES_API_PORT_RANGE` defaults to `20000-29999`. Reserve this TCP range for
|
||
Hermes. Allocation skips ports already bound on the host and assignments are
|
||
stored in the protected `api-ports.json` under `HOPE_HERMES_DATA_DIR`. Run only
|
||
one control process against this runtime root. Assignments survive restarts and
|
||
account deletion alongside retained data; changing the range affects only new
|
||
assignments. An unrelated process taking an assigned port is an operator error;
|
||
provisioning fails rather than silently changing the account endpoint.
|
||
|
||
Deploy/restart the updated control service to migrate existing accounts:
|
||
startup schedules **all accounts**, including ready ones. Provisioning replaces
|
||
owned legacy bridge containers with host-networked containers, retaining the
|
||
account name, API key, home, workspace and non-listener configuration. Only the
|
||
API host/port settings in the persisted YAML are updated. Owned legacy networks
|
||
are removed. Expect brief per-account downtime; failed startup is retriable with
|
||
the retained state. The normal reconcile loop also rejects legacy containers as
|
||
not ready and schedules provisioning. This is not a live deployment receipt.
|
||
|
||
**Security tradeoff:** host networking removes per-user network namespace
|
||
isolation. Containers can reach host-loopback services and other account API
|
||
ports (authentication is still required). Loopback-only binding prevents direct
|
||
LAN access to the Hermes API, but is not isolation from other host-networked
|
||
containers. File separation, nonroot execution, capability drops and resource
|
||
limits remain. This controls the Hermes API listener, **not arbitrary tool
|
||
processes**: terminal/browser tools can open their own LAN-facing unprivileged
|
||
ports. Strict outbound-only enforcement needs a separate host ingress firewall
|
||
policy; host networking alone cannot guarantee that. Host networking does not
|
||
bypass LAN ACLs or configure mDNS/DNS.
|
||
|
||
## Per-account Hermes WebUI
|
||
|
||
Build `hermes-webui.Dockerfile` as `hope-hermes-webui:62e4d7b`. This packages the
|
||
pinned nesquena/hermes-webui revision with the existing pinned Hermes image and
|
||
uses `bootstrap.py --foreground --no-browser --skip-agent-install` as a supervised
|
||
second service. Both gateway and WebUI exit/restart together on service failure.
|
||
|
||
Enable in the protected control runtime environment:
|
||
|
||
```dotenv
|
||
HOPE_WEBUI_ENABLED=true
|
||
HERMES_IMAGE=hope-hermes-webui:62e4d7b
|
||
HERMES_WEBUI_HOST=192.168.86.2
|
||
HERMES_WEBUI_PORT_RANGE=30000-30999
|
||
```
|
||
|
||
Each user gets a persistent port recorded in `webui-ports.json`, a separate random
|
||
browser password in `<runtime-root>/<full-account-id>/webui-password` (0600), and
|
||
private WebUI state and self-signed TLS certificate under the retained home.
|
||
User-selected WebUI passwords have **no minimum length**; only blank/whitespace
|
||
and multiline values are rejected to keep authentication enabled. After changing
|
||
the protected password file, recreate that account container to update its env.
|
||
Passwords are **not** Hope login passwords. An operator can retrieve the intended
|
||
account's password over trusted SSH; never print all credentials or Docker envs.
|
||
Certificates have the configured LAN IP SAN; clients must trust the self-signed
|
||
certificate. Only WebUI binds the configured LAN IP; gateway APIs remain loopback.
|
||
No router forwarding/public internet access is configured. No firewall changes
|
||
are needed if the allocated LAN listener is already reachable.
|
||
|
||
Browser chat uses that account's authenticated gateway and runs API, preserving
|
||
its execution boundary/concurrency limit. Both API readiness and **HTTPS** WebUI
|
||
health must pass. Separate cookie names prevent collisions between ports, but
|
||
ports on the same IP are not a cookie isolation boundary: for stronger isolation,
|
||
use per-account hostnames. WebUI grants its authenticated owner powerful access
|
||
to account files/configuration/tools. Source/dependency installers are disabled
|
||
at runtime. Recreating an existing container preserves state, keys, API ports,
|
||
WebUI ports and browser passwords. CI builds the pinned WebUI image for future
|
||
accounts; keep the above environment enabled across control redeployments.
|
||
|
||
## Provisioner interface
|
||
|
||
`create_app(settings: Settings, provisioner=...)` supports offline injection.
|
||
The adapter receives a **private trusted dict**, including `id`, `username`,
|
||
`container_name`, `desired`, and stored `hermes_url` / `hermes_api_key`.
|
||
|
||
- `async ensure(user) -> dict`: idempotently create/recover owned container;
|
||
return `{container_name,hermes_url,hermes_api_key}`. Container name must match
|
||
`hope-hermes-<username>-<id[:8]>`; metadata is persisted before readiness check.
|
||
- `async ready(user) -> bool`: owned service health, not existence alone.
|
||
- `async remove(user) -> None`: verify management/user ownership before any
|
||
removal; retain private data by default; failure raises and remains retriable.
|
||
- Optional `async aclose()`: close adapter resources during shutdown.
|
||
|
||
Async adapters offload blocking Docker/HTTP calls themselves; synchronous
|
||
adapters are supported using a worker thread. Account SQLite and scrypt work
|
||
are offloaded from the ASGI loop. Slow provision/remove run separate tasks;
|
||
request handlers do not await container startup. Exception text is not logged or
|
||
returned because provider failures may contain credentials.
|
||
|
||
Offline tests:
|
||
|
||
```sh
|
||
python -m pytest tests/unit/test_accounts.py --confcutdir=tests/unit -q
|
||
```
|
||
|
||
`--confcutdir` avoids this repository's deployment-oriented top-level fixtures.
|
||
Use a temporary DB and fake adapter; no Docker or provider calls are made.
|