diff options
| -rw-r--r-- | devel-phases-next.md | 16 | ||||
| -rw-r--r-- | docs/meshbay-draft-v5.md | 18 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/daemon.py | 129 |
3 files changed, 153 insertions, 10 deletions
diff --git a/devel-phases-next.md b/devel-phases-next.md index 902f01d..682cb1b 100644 --- a/devel-phases-next.md +++ b/devel-phases-next.md @@ -859,15 +859,23 @@ These carry NAT traversal and are not browser workarounds. > Was Phase 12 before the 2026-08-13 renumbering. **Objective:** `meshbay-node` CLI becomes a full management tool, not just a -daemon launcher. Users can manage groups, members, and node state from the -command line. More important once native clients exist, since group and GEK -management moves out of the browser. +daemon launcher. + +**Partially delivered early (2026-08-13), forced by the first real deployment.** +Every operator action lived behind a web UI on the node's own loopback interface, +so a node on a server reached over SSH could not be operated at all without +port-forwarding a browser session — and 11.5.3 added a token that had to be +copied out of a log to get in. `status`, `ui` and `gek-init` shipped to unblock +that. The remaining commands matter for the same reason: **a headless operator +still cannot invite a member or delete a file without a browser.** ### Milestones | # | Component | Description | |---|---|---| -| 14.1 | `meshbay-node status` | Show daemon state: groups, peers, connected members, uptime | +| 14.1 | `meshbay-node status` | ✅ DONE — hub, node public key, daemon state, groups, admin-key pinning. Reads the keystore directly so it works while the daemon is stopped | +| 14.1b | `meshbay-node ui` | ✅ DONE — prints the admin UI URL and the `ssh -L` line. Does not open a browser | +| 14.1c | `meshbay-node gek-init` | ✅ DONE — initialises a group key via the daemon's loopback API. Was previously only possible by clicking a button in a browser on the node's own machine | | 14.2 | `meshbay-node group list` | List configured groups with online status | | 14.3 | `meshbay-node group create` | Create group on hub, add to config, generate GEK | | 14.4 | `meshbay-node group join` | Join existing group, fetch GEK from local BundleStore, add to config | diff --git a/docs/meshbay-draft-v5.md b/docs/meshbay-draft-v5.md index 9e1ddb3..8035824 100644 --- a/docs/meshbay-draft-v5.md +++ b/docs/meshbay-draft-v5.md @@ -203,7 +203,23 @@ ordering enforced, size capped. Previously uploads landed in the shared root und client-chosen name and overwrote anything there — which also defeated deletion authorization, since overwriting a file made the attacker its recorded uploader. -### 5.3 Local admin UI +### 5.3 Operator interface + +Two personas, and they need different tools: + +| Operator | Reaches the node via | +|---|---| +| Desktop | the local admin UI in their own browser | +| **Headless / SSH** | the CLI — `meshbay-node status`, `ui`, `gek-init` | + +The CLI is the primary interface for servers, which is the normal deployment. +`status` deliberately reads the keystore and config directly so it works while +the daemon is stopped — the state an operator is most often in, since the daemon +will not stay up before its key is linked or before a group exists. Anything the +UI can do should eventually have a CLI equivalent (Phase 14); until then a +headless operator still needs a browser to invite members or delete files. + +### 5.4 Local admin UI Loopback plus a **per-run session token** (`?t=` or `X-MeshBay-Token`), printed at startup. "Localhost only" is not authentication: any local process can reach it, as can 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") |