diff --git a/cli.py b/cli.py index 3527cfa..a39cabc 100644 --- a/cli.py +++ b/cli.py @@ -81,6 +81,7 @@ STATE_DISCONNECTED = "disconnected" STATE_NEVER_SEEN = "never_seen" STATE_REVOKED = "revoked" +STATE_UNKNOWN = "unknown" # --------------------------------------------------------------------------- # Argparse setup — bound to ``hermes node `` @@ -357,7 +358,7 @@ def _cmd_list(args: argparse.Namespace) -> int: connected_names = _connected_names() rows = [ - _format_row(rec, rec.name in connected_names) + _format_row(rec, connected_names is not None and rec.name in connected_names) for rec in records ] @@ -366,6 +367,14 @@ def _cmd_list(args: argparse.Namespace) -> int: print(json.dumps(row, sort_keys=True)) return 0 + if connected_names is None: + print( + "warning: cannot query server — connection state shown as 'unknown'. " + "Run `hermes node restart` or restart the gateway if this persists.", + file=sys.stderr, + ) + print() + if not rows: print("no paired nodes. run `hermes node pair --name ` to add one.") return 0 @@ -485,14 +494,19 @@ def _cmd_restart(args: argparse.Namespace | None = None) -> int: # --------------------------------------------------------------------------- -def _format_row(record: TokenRecord, is_connected: bool) -> dict[str, Any]: +def _format_row(record: TokenRecord, is_connected: bool | None) -> dict[str, Any]: """Map a :class:`TokenRecord` + liveness bool to a list row. Pulled out as a free function so the state-derivation logic is testable without spinning up a config / store / registry. + + ``is_connected`` may be ``None`` when the server could not be + queried — the state collapses to ``unknown`` in that case. """ if record.revoked: state = STATE_REVOKED + elif is_connected is None: + state = STATE_UNKNOWN elif is_connected: state = STATE_CONNECTED elif record.last_used_at is None: @@ -508,20 +522,14 @@ def _format_row(record: TokenRecord, is_connected: bool) -> dict[str, Any]: } -def _connected_names() -> set[str]: +def _connected_names() -> set[str] | None: """Return the set of node names with a live connection. The registry is owned by the long-running server; the CLI - command is a short-lived process. We query the server's HTTP - status endpoint at ``/nodes`` rather than maintaining - our own in-process registry — the CLI's registry would be - a fresh empty one that never saw any connections. - - If the server isn't running, the port isn't listening, or - the endpoint fails, we fall back to an empty set. The list - command should still succeed in that case — connection state - just collapses to ``disconnected`` / ``never_seen`` for - every row. + command is a short-lived process. Returns ``None`` when the + server could not be queried (internal auth mismatch, server + not running, timeout) so the caller can distinguish \"no nodes + connected\" from \"don't know\". """ config = load_config() base_url = f"http://{config.connect_host}:{config.port}" @@ -540,8 +548,21 @@ def _connected_names() -> set[str]: with urllib.request.urlopen(req, timeout=2.0) as resp: data = __import__("json").loads(resp.read()) return {n["name"] for n in data.get("nodes", [])} + except urllib.error.HTTPError as exc: + # 401 / 403 — internal auth mismatch. Surface clearly so + # the operator knows `hermes node list` can't see the live + # registry, instead of silently showing everything disconnected. + if exc.code in (401, 403): + print( + f"warning: server returned {exc.code} — " + f"internal auth token mismatch; connection state is unknown. " + f"Restart the gateway to sync tokens.", + file=sys.stderr, + ) + return None except Exception: - return set() + # Server not running, timeout, etc. + return None def main() -> None: diff --git a/tools.py b/tools.py index 26fbe21..6f3f90f 100644 --- a/tools.py +++ b/tools.py @@ -382,6 +382,16 @@ def _node_list_impl( cfg = load_config() status_url = f"http://{cfg.connect_host}:{cfg.port}/nodes" data = _request_with_retry("GET", status_url, timeout=2.0) + # If the server returned an auth error (internal token mismatch), + # surface it clearly instead of silently returning an empty list. + if isinstance(data, dict) and "detail" in data and "nodes" not in data: + detail = data["detail"] + return json.dumps({ + "error": f"cannot query server: {detail}. " + f"Restart the gateway to sync the internal auth token.", + "nodes": [], + "count": 0, + }) connected: list[dict[str, Any]] = data.get("nodes", []) return json.dumps({ "nodes": connected, diff --git a/wsserver/server.py b/wsserver/server.py index 8badf35..7390192 100644 --- a/wsserver/server.py +++ b/wsserver/server.py @@ -32,6 +32,7 @@ from __future__ import annotations import asyncio +import hmac import logging import os import secrets @@ -107,19 +108,37 @@ def _ensure_internal_token() -> str: return token +def _read_token_from_disk() -> str | None: + """Read the internal auth token from the shared token file. + + Reads on every call so manual edits and auto-start regenerations + take effect without a gateway restart. Returns ``None`` when the + file is missing or empty (server not yet initialised). + """ + try: + raw = _internal_token_path().read_text(encoding="utf-8").strip() + except (FileNotFoundError, OSError): + return None + if not raw: + return None + return raw.partition("\n")[0] + + async def _verify_internal_auth(request: Request) -> None: """FastAPI dependency that guards internal HTTP endpoints. - Checks ``Authorization: Bearer `` against the token the - server generated at startup. Returns 401/503 if missing or wrong. + Reads the token from disk on every request so manual edits and + auto-start regenerations take effect without a full gateway restart. + Returns 401/503 if missing or wrong. Uses ``hmac.compare_digest`` + for constant-time comparison. """ - expected: str | None = getattr(request.app.state, "internal_token", None) + expected: str | None = _read_token_from_disk() if expected is None: raise HTTPException(status_code=503, detail="Server not ready") auth_header = request.headers.get("Authorization", "") if not auth_header.startswith("Bearer "): raise HTTPException(status_code=401, detail="Missing or malformed Authorization header") - if auth_header.removeprefix("Bearer ") != expected: + if not hmac.compare_digest(auth_header.removeprefix("Bearer "), expected): raise HTTPException(status_code=401, detail="Invalid token")