diff options
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/daemon.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/daemon.py | 103 |
1 files changed, 100 insertions, 3 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index f9e992c..e270b6c 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -415,6 +415,11 @@ class NodeDaemon: # Whether the node unfurls links members post here. "chat_link_preview": await self._roster.chat_link_preview( group_cfg.id) if self._roster else True, + # Which chat epoch key is current. Opened here if the + # group has none, because chat is always encrypted (MNP + # 2.0) and a group with no epoch is a group nobody can + # speak in — the node cannot wait for an operator to notice. + "chat_epoch": await self._ensure_chat_epoch(group_cfg.id), # Whether TMDB lookups run for this group at all — # per-group (2026-08-24, used to be node-wide), same # "read once, kept current in place by the signed op" @@ -633,6 +638,10 @@ class NodeDaemon: self._state["bundle_store"] = self._bundle_store self._state["roster"] = self._roster self._state["node_user_id"] = session.user_id + # The node's own Ed25519 key. Needed by `encrypt_chat_history`, + # which seals migrated messages under a synthetic device of the + # node's rather than pretending to hold a member's signing key. + self._state["sk_node"] = keys.sk_ed25519 self._state["webrtc"] = self._webrtc self._state["quic_server"] = self._quic_server self._state["hub"] = hub @@ -869,6 +878,7 @@ class NodeDaemon: "chat_link_preview": ( await self._roster.chat_link_preview(group_cfg.id) if self._roster else True), + "chat_epoch": await self._ensure_chat_epoch(group_cfg.id), "tmdb_enabled": ( await self._roster.tmdb_enabled(group_cfg.id) if self._roster else True), @@ -1007,6 +1017,35 @@ class NodeDaemon: log.warning("No unwrappable GEK bundle found for group %s", group_id[:8]) return None + async def _ensure_chat_epoch(self, group_id: str) -> int: + """ + The group's current chat epoch, opening the first one if it has none. + + Chat is always encrypted, so a group with no epoch key is a group in + which nobody can say anything. Attaching one is the node's job and + happens here rather than on the first message: a failure at start-up is + in the log the operator is already reading, and a failure on someone's + first message is a chat that mysteriously refuses them. + + Never fatal. A group whose epoch cannot be opened keeps every other + function — files, video, the index — and only its chat is unusable, + which is strictly better than refusing to host the group at all. + """ + if not self._bundle_store: + return 0 + try: + epoch = await self._bundle_store.latest_chat_epoch(group_id) + if epoch: + return epoch + from meshbay_node import ops + + return (await ops.open_chat_epoch(self._state, group_id))["epoch"] + except Exception as e: + log.error("chat: no epoch key for group %s (%s) — chat is " + "unusable in this group until this is fixed", + group_id[:8], e) + return 0 + async def _progress_pusher(self, indexer: DirectoryIndexer, interval: float = 2.0) -> None: """ @@ -1759,7 +1798,8 @@ def main() -> None: parser.add_argument("command", nargs="?", choices=["init", "reset", "status", "gek-init", "gek", "operator", "member", "group", "root", - "file", "video", "denylist", "stun", "reload", + "file", "video", "chat", "denylist", "stun", + "reload", "restart-daemon", "autostart", "service", "calibrate-argon2"], help="init: provision config + keystore | reset: erase all " @@ -1770,7 +1810,9 @@ def main() -> None: "| root list|add|remove|set|eject|plug " "| gek init|rotate | file list|rm " "| video rematch: re-resolve TMDB matches for a group's " - "videos | denylist show|clear " + "videos " + "| chat status|rotate|encrypt-history|prune " + "| denylist show|clear " "| stun list|add|remove|reset " "| reload: re-read node.toml (hot; systemd or the " "loopback API) | restart-daemon: restart the node " @@ -1827,7 +1869,7 @@ def main() -> None: # Query commands print a report; library logging would interleave with it. quiet = args.command in ("status", "gek-init", "gek", "operator", - "member", "group", "root", "file", "video", + "member", "group", "root", "file", "video", "chat", "denylist", "stun", "reload", "restart-daemon", "reset") logging.basicConfig( @@ -2370,6 +2412,61 @@ def main() -> None: sys.exit(1) return + if args.command == "chat": + cfg = load_config(args.config or DEFAULT_CONFIG_PATH) + sub = args.subcommand or "status" + group_id = _resolve_group(cfg, args.group) + + if sub == "status": + out = _daemon_api(cfg, f"/api/groups/{group_id}/chat") + print(f"encryption always on (MNP {MNP_VERSION})") + print(f"epoch {out.get('epoch', 0)}") + print(f"messages {out.get('encrypted_messages', 0)} encrypted, " + f"{out.get('plaintext_messages', 0)} in the clear") + if out.get("plaintext_messages"): + print("\nThose messages were written before this node spoke MNP " + "2.0 and are\nstill readable off this disk. " + "`chat encrypt-history` converts them.") + return + + if sub == "rotate": + out = _daemon_api(cfg, f"/api/groups/{group_id}/chat/epoch", + method="POST") + print(f"chat epoch {out['epoch']} opened") + print("Everyone still in the group keeps reading the history; " + "whoever left\ncannot read what is written from now on.") + return + + if sub == "encrypt-history": + if not args.yes: + print("This rewrites the only copy of this group's older " + "messages.") + print("A backup of chat.db is taken first, beside it.") + if input("re-encrypt now? [y/N] ").strip().lower() not in ("y", "yes"): + print("cancelled") + return + out = _daemon_api(cfg, f"/api/groups/{group_id}/chat/encrypt-history", + method="POST", timeout=300) + print(f"re-encrypted {out['converted']} message(s) under epoch " + f"{out['epoch']}") + print(f"backup {out['backup']}") + return + + if sub == "prune": + days = int(args.target or 0) + if days < 1: + print("usage: meshbay-node chat prune <days> [--group G]") + sys.exit(1) + out = _daemon_api( + cfg, f"/api/groups/{group_id}/chat/prune?max_age_days={days}", + method="POST") + print(f"removed {out['removed']} message(s) older than {days} day(s)") + return + + print("usage: meshbay-node chat " + "status|rotate|encrypt-history|prune [--group G]") + sys.exit(1) + if args.command == "denylist": cfg = load_config(args.config or DEFAULT_CONFIG_PATH) sub = args.subcommand or "show" |