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, defaultadmin.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 asX-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/loginJSON{username,password}→{token,user,expires_at}(expiry Unix seconds) plushope_sessioncookie.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/usersJSON{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}/passwordJSON{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-sessionJSON{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/roomsJSON{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:
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 matchhope-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:
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.