From cfc91e0a424163869c64d30e55d55a53f18a3dbf Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Tue, 1 Sep 2026 14:08:32 +0200 Subject: refactor(node): JSON-only control API, Node page absorbs the admin dashboard Remove the node daemon's server-rendered admin UI (GET / and /audit, the _render_* helpers and inline templates) and the `meshbay-node ui` CLI verb. The loopback control API stays; it is now JSON only, ruff-clean, and 453 lines (was 1074). Also drop three never-wired endpoints (/api/config, /api/chat/history, /ws/chat, plus broadcast_chat) and the pointless 18000/tcp firewall profiles. The desktop client's Node page (static/node-page.js) takes over what the dashboard showed, reorganised into six tabs (Overview, Groups, Roster, Peers, Audit, Settings): - Overview: version, node id, QUIC port, hub, index-cache maintenance - Roster: node-wide view with unpin - Peers and Audit: auto-load on open, no Load button - Audit: real usernames and group names (resolved from the roster and node.toml), Previous/Next pagination newest-first, Export CSV of every matching row - Settings: node settings, STUN, ICE, denylist, then Unlink from hub Backend: audit.get_entries gains `offset`; /api/audit and /api/peers resolve ids to names via a new _display_names helper; CSP tightened to default-src 'none' now that no HTML is served. draft-v6 sections 2.11 and 2.12 corrected -- the Node page uses the loopback API, not MNP. One capability is intentionally dropped: browser-based admin on a headless server. The CLI covers every operation there. See docs/refactor-node-ui.md. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MQCaZnde4Bjjdu84dhSuF5 --- packages/meshbay-node/src/meshbay_node/daemon.py | 59 ++++++++++-------------- 1 file changed, 24 insertions(+), 35 deletions(-) (limited to 'packages/meshbay-node/src/meshbay_node/daemon.py') diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index 2130440..056371a 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -12,14 +12,13 @@ Startup sequence: 8. Start QUIC chunk server (LAN / port-forwarded / hub-less direct access) 9. (Phase 11.5: the unauthenticated HTTP file API and the TCP+TLS server were removed) 10. Start hub WebSocket (signaling, revocations, WebRTC offers) - 11. Start local web UI on node.ui_port (localhost only) + 11. Start local control API on node.ui_port (loopback only, token-gated) 12. Run until SIGINT/SIGTERM Usage: meshbay-node # interactive password prompt meshbay-node --config /path # custom config meshbay-node status # node state + public key (works while stopped) - meshbay-node ui # print the local admin UI URL meshbay-node gek-init # initialise the group key (no browser needed) meshbay-node init # write example config + create keystore meshbay-node --calibrate-argon2 # benchmark Argon2id, suggest parameters @@ -201,19 +200,21 @@ class NodeDaemon: ) log.info("Keys loaded: %s", keys.pk_ed25519_b64[:16]) - # 2. Start admin UI early (so operator can copy node key before hub login) + # 2. Start the local control API early (so the operator can read the + # node key before hub login). It is JSON-only, loopback-only, and both + # the CLI and the desktop client's Node page are its clients. self._state["pk_node_ed25519"] = keys.pk_ed25519_b64 self._state["config"] = self._config # Where it came from, so `group add` appends to the file this process # actually read rather than guessing at the default. self._state["config_path"] = str(self._config_path) - # Per-run token for the local admin UI (11.5.3). Not a password: it keeps + # Per-run token for the control API (11.5.3). Not a password: it keeps # other local processes and rebound browser pages out of an API that can # re-initialise group keys. ui_token = base64.urlsafe_b64encode(os.urandom(18)).decode().rstrip("=") self._state["ui_token"] = ui_token - # Persisted so `meshbay-node ui` can open the browser. Nobody should ever - # have to copy a token out of a log or a terminal — that is not a workflow. + # Persisted so the CLI and the desktop client can read it — nobody + # should ever copy a token out of a log or a terminal. self._config.data_dir.mkdir(parents=True, exist_ok=True) self._ui_token_file = self._config.data_dir / "ui-token" self._ui_token_file.write_text(ui_token) @@ -228,7 +229,7 @@ class NodeDaemon: ) ui_server = uvicorn.Server(ui_cfg) self._tasks.append(asyncio.create_task(ui_server.serve())) - log.info("Admin UI ready — open it with: meshbay-node ui") + log.info("Control API on 127.0.0.1:%d", self._config.node.ui_port) # 3. Hub connection (Ed25519 auth — retries until node key is linked) hub_cfg = HubConfig( @@ -415,7 +416,7 @@ class NodeDaemon: } if not groups_ctx: - log.warning("No groups configured yet — admin UI and hub " + log.warning("No groups configured yet — the control API and hub " "connection stay up; attach a group to go live") # 5. Chat stores (one SQLite DB per group) @@ -608,7 +609,7 @@ class NodeDaemon: # authentication, for private groups too — finding C1. Every client path now # goes through the MNP handshake (JWT + group claim + GEK proof). - # 10. Update admin UI state (UI already running from step 2) + # 10. Update control API state (already running from step 2) self._state["groups_ctx"] = groups_ctx self._state["audit_store"] = self._audit_store self._state["bundle_store"] = self._bundle_store @@ -908,17 +909,19 @@ class NodeDaemon: except _httpx.HTTPStatusError as e: body = e.response.text if hasattr(e.response, 'text') else '' # Any 401 here needs a human at a browser, and the operator needs - # this daemon alive to read its public key out of the local admin - # UI. Exiting would take that UI down and strand them — which is - # exactly what happened when a node was started before its owner - # had registered. + # this daemon alive to read its public key (via `meshbay-node + # status` or the desktop client, both of which query the control + # API). Exiting would strand them — which is exactly what + # happened when a node was started before its owner had + # registered. if e.response.status_code == 401: if "No node key" in body: self._state["status"] = "waiting_for_node_key" log.warning( - "Node key not linked. Open the admin UI, copy this " - "node's key, and paste it in Settings > Link Node on " - "%s. Retrying in 5s...", + "Node key not linked. Get it from `meshbay-node " + "status` and paste it in Settings > Link Node on %s " + "(the desktop client links it automatically). " + "Retrying in 5s...", self._config.hub.url, ) else: @@ -1500,8 +1503,8 @@ def _daemon_api(cfg: Config, path: str, method: str = "GET", The daemon owns the roster, the hub session and the live group contexts, so the CLI asks it to act rather than opening its databases behind its back. It - also means every operator action goes through the same authorization as the - admin UI (the per-run session token, 11.5.3). + also means every operator action goes through the control API's per-run + session token (11.5.3), the same gate the desktop client's Node page passes. """ import json as _json import urllib.error @@ -1611,13 +1614,13 @@ def main() -> None: parser = argparse.ArgumentParser(description="MeshBay Node daemon") parser.add_argument("command", nargs="?", - choices=["init", "reset", "status", "ui", "gek-init", + choices=["init", "reset", "status", "gek-init", "gek", "operator", "member", "group", "file", "video", "denylist", "stun", "reload", "restart-daemon", "calibrate-argon2"], help="init: provision config + keystore | reset: erase all " "node state | status: node state and keys " - "| ui: print the admin UI URL | operator pair: pair a " + "| operator pair: pair a " "browser with this node | member list|invite|revoke|unpin " "| group list|add|remove | gek init|rotate | file list|rm " "| video rematch: re-resolve TMDB matches for a group's " @@ -1655,7 +1658,7 @@ def main() -> None: args = parser.parse_args() # Query commands print a report; library logging would interleave with it. - quiet = args.command in ("status", "ui", "gek-init", "gek", "operator", + quiet = args.command in ("status", "gek-init", "gek", "operator", "member", "group", "file", "video", "denylist", "stun", "reload", "restart-daemon", "reset") logging.basicConfig( @@ -1843,7 +1846,6 @@ def main() -> None: print(f"groups {live.get('group_count', 0)}" f" files {live.get('total_files', 0)}" f" peers {live.get('webrtc_peers', 0)}") - print(f"admin UI meshbay-node ui") needs = live.get("needs", []) if needs: @@ -2331,19 +2333,6 @@ def main() -> None: print(f"also written to {path}") return - if args.command == "ui": - cfg = load_config(args.config or DEFAULT_CONFIG_PATH) - token_file = cfg.data_dir / "ui-token" - if not token_file.exists(): - print("Node does not appear to be running — start it with: meshbay-node") - sys.exit(1) - print(f"http://127.0.0.1:{cfg.node.ui_port}" - f"/?t={token_file.read_text().strip()}") - print() - print("The UI listens on loopback only. From another machine:") - print(f" ssh -L {cfg.node.ui_port}:127.0.0.1:{cfg.node.ui_port} ") - return - cfg = load_config(args.config or DEFAULT_CONFIG_PATH) if not cfg.hub.username: print("Error: hub.username not set in config. Run: meshbay-node init") -- cgit v1.2.3