Files

206 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
## Image input capability
The managed upstream `auto` model is explicitly declared with
`providers.hope-upstream.models.auto.supports_vision: true`. This is an exact-model
capability, not a global `model.supports_vision` override: account-selected models
are not automatically declared vision-capable. Non-auto `HERMES_UPSTREAM_MODEL`
overrides require independent qualification before declaring image support.
New accounts receive this declaration. On control startup/reconciliation, retained
accounts with an existing managed `auto` entry are migrated from a model list to a
metadata mapping, preserving other model IDs, capability metadata and settings.
Missing/custom provider declarations are not replaced with defaults. The migration
only changes the verified `auto` capability and existing managed listener settings.
A private `config-reload-pending` marker is written before an existing config is
changed. Running owned containers restart by immutable ID to load it; new/stopped
containers load it on start. The marker clears only after authenticated gateway
and (when enabled) WebUI health pass. Failed restarts/readiness retain the marker
for retry, including across control restarts. Expect brief per-account downtime
and interruption of active runs during migration. Credentials, ports, homes and
workspaces remain unchanged. Readiness rejects accounts awaiting this migration.
## 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.