diff options
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/ui')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/ui/app.py | 162 |
1 files changed, 155 insertions, 7 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py index e671b72..2f73868 100644 --- a/packages/meshbay-node/src/meshbay_node/ui/app.py +++ b/packages/meshbay-node/src/meshbay_node/ui/app.py @@ -24,7 +24,7 @@ from fastapi.responses import HTMLResponse, JSONResponse from meshbay_node import __version__ from meshbay_common.crypto import generate_gek, wrap_gek_aes -from meshbay_common.join import ROLE_OPERATOR +from meshbay_common.join import ROLE_MEMBER, ROLE_OPERATOR log = logging.getLogger(__name__) @@ -235,11 +235,14 @@ def create_ui_app(state: dict) -> FastAPI: if not roster or not user_id: return JSONResponse({"error": "Node not connected to hub yet"}, 503) + config = state.get("config") + ttl = (config.node.pair_ttl_hours if config else 24) * 3600 code = await roster.create_invite( group_id="", # operator authority is node-wide user_id=user_id, role=ROLE_OPERATOR, created_by="local-cli", + ttl=ttl, ) invites = await roster.list_invites() expires = next((i["expires_at"] for i in invites @@ -247,16 +250,85 @@ def create_ui_app(state: dict) -> FastAPI: return {"code": code, "expires_at": expires, "user_id": user_id} @app.get("/api/roster") - async def api_roster(): + async def api_roster(group_id: str = ""): roster = state.get("roster") if not roster: - return {"identities": [], "members": [], "pending_invites": 0} + return {"identities": [], "members": [], "invites": []} return { "identities": await roster.list_identities(), - "members": await roster.list_members(), - "pending_invites": len(await roster.list_invites()), + "members": await roster.list_members(group_id or None), + "invites": await roster.list_invites(), } + @app.post("/api/groups/{group_id}/invites") + async def create_invite(group_id: str, username: str): + """ + Issue an invitation code from the CLI, without a browser. + + The hub is asked for the account id and nothing else — never for a key. + A hub that answered with the wrong account would produce an invite whose + code it never learns, since the code goes to a human out of band. + """ + roster = state.get("roster") + groups_ctx = state.get("groups_ctx", {}) + if not roster: + return JSONResponse({"error": "Roster not available"}, 503) + if group_id not in groups_ctx: + return JSONResponse({"error": "Group not hosted on this node"}, 404) + + hub = state.get("hub") + if not hub or not hub._session: + return JSONResponse({"error": "Hub not connected"}, 503) + try: + account = await hub.get_user_pubkeys(username) + except Exception as e: + return JSONResponse({"error": f"Unknown user {username!r}: {e}"}, 404) + + config = state.get("config") + ttl = (config.node.invite_ttl_hours if config else 168) * 3600 + code = await roster.create_invite( + group_id=group_id, + user_id=account["user_id"], + role=ROLE_MEMBER, + created_by="local-cli", + ttl=ttl, + ) + invites = await roster.list_invites() + expires = next((i["expires_at"] for i in invites + if i["user_id"] == account["user_id"] + and i["group_id"] == group_id), "") + return {"code": code, "expires_at": expires, + "username": username, "user_id": account["user_id"]} + + @app.post("/api/members/{user_id}/revoke") + async def revoke_member(user_id: str, group_id: str): + """ + Stop serving the group key to someone. + + Takes effect on their next connection: the key is wrapped on demand, so + there is no stored bundle left behind that would outlive this. Rotating + the group key is still required — they hold the current one. + """ + roster = state.get("roster") + if not roster: + return JSONResponse({"error": "Roster not available"}, 503) + if not await roster.set_status(group_id, user_id, "revoked"): + return JSONResponse({"error": "No such member in that group"}, 404) + log.info("Member revoked: user=%s group=%s", user_id[:8], group_id[:8]) + return {"status": "revoked", "user_id": user_id, "group_id": group_id, + "reminder": "rotate the group key: meshbay-node gek-init"} + + @app.post("/api/members/{user_id}/unpin") + async def unpin_member(user_id: str): + """Forget a pinned identity, so the person can pair again with a new key.""" + roster = state.get("roster") + if not roster: + return JSONResponse({"error": "Roster not available"}, 503) + if not await roster.unpin(user_id): + return JSONResponse({"error": "No such pinned identity"}, 404) + log.info("Identity unpinned: user=%s", user_id[:8]) + return {"status": "unpinned", "user_id": user_id} + # ── GEK initialization (operator only, localhost) ────────────────────── @app.post("/api/groups/{group_id}/gek") @@ -376,7 +448,17 @@ def create_ui_app(state: dict) -> FastAPI: @app.get("/", response_class=HTMLResponse) async def root(): - return _render_page(state) + # Roster reads are async and the page renderer is not, so gather here. + roster = state.get("roster") + roster_view = None + if roster: + identities = {i["user_id"]: i for i in await roster.list_identities()} + roster_view = { + "identities": identities, + "members": await roster.list_members(), + "invites": await roster.list_invites(), + } + return _render_page(state, roster_view) @app.get("/audit", response_class=HTMLResponse) async def audit_page(): @@ -395,7 +477,70 @@ def _fmt_size(n: int) -> str: return f"{n / (1024 * 1024 * 1024):.2f} GB" -def _render_page(state: dict) -> str: +def _render_roster(roster_view: dict | None) -> str: + """ + Who this node recognises, and which keys are theirs. + + Every value here is escaped: usernames come from the hub and pass through the + roster, so they are attacker-influenced text on the operator's own admin page + (the H2 rule applies to them exactly as it does to filenames). + """ + if roster_view is None: + return '<p class="muted">Roster unavailable</p>' + + identities = roster_view["identities"] + rows = "" + for m in roster_view["members"]: + ident = identities.get(m["user_id"], {}) + scope = escape(m["group_id"][:8]) if m["group_id"] else "node-wide" + status_color = "#22c55e" if m["status"] == "active" else "#ef4444" + rows += ( + f"<tr><td>{escape(str(ident.get('username') or m['user_id']))}</td>" + f"<td>{escape(str(m['role']))}</td>" + f"<td><span class='badge' style='background:{status_color}'>" + f"{escape(str(m['status']))}</span></td>" + f"<td>{scope}</td>" + f"<td><code>{escape(str(ident.get('pk_ed25519', ''))[:16])}…</code></td>" + f"<td>{escape(str(ident.get('pinned_at', '?')))} " + f"({escape(str(ident.get('pinned_via', '?')))})</td></tr>" + ) + if not rows: + rows = ('<tr><td colspan="6" class="muted">Nobody admitted yet — ' + 'run <code>meshbay-node member invite <username></code></td></tr>') + + invite_rows = "" + for i in roster_view["invites"]: + invite_rows += ( + f"<tr><td><code>{escape(str(i['user_id'])[:16])}</code></td>" + f"<td>{escape(str(i['group_id'][:8] or 'node-wide'))}</td>" + f"<td>{escape(str(i['role']))}</td>" + f"<td>{escape(str(i['expires_at']))}</td></tr>" + ) + invites_html = "" + if invite_rows: + invites_html = f""" + <details style="margin-top:10px"><summary>Pending invitations</summary> + <table> + <thead><tr><th>Account</th><th>Group</th><th>Role</th><th>Expires</th></tr></thead> + <tbody>{invite_rows}</tbody> + </table> + </details>""" + + return f""" + <table> + <thead><tr><th>User</th><th>Role</th><th>Status</th><th>Scope</th> + <th>Identity key</th><th>Pinned</th></tr></thead> + <tbody>{rows}</tbody> + </table> + {invites_html} + <p class="muted" style="margin-top:8px"> + Codes are issued from the CLI: <code>meshbay-node operator pair</code>, + <code>meshbay-node member invite <username></code>. They never pass + through the hub. + </p>""" + + +def _render_page(state: dict, roster_view: dict | None = None) -> str: token_js = json.dumps(state.get("ui_token", "")) status = state.get("status", "starting") indexes = state.get("indexes", {}) @@ -557,6 +702,9 @@ def _render_page(state: dict) -> str: <tbody>{peers_html}</tbody> </table> + <h2>Roster</h2> + {_render_roster(roster_view)} + <h2>Groups</h2> {groups_html or '<p class="muted">No groups configured</p>'} |