aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--devel-phases-next.md16
-rw-r--r--docs/meshbay-draft-v5.md18
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py129
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")