diff options
Diffstat (limited to 'packages/meshbay-node/src')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/daemon.py | 66 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/hub_client.py | 19 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/ui/app.py | 68 |
3 files changed, 141 insertions, 12 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index 848fc9f..6fafc74 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -98,8 +98,9 @@ class _WsSender: # ── Daemon ──────────────────────────────────────────────────────────────────── class NodeDaemon: - def __init__(self, config: Config): + def __init__(self, config: Config, config_path: Path = DEFAULT_CONFIG_PATH): self._config = config + self._config_path = config_path self._state: dict = { "status": "starting", "hub_url": config.hub.url, @@ -135,6 +136,9 @@ class NodeDaemon: # 2. Start admin UI early (so operator can copy node key before hub login) 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 # other local processes and rebound browser pages out of an API that can # re-initialise group keys. @@ -579,7 +583,7 @@ class NodeDaemon: # ── CLI helpers ─────────────────────────────────────────────────────────────── def _daemon_api(cfg: Config, path: str, method: str = "GET", - timeout: int = 30) -> dict: + timeout: int = 30, body: dict | None = None) -> dict: """ Call the daemon's loopback API. @@ -602,15 +606,23 @@ def _daemon_api(cfg: Config, path: str, method: str = "GET", url = (f"http://127.0.0.1:{cfg.node.ui_port}{path}" f"{sep}t={token_file.read_text().strip()}") try: - req = urllib.request.Request(url, method=method) + data = _json.dumps(body).encode() if body is not None else None + req = urllib.request.Request( + url, method=method, data=data, + headers={"Content-Type": "application/json"} if data else {}) with urllib.request.urlopen(req, timeout=timeout) as r: return _json.loads(r.read()) except urllib.error.HTTPError as e: - body = e.read().decode()[:300] + raw = e.read().decode()[:600] try: - detail = _json.loads(body).get("error", body) + parsed = _json.loads(raw) + detail = parsed.get("error", raw) + # Endpoints that refuse a name offer the ones that would work; a bare + # "no such group" leaves the operator guessing at a UUID. + for row in parsed.get("available", []): + detail += f"\n {row.get('name', ''):<24} {row.get('id', '')}" except Exception: - detail = body + detail = raw print(f"failed: {detail}") sys.exit(1) except Exception as e: @@ -663,15 +675,20 @@ def main() -> None: parser = argparse.ArgumentParser(description="MeshBay Node daemon") parser.add_argument("command", nargs="?", choices=["init", "status", "ui", "gek-init", "operator", - "member", "calibrate-argon2"], + "member", "group", "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 " - "| calibrate-argon2: benchmark") + "| group add <name> --dir <path>: host another of your " + "groups | calibrate-argon2: benchmark") parser.add_argument("subcommand", nargs="?", - help="'pair' for operator; list|invite|revoke|unpin for member") + help="'pair' for operator; list|invite|revoke|unpin for " + "member; 'add' for group") parser.add_argument("target", nargs="?", - help="username, for member invite|revoke|unpin") + help="username for member invite|revoke|unpin; " + "group name for group add") + parser.add_argument("--dir", default=None, + help="shared directory, for group add") parser.add_argument("--config", type=Path, default=None, help="Config file path") parser.add_argument("--group", default=None, @@ -681,7 +698,8 @@ 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", "operator", "member") + quiet = args.command in ("status", "ui", "gek-init", "operator", "member", + "group") logging.basicConfig( level=logging.ERROR if quiet else getattr(logging, args.log_level), format="%(asctime)s %(levelname)-8s %(name)s: %(message)s", @@ -868,6 +886,30 @@ def main() -> None: print(f" ! {err}") return + if args.command == "group": + if args.subcommand != "add": + print("usage: meshbay-node group add <name> --dir <path>") + sys.exit(1) + if not args.target or not args.dir: + print("usage: meshbay-node group add <name> --dir <path>") + print() + print("The group must already exist on the hub and be yours. This") + print("only tells the node to host it, and picks the directory.") + sys.exit(1) + + cfg = load_config(args.config or DEFAULT_CONFIG_PATH) + out = _daemon_api(cfg, "/api/groups/attach", method="POST", + body={"name": args.target, "shared_dir": args.dir}) + print(f"{out['name']} ({out['group_id'][:8]}) added to {out['config']}") + print(f" shared_dir {out['shared_dir']}") + print() + print("Restart the daemon so it picks the group up, then give it a key:") + print(f" meshbay-node gek-init --group {out['name']}") + print() + print("The key is this group's own — members of your other groups cannot") + print("read it, and joining one says nothing about the other.") + return + if args.command == "operator": if args.subcommand != "pair": print("usage: meshbay-node operator pair") @@ -916,7 +958,7 @@ def main() -> None: print("Error: hub.username not set in config. Run: meshbay-node init") sys.exit(1) - daemon = NodeDaemon(cfg) + daemon = NodeDaemon(cfg, Path(args.config or DEFAULT_CONFIG_PATH)) asyncio.run(daemon.run()) diff --git a/packages/meshbay-node/src/meshbay_node/hub_client.py b/packages/meshbay-node/src/meshbay_node/hub_client.py index d8417e3..b345187 100644 --- a/packages/meshbay-node/src/meshbay_node/hub_client.py +++ b/packages/meshbay-node/src/meshbay_node/hub_client.py @@ -184,6 +184,25 @@ class HubClient: log.info("Node announced: %s (hint=%s)", node_id[:8], endpoint_hint) return node_id + # ── Group lookup ────────────────────────────────────────────────────────── + + async def list_my_groups(self) -> list[dict]: + """ + The operator's groups on the hub, hosted here or not. + + Attaching a group to a node needs its id, and nobody types a UUID — + least of all over SSH on a machine whose terminal will not paste. The + operator names the group; this is what turns the name into an id. + """ + if self._session is None: + raise RuntimeError("Not logged in") + await self.ensure_fresh_token() + + r = await self._http.get("/v1/groups/mine", + headers=self._session.auth_headers) + r.raise_for_status() + return r.json().get("groups", []) + # ── User pubkey lookup ──────────────────────────────────────────────────── async def get_user_pubkeys(self, username: str) -> dict: diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py index 28654df..b78f78d 100644 --- a/packages/meshbay-node/src/meshbay_node/ui/app.py +++ b/packages/meshbay-node/src/meshbay_node/ui/app.py @@ -23,6 +23,7 @@ from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query from fastapi.responses import HTMLResponse, JSONResponse from meshbay_node import __version__ +from meshbay_node.config import DEFAULT_CONFIG_PATH from meshbay_common.crypto import generate_gek, wrap_gek_aes from meshbay_common.join import ROLE_MEMBER, ROLE_OPERATOR @@ -127,6 +128,73 @@ def create_ui_app(state: dict) -> FastAPI: }) return {"groups": result} + @app.post("/api/groups/attach") + async def attach_group(payload: dict): + """ + Write a new [[groups]] block into node.toml. + + The name-to-id lookup happens here because this process is the one logged + into the hub. Nothing is created on the hub: the group already exists, + this only tells the node to host it. + """ + name = (payload.get("name") or "").strip() + shared_dir = (payload.get("shared_dir") or "").strip() + if not name or not shared_dir: + return JSONResponse({"error": "name and shared_dir are required"}, 400) + + config = state.get("config") + if not config: + return JSONResponse({"error": "No config loaded"}, 503) + + hub = state.get("hub") + if not hub or not hub._session: + return JSONResponse({"error": "Hub not connected"}, 503) + try: + mine = await hub.list_my_groups() + except Exception as e: + return JSONResponse({"error": f"Could not list groups: {e}"}, 502) + + match = [g for g in mine if g["id"] == name or g["name"] == name] + if not match: + return JSONResponse({ + "error": f"No group of yours is called {name!r}", + "available": [{"name": g["name"], "id": g["id"]} for g in mine], + }, 404) + if len(match) > 1: + return JSONResponse({ + "error": f"Several of your groups are called {name!r} — use the id", + "available": [{"name": g["name"], "id": g["id"]} for g in match], + }, 409) + group = match[0] + + if any(g.id == group["id"] for g in config.groups): + return JSONResponse( + {"error": f"{group['name']!r} is already hosted by this node"}, 409) + + path = Path(shared_dir).expanduser() + try: + path.mkdir(parents=True, exist_ok=True) + except OSError as e: + return JSONResponse({"error": f"Cannot create {path}: {e}"}, 400) + + # Appended as text rather than re-serialised: node.toml is hand-written + # and full of comments explaining decisions, and a round trip through a + # TOML writer would throw all of that away. + conf_path = Path(state.get("config_path") or DEFAULT_CONFIG_PATH) + block = (f'\n[[groups]]\n' + f'id = "{group["id"]}"\n' + f'name = "{group["name"]}"\n' + f'shared_dir = "{path}"\n' + f'visibility = "{group.get("visibility", "private")}"\n') + try: + with conf_path.open("a") as f: + f.write(block) + except OSError as e: + return JSONResponse({"error": f"Cannot write {conf_path}: {e}"}, 500) + + return {"group_id": group["id"], "name": group["name"], + "shared_dir": str(path), "config": str(conf_path)} + @app.get("/api/groups/{group_id}/files") async def api_group_files(group_id: str): groups_ctx = state.get("groups_ctx", {}) |