summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-14 01:27:57 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-14 01:27:57 +0200
commit8f6e2f724fd24a077de11d4a3b3ae069d369324d (patch)
tree25b06d11f74b7b73e2056e1bb75c1b64af6c9650 /packages/meshbay-node/src
parentf15efd23f66c521ca9206789482bb38e7326eeb4 (diff)
downloadmeshbay-8f6e2f724fd24a077de11d4a3b3ae069d369324d.tar.gz
feat(node): operator surface — member list, invite, revoke, unpin over SSH
A node admits people from its own roster, and until now a headless operator had no way to put anyone on it: pairing worked from the CLI, everything else needed a browser on a machine that does not have one. Absorbs milestones 14.3/14.4. member list who is admitted, role, status, when and how pinned member invite <username> one-time code; the node wraps the key when they connect, so nobody has to be online then member revoke <username> stop serving them the key member unpin <username> forget the pin so they can pair again after a reset All of it goes through the daemon's loopback API with the per-run session token (11.5.3) — _daemon_api() in daemon.py, which also replaced three hand-rolled urllib blocks. `status` deliberately still reads the keystore, config and roster directly, so it works while the daemon is stopped. Two things the commands say out loud, because getting them wrong is silent: - revoke ends by telling the operator to rotate the key. The ex-member stops receiving it on their next connection, but they hold the current one, and "revoked" reads like it took the key back. - revoke/unpin refuse a username the roster does not know instead of acting on nobody. A typo must not look like success. Code lifetimes now differ by what the act is: 7 days for an invitation, which crosses a human conversation and gets answered whenever someone reads their messages, and 24 h for operator pairing, which is typed during the SSH session that printed it. Both configurable ([node] invite_ttl_hours, pair_ttl_hours). A day was long enough for the second and not for the first — a code that dies over a weekend means finding a browser to issue another one. The roster is also in the local admin UI, escaped: usernames come from the hub and land on the page that can re-key groups and read the audit log, so H2's rule covers them exactly as it covers filenames. Verified by driving the real CLI against a stub daemon over a socket, which is how the "known: <nothing>" bug in the not-found path turned up. Tests: 89 node here (roster, endpoints, CLI routing, TTL config). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/src')
-rw-r--r--packages/meshbay-node/src/meshbay_node/config.py14
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py98
-rw-r--r--packages/meshbay-node/src/meshbay_node/roster.py24
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py2
-rw-r--r--packages/meshbay-node/src/meshbay_node/ui/app.py162
5 files changed, 286 insertions, 14 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/config.py b/packages/meshbay-node/src/meshbay_node/config.py
index 04cb1d3..f3752ea 100644
--- a/packages/meshbay-node/src/meshbay_node/config.py
+++ b/packages/meshbay-node/src/meshbay_node/config.py
@@ -29,6 +29,11 @@ username = "myusername"
quic_port = 19010 # QUIC (MNP) — LAN, port-forwarded, hub-less direct access
ui_port = 18000 # local admin UI (127.0.0.1 only)
+# One-time codes. An invitation waits for someone to read their messages; an
+# operator pairing code is typed during the SSH session that printed it.
+invite_ttl_hours = 168 # 7 days
+pair_ttl_hours = 24
+
# Browser and native clients reach this node over WebRTC DataChannel via hub
# signaling — no inbound port to open. QUIC is the optional direct path.
@@ -70,6 +75,11 @@ class HubConfig:
class NodeConfig:
quic_port: int = 19010
ui_port: int = 18000
+ # How long a one-time code stays usable. Invitations travel through a human
+ # conversation and are answered days later; operator pairing happens during
+ # the SSH session that printed it.
+ invite_ttl_hours: int = 168 # 7 days
+ pair_ttl_hours: int = 24
@dataclass
@@ -129,6 +139,10 @@ def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config:
# in Phase 11.5 (findings C1, C6). Regenerate node.toml with `meshbay-node init`.
cfg.node.quic_port = nd.get("quic_port", cfg.node.quic_port)
cfg.node.ui_port = nd.get("ui_port", cfg.node.ui_port)
+ cfg.node.invite_ttl_hours = int(
+ nd.get("invite_ttl_hours", cfg.node.invite_ttl_hours))
+ cfg.node.pair_ttl_hours = int(
+ nd.get("pair_ttl_hours", cfg.node.pair_ttl_hours))
# Multi-group: [[groups]] array
if "groups" in raw:
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py
index 7f84abe..930dabc 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -286,6 +286,8 @@ class NodeDaemon:
self._webrtc._ctx["pk_x25519_b64"] = keys.pk_x25519_b64
self._webrtc._ctx["roster"] = self._roster
+ self._webrtc._ctx["invite_ttl"] = (
+ self._config.node.invite_ttl_hours * 3600)
admin_pk = self._legacy_admin_pk()
paired = await self._roster.has_operator() if self._roster else False
if admin_pk:
@@ -662,12 +664,15 @@ def main() -> None:
parser = argparse.ArgumentParser(description="MeshBay Node daemon")
parser.add_argument("command", nargs="?",
choices=["init", "status", "ui", "gek-init", "operator",
- "calibrate-argon2"],
+ "member", "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 | calibrate-argon2: benchmark")
+ "browser with this node | member list|invite|revoke|unpin "
+ "| calibrate-argon2: benchmark")
parser.add_argument("subcommand", nargs="?",
- help="'pair' for the operator command")
+ help="'pair' for operator; list|invite|revoke|unpin for member")
+ parser.add_argument("target", nargs="?",
+ help="username, for member invite|revoke|unpin")
parser.add_argument("--config", type=Path, default=None,
help="Config file path")
parser.add_argument("--group", default=None,
@@ -677,7 +682,7 @@ 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")
+ quiet = args.command in ("status", "ui", "gek-init", "operator", "member")
logging.basicConfig(
level=logging.ERROR if quiet else getattr(logging, args.log_level),
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
@@ -775,6 +780,91 @@ def main() -> None:
print(f"invites {pending} pending code(s)")
return
+ if args.command == "member":
+ cfg = load_config(args.config or DEFAULT_CONFIG_PATH)
+ sub = args.subcommand or "list"
+
+ if sub == "list":
+ group = args.group or ""
+ out = _daemon_api(
+ cfg, f"/api/roster?group_id={group}" if group else "/api/roster")
+ identities = {i["user_id"]: i for i in out.get("identities", [])}
+
+ members = out.get("members", [])
+ if not members:
+ print("no members admitted yet")
+ print("invite someone: meshbay-node member invite <username>")
+ for m in members:
+ ident = identities.get(m["user_id"], {})
+ scope = m["group_id"][:8] if m["group_id"] else "node-wide"
+ print(f"{(ident.get('username') or m['user_id'])[:20]:20} "
+ f"{m['role']:9} {m['status']:8} {scope:10} "
+ f"pinned {ident.get('pinned_at', '?')} "
+ f"({ident.get('pinned_via', '?')})")
+
+ invites = out.get("invites", [])
+ if invites:
+ print()
+ for i in invites:
+ print(f"pending invite user {i['user_id'][:12]} "
+ f"group {(i['group_id'] or 'node-wide')[:8]} "
+ f"expires {i['expires_at']}")
+ return
+
+ if not args.target:
+ print(f"usage: meshbay-node member {sub} <username>")
+ sys.exit(1)
+
+ if sub == "invite":
+ group_id = _resolve_group(cfg, args.group)
+ out = _daemon_api(
+ cfg, f"/api/groups/{group_id}/invites?username={args.target}",
+ method="POST")
+ from meshbay_node.roster import write_code_file
+ path = write_code_file(cfg.data_dir, out["code"],
+ out.get("expires_at", ""), name="invite-code")
+ print(f"INVITATION CODE {out['code']}")
+ print(f"valid until {out.get('expires_at', '?')}")
+ print()
+ print(f"Send it to {args.target} however you normally talk. It works")
+ print("once, for that account only, and never passes through the hub.")
+ print("They enter it the first time they open the group — you do not")
+ print("need to be online then.")
+ print()
+ print(f"also written to {path}")
+ return
+
+ # revoke and unpin both name a person; the daemon resolves the account.
+ roster_out = _daemon_api(cfg, "/api/roster")
+ match = next((i for i in roster_out.get("identities", [])
+ if i["username"] == args.target), None)
+ if not match:
+ known = ", ".join(i["username"]
+ for i in roster_out.get("identities", []))
+ print(f"{args.target!r} is not pinned on this node")
+ print(f"known: {known or 'nobody yet'}")
+ sys.exit(1)
+
+ if sub == "revoke":
+ group_id = _resolve_group(cfg, args.group)
+ out = _daemon_api(
+ cfg, f"/api/members/{match['user_id']}/revoke?group_id={group_id}",
+ method="POST")
+ print(f"{args.target} revoked from {group_id[:8]}")
+ print("They stop receiving the group key on their next connection.")
+ print("They still hold the current one — rotate it:")
+ print(f" meshbay-node gek-init --group {group_id}")
+ return
+
+ if sub == "unpin":
+ _daemon_api(cfg, f"/api/members/{match['user_id']}/unpin", method="POST")
+ print(f"{args.target} unpinned — they can pair again with a new key")
+ print(f"issue a code: meshbay-node member invite {args.target}")
+ return
+
+ print("usage: meshbay-node member list|invite|revoke|unpin")
+ sys.exit(1)
+
if args.command == "gek-init":
cfg = load_config(args.config or DEFAULT_CONFIG_PATH)
group_id = _resolve_group(cfg, args.group)
diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py
index f231792..dab1497 100644
--- a/packages/meshbay-node/src/meshbay_node/roster.py
+++ b/packages/meshbay-node/src/meshbay_node/roster.py
@@ -36,7 +36,22 @@ log = logging.getLogger(__name__)
# when reading a code aloud or typing it from a phone screen.
_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
CODE_LEN = 8 # 8 × 5 bits = 40 bits of entropy
-DEFAULT_INVITE_TTL = 24 * 3600 # seconds
+
+# Two different rhythms, so two different lifetimes.
+#
+# An invitation crosses a human conversation: it is sent by mail or message and
+# answered whenever the other person next looks. A day is not enough — the code
+# dies over a weekend and someone has to be at a browser, with the node online, to
+# issue another one.
+#
+# Operator pairing crosses an SSH session: the code is printed and typed minutes
+# later. There is no reason for it to outlive the sitting.
+#
+# The longer window costs little: a code is single use, bound to one account,
+# never seen by the hub, and 40 bits do not fall to guessing in a week against the
+# node-wide lockout.
+DEFAULT_INVITE_TTL = 7 * 24 * 3600 # seconds — member invitations
+DEFAULT_PAIR_TTL = 24 * 3600 # seconds — operator pairing
_SCHEMA = """\
CREATE TABLE IF NOT EXISTS identities (
@@ -358,14 +373,17 @@ async def open_roster(data_dir: Path) -> Roster:
return roster
-def write_code_file(data_dir: Path, code: str, expires_at: str) -> Path:
+def write_code_file(data_dir: Path, code: str, expires_at: str,
+ name: str = "pair-code") -> Path:
"""
Leave the code in a file as well as on stdout.
An operator working over SSH may not be able to copy out of their terminal,
and a code that can only be read off a scrolled-away screen is a dead end.
+ Pairing and invitation codes go to different files so one does not overwrite
+ the other.
"""
- path = data_dir / "pair-code"
+ path = data_dir / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(f"{code}\nexpires {expires_at}\n")
os.chmod(path, 0o600)
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
index fe3ee2e..10dfcb0 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -70,6 +70,7 @@ from meshbay_common.join import (
from meshbay_common.webcrypto import chunk_key_aes, encrypt_chunk_aes
from meshbay_common.protocol import MNP
from meshbay_node.indexer import GroupIndex
+from meshbay_node.roster import DEFAULT_INVITE_TTL
log = logging.getLogger(__name__)
@@ -1330,6 +1331,7 @@ class WebRTCPeerSession:
user_id=payload["user_id"],
role=ROLE_MEMBER,
created_by=self._user_id or "",
+ ttl=self._ctx.get("invite_ttl", DEFAULT_INVITE_TTL),
)
invites = await roster.list_invites()
expires = next(
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 &lt;username&gt;</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 &lt;username&gt;</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>'}