# The Hermes dashboard inside the Rekan.Ai panel
Every box runs the Hermes web dashboard. The panel shows it in two modes: a simple view built from the answers a business owner actually asks, and an Advanced view that passes the real dashboard through — allow-listed, because some of its pages expose or change the box's own credentials.
# Why the box dashboard is reached through the host
Three facts decide the whole design:
hermes dashboardrefuses an unauthenticated public bind. Since the June 2026 hardening,--insecureis a no-op: a non-loopback bind always requires an auth provider (password or OAuth). A loopback bind needs none.- A box lives on
10.99.0.0/24behind NAT, so a tenant's browser cannot reach it directly. - Credentials should not multiply. A second per-box password is a second thing to rotate, leak, and support.
So the dashboard binds loopback inside the box, and the control plane reaches that loopback through an SSH tunnel while presenting a per-box session token; the tenant's browser only ever holds the panel's JWT.
browser ──JWT──> control plane ──ssh -L tunnel──> box 127.0.0.1:9119 ──> hermes dashboard
└── Authorization: Bearer <per-box token>
Nothing is published on the bridge, no second password exists, and the token is derived — not stored — so the database holds no per-box credential.
# Verified on real hardware (2026-09-22)
Checked against a live box (rekan-rani-e194-40204c, Ubuntu 24.04 VM, Hermes v0.21.3):
| Claim | Result |
|---|---|
hermes dashboard runs in a box | ✅ systemctl is-active rekan-dashboard → active, loopback /api/health → 200 |
| Loopback bind needs no auth | ✅ {"ok":true,"version":"0.21.3","auth_required":false} |
| Bundle is prebuilt / buildable | ✅ image warm-up builds it; a cold box builds it in ~20s (node v26.9.0 is bundled) |
LXD proxy device with listen=unix: on a VM | ❌ Only NAT mode is supported for proxies on VM instances |
LXD NAT proxy (listen=tcp:127.0.0.1:P connect=tcp:<vm-ip>:9119) | ❌ Connect IP "<vm-ip>" must be one of the instance's static IPv4 addresses (box IPs are DHCP) |
| SSH local port-forward host→box loopback | ✅ tunnel up, /api/health → 200 |
GET /api/sessions over that tunnel, no credentials | ❌ 401 — loopback still gates /api/* (legacy _SESSION_TOKEN middleware) |
HERMES_DASHBOARD_SESSION_TOKEN + Authorization: Bearer | ✅ /api/sessions, /api/files, /api/skills, /api/analytics/usage all 200; without the token, 401 |
LXD bridge ACL (drop tcp dport 9396 before a catch-all allow) | ⚠️ attached and correctly ordered, yet an instance on the same bridge still reached the port — bridge ACLs did not filter instance-to-instance traffic here. Do not rely on them for isolation; see "Isolation" below. |
Chosen: the SSH tunnel (rekan/services/box_access.py). It needs no LXD feature, no static address and no ACL, and it is the mechanism an operator would use by hand — so there is nothing magic to debug when it breaks.
# How the control plane authenticates
Once HERMES_DASHBOARD_SESSION_TOKEN is set for the dashboard process, Hermes accepts it as Authorization: Bearer <token> or in its dedicated session header (web_server._has_valid_session_token, constant-time compare). So the control plane sets it to a derived value, HMAC(deployment secret, box id), base64url — 43 characters, nothing stored per box, and rotating REKAN_BOX_DASHBOARD_TOKEN_SECRET rotates every box on its next apply.
It has to arrive through the service environment, not ~/.hermes/.env: Hermes deliberately strips HERMES_DASHBOARD_SESSION_TOKEN when loading that file (it is a spawn credential). The unit therefore carries EnvironmentFile=-/etc/rekan/dashboard.env, which the provisioner pushes at 0600 with the token, then restarts the service.
An auth plugin (the dashboard_auth provider + register_token_route seam, as the bundled drain plugin does) would also work and was prototyped, but it is strictly more moving parts — a manifest, an enable step, and the same token — for no additional capability. The env token is the supported path and is what ships.
# Isolation (still open, and ACLs did not solve it)
Boxes on rekanbr0 can reach each other freely (measured). With the tunnel design that does not expose a dashboard — nothing listens on the box's interface — but it means a tenant box can reach any other service a sibling box binds. If we ever expose something on the interface, isolation must come from a mechanism verified to work (host nftables with bridge netfilter enabled, or a per-box firewall in the guest). The LXD ACL is attached today and is not load-bearing:
lxc network unset rekanbr0 security.acls # detach
lxc network acl delete rekan-box-isolation # remove
# What is where
| Piece | File |
|---|---|
| Unit that runs the dashboard | image/guest/rekan-dashboard.service |
Wrapper that decides --skip-build vs a first build | image/guest/rekan-box-dashboard |
| Image install + bundle warm-up | image/guest/provision-hermes.sh, image/build-image.sh |
| Push unit + token, start service, authorise key, open tunnel | rekan/services/provisioner.py (_ensure_dashboard) |
| Derived token + SSH tunnel + tunnel inventory | rekan/services/box_access.py |
| Summary + allow-listed proxy | rekan/api/hermes.py |
| Live sockets (chat, terminal) + the box credential | rekan/api/hermes_ws.py |
| Tests | tests/test_box_dashboard.py, tests/test_box_access.py, tests/test_agent_ws.py, tests/test_agent_ticket.py |
The bundle is built once during the image build (provision-hermes.sh warms it and caches hermes_cli/web_dist), so a box starts its dashboard in seconds. A box built from an older image still works: the wrapper lets Hermes build the bundle on first start (~20s, once).
# Mode 1 — Simple (GET /api/v1/boxes/{id}/hermes/summary)
One round trip, five groups, each independent:
| Group | Answers | Source |
|---|---|---|
status | "is it alive?" | /api/health |
sessions | "what did it do?" | /api/sessions?limit=8 |
usage | "what did it cost?" | /api/analytics/usage, normalised to token + USD totals |
files | "where are my documents?" | /api/files?limit=8 |
skills | "what can it do?" | /api/skills |
A group that fails returns {"ok": false, "error": "<sentence>"}: a flaky dashboard never turns the box page into an error screen. A deleted box is a 409.
Terminology in the panel, not in the API: Sessions → Percakapan, Analytics → Pemakaian, Channels → Saluran chat, Cron → Jadwal, Skills → Kemampuan, Files → Berkas.
# Mode 2 — Advanced (/api/v1/boxes/{id}/hermes/dash/<path>)
An allow list, not a deny list. The dashboard ships pages that reveal and edit the box's secrets (/env has revealed and edits in its own source, /config, /api/config/raw), read runtime logs, and restart components (/system). A deny list would grow a hole every time Hermes adds a page.
Allowed: /, /chat, /sessions, /analytics, /files, /skills, /models, /channels, /cron, /docs, their /api/... counterparts, and the static assets (/assets/*, /fonts*, /favicon.ico).
Refused with an explanation (404): /env, /config, /system, /logs, /mcp, /webhooks, /plugins, /profiles, /pairing, /api/ssh, /api/ops, /api/actions, /api/secrets, /api/oauth, /api/git, plus any path containing ...
Refused paths never reach the box — there is a test that fails if the transport is touched at all.
# The per-box origin (solved 2026-09-22, measured in a browser)
The dashboard bundle is built with absolute URLs: index.html references /assets/..., and its SPA builds its API and WebSocket URLs at runtime. Embedded in an <iframe> on the panel's origin, those resolve against the panel's root, where /assets is the panel's own static mount and /api/... the panel's API.
Three mechanisms together, all measured live (docs/v0.4.0-verification.md §5):
X-Forwarded-Prefix— every proxied request carries/api/v1/boxes/{id}/hermes/dash. Hermes' ownmount_spathen rewrites the shell's absolute asset URLs, prefixes its CSSurl(...)references, and injectswindow.__HERMES_BASE_PATH__, which is exactly what the SPA uses to build its fetch and WebSocket URLs. This is upstream's supported seam — the panel does not patch the bundle. (rekan/api/hermes.py: dash_prefix.)- The prefixed live sockets — with a base path, the SPA dials
/api/v1/boxes/{id}/hermes/dash/api/pty, sorekan/api/hermes_ws.pybridges that shape too. The prefix is stripped before the socket is dialled upstream: the box's own routes are the bare ones. GET /assets/{path}at the origin root — the pieceX-Forwarded-Prefixcannot fix. The bundle's chunk preloader is generated asXt = e => "/" + eover its__vite__mapDepstable and then resolved withimport.meta.resolve, so a lazily imported page chunk is always fetched from<origin>/assets/.... Measured: the shell rendered through the prefix and thenChatPage-*.js,xterm-*.js,vendor-*.js… all 404'd against the panel's own/assets, leaving a blank page. The proxy now serves those root paths to the box named by the ticket cookie, and the panel's own file wins whenever one exists at that path, so the panel SPA keeps its namespace.
Why not the alternatives: serving the dashboard only under the prefix cannot work while the preloader hardcodes the origin root; serving it only at the root (round-1's "cookie-routed root paths") would make a single browser ambiguous about which box it is looking at for the shell and the API, whereas a prefix is per-box by construction.
Two honest caveats, both worth a later milestone:
- The embed is same-origin with the panel. An
<iframe>at/api/v1/boxes/{id}/hermes/dash/shares the panel's origin, so anything the box serves as JavaScript executes with access to the panel's own storage. Today that is the Hermes bundle (upstream code, same on every box) plus the box's dashboard plugins, which the tenant can replace — and a tenant already has a shell in their own box through this very terminal. The blast radius is the tenant's own users, never another org's. The clean fix is a dedicated origin per box (its own hostname, or its own port), which is also what removes the last of the prefix gymnastics below. - One ticket, one box, per browser. The ticket cookie is per box and the root-level
/assetsfallback reads it, so with two dashboard tabs open on two boxes the assets come from the most recently ticketed one. The chunks are identical across boxes of the same Hermes build; if two boxes are on different builds, reload the tab. A dedicated origin removes this too. /api/profiles,/api/profiles/activeand/api/configare refused on purpose (they are config surfaces), so the dashboard's profile switcher and config page degrade instead of working. That is the allow list doing its job, not a broken URL.
# Rollout
- Rebuild the image to bake in the unit + warmed bundle (
image/build-image.sh). Existing boxes do not need it: the control plane pushes the unit and starts the service on the next apply (create or re-apply), which is exactly what_ensure_dashboarddoes. - Restart the control plane so the new code and settings load.
- Create or re-apply a box;
GET /api/v1/boxes/{id}/hermes/summaryshould reportdashboard.reachable: true.
# Settings
| Variable | Default | Meaning |
|---|---|---|
REKAN_BOX_DASHBOARD_ENABLED | true | Install/start/publish the dashboard |
REKAN_BOX_DASHBOARD_PORT | 9119 | Loopback port inside the box |
REKAN_BOX_DASHBOARD_UNIT_SOURCE | image/guest/rekan-dashboard.service | Unit pushed to boxes |
REKAN_BOX_DASHBOARD_START_TIMEOUT | 180 | Seconds allowed for systemctl enable --now |
REKAN_BOX_DASHBOARD_TOKEN_SECRET | jwt_secret | Derivation secret for per-box dashboard tokens |
REKAN_BOX_SSH_USER | rekan | Login the tunnel uses inside the box |
REKAN_BOX_SSH_KEY_PATH | /root/.ssh/id_ed25519 | Host key; its .pub is authorised in each box |
REKAN_BOX_TUNNEL_PORT_BASE / _SPAN | 9300 / 200 | Host loopback ports handed to tunnels |
REKAN_BOX_TUNNEL_KNOWN_HOSTS | /var/lib/rekan/known_hosts | Accept-new host keys live here |
REKAN_BOX_TUNNEL_START_TIMEOUT | 25 | Seconds allowed for the tunnel to come up |
# Open decisions
- Which role may open Advanced (
owner/adminonly, or any member)? - A dedicated origin per box (own hostname or port) to retire the root-level
/assetsfallback and the same-origin trust note above. The prefix mechanism stays either way: it is where the unit's URL contract already lives. /api/profiles,/api/profiles/activeand/api/configare refused; decide whether the profile switcher should work (it is the dashboard's own profile list, not the box's secrets) or whether the nav entry should be hidden.- Whether
/modelsand/channelsbelong in the simple view (they are allowed by the proxy today, but the simple view does not surface them yet). - Chat in the simple view needs a WebSocket proxy (
websocketsis already a dependency) because the dashboard's chat pane is a PTY over WS. /api/modelsand/api/channelsare allowed by the proxy but the simple view does not surface them yet.
# Live conversation (chat and terminal)
The Chat tab is a PTY over a WebSocket, not a REST call: the dashboard uses /api/pty, and also /api/chat and /api/ws. rekan/api/hermes_ws.py bridges those paths to the box.
- A browser cannot attach an
Authorizationheader to a WebSocket, so the credential is the box-scoped ticket cookie fromPOST /api/v1/boxes/{id}/hermes/ticket(minted by the panel, HttpOnly, one box, one hour). Get it wrong and the socket is refused — the terminal is too powerful to authenticate by accident. - The upstream connection is opened by the control plane, so the box's dashboard token is attached server-side and the browser never holds it.
- Only the live paths are bridged; every other path closes with 1008.
- Two client shapes, one bridge:
/api/pty(standalone, or a bundle without base path support) and the prefixed/api/v1/boxes/{id}/hermes/dash/api/pty— which is what the embedded SPA builds. On the prefixed route the ticket must name the box in the path.
# Resolved: the dashboard's WS gate takes no header (2026-09-22)
The v0.4.0 blocker was that /api/health answered 200 through the tunnel with the box token, while the WebSocket handshake answered 403. It is now measured both ways on a live box (docs/v0.4.0-verification.md has the raw runs):
| What we dial | Result |
|---|---|
/api/events with Authorization: Bearer <token> only | HTTP 403 — the blocker |
/api/events?token=<token> | 101, and a published frame is delivered byte-identical |
The cause is in the dashboard, not in the box's configuration: for HTTP its _has_valid_session_token accepts the bearer header (or X-Hermes-Session-Token), but its WebSocket gate (_ws_auth_reason) reads only ?token= from the query string on a loopback bind — there is no header path at all, and a missing credential closes 4401, which Starlette renders as HTTP
- The dashboard's own SPA authenticates its sockets exactly that way
(web_dist/assets/api-*.js builds ['token', window.__HERMES_SESSION_TOKEN__]).
So the fix is in the bridge: rekan/api/hermes_ws.py appends the derived token to the upstream query and drops any client-supplied token/ticket/internal, so the credential the box sees is always ours. Both shapes come from the same derivation in rekan/services/box_access.py.
Two consequences:
- the guest-side plugin seam
rekan_dash_authis not needed — no plugin, no manifest, no enable step. The unit'sEnvironmentFilesupplies the token, and the measured 200/101 above is that seam working.image/guest/rekan-box-dashboardandimage/guest/rekan-dashboard.serviceare unchanged; - the token reaches the browser inside the proxied
index.html(window.__HERMES_SESSION_TOKEN__), because the SPA will not open/api/wswithout it. It is box-scoped, the box is unreachable from the browser anyway, and the proxy ignores the client's value — see the verification doc's Observations.
Verify a fix like this before believing it — note the credential goes in the query, because the dashboard's WS gate reads nothing else:
# tunnel + handshake in ONE process (tunnels die with the process that opened one)
.venv/bin/python - <<'EOF'
import asyncio, websockets
from sqlalchemy import select
from rekan.db import SessionLocal
from rekan.models import Box
from rekan.services import box_access
async def main():
async with SessionLocal() as s:
b = (await s.execute(select(Box).where(Box.state == "READY"))).scalars().first()
await asyncio.to_thread(box_access.ensure_tunnel, b.id, host=b.ipv4 or "", instance=b.lxd_instance or "")
url = (box_access.base_url(b.id).replace("http://", "ws://")
+ f"/api/pty?cols=80&rows=24&token={box_access.dashboard_token(b.id)}")
async with websockets.connect(url) as ws:
print("handshake OK")
print((await ws.recv())[:60])
asyncio.run(main())
EOF