diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-13 19:31:29 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-13 19:31:29 +0200 |
| commit | 3e9c94a420b6c427eff4ee13aac80cb1430d7a7c (patch) | |
| tree | 2b8032996f4973e93ef829f9d97eebfaeeb581f9 /packages/meshbay-node/src/meshbay_node/daemon.py | |
| parent | 78639260932dbd679d0de1f3132c645440962f46 (diff) | |
| download | meshbay-3e9c94a420b6c427eff4ee13aac80cb1430d7a7c.tar.gz | |
feat(node): CLI for headless operators — status, ui, gek-init
Every operator action lived behind a web UI on the node's own loopback
interface. For the normal deployment — a node on a server reached over SSH —
that is unusable: no browser on the host, and 11.5.3 added a per-run token that
had to be copied out of a log to get in.
status hub, node public key, daemon state, groups, admin-key pinning.
Reads the keystore directly so it works while the daemon is STOPPED,
which is exactly when it is needed: the daemon cannot stay up before
its key is linked or before a group exists.
ui prints the URL and the ssh -L line. It does not open a browser —
that was an assumption about the environment, and a wrong one.
gek-init initialises a group key through the daemon's loopback API. Same
operation as the admin UI button, no browser involved.
Also fixes a latent bug in QE/deploy/deploy-node.sh: the pkill pattern was
unanchored, so it matched any shell whose command line merely mentioned the
daemon — including the one running the script. It killed a session three times
before being pinned down. Anchored to the end of the command line.
Verified against the live deployment. grenet and cbesson both connect over
WebRTC through real NAT and can browse, download, stream, upload and chat. The
node audit log confirms the security properties in production: uploads land in
.uploads/{user_id}/ (C5a), the invite required the operator's signature over an
admin transcript (C5b, H5), the pre-proof bundle window is bounded and audited
(C4), and a non-member handshake was refused.
Docs updated: Phase 14 marked partially delivered with the reason, draft-v5 §5.3
records the two operator personas, QE/deploy/README.md documents the commands
and the remaining browser-only gaps (invite, delete).
Tests: 121 node.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/daemon.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/daemon.py | 129 |
1 files changed, 124 insertions, 5 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index bedd2e9..46a1716 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -18,6 +18,9 @@ Startup sequence: 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 """ @@ -136,6 +139,12 @@ class NodeDaemon: # 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. + 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) + self._ui_token_file.chmod(0o600) from meshbay_node.ui import create_ui_app ui_app = create_ui_app(self._state) ui_cfg = uvicorn.Config( @@ -146,8 +155,7 @@ class NodeDaemon: ) ui_server = uvicorn.Server(ui_cfg) self._tasks.append(asyncio.create_task(ui_server.serve())) - log.info("Admin UI at http://127.0.0.1:%d/?t=%s", - self._config.node.ui_port, ui_token) + log.info("Admin UI ready — open it with: meshbay-node ui") # 3. Hub connection (Ed25519 auth — retries until node key is linked) hub_cfg = HubConfig( @@ -555,6 +563,10 @@ class NodeDaemon: if self._quic_server: await self._quic_server.stop() + token_file = getattr(self, "_ui_token_file", None) + if token_file is not None: + token_file.unlink(missing_ok=True) + log.info("Node stopped") @@ -565,16 +577,21 @@ def main() -> None: parser = argparse.ArgumentParser(description="MeshBay Node daemon") parser.add_argument("command", nargs="?", - choices=["init", "calibrate-argon2"], - help="init: write example config | calibrate-argon2: benchmark") + choices=["init", "status", "ui", "gek-init", "calibrate-argon2"], + help="init: write example config | status: node state and keys " + "| ui: print the admin UI URL | calibrate-argon2: benchmark") parser.add_argument("--config", type=Path, default=None, help="Config file path") + parser.add_argument("--group", default=None, + help="group id for gek-init (optional if only one is configured)") parser.add_argument("--log-level", default="INFO", choices=["DEBUG", "INFO", "WARNING", "ERROR"]) args = parser.parse_args() + # Query commands print a report; library logging would interleave with it. + quiet = args.command in ("status", "ui", "gek-init") logging.basicConfig( - level=getattr(logging, args.log_level), + level=logging.ERROR if quiet else getattr(logging, args.log_level), format="%(asctime)s %(levelname)-8s %(name)s: %(message)s", ) @@ -587,6 +604,108 @@ def main() -> None: calibrate_argon2() return + if args.command == "status": + import json as _json + import urllib.request + + cfg = load_config(args.config or DEFAULT_CONFIG_PATH) + print(f"hub {cfg.hub.url} (user {cfg.hub.username or '—'})") + + # Read straight from the keystore: the operator needs this key to link the + # node, and that happens before the daemon can ever stay running. + try: + keys = load_or_create_keystore( + path=cfg.keystore.path, unlock_file=cfg.keystore.unlock_file) + print(f"node key {keys.pk_ed25519_b64}") + except Exception as e: + print(f"node key <keystore locked: {e}>") + + token_file = cfg.data_dir / "ui-token" + live = None + if token_file.exists(): + try: + url = (f"http://127.0.0.1:{cfg.node.ui_port}" + f"/api/status?t={token_file.read_text().strip()}") + with urllib.request.urlopen(url, timeout=3) as r: + live = _json.loads(r.read()) + except Exception: + live = None + + if live: + print(f"daemon running — {live.get('status')}") + print(f"node_id {live.get('endpoint_hint') or '—'}") + 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") + else: + print("daemon not running") + + print(f"config {DEFAULT_CONFIG_PATH}") + if not cfg.groups: + print("groups none configured — create a group on the hub, then add") + print(" a [[groups]] entry with its id and shared_dir") + else: + for g in cfg.groups: + print(f" group {g.name} [{g.visibility}] {g.id or '<no id>'}") + print(f" {g.shared_dir or '<no shared_dir>'}") + if not cfg.admin_pk_ed25519: + print("admin key NOT pinned — file deletion and member invites will be") + print(" refused (node.toml: admin_pk_ed25519)") + return + + if args.command == "gek-init": + import json as _json + import urllib.error + import urllib.request + + cfg = load_config(args.config or DEFAULT_CONFIG_PATH) + group_id = args.group + if not group_id: + if len(cfg.groups) == 1: + group_id = cfg.groups[0].id + else: + print("--group is required (several groups configured)") + sys.exit(1) + + token_file = cfg.data_dir / "ui-token" + if not token_file.exists(): + print("Node is not running — start it with: meshbay-node") + sys.exit(1) + + # The daemon holds the hub session and the live group contexts, so the CLI + # asks it to do the work rather than duplicating it. Same operation as the + # admin UI button — an operator on a headless host should never need a + # browser on that host to initialise a group key. + url = (f"http://127.0.0.1:{cfg.node.ui_port}/api/groups/{group_id}/gek" + f"?t={token_file.read_text().strip()}") + try: + req = urllib.request.Request(url, method="POST") + with urllib.request.urlopen(req, timeout=60) as r: + out = _json.loads(r.read()) + except urllib.error.HTTPError as e: + print(f"failed: {e.code} {e.read().decode()[:300]}") + sys.exit(1) + + print(f"GEK ready for {group_id}") + print(f" wrapped for {out.get('wrapped_count')}/{out.get('total_members')} members") + for err in out.get("errors") or []: + print(f" ! {err}") + 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} <this-host>") + 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") |