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
51 changes: 51 additions & 0 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
# ---------------------------------------------------------------------------
Expand Down
58 changes: 29 additions & 29 deletions lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
31 changes: 31 additions & 0 deletions wsserver/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
Loading