summaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py66
-rw-r--r--packages/meshbay-node/src/meshbay_node/hub_client.py19
-rw-r--r--packages/meshbay-node/src/meshbay_node/ui/app.py68
-rw-r--r--packages/meshbay-node/tests/test_roster_pairing.py49
4 files changed, 190 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", {})
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")