From 973967640954f73d89670eb63f51678a850be718 Mon Sep 17 00:00:00 2001 From: William Turner Date: Sun, 23 Aug 2026 16:21:50 +0000 Subject: [PATCH 1/2] Add Hermes web dashboard at hermes.apps.williamturner.eu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enabled via HERMES_DASHBOARD=1 (supervised in-container alongside the gateway, per docs/user-guide/docker.md), bound to 0.0.0.0:9119 so Traefik can reach it. Two independent auth layers, not one: 1. Traefik basicauth middleware in front of the whole route. 2. Hermes's own basic-auth gate (mandatory once the bind is non-loopback). Hermes's docs explicitly call basic-auth-alone "not suitable for direct public-internet exposure" and cite a real June 2026 incident where internet scanners reached exposed dashboards and drove agents into planting SSH-key backdoors — hence the extra Traefik-level gate rather than relying on Hermes's own login page alone. Also fixes: the htpasswd hash for Traefik's basicauth needs its literal '$' characters escaped as '2824147' in .env, or docker compose's own variable interpolation corrupts it (mistook '' for further references). Also switched the hash from Python's default SHA-512 crypt ('$...') to apr1 ('$...', via openssl passwd -apr1) — Traefik's basicauth middleware doesn't accept SHA-512-crypt. Also re-adds Hermes's OPENAI_BASE_URL/OPENAI_API_KEY routing through the local litellm gateway (instead of OPENROUTER_API_KEY direct) — this was part of the now-abandoned PR #12 and never actually landed on main. --- .env.example | 13 +++++++++++++ docker-compose.yml | 31 ++++++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 9e6c232..d6dddae 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,7 @@ # --- domain / TLS --- MATRIX_SERVER_NAME=matrix.apps.williamturner.eu AGENT_HOSTNAME=agent.apps.williamturner.eu +HERMES_DASHBOARD_HOSTNAME=hermes.apps.williamturner.eu # Set to true ONLY for the first-boot window while creating the bot account, # then back to false (or unset) and redeploy. See README. MATRIX_ALLOW_REGISTRATION=false @@ -37,6 +38,18 @@ HERMES_MATRIX_ACCESS_TOKEN= # (internal network only, not published anywhere). HERMES_API_SERVER_KEY= +# --- hermes web dashboard (hermes.apps.williamturner.eu) --- +# Two independent auth layers: Hermes's own login (basic auth — its docs call this +# "not suitable for direct public-internet exposure" alone) plus a Traefik-level basic +# auth gate in front of it. Both required, different credentials recommended. +HERMES_DASHBOARD_USERNAME=william +HERMES_DASHBOARD_PASSWORD= +# 32+ random bytes — `openssl rand -base64 32` +HERMES_DASHBOARD_SECRET= +# htpasswd-format "user:hash" for Traefik's basicauth middleware. Generate with: +# python3 -c "import crypt; print('someuser:' + crypt.crypt('somepassword', crypt.mksalt(crypt.METHOD_SHA512)))" +TRAEFIK_HERMES_AUTH_HASH= + # --- portainer (GitOps redeploy) --- PORTAINER_STACK_WEBHOOK_URL= diff --git a/docker-compose.yml b/docker-compose.yml index bfed4a4..4c98a23 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -77,13 +77,28 @@ services: # rooms (DMs to it would respond unprompted, per Hermes's own default behavior). MATRIX_ALLOWED_USERS: ${MATRIX_HUMAN_USER_ID} MATRIX_REQUIRE_MENTION: "true" - OPENROUTER_API_KEY: ${OPENROUTER_API_KEY} + # Routed through the local litellm gateway, not OpenRouter directly — one place to + # hold the OpenRouter credential and swap models. Does NOT grant Hermes access to + # the Claude subscription (Anthropic-side restriction, proven earlier — the + # subscription only works through the real `claude` CLI binary, which Hermes isn't). + OPENAI_BASE_URL: http://litellm:4000/v1 + OPENAI_API_KEY: ${LITELLM_MASTER_KEY} # Left disabled: Hermes itself warns that a network-reachable API server combined # with the default unsandboxed ('local') terminal backend gives any caller full # terminal/file access within the container. Matrix is the actual interface in use; # re-enable (API_SERVER_HOST: 0.0.0.0) only alongside terminal.backend: docker if # claude-agent ever needs to call Hermes programmatically. API_SERVER_ENABLED: "false" + # Web dashboard, supervised in-container alongside the gateway (same process group, + # same s6 tree) — see docs/user-guide/docker.md "Running the dashboard". Binds + # 0.0.0.0 so Traefik (a separate container) can reach it; that makes Hermes's own + # auth gate mandatory, which it enforces automatically once the bind isn't loopback. + HERMES_DASHBOARD: "1" + HERMES_DASHBOARD_HOST: 0.0.0.0 + HERMES_DASHBOARD_PORT: "9119" + HERMES_DASHBOARD_BASIC_AUTH_USERNAME: ${HERMES_DASHBOARD_USERNAME} + HERMES_DASHBOARD_BASIC_AUTH_PASSWORD: ${HERMES_DASHBOARD_PASSWORD} + HERMES_DASHBOARD_BASIC_AUTH_SECRET: ${HERMES_DASHBOARD_SECRET} volumes: - /home/william/hermes-data:/opt/data networks: @@ -92,6 +107,20 @@ services: # immediately exits ("Input is not a terminal") since a detached container has no # stdin — the container then just sits there having done nothing, every restart. command: ["gateway", "run"] + labels: + - "traefik.enable=true" + - "traefik.http.routers.hermes-dashboard.rule=Host(`${HERMES_DASHBOARD_HOSTNAME}`)" + - "traefik.http.routers.hermes-dashboard.entrypoints=websecure" + - "traefik.http.routers.hermes-dashboard.tls.certresolver=letsencrypt" + # Second, independent auth layer in front of Hermes's own login page — its docs + # explicitly call basic-auth-only "not suitable for direct public-internet + # exposure" and cite a real June 2026 incident where scanners reached exposed + # dashboards and drove agents into planting SSH-key backdoors. This means an + # attacker has to clear Traefik's gate before ever reaching Hermes's own auth, + # not just guess one password. + - "traefik.http.routers.hermes-dashboard.middlewares=hermes-dashboard-auth" + - "traefik.http.middlewares.hermes-dashboard-auth.basicauth.users=${TRAEFIK_HERMES_AUTH_HASH}" + - "traefik.http.services.hermes-dashboard.loadbalancer.server.port=9119" claude-agent: # Gitea PR-review only now — no Matrix presence (see hermes above; only one agent From 143be8200eba948751cc2659ac1ecdb0db5a2355 Mon Sep 17 00:00:00 2001 From: William Turner Date: Sun, 23 Aug 2026 17:02:59 +0000 Subject: [PATCH 2/2] =?UTF-8?q?Drop=20the=20Traefik-level=20basic=20auth?= =?UTF-8?q?=20layer=20=E2=80=94=20keep=20only=20Hermes's=20own=20login?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit By request: one auth layer, not two. Note this isn't really removing a layer I added on top of nothing — Hermes's own gate is mandatory and can't be disabled while the dashboard is reachable through a separate Traefik container (it fails closed at startup on any non-loopback bind without a configured auth provider). The only thing actually optional was the Traefik-level middleware, so that's what comes out; Traefik now just does TLS termination + routing. --- .env.example | 9 +++------ docker-compose.yml | 13 +++++-------- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/.env.example b/.env.example index d6dddae..e61d725 100644 --- a/.env.example +++ b/.env.example @@ -39,16 +39,13 @@ HERMES_MATRIX_ACCESS_TOKEN= HERMES_API_SERVER_KEY= # --- hermes web dashboard (hermes.apps.williamturner.eu) --- -# Two independent auth layers: Hermes's own login (basic auth — its docs call this -# "not suitable for direct public-internet exposure" alone) plus a Traefik-level basic -# auth gate in front of it. Both required, different credentials recommended. +# Hermes's own login gate — mandatory once its dashboard is bound non-loopback (needed +# for Traefik, a separate container, to reach it at all), so this can't be turned off +# while the dashboard is reachable through Traefik. HERMES_DASHBOARD_USERNAME=william HERMES_DASHBOARD_PASSWORD= # 32+ random bytes — `openssl rand -base64 32` HERMES_DASHBOARD_SECRET= -# htpasswd-format "user:hash" for Traefik's basicauth middleware. Generate with: -# python3 -c "import crypt; print('someuser:' + crypt.crypt('somepassword', crypt.mksalt(crypt.METHOD_SHA512)))" -TRAEFIK_HERMES_AUTH_HASH= # --- portainer (GitOps redeploy) --- PORTAINER_STACK_WEBHOOK_URL= diff --git a/docker-compose.yml b/docker-compose.yml index 4c98a23..b8f5238 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -112,14 +112,11 @@ services: - "traefik.http.routers.hermes-dashboard.rule=Host(`${HERMES_DASHBOARD_HOSTNAME}`)" - "traefik.http.routers.hermes-dashboard.entrypoints=websecure" - "traefik.http.routers.hermes-dashboard.tls.certresolver=letsencrypt" - # Second, independent auth layer in front of Hermes's own login page — its docs - # explicitly call basic-auth-only "not suitable for direct public-internet - # exposure" and cite a real June 2026 incident where scanners reached exposed - # dashboards and drove agents into planting SSH-key backdoors. This means an - # attacker has to clear Traefik's gate before ever reaching Hermes's own auth, - # not just guess one password. - - "traefik.http.routers.hermes-dashboard.middlewares=hermes-dashboard-auth" - - "traefik.http.middlewares.hermes-dashboard-auth.basicauth.users=${TRAEFIK_HERMES_AUTH_HASH}" + # Just TLS termination + routing — no Traefik-level auth middleware. Hermes's own + # login gate is not optional here anyway: it fails closed at startup once its bind + # isn't loopback-only (required for Traefik, a separate container, to reach it at + # all), so a second gate in front of it would only add friction, not remove Hermes's + # own one. One password, at Hermes's own login page. - "traefik.http.services.hermes-dashboard.loadbalancer.server.port=9119" claude-agent: