From c8af746c846b5dbc792f7e4f0d806647d513cc5c Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 20 Aug 2026 08:56:23 +0200 Subject: feat(node): full Node admin panel — CLI parity, hot-reload, group lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node admin panel (NodePage) now covers every CLI operation over MNP: group attach/detach, roster, member unpin, GEK rotate, denylist, reload. Daemon hot-loads new groups and tears down removed ones on config reload instead of requiring a full restart. Group attach/detach via MNP or local API triggers an automatic reload so the group is live immediately. Fixed GroupPage hang on first visit to a newly created group: the JWT issued at login didn't include the new group, the node rejected with not_a_member, and the token-refresh path returned without re-triggering the connect effect (Boolean(token) didn't change). Now bumps retryKey after a successful refresh so the effect re-runs with the fresh token. NodePage marks groups hosted by the node but absent from the hub with a "not on hub" badge so stale groups are visible and easy to remove. Co-Authored-By: Claude Opus 4.6 --- packages/meshbay-node/src/meshbay_node/daemon.py | 207 ++++++++++++++++++++--- 1 file changed, 184 insertions(+), 23 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 6d4f172..7e3ebc1 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -111,6 +111,7 @@ class NodeDaemon: "quic_port": config.node.quic_port, "endpoint_hint": None, "indexes": {}, + "indexers": {}, } self._quic_server = None self._webrtc = None @@ -243,6 +244,7 @@ class NodeDaemon: await indexer.start() self._indexers.append(indexer) self._state["indexes"][group_cfg.id] = indexer.index + self._state["indexers"][group_cfg.id] = indexer log.info("Indexing group %s: %s (%d files)", group_cfg.name, ", ".join(f"{r.name}={r.path}" for r in roots), @@ -416,12 +418,15 @@ class NodeDaemon: self._state["webrtc"] = self._webrtc self._state["quic_server"] = self._quic_server self._state["hub"] = hub + self._state["reload_fn"] = self._reload_config # Rotating a key has to reach every transport holding a copy of it, # and clearing the denylist has to reach the one the handshake # consults — so both are published rather than reachable only # through the object that happens to own them. self._state["denylist"] = self._denylist self._state["pk_x25519_raw"] = pk_x_raw + self._state["sk_x25519_raw"] = sk_x_raw + self._state["sk_ed25519"] = keys.sk_ed25519 self._state["status"] = "running" log.info("Node ready — %d groups, WebRTC=%s, QUIC=%s", @@ -460,19 +465,13 @@ class NodeDaemon: async def _reload_config(self) -> None: """ - Re-read node.toml on SIGHUP. + Re-read node.toml and reconcile groups. - Deliberately narrow: it picks up **root changes on groups already - hosted**, which is what an operator adjusts day to day, and reports - anything else as needing a restart. Adding or removing a whole group - means new indexers, chat stores, GEK loads and transport contexts, and - doing that under a live daemon is how a half-built group ends up serving - content. Saying "restart for that" is honest and costs one restart. - - Nothing here touches connections: a member watching a film keeps - watching it. + Handles root changes on existing groups, hot-loads new groups, and + tears down removed groups. Existing connections are untouched: a member + watching a film keeps watching it. """ - log.info("SIGHUP — re-reading %s", self._config_path) + log.info("Reloading config from %s", self._config_path) try: fresh = load_config(self._config_path) except Exception as e: @@ -482,13 +481,8 @@ class NodeDaemon: groups_ctx = self._state.get("groups_ctx") or {} hosted = set(groups_ctx) incoming = {g.id for g in fresh.groups if g.id} - if incoming != hosted: - added = ", ".join(sorted(incoming - hosted)) or "none" - removed = ", ".join(sorted(hosted - incoming)) or "none" - log.warning("Group set changed (added: %s, removed: %s) — restart the " - "daemon for that; roots of existing groups reloaded anyway", - added, removed) + # ── Root changes on existing groups ────────────────────────────── changed = 0 for group_cfg in fresh.groups: ctx = groups_ctx.get(group_cfg.id) @@ -515,9 +509,112 @@ class NodeDaemon: ctx["roots"] = roots changed += 1 + # ── Hot-load new groups ────────────────────────────────────────── + added_names = [] + sk_ed = self._state.get("sk_ed25519") + sk_x_raw = self._state.get("sk_x25519_raw") + pk_x_raw = self._state.get("pk_x25519_raw") + node_user_id = self._state.get("node_user_id") + data_dir = fresh.data_dir + + for group_cfg in fresh.groups: + if group_cfg.id in hosted: + continue + if not group_cfg.id or not group_cfg.roots: + log.warning("New group %r has no id or roots — skipping", + group_cfg.name) + continue + if not sk_ed: + log.warning("Cannot hot-load %r — signing key not available", + group_cfg.name) + continue + + try: + roots = RootSet.build([asdict(r) for r in group_cfg.roots]) + except RootError as e: + log.error("New group %r: %s — skipping", group_cfg.name, e) + continue + roots.refresh_availability() + + gek = None + if group_cfg.visibility == "private" and sk_x_raw and pk_x_raw: + gek = await self._load_gek( + group_cfg.id, node_user_id, sk_x_raw, pk_x_raw) + if gek: + log.info("GEK loaded for new group %s", group_cfg.id[:8]) + + indexer = DirectoryIndexer( + roots=roots, + group_id=group_cfg.id, + sk_node=sk_ed, + gek=gek, + on_change=self._on_index_change, + ) + await indexer.start() + self._indexers.append(indexer) + self._state["indexes"][group_cfg.id] = indexer.index + self._state["indexers"][group_cfg.id] = indexer + + data_dir.mkdir(parents=True, exist_ok=True) + chat_db = data_dir / group_cfg.id[:16] / "chat.db" + store = ChatStore(db_path=chat_db) + await store.open() + self._chat_stores[group_cfg.id] = store + + new_ctx = { + "gek": gek, + "roots": roots, + "index": indexer.index, + "visibility": group_cfg.visibility, + "join_policy": group_cfg.join_policy, + "member_upload": ( + await self._roster.member_upload_allowed(group_cfg.id) + if self._roster else True), + "chat_store": store, + } + groups_ctx[group_cfg.id] = new_ctx + + if self._webrtc: + self._webrtc._ctx["groups"][group_cfg.id] = new_ctx + log.info("Hot-loaded group %s (%s, %d roots)", + group_cfg.name, group_cfg.id[:8], len(roots)) + added_names.append(group_cfg.name) + + # ── Tear down removed groups ───────────────────────────────────── + removed_names = [] + for gid in hosted - incoming: + indexer = next((i for i in self._indexers + if i.group_id == gid), None) + if indexer: + try: + await indexer.stop() + except Exception: + pass + self._indexers.remove(indexer) + store = self._chat_stores.pop(gid, None) + if store: + try: + await store.close() + except Exception: + pass + self._state["indexes"].pop(gid, None) + self._state["indexers"].pop(gid, None) + old_name = gid[:8] + for g_cfg in self._config.groups: + if g_cfg.id == gid: + old_name = g_cfg.name + break + groups_ctx.pop(gid, None) + if self._webrtc and self._webrtc._ctx.get("groups") is not groups_ctx: + self._webrtc._ctx["groups"].pop(gid, None) + log.info("Unloaded group %s (%s)", old_name, gid[:8]) + removed_names.append(old_name) + self._config = fresh self._state["config"] = fresh - log.info("Reload complete — %d group(s) re-rooted", changed) + self._state["groups"] = [g.name for g in fresh.groups] + log.info("Reload complete — %d re-rooted, %d added, %d removed", + changed, len(added_names), len(removed_names)) async def _login_with_retry(self, hub: HubClient): """Login to hub, retrying if the node key hasn't been linked yet.""" @@ -782,16 +879,18 @@ def main() -> None: parser.add_argument("command", nargs="?", choices=["init", "status", "ui", "gek-init", "gek", "operator", "member", "group", "file", - "denylist", "reload", "calibrate-argon2"], + "denylist", "reload", "restart-daemon", + "calibrate-argon2"], help="init: write example config | status: node state and keys " "| ui: print the admin UI URL | operator pair: pair a " "browser with this node | member list|invite|revoke|unpin " - "| group list|add | gek init|rotate | file list|rm " + "| group list|add|remove | gek init|rotate | file list|rm " "| denylist show|clear | reload: re-read node.toml " + "| restart-daemon: full stop + start " "| calibrate-argon2: benchmark") parser.add_argument("subcommand", nargs="?", help="'pair' for operator; list|invite|revoke|unpin for " - "member; list|add for group; init|rotate for gek; " + "member; list|add|remove for group; init|rotate for gek; " "list|rm for file; show|clear for denylist") parser.add_argument("target", nargs="?", help="username for member invite|revoke|unpin; group name " @@ -813,7 +912,8 @@ def main() -> None: # Query commands print a report; library logging would interleave with it. quiet = args.command in ("status", "ui", "gek-init", "gek", "operator", - "member", "group", "file", "denylist", "reload") + "member", "group", "file", "denylist", "reload", + "restart-daemon") logging.basicConfig( level=logging.ERROR if quiet else getattr(logging, args.log_level), format="%(asctime)s %(levelname)-8s %(name)s: %(message)s", @@ -1051,6 +1151,49 @@ def main() -> None: print("watch the result: tail -f /tmp/meshbay-node.log") return + if args.command == "restart-daemon": + import os as _os + import signal as _signal + import subprocess as _subprocess + cfg = load_config(args.config or DEFAULT_CONFIG_PATH) + pid_out = _subprocess.run( + ["pgrep", "-f", "--", r"-m meshbay_node\.daemon$"], + capture_output=True, text=True) + pids = [int(x) for x in pid_out.stdout.split()] + if pids: + for pid in pids: + _os.kill(pid, _signal.SIGTERM) + print(f"stopped {len(pids)} daemon process(es)") + for pid in pids: + try: + _os.waitpid(pid, 0) + except ChildProcessError: + import time as _time + _time.sleep(2) + else: + print("no running daemon found — starting fresh") + log_path = "/tmp/meshbay-node.log" + config_flag = ["--config", str(args.config)] if args.config else [] + _subprocess.Popen( + [sys.executable, "-m", "meshbay_node.daemon"] + config_flag, + stdout=open(log_path, "a"), + stderr=_subprocess.STDOUT, + start_new_session=True, + ) + import time as _time + _time.sleep(3) + pid_out2 = _subprocess.run( + ["pgrep", "-f", "--", r"-m meshbay_node\.daemon$"], + capture_output=True, text=True) + new_pids = [int(x) for x in pid_out2.stdout.split()] + if new_pids: + print(f"daemon started (PID {new_pids[0]})") + print(f"log: tail -f {log_path}") + else: + print(f"daemon may have failed to start — check {log_path}") + sys.exit(1) + return + if args.command == "denylist": cfg = load_config(args.config or DEFAULT_CONFIG_PATH) sub = args.subcommand or "show" @@ -1159,8 +1302,26 @@ def main() -> None: f"--group {g['name']}") return + if args.subcommand == "remove": + if not args.target: + print("usage: meshbay-node group remove ") + sys.exit(1) + cfg = load_config(args.config or DEFAULT_CONFIG_PATH) + if not args.yes: + answer = input(f"Remove group '{args.target}' from this node? [y/N] ") + if answer.lower() not in ("y", "yes"): + print("cancelled") + return + out = _daemon_api(cfg, "/api/groups/detach", method="POST", + body={"name": args.target}) + print(f"{out['name']} ({out['group_id'][:8]}) removed from {out['config']}") + print() + print("Restart the daemon to stop hosting it:") + print(" meshbay-node restart-daemon") + return + if args.subcommand != "add": - print("usage: meshbay-node group list|add --dir [--upload-dir ]") + print("usage: meshbay-node group list|add|remove ") sys.exit(1) if not args.target or not args.dir: print("usage: meshbay-node group add --dir [--upload-dir ]") -- cgit v1.2.3