From 8fb005e5df37b6de0966f4e6183a34e98ba7be33 Mon Sep 17 00:00:00 2001 From: joelbrilliant Date: Sat, 18 Jul 2026 06:11:52 +1000 Subject: [PATCH 1/2] =?UTF-8?q?feat(dashboard):=20add=20`hermes=20dashboar?= =?UTF-8?q?d=20proxy`=20=E2=80=94=20hardened=20API-only=20remote=20access?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop's remote-gateway mode expects "an already-running Hermes backend ... behind a trusted proxy", but the trusted proxy has always been DIY. This adds it as a first-class command, ported from a proxy that has been running in production in front of a Cloudflare tunnel since early July. hermes dashboard proxy forwards only /api/* (HTTP + WebSocket) to the loopback backend, so the SPA HTML — which inlines the dashboard session token — never crosses the tunnel. Machine-lifecycle routes are denied by default with an audit log: - /api/hermes/update (a remotely triggered self-update restarts backends with nobody at the machine; the read-only update/check stays reachable) - /api/gateway/start|stop|restart|drain - /api/ops/backup + backup/download (single-request exfiltration of the whole HERMES_HOME), ops/import + import-upload, ops/config-migrate --allow-route re-enables a denied route per explicit operator decision; --deny-route adds more. Denials are 403 + one audit line in logs/remote-proxy-denied.log. Auth is deliberately untouched: every forwarded request still hits the backend's session-token/OAuth checks. Built on the existing core stack (FastAPI/uvicorn, httpx, websockets). Docs: user-guide/remote-access.md covers the loopback + tunnel setup (Cloudflare, Tailscale, SSH -R), the deny table, and policy overrides. Tests: 29 covering route classification, deny/allow overrides, hop-by-hop header stripping, forwarding with query/header passthrough, 403 + audit on denied routes, SPA/static 404s, and WS policy close. Verified live against a running backend: forwarded routes return the upstream's own 401 without a session, denied routes 403 without ever reaching the backend. Co-Authored-By: Claude Fable 5 --- hermes_cli/main.py | 8 + hermes_cli/remote_proxy.py | 315 +++++++++++++++++++++++ hermes_cli/subcommands/dashboard.py | 62 ++++- tests/hermes_cli/test_remote_proxy.py | 198 ++++++++++++++ website/docs/user-guide/remote-access.md | 109 ++++++++ 5 files changed, 691 insertions(+), 1 deletion(-) create mode 100644 hermes_cli/remote_proxy.py create mode 100644 tests/hermes_cli/test_remote_proxy.py create mode 100644 website/docs/user-guide/remote-access.md diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 88f5fa375bf1..6eeadade5074 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -12620,6 +12620,13 @@ def cmd_dashboard_register(args): _impl(args) +def cmd_dashboard_proxy(args): + """Run the hardened API-only remote-access proxy.""" + from hermes_cli.remote_proxy import run_remote_proxy + + sys.exit(run_remote_proxy(args)) + + def cmd_gateway_enroll(args): """Enroll a self-hosted gateway with a relay connector.""" from hermes_cli.gateway_enroll import cmd_gateway_enroll as _impl @@ -15017,6 +15024,7 @@ def _export_one(session_id: str): subparsers, cmd_dashboard=cmd_dashboard, cmd_dashboard_register=cmd_dashboard_register, + cmd_dashboard_proxy=cmd_dashboard_proxy, ) diff --git a/hermes_cli/remote_proxy.py b/hermes_cli/remote_proxy.py new file mode 100644 index 000000000000..93e37f2a3e4f --- /dev/null +++ b/hermes_cli/remote_proxy.py @@ -0,0 +1,315 @@ +"""``hermes dashboard proxy`` — a hardened API-only reverse proxy for remote access. + +The desktop app's remote-gateway mode and the web dashboard both speak to the +backend over ``/api/*``. Exposing the whole dashboard server to a tunnel or +reverse proxy therefore over-shares: the SPA HTML embeds the dashboard session +token, and several ``/api`` routes perform machine-lifecycle operations +(update, gateway restart, backup download) that are safe from localhost but +dangerous from a phone on the train. + +This proxy is the piece users previously had to hand-roll ("an already-running +Hermes backend ... behind a trusted proxy", per the desktop's remote-gateway +copy). It: + +- forwards ONLY ``/api/*`` (HTTP and WebSocket) to the local backend, so the + SPA HTML — and the session token inlined into it — never crosses the tunnel; +- denies a default set of lifecycle routes outright (403), because a remote + surface triggering ``hermes update`` or ``gateway stop`` takes the machine's + gateways down with no local operator to recover them, and a backup download + is a whole-HERMES_HOME exfiltration in one request; +- audit-logs every denied request to ``logs/remote-proxy-denied.log`` for + attribution; +- strips hop-by-hop headers in both directions. + +It deliberately does NOT do authentication: the upstream dashboard server +keeps enforcing its own session-token / OAuth auth on every ``/api`` route. +The proxy reduces surface; it does not replace auth. Bind it to loopback and +point a tunnel (Cloudflare, Tailscale funnel, SSH -R, ...) at it. +""" + +# NOTE: no `from __future__ import annotations` here. The FastAPI handlers +# below are nested inside create_proxy_app and annotate parameters with +# locally imported types (Request, WebSocket); stringified annotations can't +# be resolved from module globals by FastAPI's dependency system, which would +# silently demote `request` to a query parameter (422s on every call). + +import asyncio +import datetime +import logging +from pathlib import Path +from typing import Iterable, Sequence + +logger = logging.getLogger(__name__) + +# Lifecycle and bulk-data routes a remote surface must not reach by default. +# Rationale per route family: +# - hermes/update: spawns a self-update that restarts backends; remotely +# triggered updates strand gateways with nobody at the machine. +# - gateway/*: start/stop/restart/drain of the machine's launchd/systemd +# gateways — same blast radius as update. +# - ops/backup + ops/backup/download: creates, then serves, a zip of the +# entire HERMES_HOME (sessions, config, credentials) — single-request +# exfiltration if a tunnel is ever misconfigured. +# - ops/import + ops/import-upload: restores an uploaded archive over the +# live HERMES_HOME — remote code/data injection. +# - ops/config-migrate: rewrites config on disk. +# Every one of these remains available from localhost surfaces. +DEFAULT_DENY_ROUTES: frozenset[str] = frozenset( + { + "/api/hermes/update", + "/api/gateway/start", + "/api/gateway/stop", + "/api/gateway/restart", + "/api/gateway/drain", + "/api/ops/backup", + "/api/ops/backup/download", + "/api/ops/import", + "/api/ops/import-upload", + "/api/ops/config-migrate", + } +) + +# Hop-by-hop headers (RFC 9110 §7.6.1) plus proxy-internal ones. These are +# connection-scoped and must not be forwarded in either direction. +_HOP_BY_HOP = { + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailers", + "transfer-encoding", + "upgrade", + "host", +} + + +def resolve_deny_routes( + allow: Sequence[str] | None = None, + deny: Sequence[str] | None = None, +) -> frozenset[str]: + """Apply ``--allow-route`` / ``--deny-route`` overrides to the default set. + + ``allow`` removes routes from the default deny-list (an explicit operator + decision to re-enable a lifecycle route remotely); ``deny`` adds extra + routes. Overrides are exact-path matches, normalized to no trailing slash. + """ + routes = set(DEFAULT_DENY_ROUTES) + for path in deny or (): + routes.add(_normalize(path)) + for path in allow or (): + routes.discard(_normalize(path)) + return frozenset(routes) + + +def _normalize(path: str) -> str: + path = "/" + str(path).strip().lstrip("/") + return path.rstrip("/") or "/" + + +def classify_request(path: str, deny_routes: frozenset[str]) -> str: + """Return ``forward`` | ``deny`` | ``not_found`` for a request path. + + Only ``/api/*`` is ever forwarded; everything else (the SPA, static + assets, health pages) 404s so nothing outside the JSON-RPC surface can + leak through the tunnel. Deny matches are exact-path. + """ + normalized = _normalize(path) + if normalized != "/api" and not normalized.startswith("/api/"): + return "not_found" + if normalized in deny_routes: + return "deny" + return "forward" + + +def filtered_headers(items: Iterable[tuple[str, str]]) -> list[tuple[str, str]]: + """Drop hop-by-hop headers; everything else passes through untouched.""" + return [(k, v) for k, v in items if k.lower() not in _HOP_BY_HOP] + + +def _audit_denied(deny_log: Path, method: str, path: str, client: str) -> None: + """Append one attribution line per denied request. Best-effort.""" + try: + deny_log.parent.mkdir(parents=True, exist_ok=True) + stamp = datetime.datetime.now().astimezone().isoformat(timespec="seconds") + with deny_log.open("a", encoding="utf-8") as fh: + fh.write(f"{stamp} DENIED {method} {path} client={client}\n") + except OSError: + logger.debug("remote-proxy deny log write failed", exc_info=True) + + +def create_proxy_app( + *, + upstream: str, + deny_routes: frozenset[str] = DEFAULT_DENY_ROUTES, + deny_log: Path | None = None, +): + """Build the FastAPI proxy app. Split from serving for testability.""" + import contextlib + + import httpx + from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect + from fastapi.responses import PlainTextResponse, StreamingResponse + + upstream = upstream.rstrip("/") + upstream_ws = "ws" + upstream.removeprefix("http") + state: dict = {} + + @contextlib.asynccontextmanager + async def _lifespan(app): + state["client"] = httpx.AsyncClient(base_url=upstream, timeout=None) + try: + yield + finally: + client = state.pop("client", None) + if client is not None: + await client.aclose() + + app = FastAPI( + openapi_url=None, docs_url=None, redoc_url=None, lifespan=_lifespan + ) + + async def _forward_http(request: Request, path: str): + client: httpx.AsyncClient = state["client"] + url = "/" + path + if request.url.query: + url += "?" + request.url.query + # Buffer the request body rather than streaming it through: JSON-RPC + # calls are small, the bulk-upload routes are on the deny-list, and a + # buffered body survives httpx's re-send paths (redirect/auth) that a + # one-shot generator cannot. + body = await request.body() + upstream_request = client.build_request( + request.method, + url, + headers=filtered_headers(request.headers.items()), + content=body if body else None, + ) + upstream_response = await client.send(upstream_request, stream=True) + + async def _body(): + try: + try: + async for chunk in upstream_response.aiter_raw(): + yield chunk + except httpx.StreamConsumed: + # Transports that preload content (MockTransport in tests, + # cached/intercepted responses) mark the stream consumed + # before we get to iterate; serve the buffered bytes. + yield upstream_response.content + finally: + await upstream_response.aclose() + + return StreamingResponse( + _body(), + status_code=upstream_response.status_code, + headers=dict(filtered_headers(upstream_response.headers.items())), + ) + + @app.api_route( + "/{path:path}", + methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"], + ) + async def _proxy(request: Request, path: str): + verdict = classify_request("/" + path, deny_routes) + if verdict == "not_found": + return PlainTextResponse("not found\n", status_code=404) + if verdict == "deny": + client_host = request.client.host if request.client else "?" + if deny_log is not None: + _audit_denied(deny_log, request.method, "/" + path, client_host) + logger.warning( + "remote-proxy denied %s /%s from %s", request.method, path, client_host + ) + return PlainTextResponse( + "route disabled on the remote surface\n", status_code=403 + ) + return await _forward_http(request, path) + + @app.websocket("/{path:path}") + async def _proxy_ws(websocket: WebSocket, path: str): + import websockets as ws_client + + if classify_request("/" + path, deny_routes) != "forward": + # 4403: policy close. Accept first so the close frame is delivered. + await websocket.accept() + await websocket.close(code=4403) + return + + url = f"{upstream_ws}/{path}" + if websocket.url.query: + url += "?" + websocket.url.query + headers = [ + (k, v) + for k, v in filtered_headers(websocket.headers.items()) + if k.lower() not in {"sec-websocket-key", "sec-websocket-version", "sec-websocket-extensions"} + ] + await websocket.accept(subprotocol=websocket.headers.get("sec-websocket-protocol")) + try: + async with ws_client.connect( + url, additional_headers=headers, max_size=None + ) as upstream_socket: + + async def client_to_upstream() -> None: + while True: + message = await websocket.receive() + if message.get("type") == "websocket.disconnect": + await upstream_socket.close() + return + if message.get("text") is not None: + await upstream_socket.send(message["text"]) + elif message.get("bytes") is not None: + await upstream_socket.send(message["bytes"]) + + async def upstream_to_client() -> None: + async for message in upstream_socket: + if isinstance(message, (bytes, bytearray)): + await websocket.send_bytes(bytes(message)) + else: + await websocket.send_text(message) + + done, pending = await asyncio.wait( + [ + asyncio.create_task(client_to_upstream()), + asyncio.create_task(upstream_to_client()), + ], + return_when=asyncio.FIRST_COMPLETED, + ) + for task in pending: + task.cancel() + except (WebSocketDisconnect, ConnectionError, OSError): + pass + finally: + try: + await websocket.close() + except Exception: + pass + + return app + + +def run_remote_proxy(args) -> int: + """Entry point for ``hermes dashboard proxy`` (handler injected in main).""" + import uvicorn + + from hermes_constants import get_hermes_home + + deny_routes = resolve_deny_routes( + allow=getattr(args, "allow_routes", None), + deny=getattr(args, "extra_deny_routes", None), + ) + deny_log = get_hermes_home() / "logs" / "remote-proxy-denied.log" + upstream = str(getattr(args, "upstream", "") or "http://127.0.0.1:9119") + + app = create_proxy_app( + upstream=upstream, deny_routes=deny_routes, deny_log=deny_log + ) + + host = str(getattr(args, "host", "127.0.0.1") or "127.0.0.1") + port = int(getattr(args, "port", 9123) or 9123) + print(f"Hermes remote proxy: {host}:{port} -> {upstream} (API-only)") + print(f" Denied routes ({len(deny_routes)}): " + ", ".join(sorted(deny_routes))) + print(f" Deny audit log: {deny_log}") + print(" Point your tunnel at this listener; keep the backend on loopback.") + uvicorn.run(app, host=host, port=port, log_level="warning") + return 0 diff --git a/hermes_cli/subcommands/dashboard.py b/hermes_cli/subcommands/dashboard.py index a345a9d9d599..8016725a0546 100644 --- a/hermes_cli/subcommands/dashboard.py +++ b/hermes_cli/subcommands/dashboard.py @@ -85,7 +85,11 @@ def _add_server_runtime_args(parser) -> None: def build_dashboard_parser( - subparsers, *, cmd_dashboard: Callable, cmd_dashboard_register: Callable + subparsers, + *, + cmd_dashboard: Callable, + cmd_dashboard_register: Callable, + cmd_dashboard_proxy: Callable, ) -> None: """Attach the ``dashboard`` and ``serve`` subcommands. @@ -198,3 +202,59 @@ def build_dashboard_parser( ), ) dashboard_register_parser.set_defaults(func=cmd_dashboard_register) + + # `hermes dashboard proxy` — hardened API-only reverse proxy for remote + # access. The desktop's remote-gateway mode expects "an already-running + # Hermes backend ... behind a trusted proxy"; this IS that proxy: only + # /api/* is forwarded (the SPA HTML, which inlines the session token, + # never crosses the tunnel), lifecycle routes are denied by default, and + # denials are audit-logged. Auth stays enforced by the upstream server. + dashboard_proxy_parser = dashboard_subparsers.add_parser( + "proxy", + help="Run a hardened API-only reverse proxy for remote access", + description=( + "Expose the local Hermes backend to a tunnel safely: forwards only " + "/api/* (HTTP + WebSocket), denies machine-lifecycle routes " + "(update, gateway start/stop/restart, backup/import) by default, " + "and audit-logs denied requests. The upstream server keeps " + "enforcing its own session-token/OAuth auth — this proxy reduces " + "surface, it does not replace auth. Point your tunnel (Cloudflare, " + "Tailscale, SSH -R) at this listener." + ), + ) + dashboard_proxy_parser.add_argument( + "--host", default="127.0.0.1", help="Listen host (default 127.0.0.1)" + ) + dashboard_proxy_parser.add_argument( + "--port", type=int, default=9123, help="Listen port (default 9123)" + ) + dashboard_proxy_parser.add_argument( + "--upstream", + default="http://127.0.0.1:9119", + help=( + "Base URL of the local backend to forward to (default " + "http://127.0.0.1:9119 — the `hermes dashboard`/`hermes serve` " + "default port)" + ), + ) + dashboard_proxy_parser.add_argument( + "--allow-route", + dest="allow_routes", + action="append", + default=[], + metavar="PATH", + help=( + "Remove an exact path from the default deny-list (repeatable), " + "e.g. --allow-route /api/gateway/restart. An explicit operator " + "decision to re-enable that route remotely." + ), + ) + dashboard_proxy_parser.add_argument( + "--deny-route", + dest="extra_deny_routes", + action="append", + default=[], + metavar="PATH", + help="Add an exact path to the deny-list (repeatable)", + ) + dashboard_proxy_parser.set_defaults(func=cmd_dashboard_proxy) diff --git a/tests/hermes_cli/test_remote_proxy.py b/tests/hermes_cli/test_remote_proxy.py new file mode 100644 index 000000000000..183bb511d78d --- /dev/null +++ b/tests/hermes_cli/test_remote_proxy.py @@ -0,0 +1,198 @@ +"""Tests for ``hermes dashboard proxy`` (hermes_cli/remote_proxy.py). + +The proxy's contract: only /api/* crosses the tunnel, lifecycle routes are +denied by default with an audit trail, hop-by-hop headers are stripped, and +everything else streams through with the upstream's auth untouched. +""" + +from __future__ import annotations + + + +import httpx +import pytest +from fastapi.testclient import TestClient + +from hermes_cli.remote_proxy import ( + DEFAULT_DENY_ROUTES, + classify_request, + create_proxy_app, + filtered_headers, + resolve_deny_routes, +) + + +# --------------------------------------------------------------------------- +# Pure classification / policy +# --------------------------------------------------------------------------- + +class TestClassification: + @pytest.mark.parametrize("path", [ + "/api/sessions", + "/api/config", + "/api/ws", + "/api/actions/doctor/status", + ]) + def test_api_routes_forward(self, path): + assert classify_request(path, DEFAULT_DENY_ROUTES) == "forward" + + @pytest.mark.parametrize("path", sorted(DEFAULT_DENY_ROUTES)) + def test_lifecycle_routes_denied_by_default(self, path): + assert classify_request(path, DEFAULT_DENY_ROUTES) == "deny" + + @pytest.mark.parametrize("path", [ + "/", + "/index.html", + "/assets/index.js", + "/health", + "/apiary", # prefix trick must not match /api + ]) + def test_non_api_is_not_found(self, path): + assert classify_request(path, DEFAULT_DENY_ROUTES) == "not_found" + + def test_trailing_slash_cannot_bypass_deny(self): + assert classify_request("/api/hermes/update/", DEFAULT_DENY_ROUTES) == "deny" + + def test_update_check_stays_reachable(self): + # The read-only update CHECK endpoint is not on the deny-list; only + # the mutating spawn is. + assert classify_request("/api/hermes/update/check", DEFAULT_DENY_ROUTES) == "forward" + + +class TestOverrides: + def test_allow_route_removes_from_default_deny(self): + routes = resolve_deny_routes(allow=["/api/gateway/restart"]) + assert classify_request("/api/gateway/restart", routes) == "forward" + assert classify_request("/api/hermes/update", routes) == "deny" + + def test_deny_route_adds(self): + routes = resolve_deny_routes(deny=["/api/sessions/import"]) + assert classify_request("/api/sessions/import", routes) == "deny" + + def test_normalization_tolerates_slashes(self): + routes = resolve_deny_routes(allow=["api/gateway/restart/"]) + assert classify_request("/api/gateway/restart", routes) == "forward" + + +class TestHeaderFiltering: + def test_hop_by_hop_and_host_are_dropped(self): + kept = filtered_headers([ + ("Host", "evil.example"), + ("Connection", "keep-alive"), + ("Transfer-Encoding", "chunked"), + ("Upgrade", "h2c"), + ("Authorization", "Bearer token"), + ("X-Hermes-Session", "abc"), + ]) + names = [k.lower() for k, _ in kept] + assert "host" not in names + assert "connection" not in names + assert "transfer-encoding" not in names + assert "upgrade" not in names + # End-to-end headers (auth included — the upstream enforces it) pass. + assert ("Authorization", "Bearer token") in kept + assert ("X-Hermes-Session", "abc") in kept + + +# --------------------------------------------------------------------------- +# ASGI behavior (mocked upstream) +# --------------------------------------------------------------------------- + +def test_forwarded_request_streams_upstream_response(tmp_path, monkeypatch): + seen: list[httpx.Request] = [] + + def _upstream(request: httpx.Request) -> httpx.Response: + seen.append(request) + return httpx.Response( + 200, + json={"ok": True}, + headers={"X-Upstream": "yes", "Connection": "keep-alive"}, + ) + + real_async_client = httpx.AsyncClient + + def _mock_client(*args, **kwargs): + kwargs["transport"] = httpx.MockTransport(_upstream) + return real_async_client(*args, **kwargs) + + monkeypatch.setattr(httpx, "AsyncClient", _mock_client) + + app = create_proxy_app( + upstream="http://127.0.0.1:9119", + deny_log=tmp_path / "denied.log", + ) + with TestClient(app) as client: + response = client.get("/api/sessions?limit=5", headers={"X-Hermes-Session": "abc"}) + + assert response.status_code == 200 + assert response.json() == {"ok": True} + assert response.headers["X-Upstream"] == "yes" + # Hop-by-hop from upstream must not be forwarded back. + assert "connection" not in {k.lower() for k in response.headers} + # The upstream saw the pass-through auth header and the query string. + assert seen[0].headers["X-Hermes-Session"] == "abc" + assert str(seen[0].url).endswith("/api/sessions?limit=5") + + +def test_denied_route_is_403_and_audited(tmp_path, monkeypatch): + + + called: list[str] = [] + + real_async_client = httpx.AsyncClient + + def _mock_client(*args, **kwargs): + kwargs["transport"] = httpx.MockTransport( + lambda req: called.append(str(req.url)) or httpx.Response(200) + ) + return real_async_client(*args, **kwargs) + + monkeypatch.setattr(httpx, "AsyncClient", _mock_client) + + deny_log = tmp_path / "denied.log" + app = create_proxy_app(upstream="http://127.0.0.1:9119", deny_log=deny_log) + with TestClient(app) as client: + response = client.post("/api/hermes/update") + + assert response.status_code == 403 + assert called == [], "denied requests must never reach the upstream" + logged = deny_log.read_text(encoding="utf-8") + assert "DENIED POST /api/hermes/update" in logged + + +def test_non_api_is_404_and_never_reaches_upstream(tmp_path, monkeypatch): + called: list[str] = [] + + real_async_client = httpx.AsyncClient + + def _mock_client(*args, **kwargs): + kwargs["transport"] = httpx.MockTransport( + lambda req: called.append(str(req.url)) or httpx.Response(200) + ) + return real_async_client(*args, **kwargs) + + monkeypatch.setattr(httpx, "AsyncClient", _mock_client) + + app = create_proxy_app( + upstream="http://127.0.0.1:9119", deny_log=tmp_path / "denied.log" + ) + with TestClient(app) as client: + assert client.get("/").status_code == 404 + assert client.get("/index.html").status_code == 404 + assert client.get("/assets/index-abc.js").status_code == 404 + assert called == [], "the SPA and static assets must never cross the proxy" + + +def test_denied_websocket_is_policy_closed(tmp_path): + from starlette.websockets import WebSocketDisconnect as StarletteWSDisconnect + + app = create_proxy_app( + upstream="http://127.0.0.1:9119", + deny_routes=resolve_deny_routes(deny=["/api/console"]), + deny_log=tmp_path / "denied.log", + ) + with TestClient(app) as client: + with client.websocket_connect("/api/console") as ws: + with pytest.raises(StarletteWSDisconnect) as excinfo: + ws.receive_text() + assert excinfo.value.code == 4403 diff --git a/website/docs/user-guide/remote-access.md b/website/docs/user-guide/remote-access.md new file mode 100644 index 000000000000..4e83e9b8a330 --- /dev/null +++ b/website/docs/user-guide/remote-access.md @@ -0,0 +1,109 @@ +--- +sidebar_position: 5 +--- + +# Remote Access + +Reach your Hermes backend from another machine — the desktop app's remote +gateway mode, the web dashboard from a phone, or any JSON-RPC/WS client — +without exposing more of the machine than the API itself. + +The desktop's remote gateway settings expect "an already-running Hermes +backend on another machine or behind a trusted proxy". This page is that +setup: `hermes serve` (or `hermes dashboard`) stays bound to loopback, and +`hermes dashboard proxy` is the hardened surface you point a tunnel at. + +## Why not tunnel straight to the backend? + +Tunnelling the whole dashboard server over-shares in three ways: + +1. **The SPA HTML embeds the dashboard session token.** Anyone who can load + the page over the tunnel gets the token inlined in the document. The proxy + forwards only `/api/*`; the SPA and static assets 404. +2. **Some API routes are machine-lifecycle operations.** A remote surface + that can trigger `hermes update`, stop gateways, or download a full backup + turns a leaked URL into a bricked machine or a one-request exfiltration of + your entire `HERMES_HOME`. The proxy denies those routes by default. +3. **No attribution.** The proxy audit-logs every denied request to + `~/.hermes/logs/remote-proxy-denied.log` so a surprise attempt has a + timestamp and source. + +Authentication is unchanged: every forwarded request still hits the backend's +own session-token or OAuth checks. The proxy reduces surface; it does not +replace auth, and it must not be your only line of defence. + +## Setup + +Run the backend and the proxy on the machine that hosts Hermes: + +```bash +# 1. The backend, loopback-only (the default). `hermes dashboard` works too. +hermes serve --port 9119 + +# 2. The hardened remote surface. +hermes dashboard proxy --port 9123 --upstream http://127.0.0.1:9119 +``` + +Then point your tunnel at the proxy, never at the backend directly: + +```bash +# Cloudflare tunnel +cloudflared tunnel --url http://127.0.0.1:9123 + +# Tailscale (tailnet-only) +tailscale serve 9123 + +# Plain SSH reverse tunnel +ssh -N -R 9123:127.0.0.1:9123 you@your-vps +``` + +In the desktop app, set the remote gateway URL to the tunnel hostname and +sign in as usual — token and OAuth flows pass through the proxy untouched, +WebSockets included. + +## Denied routes + +By default the proxy blocks the routes whose blast radius is the machine, not +the conversation: + +| Route | Why it is denied remotely | +|-------|---------------------------| +| `/api/hermes/update` | Spawns a self-update that restarts backends with nobody at the machine | +| `/api/gateway/start` `stop` `restart` `drain` | Lifecycle control of the machine's gateways | +| `/api/ops/backup`, `/api/ops/backup/download` | Creates, then serves, a zip of the entire `HERMES_HOME` | +| `/api/ops/import`, `/api/ops/import-upload` | Restores an uploaded archive over the live `HERMES_HOME` | +| `/api/ops/config-migrate` | Rewrites config on disk | + +All of them remain available from localhost surfaces. Read-only companions +(for example `/api/hermes/update/check`) are not blocked. + +To change the policy per invocation: + +```bash +# Re-enable a route remotely (an explicit operator decision): +hermes dashboard proxy --allow-route /api/gateway/restart + +# Block something extra: +hermes dashboard proxy --deny-route /api/sessions/import +``` + +Denied requests return `403` with a one-line body and are appended to +`~/.hermes/logs/remote-proxy-denied.log`: + +``` +2026-07-18T06:10:54+10:00 DENIED POST /api/hermes/update client=127.0.0.1 +``` + +## Operational notes + +- Keep both the backend and the proxy on `127.0.0.1`. The tunnel is the only + thing that should reach the proxy; nothing should reach the backend but the + proxy and local clients. +- The proxy is stateless: restart it freely, run it under launchd/systemd + alongside the gateway, or only when travelling. +- WebSocket routes (`/api/ws`, `/api/events`, `/api/console`, `/api/pty`) + pass through with the same deny-list applied. If you do not want a remote + terminal at all, add `--deny-route /api/pty`. +- A denied route is exact-path. The deny-list is a safety net for the + machine-lifecycle surface, not a substitute for keeping your tunnel + authenticated (Cloudflare Access, Tailscale ACLs, or SSH). From 5a4161333370ef8f98f09a51988cf1aa06708971 Mon Sep 17 00:00:00 2001 From: joelbrilliant Date: Sun, 19 Jul 2026 13:32:10 +1000 Subject: [PATCH 2/2] fix(dashboard): harden remote proxy token topology Address hermes-sweeper review on #66498: - Document Desktop token mode as the supported API-only topology and keep browser OAuth routes closed. - Preserve repeated upstream response headers and audit denied WebSocket upgrades. - Add a production-shaped regression against the real loopback backend token gate. - Wire the proxy handler into existing parser test harnesses to fix the seven CI failures. --- hermes_cli/remote_proxy.py | 35 +++++++++++----- hermes_cli/subcommands/dashboard.py | 11 +++--- tests/hermes_cli/test_remote_proxy.py | 46 +++++++++++++++++++++- tests/hermes_cli/test_serve_command.py | 5 +++ tests/hermes_cli/test_subcommands_batch.py | 15 +++++-- website/docs/user-guide/remote-access.md | 36 +++++++++++------ 6 files changed, 119 insertions(+), 29 deletions(-) diff --git a/hermes_cli/remote_proxy.py b/hermes_cli/remote_proxy.py index 93e37f2a3e4f..4f52408402a8 100644 --- a/hermes_cli/remote_proxy.py +++ b/hermes_cli/remote_proxy.py @@ -1,7 +1,7 @@ """``hermes dashboard proxy`` — a hardened API-only reverse proxy for remote access. -The desktop app's remote-gateway mode and the web dashboard both speak to the -backend over ``/api/*``. Exposing the whole dashboard server to a tunnel or +The desktop app's remote-gateway mode speaks to the backend over ``/api/*``. +Exposing the whole dashboard server to a tunnel or reverse proxy therefore over-shares: the SPA HTML embeds the dashboard session token, and several ``/api`` routes perform machine-lifecycle operations (update, gateway restart, backup download) that are safe from localhost but @@ -21,10 +21,12 @@ attribution; - strips hop-by-hop headers in both directions. -It deliberately does NOT do authentication: the upstream dashboard server -keeps enforcing its own session-token / OAuth auth on every ``/api`` route. -The proxy reduces surface; it does not replace auth. Bind it to loopback and -point a tunnel (Cloudflare, Tailscale funnel, SSH -R, ...) at it. +It deliberately does NOT duplicate authentication: the loopback dashboard +server keeps enforcing its session token on every protected HTTP and WebSocket +route. Browser OAuth is intentionally unavailable because the proxy does not +forward ``/login`` or ``/auth/callback``. Bind it to loopback, configure a fixed +``HERMES_DASHBOARD_SESSION_TOKEN`` for the backend, and point a tunnel +(Cloudflare, Tailscale funnel, SSH -R, ...) at the proxy. """ # NOTE: no `from __future__ import annotations` here. The FastAPI handlers @@ -200,11 +202,18 @@ async def _body(): finally: await upstream_response.aclose() - return StreamingResponse( + response = StreamingResponse( _body(), status_code=upstream_response.status_code, - headers=dict(filtered_headers(upstream_response.headers.items())), ) + # Starlette's mapping-style ``headers=`` argument collapses repeated + # fields. Preserve the raw list so multiple Set-Cookie values and other + # repeatable end-to-end headers survive the proxy boundary. + response.raw_headers = [ + (name.lower().encode("latin-1"), value.encode("latin-1")) + for name, value in filtered_headers(upstream_response.headers.multi_items()) + ] + return response @app.api_route( "/{path:path}", @@ -230,7 +239,15 @@ async def _proxy(request: Request, path: str): async def _proxy_ws(websocket: WebSocket, path: str): import websockets as ws_client - if classify_request("/" + path, deny_routes) != "forward": + verdict = classify_request("/" + path, deny_routes) + if verdict != "forward": + if verdict == "deny": + client_host = websocket.client.host if websocket.client else "?" + if deny_log is not None: + _audit_denied(deny_log, "WS", "/" + path, client_host) + logger.warning( + "remote-proxy denied WS /%s from %s", path, client_host + ) # 4403: policy close. Accept first so the close frame is delivered. await websocket.accept() await websocket.close(code=4403) diff --git a/hermes_cli/subcommands/dashboard.py b/hermes_cli/subcommands/dashboard.py index 8016725a0546..b84fe6169ee8 100644 --- a/hermes_cli/subcommands/dashboard.py +++ b/hermes_cli/subcommands/dashboard.py @@ -208,7 +208,8 @@ def build_dashboard_parser( # Hermes backend ... behind a trusted proxy"; this IS that proxy: only # /api/* is forwarded (the SPA HTML, which inlines the session token, # never crosses the tunnel), lifecycle routes are denied by default, and - # denials are audit-logged. Auth stays enforced by the upstream server. + # denials are audit-logged. The loopback backend's session token remains + # authoritative; browser OAuth routes are intentionally not exposed. dashboard_proxy_parser = dashboard_subparsers.add_parser( "proxy", help="Run a hardened API-only reverse proxy for remote access", @@ -216,10 +217,10 @@ def build_dashboard_parser( "Expose the local Hermes backend to a tunnel safely: forwards only " "/api/* (HTTP + WebSocket), denies machine-lifecycle routes " "(update, gateway start/stop/restart, backup/import) by default, " - "and audit-logs denied requests. The upstream server keeps " - "enforcing its own session-token/OAuth auth — this proxy reduces " - "surface, it does not replace auth. Point your tunnel (Cloudflare, " - "Tailscale, SSH -R) at this listener." + "and audit-logs denied requests. The loopback backend keeps " + "enforcing its session token. Browser OAuth routes are not " + "exposed. Point your tunnel (Cloudflare, Tailscale, SSH -R) at " + "this listener." ), ) dashboard_proxy_parser.add_argument( diff --git a/tests/hermes_cli/test_remote_proxy.py b/tests/hermes_cli/test_remote_proxy.py index 183bb511d78d..ab3aedbbc4a4 100644 --- a/tests/hermes_cli/test_remote_proxy.py +++ b/tests/hermes_cli/test_remote_proxy.py @@ -45,6 +45,8 @@ def test_lifecycle_routes_denied_by_default(self, path): "/index.html", "/assets/index.js", "/health", + "/login", + "/auth/callback", "/apiary", # prefix trick must not match /api ]) def test_non_api_is_not_found(self, path): @@ -106,7 +108,12 @@ def _upstream(request: httpx.Request) -> httpx.Response: return httpx.Response( 200, json={"ok": True}, - headers={"X-Upstream": "yes", "Connection": "keep-alive"}, + headers=[ + ("X-Upstream", "yes"), + ("Set-Cookie", "access=one; Path=/"), + ("Set-Cookie", "refresh=two; Path=/"), + ("Connection", "keep-alive"), + ], ) real_async_client = httpx.AsyncClient @@ -127,6 +134,10 @@ def _mock_client(*args, **kwargs): assert response.status_code == 200 assert response.json() == {"ok": True} assert response.headers["X-Upstream"] == "yes" + assert response.headers.get_list("set-cookie") == [ + "access=one; Path=/", + "refresh=two; Path=/", + ] # Hop-by-hop from upstream must not be forwarded back. assert "connection" not in {k.lower() for k in response.headers} # The upstream saw the pass-through auth header and the query string. @@ -134,6 +145,34 @@ def _mock_client(*args, **kwargs): assert str(seen[0].url).endswith("/api/sessions?limit=5") +def test_proxy_preserves_real_loopback_backend_token_auth(tmp_path, monkeypatch): + import hermes_cli.web_server as web_server + + real_async_client = httpx.AsyncClient + + def _in_process_backend(*args, **kwargs): + kwargs["transport"] = httpx.ASGITransport(app=web_server.app) + return real_async_client(*args, **kwargs) + + monkeypatch.setattr(httpx, "AsyncClient", _in_process_backend) + monkeypatch.setattr(web_server.app.state, "auth_required", False, raising=False) + monkeypatch.setattr(web_server.app.state, "bound_host", "127.0.0.1", raising=False) + + app = create_proxy_app( + upstream="http://127.0.0.1:9119", + deny_log=tmp_path / "denied.log", + ) + with TestClient(app) as client: + denied = client.get("/api/config/raw") + allowed = client.get( + "/api/config/raw", + headers={web_server._SESSION_HEADER_NAME: web_server._SESSION_TOKEN}, + ) + + assert denied.status_code == 401 + assert allowed.status_code == 200 + + def test_denied_route_is_403_and_audited(tmp_path, monkeypatch): @@ -180,6 +219,8 @@ def _mock_client(*args, **kwargs): assert client.get("/").status_code == 404 assert client.get("/index.html").status_code == 404 assert client.get("/assets/index-abc.js").status_code == 404 + assert client.get("/login").status_code == 404 + assert client.get("/auth/callback").status_code == 404 assert called == [], "the SPA and static assets must never cross the proxy" @@ -196,3 +237,6 @@ def test_denied_websocket_is_policy_closed(tmp_path): with pytest.raises(StarletteWSDisconnect) as excinfo: ws.receive_text() assert excinfo.value.code == 4403 + + logged = (tmp_path / "denied.log").read_text(encoding="utf-8") + assert "DENIED WS /api/console" in logged diff --git a/tests/hermes_cli/test_serve_command.py b/tests/hermes_cli/test_serve_command.py index 911b0db95834..b2781590317f 100644 --- a/tests/hermes_cli/test_serve_command.py +++ b/tests/hermes_cli/test_serve_command.py @@ -24,12 +24,17 @@ def _register(args): return args +def _proxy(args): + return args + + def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser() build_dashboard_parser( parser.add_subparsers(dest="command"), cmd_dashboard=_dash, cmd_dashboard_register=_register, + cmd_dashboard_proxy=_proxy, ) return parser diff --git a/tests/hermes_cli/test_subcommands_batch.py b/tests/hermes_cli/test_subcommands_batch.py index d4ec37b6f3c7..56dc2fcea882 100644 --- a/tests/hermes_cli/test_subcommands_batch.py +++ b/tests/hermes_cli/test_subcommands_batch.py @@ -105,15 +105,24 @@ def test_config_get_unset_subcommands_parse(): assert ns.key == "terminal.backend" -def test_dashboard_builder_two_handlers(): +def test_dashboard_builder_three_handlers(): parser = argparse.ArgumentParser(prog="hermes") sub = parser.add_subparsers(dest="command") - dash, reg = _h("dashboard"), _h("dashboard_register") - build_dashboard_parser(sub, cmd_dashboard=dash, cmd_dashboard_register=reg) + dash = _h("dashboard") + reg = _h("dashboard_register") + proxy = _h("dashboard_proxy") + build_dashboard_parser( + sub, + cmd_dashboard=dash, + cmd_dashboard_register=reg, + cmd_dashboard_proxy=proxy, + ) # bare dashboard -> launch handler assert parser.parse_args(["dashboard"]).func is dash # dashboard register -> register handler assert parser.parse_args(["dashboard", "register"]).func is reg + # dashboard proxy -> proxy handler + assert parser.parse_args(["dashboard", "proxy"]).func is proxy # ── deprecated `hermes login` fails gracefully, not with argparse error ──── diff --git a/website/docs/user-guide/remote-access.md b/website/docs/user-guide/remote-access.md index 4e83e9b8a330..4c4cc00de82d 100644 --- a/website/docs/user-guide/remote-access.md +++ b/website/docs/user-guide/remote-access.md @@ -4,9 +4,9 @@ sidebar_position: 5 # Remote Access -Reach your Hermes backend from another machine — the desktop app's remote -gateway mode, the web dashboard from a phone, or any JSON-RPC/WS client — -without exposing more of the machine than the API itself. +Reach your Hermes backend from another machine using the desktop app's remote +gateway mode or another API client, without exposing more of the machine than +the API itself. The desktop's remote gateway settings expect "an already-running Hermes backend on another machine or behind a trusted proxy". This page is that @@ -28,19 +28,24 @@ Tunnelling the whole dashboard server over-shares in three ways: `~/.hermes/logs/remote-proxy-denied.log` so a surprise attempt has a timestamp and source. -Authentication is unchanged: every forwarded request still hits the backend's -own session-token or OAuth checks. The proxy reduces surface; it does not -replace auth, and it must not be your only line of defence. +Authentication is unchanged: every protected forwarded request still hits the +loopback backend's session-token checks. The proxy reduces surface and does not +replace that token gate. Browser OAuth is intentionally unavailable because +`/login` and `/auth/callback` do not cross this API-only proxy. ## Setup Run the backend and the proxy on the machine that hosts Hermes: ```bash -# 1. The backend, loopback-only (the default). `hermes dashboard` works too. +# 1. Generate a fixed backend session token in this shell. +export HERMES_DASHBOARD_SESSION_TOKEN="$(python -c 'print(__import__("secrets").token_urlsafe(32))')" +printf '%s\n' "$HERMES_DASHBOARD_SESSION_TOKEN" + +# 2. Start the loopback-only backend. `hermes dashboard` works too. hermes serve --port 9119 -# 2. The hardened remote surface. +# 3. In another terminal, start the remote surface. hermes dashboard proxy --port 9123 --upstream http://127.0.0.1:9119 ``` @@ -57,9 +62,16 @@ tailscale serve 9123 ssh -N -R 9123:127.0.0.1:9123 you@your-vps ``` -In the desktop app, set the remote gateway URL to the tunnel hostname and -sign in as usual — token and OAuth flows pass through the proxy untouched, -WebSockets included. +In the desktop app, select token authentication, set the remote gateway URL to +the tunnel hostname, and enter the value of +`HERMES_DASHBOARD_SESSION_TOKEN`. Desktop sends it in +`X-Hermes-Session-Token` for HTTP and `?token=` for WebSockets. Missing or +incorrect tokens are rejected by the loopback backend after passing through +the proxy. + +This API-only topology does not support the browser dashboard or browser OAuth. +Use the normal authenticated non-loopback dashboard deployment when those +surfaces are required. ## Denied routes @@ -101,6 +113,8 @@ Denied requests return `403` with a one-line body and are appended to proxy and local clients. - The proxy is stateless: restart it freely, run it under launchd/systemd alongside the gateway, or only when travelling. +- Keep `HERMES_DASHBOARD_SESSION_TOKEN` secret and stable across backend + restarts. Changing it requires updating the saved token in Desktop. - WebSocket routes (`/api/ws`, `/api/events`, `/api/console`, `/api/pty`) pass through with the same deny-list applied. If you do not want a remote terminal at all, add `--deny-route /api/pty`.