Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 35 additions & 14 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
STATE_DISCONNECTED = "disconnected"
STATE_NEVER_SEEN = "never_seen"
STATE_REVOKED = "revoked"
STATE_UNKNOWN = "unknown"

# ---------------------------------------------------------------------------
# Argparse setup — bound to ``hermes node <subcommand>``
Expand Down Expand Up @@ -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
]

Expand All @@ -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 <name>` to add one.")
return 0
Expand Down Expand Up @@ -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:
Expand All @@ -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}"
Expand All @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
27 changes: 23 additions & 4 deletions wsserver/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from __future__ import annotations

import asyncio
import hmac
import logging
import os
import secrets
Expand Down Expand Up @@ -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 <token>`` 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")


Expand Down
Loading