aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/daemon.py
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/meshbay_node/daemon.py
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/meshbay_node/daemon.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py98
1 files changed, 94 insertions, 4 deletions
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)