diff options
| -rw-r--r-- | docs/USERGUIDE.md | 34 | ||||
| -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 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_roster_pairing.py | 49 |
5 files changed, 224 insertions, 12 deletions
diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md index 8a1e405..e2193f0 100644 --- a/docs/USERGUIDE.md +++ b/docs/USERGUIDE.md @@ -373,6 +373,40 @@ argon2_memory_cost = 262144 # 256 MB argon2_parallelism = 1 ``` +### Hosting another of your groups + +A node can host several groups, each with its own directory and its own key. +Create the group in the web app first, then, on the node: + +```bash +meshbay-node group add grenet --dir ~/grenet-share +# grenet (480d553f) added to /home/cbesson/.config/meshbay/node.toml +# shared_dir /home/cbesson/grenet-share + +# restart the daemon, then: +meshbay-node gek-init --group grenet +``` + +`group add` looks the name up among your groups on the hub, appends a +`[[groups]]` block to your `node.toml` — comments and all, it is appended, not +rewritten — and creates the directory. The daemon reads its config at startup, so +it needs a restart before the group exists for it; `gek-init` then generates that +group's key. + +Three things follow from the design, and are worth being explicit about: + +- **Each group's key is its own.** Members of one group cannot read another's + files, and admitting someone to one says nothing about the other. That is why + `gek-init` is per group. +- **Pairing is not.** `meshbay-node operator pair` pairs a *browser* with the + *node*: one paired browser can invite to, and delete files in, every group the + node hosts. It takes no `--group`. +- **Members are per group.** `meshbay-node member invite alice --group grenet` + admits alice to that group only. The roster keeps one row per group. + +`meshbay-node status` prints what the node hosts, with each directory — the +quickest way to see whether a group made in the browser is attached here yet. + ### Environment variables (alternative to node.toml) | Variable | Equivalent config | 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", {}) diff --git a/packages/meshbay-node/tests/test_roster_pairing.py b/packages/meshbay-node/tests/test_roster_pairing.py index a2f7cd1..a5a48e4 100644 --- a/packages/meshbay-node/tests/test_roster_pairing.py +++ b/packages/meshbay-node/tests/test_roster_pairing.py @@ -756,3 +756,52 @@ def test_admin_authority_is_never_fetched_from_the_hub(): assert "admin_pk_ed25519" not in daemon, ( "the node.toml operator key is gone; it must not come back as a second " "source of authority") + + +# ── Hosting another group ──────────────────────────────────────────────────── + +async def test_group_add_appends_without_rewriting_the_file(tmp_path): + """ + node.toml is hand-written and full of comments explaining decisions. The + block is appended as text for that reason: a round trip through a TOML + writer would silently throw all of it away. + """ + from meshbay_node.config import load_config + + conf = tmp_path / "node.toml" + conf.write_text( + '# keep me\n[hub]\nurl = "https://meshbay.org"\nusername = "grenet"\n\n' + '[[groups]]\nid = "aaaa"\nname = "first"\nshared_dir = "/tmp/a"\n') + + block = ('\n[[groups]]\n' + 'id = "bbbb"\n' + 'name = "second"\n' + 'shared_dir = "/tmp/b"\n' + 'visibility = "private"\n') + with conf.open("a") as f: + f.write(block) + + assert "# keep me" in conf.read_text(), "comments must survive" + cfg = load_config(conf) + assert [g.name for g in cfg.groups] == ["first", "second"] + assert [g.shared_dir for g in cfg.groups] == ["/tmp/a", "/tmp/b"] + + +async def test_each_group_gets_its_own_key(tmp_path, roster): + """ + Two groups on one node are two separate memberships and two separate keys: + being admitted to one must say nothing about the other. This is the property + that makes hosting a second group meaningful rather than cosmetic. + """ + from meshbay_common.crypto import generate_gek + + gek_a, gek_b = generate_gek(), generate_gek() + assert gek_a != gek_b + + sk_ed, pk_ed_b64, pk_x_b64 = _keypair() + await roster.pin_identity("member", "member", pk_ed_b64, pk_x_b64, "code") + await roster.set_member("group-a", "member", ROLE_MEMBER, "active", "op") + + assert await roster.is_authorized("group-a", "member") is True + assert await roster.is_authorized("group-b", "member") is False, ( + "membership of one group must not admit anyone to another") |