# 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:

  1. hermes dashboard refuses an unauthenticated public bind. Since the June 2026 hardening, --insecure is a no-op: a non-loopback bind always requires an auth provider (password or OAuth). A loopback bind needs none.
  2. A box lives on 10.99.0.0/24 behind NAT, so a tenant's browser cannot reach it directly.
  3. 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):

ClaimResult
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

PieceFile
Unit that runs the dashboardimage/guest/rekan-dashboard.service
Wrapper that decides --skip-build vs a first buildimage/guest/rekan-box-dashboard
Image install + bundle warm-upimage/guest/provision-hermes.sh, image/build-image.sh
Push unit + token, start service, authorise key, open tunnelrekan/services/provisioner.py (_ensure_dashboard)
Derived token + SSH tunnel + tunnel inventoryrekan/services/box_access.py
Summary + allow-listed proxyrekan/api/hermes.py
Live sockets (chat, terminal) + the box credentialrekan/api/hermes_ws.py
Teststests/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:

GroupAnswersSource
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):

  1. X-Forwarded-Prefix — every proxied request carries /api/v1/boxes/{id}/hermes/dash. Hermes' own mount_spa then rewrites the shell's absolute asset URLs, prefixes its CSS url(...) references, and injects window.__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.)
  2. The prefixed live sockets — with a base path, the SPA dials /api/v1/boxes/{id}/hermes/dash/api/pty, so rekan/api/hermes_ws.py bridges that shape too. The prefix is stripped before the socket is dialled upstream: the box's own routes are the bare ones.
  3. GET /assets/{path} at the origin root — the piece X-Forwarded-Prefix cannot fix. The bundle's chunk preloader is generated as Xt = e => "/" + e over its __vite__mapDeps table and then resolved with import.meta.resolve, so a lazily imported page chunk is always fetched from <origin>/assets/.... Measured: the shell rendered through the prefix and then ChatPage-*.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 /assets fallback 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/active and /api/config are 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

  1. 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_dashboard does.
  2. Restart the control plane so the new code and settings load.
  3. Create or re-apply a box; GET /api/v1/boxes/{id}/hermes/summary should report dashboard.reachable: true.

# Settings

VariableDefaultMeaning
REKAN_BOX_DASHBOARD_ENABLEDtrueInstall/start/publish the dashboard
REKAN_BOX_DASHBOARD_PORT9119Loopback port inside the box
REKAN_BOX_DASHBOARD_UNIT_SOURCEimage/guest/rekan-dashboard.serviceUnit pushed to boxes
REKAN_BOX_DASHBOARD_START_TIMEOUT180Seconds allowed for systemctl enable --now
REKAN_BOX_DASHBOARD_TOKEN_SECRETjwt_secretDerivation secret for per-box dashboard tokens
REKAN_BOX_SSH_USERrekanLogin the tunnel uses inside the box
REKAN_BOX_SSH_KEY_PATH/root/.ssh/id_ed25519Host key; its .pub is authorised in each box
REKAN_BOX_TUNNEL_PORT_BASE / _SPAN9300 / 200Host loopback ports handed to tunnels
REKAN_BOX_TUNNEL_KNOWN_HOSTS/var/lib/rekan/known_hostsAccept-new host keys live here
REKAN_BOX_TUNNEL_START_TIMEOUT25Seconds allowed for the tunnel to come up

# Open decisions

  • Which role may open Advanced (owner/admin only, or any member)?
  • A dedicated origin per box (own hostname or port) to retire the root-level /assets fallback 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/active and /api/config are 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 /models and /channels belong 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 (websockets is already a dependency) because the dashboard's chat pane is a PTY over WS.
  • /api/models and /api/channels are 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 Authorization header to a WebSocket, so the credential is the box-scoped ticket cookie from POST /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 dialResult
/api/events with Authorization: Bearer <token> onlyHTTP 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

  1. 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_auth is not needed — no plugin, no manifest, no enable step. The unit's EnvironmentFile supplies the token, and the measured 200/101 above is that seam working. image/guest/rekan-box-dashboard and image/guest/rekan-dashboard.service are unchanged;
  • the token reaches the browser inside the proxied index.html (window.__HERMES_SESSION_TOKEN__), because the SPA will not open /api/ws without 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