diff --git a/cli.py b/cli.py index f30a82c..3527cfa 100644 --- a/cli.py +++ b/cli.py @@ -170,6 +170,12 @@ def setup_node_cli(subparser: argparse.ArgumentParser) -> None: help="Show whether the WSS node server is running.", ) + # --- restart ----------------------------------------------------------- + subs.add_parser( + "restart", + help="Drain and restart the WSS server to pick up a new token.", + ) + subparser.set_defaults(func=node_command) @@ -203,6 +209,8 @@ def node_command(args: argparse.Namespace) -> int: return _cmd_revoke(args) if action == "status": return _cmd_status() + if action == "restart": + return _cmd_restart() except TokenStoreError as exc: # FR-4.2: surface the operator error message verbatim. Most # common is the missing-Fernet-key case on a fresh install. @@ -429,6 +437,49 @@ def _cmd_status(args: argparse.Namespace | None = None) -> int: return 1 +def _cmd_restart(args: argparse.Namespace | None = None) -> int: + """POST /admin/restart to drain and restart the WSS server in-place. + + The server re-reads the internal auth token from + ``~/.hermes/nodes-internal-token`` during restart, so manual token + edits take effect without restarting the gateway. + """ + import httpx + + config = load_config() + url = f"http://{config.connect_host}:{config.port}/admin/restart" + + # Read the current token to authenticate. + from .tools import _read_internal_token + + token = _read_internal_token() + if not token: + print("error: server not ready (no internal token found)", file=sys.stderr) + return 1 + + try: + with httpx.Client(timeout=10.0) as client: + resp = client.post(url, headers={"Authorization": f"Bearer {token}"}) + except httpx.ConnectError: + print( + f"error: cannot reach server at {config.connect_host}:{config.port}", + file=sys.stderr, + ) + return 1 + except Exception as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + if resp.status_code == 200: + data = resp.json() + print(f"restarted — listening on port {data.get('port')}") + return 0 + else: + detail = resp.json().get("detail", resp.text) + print(f"error: {resp.status_code} — {detail}", file=sys.stderr) + return 1 + + # --------------------------------------------------------------------------- # Helpers (testable in isolation) # --------------------------------------------------------------------------- diff --git a/lifecycle.py b/lifecycle.py index ffd2ec2..bdf6c79 100644 --- a/lifecycle.py +++ b/lifecycle.py @@ -268,42 +268,38 @@ async def _serve() -> None: f"{self._config.port} within 5s" ) - # Token setup: generate if we bound the port, read existing if - # another gateway already owns it. This must happen AFTER the - # bind-wait so we don't overwrite the token when the port is - # already occupied by the default gateway. + # Token setup: always read the existing file first so manual + # edits to the token are picked up on restart. Only generate a + # fresh token when the file is missing or empty AND we own the + # port (so we have the authority to mint new credentials). from .server import _ensure_internal_token, _internal_token_path - if self._server.started: - token_path = _internal_token_path() + token_path = _internal_token_path() + existing: str | None = None + try: + raw = token_path.read_text().strip() + if raw: + existing = raw.partition("\n")[0] + except (FileNotFoundError, OSError): + pass + + if existing: + self._app.state.internal_token = existing + logger.info( + "hermes-node: using existing token from %s", token_path + ) + elif self._server.started: logger.info( - "hermes-node: generating internal auth token at %s", - token_path, + "hermes-node: no token at %s — generating new one", token_path ) self._app.state.internal_token = _ensure_internal_token() logger.info("hermes-node: internal auth token generated") else: - token_path = _internal_token_path() - try: - existing = token_path.read_text().strip().partition("\n")[0] - if existing: - self._app.state.internal_token = existing - logger.info( - "hermes-node: using existing token from %s", - token_path, - ) - else: - logger.warning( - "hermes-node: token file %s is empty — " - "internal endpoints will return 503", - token_path, - ) - except (FileNotFoundError, OSError): - logger.warning( - "hermes-node: cannot read token from %s — " - "internal endpoints will return 503", - token_path, - ) + logger.warning( + "hermes-node: no token at %s and port %d is occupied — " + "internal endpoints will return 503", + token_path, self._config.port, + ) if not self._server.started and self._server.should_exit: # Server couldn't start (EADDRINUSE, etc.). Surface the @@ -323,6 +319,10 @@ async def _serve() -> None: self._server = None return + # Store the runner on the FastAPI app so the + # ``POST /admin/restart`` endpoint can drain + restart. + self._app.state.runner = self + logger.info( "hermes-node WSS server listening on %s:%d", self._config.host, diff --git a/wsserver/server.py b/wsserver/server.py index 06b12ad..8badf35 100644 --- a/wsserver/server.py +++ b/wsserver/server.py @@ -789,6 +789,37 @@ async def nodes_write( except Exception as e: return {"status": "error", "code": 500, "reason": str(e)} + # -- Admin restart endpoint ------------------------------------------ + + @app.post("/admin/restart") + async def admin_restart( + _: None = Depends(_verify_internal_auth), + ) -> dict[str, Any]: + """Drain and restart the WSS server in-place. + + Re-reads the internal auth token from disk, so manual edits to + ``~/.hermes/nodes-internal-token`` take effect without a full + gateway restart. WebSocket connections are closed during drain. + """ + runner = getattr(app.state, "runner", None) + if runner is None: + raise HTTPException( + status_code=503, detail="Server runner not available" + ) + if not runner.is_running: + raise HTTPException( + status_code=409, detail="Server is not running" + ) + try: + await runner.drain(timeout=5.0) + await runner.start() + except Exception as exc: + logger.error("hermes-node: admin restart failed: %s", exc) + raise HTTPException( + status_code=500, detail=f"Restart failed: {exc}" + ) from exc + return {"status": "restarted", "port": runner.port} + return app