diff --git a/CHANGELOG.md b/CHANGELOG.md index ef81c83..b2dd9d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ This project follows the versioning policy in VERSIONING.md. +## 0.2.0 - 2026-01-07 + +- **Major internal refactoring**: Improved code organization and maintainability +- Extracted modular components from monolithic service class: + - `SessionManager`: Centralized session lifecycle and state management + - `MessageRouter`: Message routing and forwarding logic + - `CommandHandler`: Slash-command parsing and execution + - `RoomManager`: Room state, membership, and mode management + - `ResourceManager`: RNS.Resource transfer handling and coordination + - `TrustManager`: Operator and ban list management + - `StatsManager`: Statistics tracking and reporting + - `ConfigManager`: Enhanced configuration loading and validation +- Moved message chunking and encoding logic to dedicated `messages` module +- Consolidated constants and improved code organization +- Reduced service.py from ~4000 lines to <600 lines by delegating to specialized managers +- No breaking changes to protocol, configuration format, or user-facing behavior + +Future development will focus on testing, feature enhancements, and optimizations rather than large structural changes. + ## 0.1.3 - 2026-01-05 - Added `/list` command to discover registered public rooms with their topics (available to all users) diff --git a/pyproject.toml b/pyproject.toml index 809de57..6245938 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,7 @@ version = {attr = "rrcd.__version__"} [tool.ruff] target-version = "py311" -line-length = 88 +line-length = 100 [tool.ruff.lint] select = ["E", "F", "I", "B", "UP"] diff --git a/rrcd/__init__.py b/rrcd/__init__.py index d4516cb..ae3175a 100644 --- a/rrcd/__init__.py +++ b/rrcd/__init__.py @@ -1,3 +1,3 @@ __all__ = ["__version__"] -__version__ = "0.1.3" +__version__ = "0.2.0" diff --git a/rrcd/cli.py b/rrcd/cli.py index 87670b5..3dd4726 100644 --- a/rrcd/cli.py +++ b/rrcd/cli.py @@ -4,6 +4,7 @@ import argparse import os import sys from dataclasses import asdict, replace +from pathlib import Path import RNS @@ -77,11 +78,11 @@ def _apply_config_file(cfg: HubRuntimeConfig, path: str) -> HubRuntimeConfig: def _write_default_config(config_path: str, identity_path: str) -> None: cfg_dir = os.path.dirname(config_path) if cfg_dir: - ensure_private_dir(__import__("pathlib").Path(cfg_dir)) + ensure_private_dir(Path(cfg_dir)) storage_dir = os.path.dirname(identity_path) if storage_dir: - ensure_private_dir(__import__("pathlib").Path(storage_dir)) + ensure_private_dir(Path(storage_dir)) room_registry_path = str(default_room_registry_path()) @@ -208,7 +209,7 @@ def _ensure_first_run_files( if not os.path.exists(identity_path): storage_dir = os.path.dirname(identity_path) if storage_dir: - ensure_private_dir(__import__("pathlib").Path(storage_dir)) + ensure_private_dir(Path(storage_dir)) ident = RNS.Identity() ident.to_file(identity_path) try: @@ -220,7 +221,7 @@ def _ensure_first_run_files( if room_registry_path and not os.path.exists(room_registry_path): storage_dir = os.path.dirname(room_registry_path) if storage_dir: - ensure_private_dir(__import__("pathlib").Path(storage_dir)) + ensure_private_dir(Path(storage_dir)) content = """# rrcd room registry (TOML) # # This file stores registered rooms and their moderation state. @@ -382,8 +383,14 @@ def main(argv: list[str] | None = None) -> None: cfg = replace(cfg, config_path=config_path) cfg = replace(cfg, room_registry_path=room_registry_path) + # Use ConfigManager to load config file if config_path: - cfg = _apply_config_file(cfg, config_path) + from .config import ConfigManager + # Create temporary manager for loading + temp_hub = type('obj', (object,), {'config': cfg, 'log': None, '_state_lock': None})() + temp_mgr = ConfigManager(temp_hub) # type: ignore + data = temp_mgr.load_toml(config_path) + cfg = temp_mgr.apply_config_data(cfg, data) if args.dest_name is not None: cfg = replace(cfg, dest_name=args.dest_name) diff --git a/rrcd/commands.py b/rrcd/commands.py new file mode 100644 index 0000000..6b689ea --- /dev/null +++ b/rrcd/commands.py @@ -0,0 +1,1204 @@ +"""Command handling for RRCD operator commands.""" + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING + +import RNS + +from rrcd.constants import T_ERROR, T_NOTICE +from rrcd.envelope import make_envelope + +if TYPE_CHECKING: + from rrcd.service import HubService + + +class CommandHandler: + """Handles operator commands for the RRC hub.""" + + def __init__(self, hub: HubService) -> None: + self.hub = hub + + def handle_operator_command( + self, + link: RNS.Link, + peer_hash: bytes, + room: str | None, + text: str, + *, + outgoing: list[tuple[RNS.Link, bytes]] | None = None, + ) -> bool: + """Handle an operator command. + + Returns True if it was a recognized command (handled). Unknown commands + return False so the message can be forwarded as normal chat. + """ + cmdline = text.strip() + if not cmdline.startswith("/"): + return False + + parts = [p for p in cmdline[1:].split() if p] + if not parts: + return False + + cmd = parts[0].lower() + + if cmd == "reload": + if not self.hub.trust_manager.is_server_op(peer_hash): + if self.hub.identity is not None: + self._emit_error( + outgoing, + link, + src=self.hub.identity.hash, + text="not authorized", + room=None, + ) + return True + self.hub._reload_config_and_rooms(link, None, outgoing) + return True + + if cmd == "stats": + if not self.hub.trust_manager.is_server_op(peer_hash): + if self.hub.identity is not None: + self._emit_error( + outgoing, + link, + src=self.hub.identity.hash, + text="not authorized", + room=None, + ) + return True + self.hub.message_helper.emit_notice( + outgoing, link, None, self.hub.stats_manager.format_stats() + ) + return True + + if cmd == "list": + with self.hub._state_lock: + registered_rooms = [] + for room_name, st in self.hub.room_manager._room_state.items(): + if st.get("registered") and not st.get("private"): + topic = st.get("topic") + registered_rooms.append((room_name, topic)) + + for room_name, reg in self.hub.room_manager._room_registry.items(): + if room_name not in self.hub.room_manager._room_state: + if not reg.get("private"): + topic = reg.get("topic") + registered_rooms.append((room_name, topic)) + + if not registered_rooms: + self.hub.message_helper.emit_notice( + outgoing, link, None, "No public rooms registered" + ) + return True + + registered_rooms.sort(key=lambda x: x[0]) + + lines = ["Registered public rooms:"] + for room_name, topic in registered_rooms: + if topic: + lines.append(f" {room_name} - {topic}") + else: + lines.append(f" {room_name}") + + self.hub.message_helper.emit_notice(outgoing, link, None, "\n".join(lines)) + return True + + if cmd in ("who", "names"): + target_room = room + if len(parts) >= 2: + target_room = parts[1] + if not isinstance(target_room, str) or not target_room: + self.hub.message_helper.emit_notice( + outgoing, link, None, "usage: /who [room]" + ) + return True + try: + r = self.hub._norm_room(target_room) + except Exception as e: + self.hub.message_helper.emit_notice( + outgoing, link, None, f"bad room: {e}" + ) + return True + + st = self.hub.room_manager._room_state_get(r) + if st and st.get("private"): + if not self.hub.trust_manager.is_server_op(peer_hash): + self.hub.message_helper.emit_notice( + outgoing, link, None, f"room {r} is private" + ) + return True + + members = [] + for other in sorted( + self.hub.room_manager.get_room_members(r), key=lambda x: id(x) + ): + s = self.hub.session_manager.sessions.get(other) + if not s: + continue + nick = s.get("nick") + ph = s.get("peer") + ident = bytes(ph).hex() if isinstance(ph, (bytes, bytearray)) else "?" + if isinstance(nick, str) and nick: + members.append(f"{nick} ({ident[:12]})") + else: + members.append(ident) + self.hub.message_helper.emit_notice( + outgoing, + link, + None, + f"members in {r}: " + (", ".join(members) if members else "(none)"), + ) + return True + + if cmd == "kick": + if len(parts) < 3: + self.hub.message_helper.emit_notice( + outgoing, link, None, "usage: /kick " + ) + return True + target_room = parts[1] + target = parts[2] + try: + r = self.hub._norm_room(target_room) + except Exception as e: + self.hub.message_helper.emit_notice( + outgoing, link, room, f"bad room: {e}" + ) + return True + + if not self.hub.room_manager.is_room_op(r, peer_hash): + if self.hub.identity is not None: + self._emit_error( + outgoing, + link, + src=self.hub.identity.hash, + text="not authorized", + room=r, + ) + return True + + target_link = self._find_target_link(target, room=r) + if target_link is None: + all_matches = self._find_target_links(target, room=r) + self.hub.message_helper.emit_notice( + outgoing, + link, + room, + self._format_ambiguous_targets(target, all_matches), + ) + return True + + tsess = self.hub.session_manager.sessions.get(target_link) + if not tsess or r not in tsess.get("rooms", set()): + self.hub.message_helper.emit_notice( + outgoing, link, room, "target not in room" + ) + return True + + tsess["rooms"].discard(r) + if self.hub.room_manager.get_room_members(r): + self.hub.room_manager.rooms[r].discard(target_link) + if not self.hub.room_manager.rooms[r]: + pass + + if self.hub.identity is not None: + self._emit_error( + outgoing, + target_link, + src=self.hub.identity.hash, + text=f"kicked from {r}", + room=r, + ) + self.hub.message_helper.emit_notice( + outgoing, link, room, f"kicked {target} from {r}" + ) + return True + + if cmd == "kline": + if not self.hub.trust_manager.is_server_op(peer_hash): + if self.hub.identity is not None: + self._emit_error( + outgoing, + link, + src=self.hub.identity.hash, + text="not authorized", + room=None, + ) + return True + + if len(parts) < 2: + self.hub.message_helper.emit_notice( + outgoing, + link, + None, + "usage: /kline add|del|list [nick|hashprefix|hash]", + ) + return True + + op = parts[1].strip().lower() + if op == "list": + with self.hub._state_lock: + items = sorted(h.hex() for h in self.hub.trust_manager._banned) + self.hub.message_helper.emit_notice( + outgoing, + link, + None, + "klines: " + (", ".join(items) if items else "(none)"), + ) + return True + + if op not in ("add", "del"): + self.hub.message_helper.emit_notice( + outgoing, + link, + None, + "usage: /kline add|del|list [nick|hashprefix|hash]", + ) + return True + + if len(parts) < 3: + self.hub.message_helper.emit_notice( + outgoing, link, None, f"usage: /kline {op} " + ) + return True + + target = parts[2] + if op == "add": + target_link = self._find_target_link(target) + if target_link is not None: + tsess = self.hub.session_manager.sessions.get(target_link) + ph = tsess.get("peer") if tsess else None + if isinstance(ph, (bytes, bytearray)): + self.hub.trust_manager.add_ban(bytes(ph)) + self.hub.trust_manager.persist_banned_identities_to_config( + link, None, outgoing + ) + try: + target_link.teardown() + except Exception: + pass + self.hub.message_helper.emit_notice( + outgoing, link, None, f"kline added for {target}" + ) + return True + + all_matches = self._find_target_links(target, room=None) + if all_matches: + self.hub.message_helper.emit_notice( + outgoing, + link, + None, + self._format_ambiguous_targets(target, all_matches), + ) + return True + + try: + h = self.hub._parse_identity_hash(target) + except Exception as e: + self.hub.message_helper.emit_notice( + outgoing, link, None, f"bad identity hash: {e}" + ) + return True + self.hub.trust_manager.add_ban(h) + self.hub.trust_manager.persist_banned_identities_to_config( + link, None, outgoing + ) + self.hub.message_helper.emit_notice( + outgoing, link, None, f"kline added for {h.hex()}" + ) + return True + + try: + h = self.hub._parse_identity_hash(target) + except Exception as e: + self.hub.message_helper.emit_notice( + outgoing, link, None, f"bad identity hash: {e}" + ) + return True + + if self.hub.trust_manager.is_banned(h): + self.hub.trust_manager.remove_ban(h) + self.hub.trust_manager.persist_banned_identities_to_config( + link, None, outgoing + ) + self.hub.message_helper.emit_notice( + outgoing, link, None, f"kline removed for {h.hex()}" + ) + else: + self.hub.message_helper.emit_notice( + outgoing, link, None, f"not klined: {h.hex()}" + ) + return True + + if cmd == "register": + if len(parts) < 2: + self.hub.message_helper.emit_notice( + outgoing, link, None, "usage: /register " + ) + return True + try: + r = self.hub._norm_room(parts[1]) + except Exception as e: + self.hub.message_helper.emit_notice( + outgoing, link, None, f"bad room: {e}" + ) + return True + if ( + not room + or self.hub._norm_room(room) != r + or r + not in self.hub.session_manager.sessions.get(link, {}).get( + "rooms", set() + ) + ): + self.hub.message_helper.emit_notice( + outgoing, link, room, "must be present in the room to register it" + ) + return True + + st = self.hub.room_manager._room_state_ensure(r) + + if self.hub.room_manager.prune_expired_invites(r) and bool( + st.get("registered") + ): + self.hub.room_manager.persist_room_state(link, r) + founder = st.get("founder") + if not ( + isinstance(founder, (bytes, bytearray)) and bytes(founder) == peer_hash + ): + if self.hub.identity is not None: + self._emit_error( + outgoing, + link, + src=self.hub.identity.hash, + text="only the room founder can register", + room=r, + ) + return True + + if not self.hub.room_manager.get_registry_path_for_writes(): + self.hub.message_helper.emit_notice( + outgoing, link, room, "cannot register room: no room_registry_path" + ) + return True + st["registered"] = True + st["no_outside_msgs"] = True + st["topic_ops_only"] = True + if isinstance(founder, (bytes, bytearray)): + st.setdefault("ops", set()).add(bytes(founder)) + self.hub.room_manager.touch_room(r) + + self.hub.room_manager._room_registry[r] = { + "founder": ( + bytes(founder) if isinstance(founder, (bytes, bytearray)) else None + ), + "registered": True, + "topic": st.get("topic"), + "moderated": bool(st.get("moderated", False)), + "ops": ( + set(st.get("ops", set())) + if isinstance(st.get("ops"), set) + else set() + ), + "voiced": ( + set(st.get("voiced", set())) + if isinstance(st.get("voiced"), set) + else set() + ), + "bans": ( + set(st.get("bans", set())) + if isinstance(st.get("bans"), set) + else set() + ), + "last_used_ts": st.get("last_used_ts"), + } + + self.hub.room_manager.persist_room_state(link, r) + self.hub.message_helper.emit_notice( + outgoing, link, room, f"registered room {r}" + ) + return True + + if cmd == "unregister": + if len(parts) < 2: + self.hub.message_helper.emit_notice( + outgoing, link, None, "usage: /unregister " + ) + return True + try: + r = self.hub._norm_room(parts[1]) + except Exception as e: + self.hub.message_helper.emit_notice( + outgoing, link, None, f"bad room: {e}" + ) + return True + + if ( + not room + or self.hub._norm_room(room) != r + or r + not in self.hub.session_manager.sessions.get(link, {}).get( + "rooms", set() + ) + ): + self.hub.message_helper.emit_notice( + outgoing, link, room, "must be present in the room to unregister it" + ) + return True + + st = self.hub.room_manager._room_state_ensure(r) + founder = st.get("founder") + if not ( + isinstance(founder, (bytes, bytearray)) and bytes(founder) == peer_hash + ): + if self.hub.identity is not None: + self._emit_error( + outgoing, + link, + src=self.hub.identity.hash, + text="only the room founder can unregister", + room=r, + ) + return True + + if not st.get("registered"): + self.hub.message_helper.emit_notice( + outgoing, link, room, f"room {r} is not registered" + ) + return True + + st["registered"] = False + self.hub.room_manager._room_registry.pop(r, None) + self.hub.room_manager.delete_room_from_registry(link, r) + if not self.hub.room_manager.get_room_members( + r + ) or not self.hub.room_manager.get_room_members(r): + self.hub.room_manager._room_state.pop(r, None) + self.hub.message_helper.emit_notice( + outgoing, link, room, f"unregistered room {r}" + ) + return True + + if cmd == "topic": + if len(parts) < 2: + self.hub.message_helper.emit_notice( + outgoing, link, None, "usage: /topic [topic]" + ) + return True + try: + r = self.hub._norm_room(parts[1]) + except Exception as e: + self.hub.message_helper.emit_notice( + outgoing, link, None, f"bad room: {e}" + ) + return True + st = self.hub.room_manager._room_state_ensure(r) + if len(parts) == 2: + topic = st.get("topic") + self.hub.message_helper.emit_notice( + outgoing, + link, + room, + f"topic for {r}: {topic if topic else '(none)'}", + ) + return True + + if not self.hub.room_manager.is_room_op(r, peer_hash): + st = self.hub.room_manager._room_state_ensure(r) + if bool(st.get("topic_ops_only", False)): + if self.hub.identity is not None: + self._emit_error( + outgoing, + link, + src=self.hub.identity.hash, + text="not authorized (+t)", + room=r, + ) + return True + + topic = " ".join(parts[2:]).strip() + st["topic"] = topic if topic else None + self.hub.room_manager.touch_room(r) + self.hub.room_manager.persist_room_state(link, r) + for other in list(self.hub.room_manager.get_room_members(r)): + self.hub.message_helper.emit_notice( + outgoing, + other, + r, + f"topic for {r} is now: {topic if topic else '(cleared)'}", + ) + return True + + if cmd in ("op", "deop", "voice", "devoice"): + if len(parts) < 3: + self.hub.message_helper.emit_notice( + outgoing, link, None, f"usage: /{cmd} " + ) + return True + try: + r = self.hub._norm_room(parts[1]) + except Exception as e: + self.hub.message_helper.emit_notice( + outgoing, link, None, f"bad room: {e}" + ) + return True + if not self.hub.room_manager.is_room_op(r, peer_hash): + if self.hub.identity is not None: + self._emit_error( + outgoing, + link, + src=self.hub.identity.hash, + text="not authorized", + room=r, + ) + return True + + target_hash, all_matches = self.hub._resolve_identity_hash_with_matches( + parts[2], room=r + ) + if target_hash is None: + self.hub.message_helper.emit_notice( + outgoing, + link, + room, + self._format_ambiguous_targets(parts[2], all_matches), + ) + return True + + st = self.hub.room_manager._room_state_ensure(r) + founder = st.get("founder") + founder_b = ( + bytes(founder) if isinstance(founder, (bytes, bytearray)) else None + ) + + if cmd in ("op", "deop"): + ops = st.setdefault("ops", set()) + if not isinstance(ops, set): + ops = set() + st["ops"] = ops + if cmd == "op": + ops.add(target_hash) + self.hub.room_manager.touch_room(r) + self.hub.room_manager.persist_room_state(link, r) + self.hub.message_helper.emit_notice( + outgoing, link, room, f"op granted in {r}" + ) + return True + else: + if founder_b is not None and target_hash == founder_b: + self.hub.message_helper.emit_notice( + outgoing, link, room, "cannot deop founder" + ) + return True + ops.discard(target_hash) + self.hub.room_manager.touch_room(r) + self.hub.room_manager.persist_room_state(link, r) + self.hub.message_helper.emit_notice( + outgoing, link, room, f"op removed in {r}" + ) + return True + + voiced = st.setdefault("voiced", set()) + if not isinstance(voiced, set): + voiced = set() + st["voiced"] = voiced + if cmd == "voice": + voiced.add(target_hash) + self.hub.room_manager.touch_room(r) + self.hub.room_manager.persist_room_state(link, r) + self.hub.message_helper.emit_notice( + outgoing, link, room, f"voice granted in {r}" + ) + return True + else: + voiced.discard(target_hash) + self.hub.room_manager.touch_room(r) + self.hub.room_manager.persist_room_state(link, r) + self.hub.message_helper.emit_notice( + outgoing, link, room, f"voice removed in {r}" + ) + return True + + if cmd == "mode": + if len(parts) < 3: + self.hub.message_helper.emit_notice( + outgoing, + link, + None, + "usage: /mode (+m|-m|+i|-i|+t|-t|+n|-n|+p|-p|+k|-k|+r|-r) [key] | /mode (+o|-o|+v|-v) ", + ) + return True + try: + r = self.hub._norm_room(parts[1]) + except Exception as e: + self.hub.message_helper.emit_notice( + outgoing, link, None, f"bad room: {e}" + ) + return True + if not self.hub.room_manager.is_room_op(r, peer_hash): + if self.hub.identity is not None: + self._emit_error( + outgoing, + link, + src=self.hub.identity.hash, + text="not authorized", + room=r, + ) + return True + flag = parts[2].strip().lower() + st = self.hub.room_manager._room_state_ensure(r) + + if flag in ("+m", "-m"): + st["moderated"] = flag == "+m" + self.hub.room_manager.touch_room(r) + self.hub.room_manager.persist_room_state(link, r) + self.hub.room_manager.broadcast_room_mode(r, outgoing) + return True + + if flag in ("+i", "-i"): + st["invite_only"] = flag == "+i" + self.hub.room_manager.touch_room(r) + self.hub.room_manager.persist_room_state(link, r) + self.hub.room_manager.broadcast_room_mode(r, outgoing) + return True + + if flag in ("+t", "-t"): + st["topic_ops_only"] = flag == "+t" + self.hub.room_manager.touch_room(r) + self.hub.room_manager.persist_room_state(link, r) + self.hub.room_manager.broadcast_room_mode(r, outgoing) + return True + + if flag in ("+n", "-n"): + st["no_outside_msgs"] = flag == "+n" + self.hub.room_manager.touch_room(r) + self.hub.room_manager.persist_room_state(link, r) + self.hub.room_manager.broadcast_room_mode(r, outgoing) + return True + + if flag in ("+p", "-p"): + st["private"] = flag == "+p" + self.hub.room_manager.touch_room(r) + self.hub.room_manager.persist_room_state(link, r) + self.hub.room_manager.broadcast_room_mode(r, outgoing) + return True + + if flag in ("+k", "-k"): + if flag == "+k": + if len(parts) < 4: + self.hub.message_helper.emit_notice( + outgoing, link, room, "usage: /mode +k " + ) + return True + key = " ".join(parts[3:]).strip() + if not key: + self.hub.message_helper.emit_notice( + outgoing, link, room, "key must not be empty" + ) + return True + st["key"] = key + else: + st["key"] = None + self.hub.room_manager.touch_room(r) + self.hub.room_manager.persist_room_state(link, r) + self.hub.room_manager.broadcast_room_mode(r, outgoing) + return True + + if flag in ("+r", "-r"): + self.hub.message_helper.emit_notice( + outgoing, link, room, "use /register or /unregister to change +r" + ) + return True + + if flag in ("+o", "-o", "+v", "-v"): + if len(parts) < 4: + self.hub.message_helper.emit_notice( + outgoing, + link, + room, + "usage: /mode (+o|-o|+v|-v) ", + ) + return True + + target_hash, all_matches = self.hub._resolve_identity_hash_with_matches( + parts[3], room=r + ) + if target_hash is None: + self.hub.message_helper.emit_notice( + outgoing, + link, + room, + self._format_ambiguous_targets(parts[3], all_matches), + ) + return True + + founder = st.get("founder") + founder_b = ( + bytes(founder) if isinstance(founder, (bytes, bytearray)) else None + ) + + if flag in ("+o", "-o"): + ops = st.setdefault("ops", set()) + if not isinstance(ops, set): + ops = set() + st["ops"] = ops + + if flag == "+o": + ops.add(target_hash) + else: + if founder_b is not None and target_hash == founder_b: + self.hub.message_helper.emit_notice( + outgoing, link, room, "cannot deop founder" + ) + return True + ops.discard(target_hash) + + self.hub.room_manager.touch_room(r) + self.hub.room_manager.persist_room_state(link, r) + for other in list(self.hub.room_manager.get_room_members(r)): + self.hub.message_helper.emit_notice( + outgoing, + other, + r, + f"mode for {r} is now: {flag} {target_hash.hex()[:12]}", + ) + return True + + voiced = st.setdefault("voiced", set()) + if not isinstance(voiced, set): + voiced = set() + st["voiced"] = voiced + if flag == "+v": + voiced.add(target_hash) + else: + voiced.discard(target_hash) + + self.hub.room_manager.touch_room(r) + self.hub.room_manager.persist_room_state(link, r) + for other in list(self.hub.room_manager.get_room_members(r)): + self.hub.message_helper.emit_notice( + outgoing, + other, + r, + f"mode for {r} is now: {flag} {target_hash.hex()[:12]}", + ) + return True + + self.hub.message_helper.emit_notice( + outgoing, + link, + room, + "supported modes: +m -m +i -i +k -k +t -t +n -n +p -p +r -r +o -o +v -v", + ) + return True + + if cmd == "ban": + if len(parts) < 3: + self.hub.message_helper.emit_notice( + outgoing, + link, + None, + "usage: /ban add|del|list [nick|hashprefix|hash]", + ) + return True + + try: + r = self.hub._norm_room(parts[1]) + except Exception as e: + self.hub.message_helper.emit_notice( + outgoing, link, None, f"bad room: {e}" + ) + return True + + op = parts[2].strip().lower() + if op == "list": + st = self.hub.room_manager._room_state_ensure(r) + bans = st.get("bans") + if not isinstance(bans, set) or not bans: + self.hub.message_helper.emit_notice( + outgoing, link, room, f"no bans in {r}" + ) + return True + items = sorted( + bytes(x).hex() for x in bans if isinstance(x, (bytes, bytearray)) + ) + self.hub.message_helper.emit_notice( + outgoing, link, room, f"bans in {r}: " + ", ".join(items) + ) + return True + + if op not in ("add", "del"): + self.hub.message_helper.emit_notice( + outgoing, + link, + room, + "usage: /ban add|del|list [nick|hashprefix|hash]", + ) + return True + + if len(parts) < 4: + self.hub.message_helper.emit_notice( + outgoing, link, room, f"usage: /ban {r} {op} " + ) + return True + + if not self.hub.room_manager.is_room_op(r, peer_hash): + if self.hub.identity is not None: + self._emit_error( + outgoing, + link, + src=self.hub.identity.hash, + text="not authorized", + room=r, + ) + return True + + target_hash, all_matches = self.hub._resolve_identity_hash_with_matches( + parts[3], room=r + ) + if target_hash is None: + self.hub.message_helper.emit_notice( + outgoing, + link, + room, + self._format_ambiguous_targets(parts[3], all_matches), + ) + return True + + st = self.hub.room_manager._room_state_ensure(r) + bans = st.setdefault("bans", set()) + if not isinstance(bans, set): + bans = set() + st["bans"] = bans + + if op == "add": + bans.add(target_hash) + self.hub.room_manager.touch_room(r) + self.hub.room_manager.persist_room_state(link, r) + + for other in list(self.hub.room_manager.get_room_members(r)): + s = self.hub.session_manager.sessions.get(other) + ph = s.get("peer") if s else None + if isinstance(ph, (bytes, bytearray)) and bytes(ph) == target_hash: + s.get("rooms", set()).discard(r) + self.hub.room_manager.get_room_members(r).discard(other) + if self.hub.identity is not None: + self._emit_error( + outgoing, + other, + src=self.hub.identity.hash, + text=f"banned from {r}", + room=r, + ) + if ( + self.hub.room_manager.get_room_members(r) + and not self.hub.room_manager.rooms[r] + ): + pass + self.hub.message_helper.emit_notice( + outgoing, link, room, f"ban added in {r}" + ) + return True + + bans.discard(target_hash) + self.hub.room_manager.touch_room(r) + self.hub.room_manager.persist_room_state(link, r) + self.hub.message_helper.emit_notice( + outgoing, link, room, f"ban removed in {r}" + ) + return True + + if cmd == "invite": + if len(parts) < 3: + self.hub.message_helper.emit_notice( + outgoing, + link, + None, + "usage: /invite add|del|list [nick|hashprefix|hash]", + ) + return True + + try: + r = self.hub._norm_room(parts[1]) + except Exception as e: + self.hub.message_helper.emit_notice( + outgoing, link, None, f"bad room: {e}" + ) + return True + + if not self.hub.room_manager.is_room_op(r, peer_hash): + if self.hub.identity is not None: + self._emit_error( + outgoing, + link, + src=self.hub.identity.hash, + text="not authorized", + room=r, + ) + return True + + op = parts[2].strip().lower() + st = self.hub.room_manager._room_state_ensure(r) + + invited = st.setdefault("invited", {}) + if not isinstance(invited, dict): + invited = {} + st["invited"] = invited + + pruned = self.hub.room_manager.prune_expired_invites(r) + + if op == "list": + now = float(time.time()) + items = [] + for h, exp in invited.items(): + if not isinstance(h, (bytes, bytearray)): + continue + try: + exp_f = float(exp) + except Exception: + continue + if exp_f <= now: + continue + items.append(f"{bytes(h).hex()} expires_in={int(exp_f - now)}s") + items.sort() + if pruned: + self.hub.room_manager.touch_room(r) + self.hub.room_manager.persist_room_state(link, r) + self.hub.message_helper.emit_notice( + outgoing, + link, + room, + f"invites in {r}: " + (", ".join(items) if items else "(none)"), + ) + return True + + if op not in ("add", "del"): + self.hub.message_helper.emit_notice( + outgoing, + link, + room, + "usage: /invite add|del|list [nick|hashprefix|hash]", + ) + return True + + if len(parts) < 4: + self.hub.message_helper.emit_notice( + outgoing, + link, + room, + f"usage: /invite {r} {op} ", + ) + return True + + if op == "add": + token = parts[3] + target_link = self._find_target_link(token, room=None) + if target_link is None: + all_matches = self._find_target_links(token, room=None) + if self.hub.identity is not None: + self._emit_error( + outgoing, + link, + src=self.hub.identity.hash, + text=f"invite failed: {self._format_ambiguous_targets(token, all_matches)}", + room=r, + ) + return True + + tsess = self.hub.session_manager.sessions.get(target_link) + ph = tsess.get("peer") if tsess else None + if not isinstance(ph, (bytes, bytearray)): + if self.hub.identity is not None: + self._emit_error( + outgoing, + link, + src=self.hub.identity.hash, + text="invite failed: target not identified", + room=r, + ) + return True + target_hash = bytes(ph) + + key = st.get("key") + is_keyed = isinstance(key, str) and bool(key) + is_invite_only = bool(st.get("invite_only", False)) + + if is_keyed: + self.hub.message_helper.emit_notice( + outgoing, + target_link, + r, + f"You have been invited to join {r}. This invite allows joining without the key (+k).", + ) + else: + self.hub.message_helper.emit_notice( + outgoing, target_link, r, f"You have been invited to join {r}." + ) + + if is_keyed or is_invite_only: + ttl = ( + float(self.hub.config.room_invite_timeout_s) + if self.hub.config.room_invite_timeout_s + else 0.0 + ) + if ttl <= 0: + ttl = 900.0 + exp = float(time.time()) + ttl + invited[target_hash] = exp + self.hub.room_manager.touch_room(r) + self.hub.room_manager.persist_room_state(link, r) + self.hub.message_helper.emit_notice( + outgoing, + link, + room, + f"invite added in {r} (expires in {int(ttl)}s)", + ) + else: + self.hub.message_helper.emit_notice( + outgoing, link, room, f"invite sent to {token} for {r}" + ) + return True + + target_hash, all_matches = self.hub._resolve_identity_hash_with_matches( + parts[3], room=None + ) + if target_hash is None: + self.hub.message_helper.emit_notice( + outgoing, + link, + room, + self._format_ambiguous_targets(parts[3], all_matches), + ) + return True + + if target_hash in invited: + invited.pop(target_hash, None) + self.hub.room_manager.touch_room(r) + self.hub.room_manager.persist_room_state(link, r) + self.hub.message_helper.emit_notice( + outgoing, link, room, f"invite removed in {r}" + ) + return True + + return False + + def _find_target_link(self, token: str, room: str | None = None) -> RNS.Link | None: + """Find a link by nick or identity hash prefix. Uses indexes for O(1) lookups. + Returns the link if exactly one match, None otherwise. + """ + result = self._find_target_links(token, room) + if len(result) == 1: + return result[0] + return None + + def _find_target_links(self, token: str, room: str | None = None) -> list[RNS.Link]: + """Find all links matching a nick or identity hash prefix. + Returns list of matching links (empty if none, multiple if ambiguous). + """ + t = token.strip().lower() + if not t: + return [] + + hex_candidate = t[2:] if t.startswith("0x") else t + if ( + all(c in "0123456789abcdef" for c in hex_candidate) + and len(hex_candidate) >= 6 + ): + try: + prefix = bytes.fromhex(hex_candidate) + except Exception: + prefix = None + + if prefix is not None: + with self.hub._state_lock: + matches: list[RNS.Link] = [] + for ( + peer_hash, + candidate_link, + ) in self.hub.session_manager._index_by_hash.items(): + if peer_hash.startswith(prefix): + if room is not None: + sess = self.hub.session_manager.sessions.get( + candidate_link + ) + if sess and room not in sess.get("rooms", set()): + continue + matches.append(candidate_link) + + return matches + + with self.hub._state_lock: + candidate_links = self.hub.session_manager._index_by_nick.get(t, set()) + if not candidate_links: + return [] + + if room is not None: + matches = [] + for candidate_link in candidate_links: + sess = self.hub.session_manager.sessions.get(candidate_link) + if sess and room in sess.get("rooms", set()): + matches.append(candidate_link) + else: + matches = list(candidate_links) + + return matches + + def _format_ambiguous_targets(self, token: str, matches: list[RNS.Link]) -> str: + """Format a helpful message when target lookup is ambiguous.""" + if not matches: + return f"target '{token}' not found" + + with self.hub._state_lock: + items = [] + for match_link in matches: + sess = self.hub.session_manager.sessions.get(match_link) + if not sess: + continue + peer = sess.get("peer") + nick = sess.get("nick") + hash_str = self.hub._fmt_hash(peer, prefix=16) if peer else "?" + nick_str = f"nick={nick!r}" if nick else "(no nick)" + items.append(f"{hash_str} {nick_str}") + + if len(items) == 0: + return f"target '{token}' not found" + + return ( + f"ambiguous: '{token}' matches {len(items)} identities:\n" + + "\n".join(f" - {item}" for item in items) + + "\nUse full or longer identity hash to disambiguate." + ) + + def _emit_notice( + self, + outgoing: list[tuple[RNS.Link, bytes]] | None, + link: RNS.Link, + room: str | None, + text: str, + ) -> None: + if self.hub.identity is None: + return + env = make_envelope(T_NOTICE, src=self.hub.identity.hash, room=room, body=text) + if outgoing is None: + self.hub.message_helper.send(link, env) + else: + self.hub.message_helper.queue_env(outgoing, link, env) + + def _emit_error( + self, + outgoing: list[tuple[RNS.Link, bytes]] | None, + link: RNS.Link, + *, + src: bytes, + text: str, + room: str | None = None, + ) -> None: + self.hub.stats_manager.inc("errors_sent") + env = make_envelope(T_ERROR, src=src, room=room, body=text) + if outgoing is None: + self.hub.message_helper.send(link, env) + else: + self.hub.message_helper.queue_env(outgoing, link, env) diff --git a/rrcd/config.py b/rrcd/config.py index 7a82033..2c62a11 100644 --- a/rrcd/config.py +++ b/rrcd/config.py @@ -1,6 +1,11 @@ from __future__ import annotations -from dataclasses import dataclass +import threading +from dataclasses import asdict, dataclass, replace +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from .service import HubService @dataclass(frozen=True) @@ -26,7 +31,7 @@ class HubRuntimeConfig: rate_limit_msgs_per_minute: int = 240 ping_interval_s: float = 0.0 ping_timeout_s: float = 0.0 - max_resource_bytes: int = 256 * 1024 # 256 KiB default + max_resource_bytes: int = 256 * 1024 max_pending_resource_expectations: int = 8 resource_expectation_ttl_s: float = 30.0 enable_resource_transfer: bool = True @@ -36,3 +41,122 @@ class HubRuntimeConfig: log_file: str | None = None log_format: str = "%(asctime)s %(levelname)s %(name)s[%(threadName)s]: %(message)s" log_datefmt: str | None = None + + +class ConfigManager: + """ + Manages hub configuration loading, reloading, and persistence. + + Handles: + - Loading TOML configuration files + - Applying configuration updates + - Reloading configuration at runtime + - Config diffing and comparison + - Config file path resolution + """ + + def __init__(self, hub: HubService) -> None: + self.hub = hub + self.log = hub.log + self._write_lock = threading.Lock() + + def load_toml(self, path: str) -> dict: + """Load a TOML file and return its contents as a dictionary.""" + import tomllib + + with open(path, "rb") as f: + data = tomllib.load(f) + return data if isinstance(data, dict) else {} + + def apply_config_data(self, base: HubRuntimeConfig, data: dict) -> HubRuntimeConfig: + """Apply configuration data from TOML to a runtime config instance.""" + hub = data.get("hub") if isinstance(data, dict) else None + if isinstance(hub, dict): + data = {**data, **hub} + + log_table = data.get("logging") if isinstance(data, dict) else None + if isinstance(log_table, dict): + mapped: dict[str, object] = {} + if "level" in log_table: + mapped["log_level"] = log_table.get("level") + if "rns_level" in log_table: + mapped["log_rns_level"] = log_table.get("rns_level") + if "console" in log_table: + mapped["log_console"] = log_table.get("console") + if "file" in log_table: + mapped["log_file"] = log_table.get("file") + if "format" in log_table: + mapped["log_format"] = log_table.get("format") + if "datefmt" in log_table: + mapped["log_datefmt"] = log_table.get("datefmt") + data = {**data, **mapped} + + allowed = set(asdict(base).keys()) + allowed.discard("config_path") + + updates = {k: v for k, v in data.items() if k in allowed} + + for list_key in ("trusted_identities", "banned_identities"): + if list_key in updates and isinstance(updates[list_key], list): + updates[list_key] = tuple(str(x) for x in updates[list_key]) + + if "announce" in data and "announce_on_start" not in updates: + try: + updates["announce_on_start"] = bool(data["announce"]) + except Exception: + pass + if "configdir" in updates and updates["configdir"] == "": + updates["configdir"] = None + if "greeting" in updates and updates["greeting"] == "": + updates["greeting"] = None + if "log_file" in updates and updates["log_file"] == "": + updates["log_file"] = None + if "log_datefmt" in updates and updates["log_datefmt"] == "": + updates["log_datefmt"] = None + + return replace(base, **updates) if updates else base + + def format_reload_value(self, v: Any) -> str: + """Format a config value for display in reload summaries.""" + if v is None: + return "(none)" + if isinstance(v, (bool, int, float)): + return str(v) + if isinstance(v, (tuple, list, set)): + return f"len={len(v)}" + s = str(v) + s = " ".join(s.split()) + if len(s) > 80: + s = s[:77] + "..." + return s + + def diff_config_summary( + self, old: HubRuntimeConfig, new: HubRuntimeConfig + ) -> list[str]: + """Generate a summary of differences between two config instances.""" + old_d = asdict(old) + new_d = asdict(new) + old_d.pop("config_path", None) + new_d.pop("config_path", None) + + changed: list[str] = [] + for k in sorted(new_d.keys()): + if old_d.get(k) == new_d.get(k): + continue + changed.append( + f"{k}: {self.format_reload_value(old_d.get(k))} -> {self.format_reload_value(new_d.get(k))}" + ) + return changed + + def get_config_path_for_writes(self) -> str | None: + """Get the resolved config file path for write operations.""" + from .util import expand_path + + p = self.hub.config.config_path + if not p: + return None + return expand_path(str(p)) + + def get_write_lock(self) -> threading.Lock: + """Get the lock used for config file write operations.""" + return self._write_lock diff --git a/rrcd/envelope.py b/rrcd/envelope.py index 366d420..cb7944a 100644 --- a/rrcd/envelope.py +++ b/rrcd/envelope.py @@ -85,11 +85,8 @@ def validate_envelope(env: dict) -> None: room = env[K_ROOM] if not isinstance(room, str): raise TypeError("room name must be a string") - # Per RRC spec, room field may be empty (e.g., for hub commands) if K_NICK in env: nick = env[K_NICK] if not isinstance(nick, str): raise TypeError("nickname must be a string") - # Per spec, nicknames are advisory and may be empty or "ridiculous". - # Type-check only; implementations may sanitize/ignore for display. diff --git a/rrcd/logging_config.py b/rrcd/logging_config.py index a5e9180..f953aed 100644 --- a/rrcd/logging_config.py +++ b/rrcd/logging_config.py @@ -99,7 +99,6 @@ def configure_logging( root.setLevel(level) - # Library loggers logging.getLogger("RNS").setLevel(rns_level) logging.captureWarnings(True) diff --git a/rrcd/messages.py b/rrcd/messages.py new file mode 100644 index 0000000..c73d77e --- /dev/null +++ b/rrcd/messages.py @@ -0,0 +1,294 @@ +"""Message sending and queueing utilities for the RRC hub.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import RNS + +from .codec import encode +from .constants import B_WELCOME_HUB, B_WELCOME_VER, T_ERROR, T_NOTICE, T_WELCOME +from .envelope import make_envelope + +if TYPE_CHECKING: + from .service import HubService + +# Maximum characters per NOTICE chunk for MTU-safe delivery +MAX_NOTICE_CHUNK_CHARS = 512 + + +class MessageHelper: + """ + Helper methods for sending and queueing messages. + + Handles: + - Message queueing (outgoing lists) + - Notice chunking for large messages + - WELCOME message construction + - Error and notice emission + - Smart text sending (resource vs chunks) + """ + + def __init__(self, hub: HubService) -> None: + self.hub = hub + self.log = hub.log + + def packet_would_fit(self, link: RNS.Link, payload: bytes) -> bool: + """Check if payload fits within link MDU without creating/packing packets.""" + try: + if hasattr(link, "MDU") and link.MDU is not None: + return len(payload) <= link.MDU + pkt = RNS.Packet(link, payload) + pkt.pack() + return True + except Exception: + return False + + def queue_payload( + self, outgoing: list[tuple[RNS.Link, bytes]], link: RNS.Link, payload: bytes + ) -> None: + """Add a raw payload to the outgoing queue.""" + self.hub.stats_manager.inc("bytes_out", len(payload)) + outgoing.append((link, payload)) + + def queue_env( + self, outgoing: list[tuple[RNS.Link, bytes]], link: RNS.Link, env: dict + ) -> None: + """Encode and queue an envelope.""" + payload = encode(env) + self.queue_payload(outgoing, link, payload) + + def queue_notice_chunks( + self, + outgoing: list[tuple[RNS.Link, bytes]], + link: RNS.Link, + *, + room: str | None, + text: str, + ) -> None: + """Split and queue a notice message into MTU-sized chunks.""" + if self.hub.identity is None: + return + if not text: + return + + lines = text.splitlines() or [text] + for line in lines: + remaining = line + if not remaining: + continue + + max_chars = min(len(remaining), MAX_NOTICE_CHUNK_CHARS) + while remaining: + take = min(len(remaining), max_chars) + chunk = remaining[:take] + env = make_envelope( + T_NOTICE, + src=self.hub.identity.hash, + room=room, + body=chunk, + ) + payload = encode(env) + if self.packet_would_fit(link, payload): + self.queue_payload(outgoing, link, payload) + remaining = remaining[take:] + max_chars = min(max_chars, MAX_NOTICE_CHUNK_CHARS) + continue + + if max_chars <= 1: + self.log.warning( + "NOTICE chunk would not fit MTU; dropping remainder (%s chars)", + len(remaining), + ) + break + + max_chars = max(1, max_chars // 2) + + def queue_welcome( + self, + outgoing: list[tuple[RNS.Link, bytes]], + link: RNS.Link, + *, + peer_hash: Any, + motd: str | None, + ) -> None: + """Queue a WELCOME message for a newly connected peer.""" + if self.hub.identity is None: + return + + from . import __version__ + + body_w: dict[int, Any] = { + B_WELCOME_HUB: self.hub.config.hub_name, + B_WELCOME_VER: str(__version__), + } + + welcome = make_envelope(T_WELCOME, src=self.hub.identity.hash, body=body_w) + welcome_payload = encode(welcome) + + if not self.packet_would_fit(link, welcome_payload): + self.log.warning( + "WELCOME would not fit MTU; cannot welcome peer=%s link_id=%s", + self.hub._fmt_hash(peer_hash), + self.hub._fmt_link_id(link), + ) + return + + self.queue_payload(outgoing, link, welcome_payload) + self.log.debug( + "Queued WELCOME peer=%s link_id=%s", + self.hub._fmt_hash(peer_hash), + self.hub._fmt_link_id(link), + ) + + def send_text_smart( + self, + link: RNS.Link, + *, + msg_type: int, + text: str, + room: str | None = None, + kind: str | None = None, + outgoing: list[tuple[RNS.Link, bytes]] | None = None, + encoding: str = "utf-8", + ) -> None: + """ + Send text message using the most efficient method: + - Resource transfer for large messages (if enabled and outgoing is None) + - Chunked messages otherwise + """ + from .constants import RES_KIND_MOTD, RES_KIND_NOTICE + + resource_kind = kind + if resource_kind is None: + resource_kind = ( + RES_KIND_MOTD + if msg_type == T_NOTICE and room is None + else RES_KIND_NOTICE + ) + + if ( + self.hub.config.enable_resource_transfer + and outgoing is None + and len(text.encode(encoding, errors="replace")) > 512 + ): + self.log.debug( + "Attempting resource transfer link_id=%s kind=%s chars=%s", + self.hub._fmt_link_id(link), + resource_kind, + len(text), + ) + if self.hub.resource_manager.send_via_resource( + link, + kind=resource_kind, + payload=text.encode(encoding, errors="replace"), + room=room, + encoding=encoding, + ): + self.log.debug( + "Sent large text via resource link_id=%s kind=%s chars=%s", + self.hub._fmt_link_id(link), + resource_kind, + len(text), + ) + return + else: + self.log.warning( + "Resource send failed, falling back to chunks link_id=%s", + self.hub._fmt_link_id(link), + ) + + if msg_type == T_NOTICE: + self.log.debug( + "Falling back to chunking link_id=%s outgoing_is_none=%s", + self.hub._fmt_link_id(link), + outgoing is None, + ) + if outgoing is None: + outgoing = [] + self.queue_notice_chunks(outgoing, link, room=room, text=text) + for out_link, chunk_payload in outgoing: + self.hub.stats_manager.inc("bytes_out", len(chunk_payload)) + try: + RNS.Packet(out_link, chunk_payload).send() + except Exception as e: + self.log.warning( + "Failed to send chunk link_id=%s: %s", + self.hub._fmt_link_id(out_link), + e, + ) + else: + self.queue_notice_chunks(outgoing, link, room=room, text=text) + else: + self.log.error( + "Message too large and not NOTICE link_id=%s type=%s", + self.hub._fmt_link_id(link), + msg_type, + ) + + def emit_notice( + self, + outgoing: list[tuple[RNS.Link, bytes]] | None, + link: RNS.Link, + room: str | None, + text: str, + ) -> None: + """Emit a notice message (queued or immediate).""" + if self.hub.identity is None: + return + env = make_envelope(T_NOTICE, src=self.hub.identity.hash, room=room, body=text) + if outgoing is None: + self.send(link, env) + else: + self.queue_env(outgoing, link, env) + + def emit_error( + self, + outgoing: list[tuple[RNS.Link, bytes]] | None, + link: RNS.Link, + *, + src: bytes, + text: str, + room: str | None = None, + ) -> None: + """Emit an error message (queued or immediate).""" + self.hub.stats_manager.inc("errors_sent") + env = make_envelope(T_ERROR, src=src, room=room, body=text) + if outgoing is None: + self.send(link, env) + else: + self.queue_env(outgoing, link, env) + + def notice_to(self, link: RNS.Link, room: str | None, text: str) -> None: + """Send a notice message immediately.""" + if self.hub.identity is None: + return + env = make_envelope(T_NOTICE, src=self.hub.identity.hash, room=room, body=text) + self.send(link, env) + + def error( + self, link: RNS.Link, src: bytes, text: str, room: str | None = None + ) -> None: + """Send an error message immediately.""" + self.emit_error(None, link, src=src, text=text, room=room) + + def send(self, link: RNS.Link, env: dict) -> None: + """Send an envelope immediately (not queued).""" + payload = encode(env) + self.hub.stats_manager.inc("bytes_out", len(payload)) + try: + RNS.Packet(link, payload).send() + except OSError as e: + self.log.warning( + "Send failed link_id=%s bytes=%s err=%s", + self.hub._fmt_link_id(link), + len(payload), + e, + ) + except Exception: + self.log.debug( + "Send failed link_id=%s bytes=%s", + self.hub._fmt_link_id(link), + len(payload), + exc_info=True, + ) diff --git a/rrcd/resources.py b/rrcd/resources.py new file mode 100644 index 0000000..62c19e0 --- /dev/null +++ b/rrcd/resources.py @@ -0,0 +1,563 @@ +"""Resource transfer management for RRCD.""" + +from __future__ import annotations + +import hashlib +import os +import time +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import RNS + +from rrcd.codec import encode +from rrcd.constants import ( + B_RES_ENCODING, + B_RES_ID, + B_RES_KIND, + B_RES_SHA256, + B_RES_SIZE, + RES_KIND_BLOB, + RES_KIND_MOTD, + RES_KIND_NOTICE, + T_NOTICE, + T_RESOURCE_ENVELOPE, +) +from rrcd.envelope import make_envelope + +if TYPE_CHECKING: + from rrcd.service import HubService + + +@dataclass +class _ResourceExpectation: + """Tracks an expected incoming Resource transfer.""" + + id: bytes + kind: str + size: int + sha256: bytes | None + encoding: str | None + created_at: float + expires_at: float + room: str | None = None + + +class ResourceManager: + """Manages RNS Resource transfers for the hub.""" + + def __init__(self, hub: HubService) -> None: + self.hub = hub + self.log = hub.log + self._resource_expectations: dict[ + RNS.Link, dict[bytes, _ResourceExpectation] + ] = {} + self._active_resources: dict[RNS.Link, set[RNS.Resource]] = {} + self._resource_bindings: dict[RNS.Resource, bytes] = {} + + def on_link_established(self, link: RNS.Link) -> None: + """Initialize resource tracking for a new link.""" + self._resource_expectations[link] = {} + self._active_resources[link] = set() + + def on_link_closed(self, link: RNS.Link) -> None: + """Clean up resource state when a link closes.""" + self._resource_expectations.pop(link, None) + self._active_resources.pop(link, None) + + def clear_all(self) -> None: + """Clear all resource state (called during shutdown).""" + self._resource_expectations.clear() + self._active_resources.clear() + + def configure_link_callbacks(self, link: RNS.Link) -> None: + """Set up resource callbacks for a link if resource transfer is enabled.""" + if not self.hub.config.enable_resource_transfer: + return + + try: + link.set_resource_strategy(RNS.Link.ACCEPT_APP) + link.set_resource_callback(self._resource_advertised) + link.set_resource_concluded_callback(self._resource_concluded) + self.log.debug( + "Resource callbacks configured link_id=%s", + self.hub._fmt_link_id(link), + ) + except Exception as e: + self.log.warning( + "Failed to set resource callbacks link_id=%s: %s", + self.hub._fmt_link_id(link), + e, + ) + + def cleanup_expired_expectations(self, link: RNS.Link) -> None: + """Remove expired resource expectations for a link.""" + now = time.time() + exp_dict = self._resource_expectations.get(link) + if not exp_dict: + return + + expired = [rid for rid, exp in exp_dict.items() if exp.expires_at <= now] + for rid in expired: + exp_dict.pop(rid, None) + self.log.debug( + "Expired resource expectation link_id=%s rid=%s", + self.hub._fmt_link_id(link), + rid.hex() if isinstance(rid, bytes) else rid, + ) + + def cleanup_all_expired_expectations(self) -> None: + """Cleanup expired resource expectations across all links.""" + now = time.time() + with self.hub._state_lock: + for link, exp_dict in list(self._resource_expectations.items()): + if not exp_dict: + continue + + expired = [ + rid for rid, exp in exp_dict.items() if exp.expires_at <= now + ] + for rid in expired: + exp_dict.pop(rid, None) + self.log.debug( + "Expired resource expectation link_id=%s rid=%s", + self.hub._fmt_link_id(link), + rid.hex() if isinstance(rid, bytes) else rid, + ) + + def add_resource_expectation( + self, + link: RNS.Link, + *, + rid: bytes, + kind: str, + size: int, + sha256: bytes | None = None, + encoding: str | None = None, + room: str | None = None, + ) -> bool: + """Add a resource expectation. Returns False if limit exceeded.""" + self.cleanup_expired_expectations(link) + + exp_dict = self._resource_expectations.setdefault(link, {}) + + if len(exp_dict) >= self.hub.config.max_pending_resource_expectations: + self.log.warning( + "Max pending expectations exceeded link_id=%s", + self.hub._fmt_link_id(link), + ) + return False + + now = time.time() + exp = _ResourceExpectation( + id=rid, + kind=kind, + size=size, + sha256=sha256, + encoding=encoding, + created_at=now, + expires_at=now + self.hub.config.resource_expectation_ttl_s, + room=room, + ) + exp_dict[rid] = exp + + self.log.debug( + "Added resource expectation link_id=%s rid=%s kind=%s size=%s", + self.hub._fmt_link_id(link), + rid.hex(), + kind, + size, + ) + return True + + def find_resource_expectation( + self, link: RNS.Link, size: int + ) -> _ResourceExpectation | None: + """Find a matching resource expectation by size (fallback matching).""" + self.cleanup_expired_expectations(link) + + exp_dict = self._resource_expectations.get(link) + if not exp_dict: + return None + + for exp in exp_dict.values(): + if exp.size == size: + return exp + + return None + + def get_resource_expectation_by_rid( + self, link: RNS.Link, rid: bytes + ) -> _ResourceExpectation | None: + """Lookup an expectation by RID without removing it.""" + exp_dict = self._resource_expectations.get(link) + if not exp_dict: + return None + return exp_dict.get(rid) + + def match_resource_expectation( + self, link: RNS.Link, *, rid: bytes | None, size: int, sha256: bytes | None + ) -> _ResourceExpectation | None: + """Find the expectation that should satisfy a completed resource. + + Preference order: + 1) Bound RID (from advertisement) when available. + 2) Exact RID lookup. + 3) Fallback: first size match whose sha256 (if present) matches. + """ + self.cleanup_expired_expectations(link) + + if rid is not None: + exp = self.get_resource_expectation_by_rid(link, rid) + if exp is not None: + return exp + + exp_dict = self._resource_expectations.get(link) + if not exp_dict: + return None + + for exp in exp_dict.values(): + if exp.size != size: + continue + if exp.sha256 and sha256 and exp.sha256 != sha256: + continue + return exp + return None + + def pop_resource_expectation( + self, link: RNS.Link, rid: bytes + ) -> _ResourceExpectation | None: + """Remove and return a resource expectation.""" + exp_dict = self._resource_expectations.get(link) + if not exp_dict: + return None + return exp_dict.pop(rid, None) + + def _resource_advertised(self, resource: RNS.Resource) -> bool: + """ + Callback when a Resource is advertised by remote peer. + Returns True to accept, False to reject. + + Minimize lock scope to prevent potential deadlocks with RNS internal locks. + """ + link = resource.link + + if not self.hub.config.enable_resource_transfer: + self.log.debug( + "Rejecting resource (disabled) link_id=%s", + self.hub._fmt_link_id(link), + ) + self.hub.stats_manager.inc("resources_rejected") + return False + + size = resource.total_size if hasattr(resource, "total_size") else resource.size + if size > self.hub.config.max_resource_bytes: + self.log.warning( + "Rejecting resource (too large: %s > %s) link_id=%s", + size, + self.hub.config.max_resource_bytes, + self.hub._fmt_link_id(link), + ) + self.hub.stats_manager.inc("resources_rejected") + return False + + with self.hub._state_lock: + sess = self.hub.session_manager.sessions.get(link) + if not sess: + self.log.debug( + "Rejecting resource (no session) link_id=%s", + self.hub._fmt_link_id(link), + ) + self.hub.stats_manager.inc("resources_rejected") + return False + + exp = self.find_resource_expectation(link, size) + + if not exp: + self.log.warning( + "Rejecting resource (no matching expectation) link_id=%s size=%s", + self.hub._fmt_link_id(link), + size, + ) + self.hub.stats_manager.inc("resources_rejected") + return False + + self.log.info( + "Accepting resource link_id=%s size=%s kind=%s", + self.hub._fmt_link_id(link), + size, + exp.kind, + ) + + with self.hub._state_lock: + self._active_resources.setdefault(link, set()).add(resource) + self._resource_bindings[resource] = exp.id + + return True + + def _resource_concluded(self, resource: RNS.Resource) -> None: + """Callback when a Resource transfer completes.""" + link = resource.link + + with self.hub._state_lock: + active_set = self._active_resources.get(link) + if active_set: + active_set.discard(resource) + bound_rid = self._resource_bindings.pop(resource, None) + + if resource.status != RNS.Resource.COMPLETE: + self.log.warning( + "Resource transfer failed link_id=%s status=%s", + self.hub._fmt_link_id(link), + resource.status, + ) + return + + try: + payload = ( + resource.data.read() + if hasattr(resource.data, "read") + else resource.data + ) + if isinstance(payload, bytearray): + payload = bytes(payload) + except Exception as e: + self.log.error( + "Failed to read resource data link_id=%s: %s", + self.hub._fmt_link_id(link), + e, + ) + return + + size = len(payload) + actual_hash = hashlib.sha256(payload).digest() + + exp = self.match_resource_expectation( + link, rid=bound_rid, size=size, sha256=actual_hash + ) + if not exp: + self.log.warning( + "Received resource without expectation link_id=%s size=%s", + self.hub._fmt_link_id(link), + size, + ) + return + + if exp.sha256 and actual_hash != exp.sha256: + self.log.error( + "Resource SHA256 mismatch link_id=%s expected=%s actual=%s", + self.hub._fmt_link_id(link), + exp.sha256.hex(), + actual_hash.hex(), + ) + return + + self.pop_resource_expectation(link, exp.id) + + self.hub.stats_manager.inc("resources_received") + self.hub.stats_manager.inc("resource_bytes_received", size) + + self.log.info( + "Resource received link_id=%s size=%s kind=%s", + self.hub._fmt_link_id(link), + size, + exp.kind, + ) + + try: + self._dispatch_received_resource(link, exp, payload) + except Exception as e: + self.log.exception( + "Failed to dispatch resource link_id=%s kind=%s: %s", + self.hub._fmt_link_id(link), + exp.kind, + e, + ) + + def _dispatch_received_resource( + self, link: RNS.Link, exp: _ResourceExpectation, payload: bytes + ) -> None: + """Dispatch a received resource payload to appropriate handler.""" + if exp.kind == RES_KIND_NOTICE: + encoding = exp.encoding or "utf-8" + try: + text = payload.decode(encoding) + except Exception as e: + self.log.error( + "Failed to decode notice resource link_id=%s encoding=%s: %s", + self.hub._fmt_link_id(link), + encoding, + e, + ) + return + + self.log.info( + "Received large NOTICE via resource link_id=%s room=%r chars=%s", + self.hub._fmt_link_id(link), + exp.room, + len(text), + ) + + if exp.room and self.hub.identity is not None: + with self.hub._state_lock: + sess = self.hub.session_manager.sessions.get(link) + peer_hash = sess.get("peer") if sess else None + room_members = self.hub.room_manager.get_room_members(exp.room) + + if peer_hash and room_members: + notice_env = make_envelope( + T_NOTICE, + src=peer_hash, + room=exp.room, + body=text, + ) + notice_payload = encode(notice_env) + + forwarded = 0 + for other in room_members: + if other != link: + try: + other.packet(notice_payload) + forwarded += 1 + except Exception as e: + self.log.warning( + "Failed to forward NOTICE resource link_id=%s: %s", + self.hub._fmt_link_id(other), + e, + ) + + if forwarded > 0: + self.hub.stats_manager.inc("notices_forwarded") + self.log.debug( + "Forwarded NOTICE resource to %d members room=%s", + forwarded, + exp.room, + ) + + elif exp.kind == RES_KIND_MOTD: + encoding = exp.encoding or "utf-8" + try: + text = payload.decode(encoding) + except Exception as e: + self.log.error( + "Failed to decode MOTD resource link_id=%s: %s", + self.hub._fmt_link_id(link), + e, + ) + return + + self.log.info( + "Received MOTD via resource link_id=%s chars=%s", + self.hub._fmt_link_id(link), + len(text), + ) + + elif exp.kind == RES_KIND_BLOB: + self.log.info( + "Received BLOB via resource link_id=%s bytes=%s", + self.hub._fmt_link_id(link), + len(payload), + ) + else: + self.log.warning( + "Unknown resource kind link_id=%s kind=%s", + self.hub._fmt_link_id(link), + exp.kind, + ) + + def send_via_resource( + self, + link: RNS.Link, + *, + kind: str, + payload: bytes, + room: str | None = None, + encoding: str | None = None, + ) -> bool: + """ + Send large payload via Resource. + Returns True if successfully initiated, False otherwise. + + Note: This sends the resource envelope immediately, then creates + and advertises the resource. Should only be called when immediate + sending is desired (not when batching messages). + """ + if not self.hub.config.enable_resource_transfer: + return False + + size = len(payload) + if size > self.hub.config.max_resource_bytes: + self.log.error( + "Payload too large for resource transfer: %s > %s", + size, + self.hub.config.max_resource_bytes, + ) + return False + + rid = os.urandom(8) + sha256 = hashlib.sha256(payload).digest() + + if self.hub.identity is None: + return False + + envelope_body = { + B_RES_ID: rid, + B_RES_KIND: kind, + B_RES_SIZE: size, + B_RES_SHA256: sha256, + } + if encoding: + envelope_body[B_RES_ENCODING] = encoding + + envelope = make_envelope( + T_RESOURCE_ENVELOPE, + src=self.hub.identity.hash, + room=room, + body=envelope_body, + ) + + try: + envelope_payload = encode(envelope) + RNS.Packet(link, envelope_payload).send() + self.hub.stats_manager.inc("bytes_out", len(envelope_payload)) + + self.log.debug( + "Sent resource envelope link_id=%s rid=%s kind=%s size=%s", + self.hub._fmt_link_id(link), + rid.hex(), + kind, + size, + ) + except Exception as e: + self.log.error( + "Failed to send resource envelope link_id=%s: %s", + self.hub._fmt_link_id(link), + e, + ) + return False + + try: + resource = RNS.Resource(payload, link, advertise=True, auto_compress=False) + + with self.hub._state_lock: + self._active_resources.setdefault(link, set()).add(resource) + + self.hub.stats_manager.inc("resources_sent") + self.hub.stats_manager.inc("resource_bytes_sent", size) + + self.log.info( + "Sent resource link_id=%s rid=%s kind=%s size=%s", + self.hub._fmt_link_id(link), + rid.hex(), + kind, + size, + ) + return True + + except Exception as e: + self.log.error( + "Failed to create resource link_id=%s: %s", + self.hub._fmt_link_id(link), + e, + ) + return False diff --git a/rrcd/rooms.py b/rrcd/rooms.py new file mode 100644 index 0000000..7ea212b --- /dev/null +++ b/rrcd/rooms.py @@ -0,0 +1,694 @@ +"""Room management for RRCD hub. + +This module handles all room-related functionality including: +- Room membership tracking +- Room state (modes, topic, permissions) +- Room registry persistence to TOML +- Permission management (ops, voiced, bans) +- Invite tracking with expiration +""" + +from __future__ import annotations + +import logging +import os +import threading +import time +from typing import TYPE_CHECKING, Any + +import RNS + +if TYPE_CHECKING: + from .service import HubService + + +class RoomManager: + """Manages room memberships, state, permissions, and registry persistence.""" + + def __init__(self, hub: HubService) -> None: + self.hub = hub + self.log = logging.getLogger("rrcd.rooms") + self.rooms: dict[str, set[RNS.Link]] = {} + self._room_state: dict[str, dict[str, Any]] = {} + self._room_registry: dict[str, dict[str, Any]] = {} + + self._room_registry_write_lock = threading.Lock() + + def clear_all(self) -> None: + """Clear all room state. Called during hub shutdown.""" + self.rooms.clear() + self._room_state.clear() + self._room_registry.clear() + + def get_room_members(self, room: str) -> set[RNS.Link]: + """Get set of links currently in a room.""" + return self.rooms.get(room, set()) + + def add_member( + self, room: str, link: RNS.Link, *, founder: bytes | None = None + ) -> None: + """Add a link to a room, creating the room if needed.""" + if room not in self.rooms: + self.rooms[room] = set() + self._room_state_ensure(room, founder=founder) + + self.rooms.setdefault(room, set()).add(link) + + def remove_member(self, room: str, link: RNS.Link) -> None: + """Remove a link from a room, cleaning up empty rooms.""" + if room in self.rooms: + self.rooms[room].discard(link) + if not self.rooms[room]: + self.rooms.pop(room, None) + st = self._room_state_get(room) + if st is not None and not st.get("registered"): + self._room_state.pop(room, None) + + def remove_member_from_all(self, link: RNS.Link) -> int: + """Remove a link from all rooms. Returns number of rooms left.""" + rooms_to_remove = [r for r, links in self.rooms.items() if link in links] + for room in rooms_to_remove: + self.remove_member(room, link) + return len(rooms_to_remove) + + def get_member_rooms(self, link: RNS.Link) -> list[str]: + """Get list of rooms a link is currently in.""" + return [room for room, links in self.rooms.items() if link in links] + + def get_stats(self) -> dict[str, Any]: + """Get room statistics for hub stats.""" + rooms_total = len(self.rooms) + memberships = sum(len(v) for v in self.rooms.values()) + top_rooms = sorted( + ((room, len(links)) for room, links in self.rooms.items()), + key=lambda x: (-x[1], x[0]), + )[:5] + return { + "rooms_total": rooms_total, + "memberships": memberships, + "top_rooms": top_rooms, + } + + def _room_state_get(self, room: str) -> dict[str, Any] | None: + """Get room state dict if it exists.""" + return self._room_state.get(room) + + def _room_state_ensure( + self, room: str, *, founder: bytes | None = None + ) -> dict[str, Any]: + """Ensure room state exists, creating from registry or defaults.""" + st = self._room_state.get(room) + if st is not None: + if st.get("founder") is None and founder is not None: + st["founder"] = founder + st.setdefault("ops", set()).add(founder) + return st + + if room in self._room_registry: + base = self._room_registry[room] + invited = base.get("invited") + invited_dict: dict[bytes, float] = {} + if isinstance(invited, dict): + for k, v in invited.items(): + if isinstance(k, (bytes, bytearray)): + try: + invited_dict[bytes(k)] = float(v) + except Exception: + continue + st = { + "founder": base.get("founder"), + "registered": True, + "topic": base.get("topic"), + "moderated": bool(base.get("moderated", False)), + "invite_only": bool(base.get("invite_only", False)), + "topic_ops_only": bool(base.get("topic_ops_only", False)), + "no_outside_msgs": bool(base.get("no_outside_msgs", False)), + "private": bool(base.get("private", False)), + "key": base.get("key"), + "ops": set(base.get("ops", set())), + "voiced": set(base.get("voiced", set())), + "bans": set(base.get("bans", set())), + "invited": invited_dict, + "last_used_ts": base.get("last_used_ts"), + } + self._room_state[room] = st + return st + + st = { + "founder": founder, + "registered": False, + "topic": None, + "moderated": False, + "invite_only": False, + "topic_ops_only": False, + "no_outside_msgs": False, + "private": False, + "key": None, + "ops": set([founder]) if founder is not None else set(), + "voiced": set(), + "bans": set(), + "invited": {}, + "last_used_ts": None, + } + self._room_state[room] = st + return st + + def touch_room(self, room: str) -> None: + """Update last_used_ts for a room.""" + try: + st = self._room_state_ensure(room) + ts = float(time.time()) + st["last_used_ts"] = ts + reg = self._room_registry.get(room) + if isinstance(reg, dict): + reg["last_used_ts"] = ts + except Exception: + pass + + def get_room_modes(self, room: str) -> dict[str, Any]: + """Get dict of room mode flags.""" + st = self._room_state_ensure(room) + registered = bool(st.get("registered", False)) + moderated = bool(st.get("moderated", False)) + invite_only = bool(st.get("invite_only", False)) + topic_ops_only = bool(st.get("topic_ops_only", False)) + no_outside_msgs = bool(st.get("no_outside_msgs", False)) + private = bool(st.get("private", False)) + key = st.get("key") + has_key = isinstance(key, str) and bool(key) + return { + "registered": registered, + "moderated": moderated, + "invite_only": invite_only, + "topic_ops_only": topic_ops_only, + "no_outside_msgs": no_outside_msgs, + "private": private, + "has_key": has_key, + } + + def get_room_mode_string(self, room: str) -> str: + """Get IRC-style mode string for a room.""" + m = self.get_room_modes(room) + flags: list[str] = [] + if m.get("invite_only"): + flags.append("i") + if m.get("has_key"): + flags.append("k") + if m.get("moderated"): + flags.append("m") + if m.get("no_outside_msgs"): + flags.append("n") + if m.get("private"): + flags.append("p") + if m.get("registered"): + flags.append("r") + if m.get("topic_ops_only"): + flags.append("t") + return "+" + "".join(flags) if flags else "(none)" + + def broadcast_room_mode( + self, room: str, outgoing: list[tuple[RNS.Link, bytes]] | None = None + ) -> None: + """Broadcast current room mode to all members.""" + mode_txt = self.get_room_mode_string(room) + recipients = list(self.get_room_members(room)) + for other in recipients: + self.hub.message_helper.emit_notice( + outgoing, other, room, f"mode for {room} is now: {mode_txt}" + ) + + def is_room_moderated(self, room: str) -> bool: + """Check if room is moderated.""" + st = self._room_state_ensure(room) + return bool(st.get("moderated", False)) + + def is_room_op(self, room: str, peer_hash: bytes | None) -> bool: + """Check if peer is a room operator.""" + if peer_hash is None: + return False + if self.hub.trust_manager.is_server_op(peer_hash): + return True + st = self._room_state_ensure(room) + founder = st.get("founder") + if isinstance(founder, (bytes, bytearray)) and bytes(founder) == peer_hash: + return True + ops = st.get("ops") + return isinstance(ops, set) and peer_hash in ops + + def is_room_voiced(self, room: str, peer_hash: bytes | None) -> bool: + """Check if peer has voice in room.""" + if peer_hash is None: + return False + if self.is_room_op(room, peer_hash): + return True + st = self._room_state_ensure(room) + voiced = st.get("voiced") + return isinstance(voiced, set) and peer_hash in voiced + + def is_room_banned(self, room: str, peer_hash: bytes | None) -> bool: + """Check if peer is banned from room.""" + if peer_hash is None: + return False + st = self._room_state_ensure(room) + bans = st.get("bans") + return isinstance(bans, set) and peer_hash in bans + + def is_invited(self, room: str, peer_hash: bytes) -> bool: + """Check if peer has a valid (non-expired) invite.""" + st = self._room_state_ensure(room) + inv = st.get("invited") + if not isinstance(inv, dict) or not inv: + return False + now = float(time.time()) + exp = inv.get(peer_hash) + try: + exp_f = float(exp) if exp is not None else 0.0 + except Exception: + exp_f = 0.0 + if exp_f <= now: + inv.pop(peer_hash, None) + return False + return True + + def prune_expired_invites(self, room: str) -> bool: + """Remove expired invites from a room. Returns True if any were removed.""" + st = self._room_state_ensure(room) + inv = st.get("invited") + if not isinstance(inv, dict) or not inv: + return False + now = float(time.time()) + removed_any = False + for h, exp in list(inv.items()): + try: + exp_f = float(exp) + except Exception: + exp_f = 0.0 + if exp_f <= now: + inv.pop(h, None) + removed_any = True + return removed_any + + def load_registry_from_path( + self, path: str, *, invite_timeout_s: float + ) -> tuple[dict[str, dict[str, Any]], str | None]: + """Load room registry from TOML file. Returns (registry, error_msg).""" + if not path or not os.path.exists(path): + return {}, None + + try: + from tomlkit import parse # type: ignore + except ImportError: + return {}, "missing dependency tomlkit" + + try: + with open(path, encoding="utf-8") as f: + doc = parse(f.read()) + except Exception as e: + return {}, f"parse error: {e}" + + rooms_section = doc.get("rooms") + if not isinstance(rooms_section, dict): + return {}, None + + registry: dict[str, dict[str, Any]] = {} + now = float(time.time()) + + for room_name, room_data in rooms_section.items(): + if not isinstance(room_data, dict): + continue + + founder = room_data.get("founder") + if isinstance(founder, str): + try: + founder = bytes.fromhex(founder.strip().lower().removeprefix("0x")) + except Exception: + founder = None + + topic = room_data.get("topic") + if not isinstance(topic, str): + topic = None + + moderated = bool(room_data.get("moderated", False)) + invite_only = bool(room_data.get("invite_only", False)) + topic_ops_only = bool(room_data.get("topic_ops_only", False)) + no_outside_msgs = bool(room_data.get("no_outside_msgs", False)) + private = bool(room_data.get("private", False)) + + key = room_data.get("key") + if not isinstance(key, str): + key = None + + operators = room_data.get("operators", []) + ops: set[bytes] = set() + if isinstance(operators, list): + for op in operators: + if isinstance(op, str): + try: + ops.add( + bytes.fromhex(op.strip().lower().removeprefix("0x")) + ) + except Exception: + continue + + voiced_list = room_data.get("voiced", []) + voiced: set[bytes] = set() + if isinstance(voiced_list, list): + for v in voiced_list: + if isinstance(v, str): + try: + voiced.add( + bytes.fromhex(v.strip().lower().removeprefix("0x")) + ) + except Exception: + continue + + bans_list = room_data.get("bans", []) + bans: set[bytes] = set() + if isinstance(bans_list, list): + for b in bans_list: + if isinstance(b, str): + try: + bans.add( + bytes.fromhex(b.strip().lower().removeprefix("0x")) + ) + except Exception: + continue + + invited_dict = room_data.get("invited", {}) + invited: dict[bytes, float] = {} + if isinstance(invited_dict, dict): + for h, exp in invited_dict.items(): + if isinstance(h, str): + try: + h_bytes = bytes.fromhex( + h.strip().lower().removeprefix("0x") + ) + exp_f = float(exp) + if exp_f > now: + invited[h_bytes] = exp_f + except Exception: + continue + + last_used_ts = room_data.get("last_used_ts") + try: + last_used_ts = float(last_used_ts) if last_used_ts is not None else None + except Exception: + last_used_ts = None + + registry[room_name] = { + "founder": founder, + "topic": topic, + "moderated": moderated, + "invite_only": invite_only, + "topic_ops_only": topic_ops_only, + "no_outside_msgs": no_outside_msgs, + "private": private, + "key": key, + "ops": ops, + "voiced": voiced, + "bans": bans, + "invited": invited, + "last_used_ts": last_used_ts, + } + + return registry, None + + def diff_registry_summary( + self, old: dict[str, dict[str, Any]], new: dict[str, dict[str, Any]] + ) -> list[str]: + """Generate human-readable summary of registry changes.""" + old_rooms = set(old.keys()) + new_rooms = set(new.keys()) + added = sorted(new_rooms - old_rooms) + removed = sorted(old_rooms - new_rooms) + + lines: list[str] = [] + if added: + preview = ", ".join(added[:10]) + suffix = "" if len(added) <= 10 else f" (+{len(added) - 10} more)" + lines.append(f"rooms_added={len(added)}: {preview}{suffix}") + if removed: + preview = ", ".join(removed[:10]) + suffix = "" if len(removed) <= 10 else f" (+{len(removed) - 10} more)" + lines.append(f"rooms_removed={len(removed)}: {preview}{suffix}") + if not lines: + lines.append(f"rooms_changed=0 (registered_rooms={len(new_rooms)})") + return lines + + def get_registry_path_for_writes(self) -> str | None: + """Get path to room registry file for write operations.""" + from .util import expand_path + + p = self.hub.config.room_registry_path + if not p: + return None + return expand_path(str(p)) + + def persist_room_state(self, link: RNS.Link, room: str | None) -> None: + """Persist room state to registry TOML file.""" + if room is None: + return + reg_path = self.get_registry_path_for_writes() + if not reg_path: + return + st = self._room_state_get(room) + if not st or not st.get("registered"): + return + + try: + from tomlkit import dumps, parse, table # type: ignore + except Exception: + return + + try: + with self._room_registry_write_lock: + file_stat = None + try: + file_stat = os.stat(reg_path) + except Exception: + file_stat = None + + with open(reg_path, encoding="utf-8") as f: + doc = parse(f.read()) + + rooms = doc.get("rooms") + if rooms is None: + rooms = table() + doc["rooms"] = rooms + + room_tbl = rooms.get(room) + if room_tbl is None: + room_tbl = table() + rooms[room] = room_tbl + + founder = st.get("founder") + if isinstance(founder, (bytes, bytearray)): + room_tbl["founder"] = bytes(founder).hex() + + topic = st.get("topic") + if isinstance(topic, str) and topic.strip(): + room_tbl["topic"] = topic + else: + if "topic" in room_tbl: + del room_tbl["topic"] + + room_tbl["moderated"] = bool(st.get("moderated", False)) + room_tbl["invite_only"] = bool(st.get("invite_only", False)) + room_tbl["topic_ops_only"] = bool(st.get("topic_ops_only", False)) + room_tbl["no_outside_msgs"] = bool(st.get("no_outside_msgs", False)) + + key = st.get("key") + if isinstance(key, str) and key: + room_tbl["key"] = key + else: + if "key" in room_tbl: + del room_tbl["key"] + + last_used_ts = st.get("last_used_ts") + if last_used_ts is None: + last_used_ts = float(time.time()) + try: + room_tbl["last_used_ts"] = float(last_used_ts) + except Exception: + room_tbl["last_used_ts"] = float(time.time()) + + ops = st.get("ops") + if isinstance(ops, set): + room_tbl["operators"] = sorted( + bytes(x).hex() for x in ops if isinstance(x, (bytes, bytearray)) + ) + + voiced = st.get("voiced") + if isinstance(voiced, set): + room_tbl["voiced"] = sorted( + bytes(x).hex() + for x in voiced + if isinstance(x, (bytes, bytearray)) + ) + + bans = st.get("bans") + if isinstance(bans, set): + room_tbl["bans"] = sorted( + bytes(x).hex() + for x in bans + if isinstance(x, (bytes, bytearray)) + ) + + invited = st.get("invited") + if isinstance(invited, dict): + inv_tbl = {} + now = float(time.time()) + for h, exp in invited.items(): + if not isinstance(h, (bytes, bytearray)): + continue + try: + exp_f = float(exp) + except Exception: + continue + if exp_f > now: + inv_tbl[bytes(h).hex()] = exp_f + room_tbl["invited"] = inv_tbl + + new_text = dumps(doc) + with open(reg_path, "w", encoding="utf-8") as f: + f.write(new_text) + + if file_stat is not None: + try: + os.chmod(reg_path, file_stat.st_mode) + except Exception: + pass + except Exception as e: + self.hub.message_helper.notice_to( + link, room, f"room config persist failed: {e}" + ) + + def delete_room_from_registry(self, link: RNS.Link, room: str) -> None: + """Remove a room from the registry TOML file.""" + reg_path = self.get_registry_path_for_writes() + if not reg_path: + return + try: + from tomlkit import dumps, parse # type: ignore + except Exception: + return + + try: + with self._room_registry_write_lock: + file_stat = None + try: + file_stat = os.stat(reg_path) + except Exception: + file_stat = None + + with open(reg_path, encoding="utf-8") as f: + doc = parse(f.read()) + + rooms = doc.get("rooms") + if isinstance(rooms, dict) and room in rooms: + try: + del rooms[room] + except Exception: + rooms.pop(room, None) + + new_text = dumps(doc) + with open(reg_path, "w", encoding="utf-8") as f: + f.write(new_text) + + if file_stat is not None: + try: + os.chmod(reg_path, file_stat.st_mode) + except Exception: + pass + except Exception as e: + self.hub.message_helper.notice_to( + link, room, f"room unregister persist failed: {e}" + ) + + def prune_unused_registered_rooms( + self, prune_after_s: float, started_wall_time: float + ) -> list[str]: + """ + Prune registered rooms that haven't been used recently. + + Returns list of pruned room names. + """ + now = float(time.time()) + rooms_to_prune: list[str] = [] + + for room, reg in list(self._room_registry.items()): + if room in self.rooms and self.rooms.get(room): + continue + + last_used = reg.get("last_used_ts") + try: + last_used = float(last_used) if last_used is not None else None + except Exception: + last_used = None + if last_used is None: + last_used = started_wall_time + + if (now - float(last_used)) < prune_after_s: + continue + + self._room_registry.pop(room, None) + self._room_state.pop(room, None) + rooms_to_prune.append(room) + + return rooms_to_prune + + def merge_registry_into_state(self, registry: dict[str, dict[str, Any]]) -> None: + """ + Merge registry into live room state. + + Updates in-memory state for active rooms with registry data. + """ + for r, st in list(self._room_state.items()): + if not isinstance(st, dict): + continue + + reg = registry.get(r) + if reg is None: + if st.get("registered"): + st["registered"] = False + continue + + st["registered"] = True + + founder = reg.get("founder") + if isinstance(founder, (bytes, bytearray)): + st["founder"] = bytes(founder) + + topic = reg.get("topic") + if isinstance(topic, str): + st["topic"] = topic + + st["moderated"] = bool(reg.get("moderated", False)) + st["invite_only"] = bool(reg.get("invite_only", False)) + st["topic_ops_only"] = bool(reg.get("topic_ops_only", False)) + st["no_outside_msgs"] = bool(reg.get("no_outside_msgs", False)) + st["private"] = bool(reg.get("private", False)) + + key = reg.get("key") + if isinstance(key, str): + st["key"] = key + + ops = reg.get("ops") + if isinstance(ops, set): + st["ops"] = set(ops) + + voiced = reg.get("voiced") + if isinstance(voiced, set): + st["voiced"] = set(voiced) + + bans = reg.get("bans") + if isinstance(bans, set): + st["bans"] = set(bans) + + invited = reg.get("invited") + if isinstance(invited, dict): + st["invited"] = dict(invited) + + last_used_ts = reg.get("last_used_ts") + if last_used_ts is not None: + st["last_used_ts"] = last_used_ts diff --git a/rrcd/router.py b/rrcd/router.py new file mode 100644 index 0000000..27ea735 --- /dev/null +++ b/rrcd/router.py @@ -0,0 +1,788 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +import RNS + +from .codec import decode, encode +from .constants import ( + B_HELLO_CAPS, + B_HELLO_NICK_LEGACY, + B_RES_ENCODING, + B_RES_ID, + B_RES_KIND, + B_RES_SHA256, + B_RES_SIZE, + K_BODY, + K_NICK, + K_ROOM, + K_SRC, + K_T, + T_HELLO, + T_JOIN, + T_JOINED, + T_MSG, + T_NOTICE, + T_PART, + T_PARTED, + T_PING, + T_PONG, + T_RESOURCE_ENVELOPE, +) +from .envelope import make_envelope, validate_envelope +from .util import normalize_nick + + +class OutgoingList(list): + """Custom list that allows attaching callback attributes.""" + + pass + + +if TYPE_CHECKING: + from .service import HubService + + +class MessageRouter: + """ + Handles message routing and dispatching for the RRC hub. + + This class is responsible for: + - Decoding and validating incoming packets + - Dispatching messages by type (HELLO, JOIN, PART, MSG, NOTICE, PING, etc.) + - Forwarding messages to appropriate rooms/recipients + - Rate limiting + - Protocol validation + """ + + def __init__(self, hub: HubService) -> None: + self.hub = hub + self.log = logging.getLogger("rrcd.router") + + def route_packet( + self, + link: RNS.Link, + data: bytes, + outgoing: list[tuple[RNS.Link, bytes]], + ) -> None: + """ + Main entry point for routing an incoming packet. + + This method should be called with the state lock held. + """ + sess = self.hub.session_manager.sessions.get(link) + if sess is None: + return + + self.hub.stats_manager.inc("pkts_in") + self.hub.stats_manager.inc("bytes_in", len(data)) + + peer_hash = sess.get("peer") + if peer_hash is None: + ri = link.get_remote_identity() + if ri is None: + return + peer_hash = ri.hash + sess["peer"] = peer_hash + + if not self.hub.session_manager.refill_and_take(link, 1.0): + self.hub.stats_manager.inc("rate_limited") + if self.log.isEnabledFor(logging.DEBUG): + self.log.debug( + "Rate limited peer=%s link_id=%s", + self.hub._fmt_hash(peer_hash), + self.hub._fmt_link_id(link), + ) + if self.hub.identity is not None: + self.hub.message_helper.emit_error( + outgoing, link, src=self.hub.identity.hash, text="rate limited" + ) + return + + try: + env = decode(data) + validate_envelope(env) + except Exception as e: + self.hub.stats_manager.inc("pkts_bad") + self.log.debug( + "Bad packet peer=%s link_id=%s bytes=%s err=%s", + self.hub._fmt_hash(peer_hash), + self.hub._fmt_link_id(link), + len(data), + e, + ) + if self.hub.identity is not None: + self.hub.message_helper.emit_error( + outgoing, link, src=self.hub.identity.hash, text=f"bad message: {e}" + ) + return + + t = env.get(K_T) + room = env.get(K_ROOM) + body = env.get(K_BODY) + + if self.log.isEnabledFor(logging.DEBUG): + body_len = None + if isinstance(body, (bytes, bytearray)): + body_len = len(body) + elif isinstance(body, str): + body_len = len(body) + self.log.debug( + "RX peer=%s link_id=%s t=%s room=%r bytes=%s body_type=%s body_len=%s", + self.hub._fmt_hash(peer_hash), + self.hub._fmt_link_id(link), + t, + room, + len(data), + type(body).__name__, + body_len, + ) + + if t == T_PONG: + self._handle_pong(link, sess) + elif t == T_RESOURCE_ENVELOPE: + self._handle_resource_envelope(link, sess, env, outgoing) + elif not sess["welcomed"]: + self._handle_pre_welcome(link, sess, peer_hash, env, outgoing) + elif t == T_HELLO: + self._handle_re_hello(link, sess, peer_hash, env, outgoing) + elif t == T_JOIN: + self._handle_join(link, sess, peer_hash, env, outgoing) + elif t == T_PART: + self._handle_part(link, sess, peer_hash, env, outgoing) + elif t in (T_MSG, T_NOTICE): + self._handle_message(link, sess, peer_hash, env, outgoing) + elif t == T_PING: + self._handle_ping(link, env, outgoing) + + def _handle_pong(self, link: RNS.Link, sess: dict[str, Any]) -> None: + """Handle PONG message.""" + self.hub.stats_manager.inc("pongs_in") + sess["awaiting_pong"] = None + + def _handle_resource_envelope( + self, + link: RNS.Link, + sess: dict[str, Any], + env: dict, + outgoing: list[tuple[RNS.Link, bytes]], + ) -> None: + """Handle RESOURCE_ENVELOPE message.""" + room = env.get(K_ROOM) + body = env.get(K_BODY) + + if not self.hub.config.enable_resource_transfer: + if self.hub.identity is not None: + self.hub.message_helper.emit_error( + outgoing, + link, + src=self.hub.identity.hash, + text="resource transfer disabled", + room=room, + ) + return + + if not isinstance(body, dict): + if self.hub.identity is not None: + self.hub.message_helper.emit_error( + outgoing, + link, + src=self.hub.identity.hash, + text="invalid resource envelope body", + room=room, + ) + return + + rid = body.get(B_RES_ID) + kind = body.get(B_RES_KIND) + size = body.get(B_RES_SIZE) + sha256 = body.get(B_RES_SHA256) + encoding = body.get(B_RES_ENCODING) + + if not isinstance(rid, (bytes, bytearray)): + if self.hub.identity is not None: + self.hub.message_helper.emit_error( + outgoing, + link, + src=self.hub.identity.hash, + text="resource envelope missing id", + room=room, + ) + return + + if not isinstance(kind, str) or not kind: + if self.hub.identity is not None: + self.hub.message_helper.emit_error( + outgoing, + link, + src=self.hub.identity.hash, + text="resource envelope missing kind", + room=room, + ) + return + + if not isinstance(size, int) or size < 0: + if self.hub.identity is not None: + self.hub.message_helper.emit_error( + outgoing, + link, + src=self.hub.identity.hash, + text="resource envelope invalid size", + room=room, + ) + return + + if size > self.hub.config.max_resource_bytes: + if self.hub.identity is not None: + self.hub.message_helper.emit_error( + outgoing, + link, + src=self.hub.identity.hash, + text=f"resource too large: {size} > {self.hub.config.max_resource_bytes}", + room=room, + ) + return + + if sha256 is not None and not isinstance(sha256, (bytes, bytearray)): + if self.hub.identity is not None: + self.hub.message_helper.emit_error( + outgoing, + link, + src=self.hub.identity.hash, + text="resource envelope invalid sha256", + room=room, + ) + return + + if encoding is not None and not isinstance(encoding, str): + encoding = None + + if not self.hub.resource_manager.add_resource_expectation( + link, + rid=bytes(rid), + kind=kind, + size=size, + sha256=bytes(sha256) if sha256 else None, + encoding=encoding, + room=room, + ): + if self.hub.identity is not None: + self.hub.message_helper.emit_error( + outgoing, + link, + src=self.hub.identity.hash, + text="too many pending resource expectations", + room=room, + ) + + def _handle_pre_welcome( + self, + link: RNS.Link, + sess: dict[str, Any], + peer_hash: bytes, + env: dict, + outgoing: list[tuple[RNS.Link, bytes]], + ) -> None: + """Handle messages before WELCOME (only HELLO is allowed).""" + t = env.get(K_T) + nick = env.get(K_NICK) + body = env.get(K_BODY) + + if t != T_HELLO: + if self.hub.identity is not None: + self.hub.message_helper.emit_error( + outgoing, link, src=self.hub.identity.hash, text="send HELLO first" + ) + return + + old_nick = sess.get("nick") + new_nick = None + + if isinstance(nick, str): + n = normalize_nick(nick, max_chars=self.hub.config.nick_max_chars) + if n is not None: + new_nick = n + sess["nick"] = n + + if isinstance(body, dict): + sess["peer_caps"] = self._extract_caps(body) + + if new_nick is None: + legacy_nick = body.get(B_HELLO_NICK_LEGACY) + n2 = normalize_nick( + legacy_nick, max_chars=self.hub.config.nick_max_chars + ) + if n2 is not None: + new_nick = n2 + sess["nick"] = n2 + + self.log.info( + "HELLO peer=%s nick=%r link_id=%s", + self.hub._fmt_hash(peer_hash), + sess.get("nick"), + self.hub._fmt_link_id(link), + ) + + self.hub.session_manager.send_welcome( + link, + outgoing, + peer_hash=peer_hash, + old_nick=old_nick, + new_nick=new_nick, + ) + + def _handle_re_hello( + self, + link: RNS.Link, + sess: dict[str, Any], + peer_hash: bytes, + env: dict, + outgoing: list[tuple[RNS.Link, bytes]], + ) -> None: + """Handle re-authentication (HELLO after already welcomed).""" + nick = env.get(K_NICK) + body = env.get(K_BODY) + + if self.hub.identity is None: + return + + old_nick = sess.get("nick") + old_rooms = set(sess.get("rooms", set())) + sess["welcomed"] = False + sess["rooms"] = set() + sess["nick"] = None + sess["peer_caps"] = {} + + for r in old_rooms: + self.hub.room_manager.remove_member(r, link) + + new_nick = None + + if isinstance(nick, str): + n = normalize_nick(nick, max_chars=self.hub.config.nick_max_chars) + if n is not None: + new_nick = n + sess["nick"] = n + + if isinstance(body, dict): + sess["peer_caps"] = self._extract_caps(body) + if new_nick is None: + legacy_nick = body.get(B_HELLO_NICK_LEGACY) + n2 = normalize_nick( + legacy_nick, max_chars=self.hub.config.nick_max_chars + ) + if n2 is not None: + new_nick = n2 + sess["nick"] = n2 + + self.log.info( + "Re-HELLO peer=%s nick=%r link_id=%s", + self.hub._fmt_hash(peer_hash), + sess.get("nick"), + self.hub._fmt_link_id(link), + ) + + self.hub.session_manager.send_welcome( + link, + outgoing, + peer_hash=peer_hash, + old_nick=old_nick, + new_nick=new_nick, + ) + + def _handle_join( + self, + link: RNS.Link, + sess: dict[str, Any], + peer_hash: bytes, + env: dict, + outgoing: list[tuple[RNS.Link, bytes]], + ) -> None: + """Handle JOIN message.""" + room = env.get(K_ROOM) + body = env.get(K_BODY) + + self.hub.stats_manager.inc("joins") + if not isinstance(room, str) or not room: + if self.hub.identity is not None: + self.hub.message_helper.emit_error( + outgoing, + link, + src=self.hub.identity.hash, + text="JOIN requires room name", + ) + return + + if len(sess["rooms"]) >= int(self.hub.config.max_rooms_per_session): + if self.hub.identity is not None: + self.hub.message_helper.emit_error( + outgoing, link, src=self.hub.identity.hash, text="too many rooms" + ) + return + + try: + r = self.hub._norm_room(room) + except Exception as e: + if self.hub.identity is not None: + self.hub.message_helper.emit_error( + outgoing, link, src=self.hub.identity.hash, text=str(e) + ) + return + + if r in self.hub.room_manager._room_registry: + self.hub.room_manager._room_state_ensure(r) + + st = self.hub.room_manager._room_state_ensure(r) + + if bool(st.get("invite_only", False)): + is_invited = self.hub.room_manager.is_invited(r, peer_hash) + if not self.hub.room_manager.is_room_op(r, peer_hash) and not is_invited: + if self.hub.identity is not None: + self.hub.message_helper.emit_error( + outgoing, + link, + src=self.hub.identity.hash, + text="invite-only (+i)", + room=r, + ) + return + + key = st.get("key") + if isinstance(key, str) and key: + is_invited = self.hub.room_manager.is_invited(r, peer_hash) + if not self.hub.room_manager.is_room_op(r, peer_hash) and not is_invited: + provided = body if isinstance(body, str) else None + if provided != key: + if self.hub.identity is not None: + self.hub.message_helper.emit_error( + outgoing, + link, + src=self.hub.identity.hash, + text="bad key (+k)", + room=r, + ) + return + + if self.hub.room_manager.is_room_banned(r, peer_hash): + if self.hub.identity is not None: + self.hub.message_helper.emit_error( + outgoing, + link, + src=self.hub.identity.hash, + text="banned from room", + room=r, + ) + return + + if not self.hub.room_manager.get_room_members(r): + pass + self.hub.room_manager._room_state_ensure(r, founder=peer_hash) + + sess["rooms"].add(r) + self.hub.room_manager.add_member(r, link) + + self.log.info( + "JOIN peer=%s nick=%r room=%s link_id=%s", + self.hub._fmt_hash(peer_hash), + sess.get("nick"), + r, + self.hub._fmt_link_id(link), + ) + + self.hub.room_manager.touch_room(r) + + joined_body = None + if self.hub.config.include_joined_member_list: + members: list[bytes] = [] + for member_link in self.hub.room_manager.get_room_members(r): + s = self.hub.session_manager.sessions.get(member_link) + ph = s.get("peer") if s else None + if isinstance(ph, (bytes, bytearray)): + members.append(bytes(ph)) + joined_body = members + + joined = make_envelope( + T_JOINED, src=self.hub.identity.hash, room=r, body=joined_body + ) + self.hub.message_helper.queue_env(outgoing, link, joined) + + try: + inv = st.get("invited") + if isinstance(inv, dict) and peer_hash in inv: + inv.pop(peer_hash, None) + if bool(st.get("registered")): + self.hub.room_manager.persist_room_state(link, r) + except Exception: + pass + + try: + registered = bool(st.get("registered", False)) + topic = st.get("topic") if isinstance(st.get("topic"), str) else None + mode_txt = self.hub.room_manager.get_room_mode_string(r) + topic_txt = topic if topic else "(none)" + reg_txt = "registered" if registered else "unregistered" + self.hub.message_helper.emit_notice( + outgoing, + link, + r, + f"room {r}: {reg_txt}; mode={mode_txt}; topic={topic_txt}", + ) + except Exception: + pass + + def _handle_part( + self, + link: RNS.Link, + sess: dict[str, Any], + peer_hash: bytes, + env: dict, + outgoing: list[tuple[RNS.Link, bytes]], + ) -> None: + """Handle PART message.""" + room = env.get(K_ROOM) + + self.hub.stats_manager.inc("parts") + if not isinstance(room, str) or not room: + if self.hub.identity is not None: + self.hub.message_helper.emit_error( + outgoing, + link, + src=self.hub.identity.hash, + text="PART requires room name", + ) + return + + try: + r = self.hub._norm_room(room) + except Exception as e: + if self.hub.identity is not None: + self.hub.message_helper.emit_error( + outgoing, link, src=self.hub.identity.hash, text=str(e) + ) + return + + sess["rooms"].discard(r) + if self.hub.room_manager.get_room_members(r): + self.hub.room_manager.remove_member(r, link) + if not self.hub.room_manager.get_room_members(r): + self.hub.room_manager.remove_member(r, link) + st = self.hub.room_manager._room_state_get(r) + if st is not None: + self.hub.room_manager.touch_room(r) + if st.get("registered"): + self.hub.room_manager.persist_room_state(link, r) + if st is not None and not st.get("registered"): + self.hub.room_manager._room_state.pop(r, None) + + parted_body = None + if self.hub.config.include_joined_member_list: + members: list[bytes] = [] + for member_link in self.hub.room_manager.get_room_members(r): + s = self.hub.session_manager.sessions.get(member_link) + ph = s.get("peer") if s else None + if isinstance(ph, (bytes, bytearray)): + members.append(bytes(ph)) + parted_body = members + + if self.hub.identity is not None: + parted = make_envelope( + T_PARTED, src=self.hub.identity.hash, room=r, body=parted_body + ) + self.hub.message_helper.queue_env(outgoing, link, parted) + + self.log.info( + "PART peer=%s nick=%r room=%s link_id=%s", + self.hub._fmt_hash(peer_hash), + sess.get("nick"), + r, + self.hub._fmt_link_id(link), + ) + + def _handle_message( + self, + link: RNS.Link, + sess: dict[str, Any], + peer_hash: bytes, + env: dict, + outgoing: list[tuple[RNS.Link, bytes]], + ) -> None: + """Handle MSG and NOTICE messages.""" + t = env.get(K_T) + room = env.get(K_ROOM) + body = env.get(K_BODY) + + if isinstance(body, str): + cmdline = body.strip() + if cmdline.startswith("/"): + if self.log.isEnabledFor(logging.DEBUG): + self.log.debug( + "Slash command peer=%s link_id=%s cmd=%r room=%r", + self.hub._fmt_hash(peer_hash), + self.hub._fmt_link_id(link), + cmdline, + room, + ) + handled = self.hub.command_handler.handle_operator_command( + link, peer_hash=peer_hash, room=room, text=body, outgoing=outgoing + ) + if handled: + if self.log.isEnabledFor(logging.DEBUG): + self.log.debug( + "Slash command handled, queued=%d responses", + len(outgoing), + ) + return + if self.hub.identity is not None: + self.hub.message_helper.emit_error( + outgoing, + link, + src=self.hub.identity.hash, + text="unrecognized command", + room=room, + ) + return + + if t == T_MSG: + if not isinstance(room, str) or not room: + if self.hub.identity is not None: + self.hub.message_helper.emit_error( + outgoing, + link, + src=self.hub.identity.hash, + text="message requires room name", + ) + return + elif t == T_NOTICE: + if not isinstance(room, str) or not room: + return + + try: + r = self.hub._norm_room(room) + except Exception as e: + if self.hub.identity is not None: + self.hub.message_helper.emit_error( + outgoing, link, src=self.hub.identity.hash, text=str(e) + ) + return + + if r not in sess["rooms"]: + st = None + if r in self.hub.room_manager._room_registry: + st = self.hub.room_manager._room_state_ensure(r) + elif self.hub.room_manager.get_room_members(r): + st = self.hub.room_manager._room_state_ensure(r) + + if st is None: + if self.hub.identity is not None: + self.hub.message_helper.emit_error( + outgoing, + link, + src=self.hub.identity.hash, + text="no such room", + room=r, + ) + return + + if bool(st.get("no_outside_msgs", False)): + if self.hub.identity is not None: + self.hub.message_helper.emit_error( + outgoing, + link, + src=self.hub.identity.hash, + text="no outside messages (+n)", + room=r, + ) + return + + if self.hub.room_manager.is_room_banned(r, peer_hash): + if self.hub.identity is not None: + self.hub.message_helper.emit_error( + outgoing, + link, + src=self.hub.identity.hash, + text="banned from room", + room=r, + ) + return + if self.hub.room_manager.is_room_moderated( + r + ) and not self.hub.room_manager.is_room_voiced(r, peer_hash): + if self.hub.identity is not None: + self.hub.message_helper.emit_error( + outgoing, + link, + src=self.hub.identity.hash, + text="room is moderated (+m)", + room=r, + ) + return + + if peer_hash is not None: + env[K_SRC] = ( + bytes(peer_hash) + if isinstance(peer_hash, (bytes, bytearray)) + else peer_hash + ) + env[K_ROOM] = r + + incoming_nick = env.get(K_NICK) + if incoming_nick is not None: + n = normalize_nick(incoming_nick, max_chars=self.hub.config.nick_max_chars) + if n is not None: + old_session_nick = sess.get("nick") + if old_session_nick != n: + sess["nick"] = n + self.hub.session_manager.update_nick_index( + link, old_session_nick, n + ) + env[K_NICK] = n + else: + env.pop(K_NICK, None) + else: + nick = sess.get("nick") + n = normalize_nick(nick, max_chars=self.hub.config.nick_max_chars) + if n is not None: + env[K_NICK] = n + + payload = encode(env) + for other in list(self.hub.room_manager.get_room_members(r)): + self.hub.message_helper.queue_payload(outgoing, other, payload) + + if self.log.isEnabledFor(logging.DEBUG): + self.log.debug( + "Forwarded t=%s peer=%s nick=%r room=%s recipients=%s body_type=%s", + t, + self.hub._fmt_hash(peer_hash), + sess.get("nick"), + r, + len(self.hub.room_manager.get_room_members(r)), + type(body).__name__, + ) + + if t == T_MSG: + self.hub.stats_manager.inc("msgs_forwarded") + else: + self.hub.stats_manager.inc("notices_forwarded") + + def _handle_ping( + self, + link: RNS.Link, + env: dict, + outgoing: list[tuple[RNS.Link, bytes]], + ) -> None: + """Handle PING message.""" + body = env.get(K_BODY) + + self.hub.stats_manager.inc("pings_in") + if self.hub.identity is not None: + pong = make_envelope(T_PONG, src=self.hub.identity.hash, body=body) + self.hub.stats_manager.inc("pongs_out") + self.hub.message_helper.queue_env(outgoing, link, pong) + + def _extract_caps(self, body: Any) -> dict[int, Any]: + """Extract capabilities from HELLO body.""" + if not isinstance(body, dict): + return {} + caps = body.get(B_HELLO_CAPS) + return caps if isinstance(caps, dict) else {} diff --git a/rrcd/service.py b/rrcd/service.py index 8547d52..9d92835 100644 --- a/rrcd/service.py +++ b/rrcd/service.py @@ -1,887 +1,68 @@ from __future__ import annotations -import hashlib import logging import os import signal import threading import time -from dataclasses import asdict, dataclass, replace from typing import Any import RNS -from . import __version__ -from .codec import decode, encode -from .config import HubRuntimeConfig +from .codec import encode +from .commands import CommandHandler +from .config import ConfigManager, HubRuntimeConfig from .constants import ( - B_HELLO_CAPS, - B_HELLO_NICK_LEGACY, - B_RES_ENCODING, - B_RES_ID, - B_RES_KIND, - B_RES_SHA256, - B_RES_SIZE, - B_WELCOME_HUB, - B_WELCOME_VER, - K_BODY, - K_NICK, - K_ROOM, - K_SRC, - K_T, - RES_KIND_BLOB, - RES_KIND_MOTD, - RES_KIND_NOTICE, - T_ERROR, - T_HELLO, - T_JOIN, - T_JOINED, - T_MSG, - T_NOTICE, - T_PART, - T_PARTED, T_PING, - T_PONG, - T_RESOURCE_ENVELOPE, - T_WELCOME, ) -from .envelope import make_envelope, validate_envelope +from .envelope import make_envelope from .logging_config import configure_logging -from .util import expand_path, normalize_nick - - -@dataclass -class _RateState: - tokens: float - last_refill: float - - -@dataclass -class _ResourceExpectation: - """Tracks an expected incoming Resource transfer.""" - id: bytes - kind: str - size: int - sha256: bytes | None - encoding: str | None - created_at: float - expires_at: float - room: str | None = None +from .messages import MessageHelper +from .resources import ResourceManager +from .rooms import RoomManager +from .router import MessageRouter, OutgoingList +from .session import SessionManager +from .stats import StatsManager +from .trust import TrustManager +from .util import expand_path class HubService: def __init__(self, config: HubRuntimeConfig) -> None: self.config = config self.log = logging.getLogger("rrcd.hub") - - # Shared mutable state (sessions/rooms/room registry/etc) is accessed from - # Reticulum callbacks and background worker threads. Guard it with a - # single re-entrant lock. self._state_lock = threading.RLock() - self._shutdown = threading.Event() - + self.router = MessageRouter(self) + self.session_manager = SessionManager(self) + self.command_handler = CommandHandler(self) + self.resource_manager = ResourceManager(self) + self.room_manager = RoomManager(self) + self.stats_manager = StatsManager(self) + self.trust_manager = TrustManager(self) + self.config_manager = ConfigManager(self) + self.message_helper = MessageHelper(self) self.identity: RNS.Identity | None = None self.destination: RNS.Destination | None = None - - self.rooms: dict[str, set[RNS.Link]] = {} - self.sessions: dict[RNS.Link, dict[str, Any]] = {} - self._rate: dict[RNS.Link, _RateState] = {} - - # Resource transfer state - self._resource_expectations: dict[RNS.Link, dict[bytes, _ResourceExpectation]] = {} - self._active_resources: dict[RNS.Link, set[RNS.Resource]] = {} - # Tracks which expectation RID was matched to an advertised Resource. - self._resource_bindings: dict[RNS.Resource, bytes] = {} - - self._trusted: set[bytes] = set() - self._banned: set[bytes] = set() - - # Secondary indexes for efficient link lookups (O(1) instead of O(n)). - # These are maintained alongside sessions and must stay in sync. - self._index_by_hash: dict[bytes, RNS.Link] = {} # identity hash -> link - self._index_by_nick: dict[str, set[RNS.Link]] = {} # normalized nick (lowercase) -> links - - # Room state (hub-local conventions; no new on-wire message types). - # _room_state holds active in-memory state (and registered state for empty rooms). - # _room_registry holds registered rooms loaded from config. - self._room_state: dict[str, dict[str, Any]] = {} - self._room_registry: dict[str, dict[str, Any]] = {} - - self._room_registry_write_lock = threading.Lock() self._prune_thread: threading.Thread | None = None - self._ping_thread: threading.Thread | None = None self._announce_thread: threading.Thread | None = None self._resource_cleanup_thread: threading.Thread | None = None - self._config_write_lock = threading.Lock() - - self._started_wall_time: float | None = None - self._started_monotonic: float | None = None - # Lifetime counters for uptime statistics (monotonically increasing after startup). - # Python int has arbitrary precision, so overflow is not a concern. - self._counters: dict[str, int] = { - "bytes_in": 0, - "bytes_out": 0, - "pkts_in": 0, - "pkts_bad": 0, - "rate_limited": 0, - "errors_sent": 0, - "joins": 0, - "parts": 0, - "msgs_forwarded": 0, - "notices_forwarded": 0, - "pings_in": 0, - "pongs_in": 0, - "pings_out": 0, - "pongs_out": 0, - "announces": 0, - "resources_sent": 0, - "resources_received": 0, - "resources_rejected": 0, - "resource_bytes_sent": 0, - "resource_bytes_received": 0, - } - - def _extract_caps(self, body: Any) -> dict[int, Any]: - if not isinstance(body, dict): - return {} - caps = body.get(B_HELLO_CAPS) - return caps if isinstance(caps, dict) else {} - - def _fmt_hash(self, h: Any, *, prefix: int = 12) -> str: - if isinstance(h, (bytes, bytearray)): - s = bytes(h).hex() - return s if prefix <= 0 else s[: min(prefix, len(s))] - return "-" - - def _fmt_link_id(self, link: RNS.Link) -> str: - lid = getattr(link, "link_id", None) - if isinstance(lid, (bytes, bytearray)): - return bytes(lid).hex() - h = getattr(link, "hash", None) - if isinstance(h, (bytes, bytearray)): - return bytes(h).hex() - return "-" - - def _packet_would_fit(self, link: RNS.Link, payload: bytes) -> bool: - """Check if payload fits within link MDU without creating/packing packets.""" - try: - # Query link MDU directly if available (more efficient than packing) - if hasattr(link, 'MDU') and link.MDU is not None: - return len(payload) <= link.MDU - # Fall back to packet creation if MDU not available - pkt = RNS.Packet(link, payload) - pkt.pack() - return True - except Exception: - return False - - def _queue_notice_chunks( - self, - outgoing: list[tuple[RNS.Link, bytes]], - link: RNS.Link, - *, - room: str | None, - text: str, - ) -> None: - if self.identity is None: - return - if not text: - return - - # Prefer splitting on lines for readability. If a single line is too - # large, further split it by characters using a pack preflight. - lines = text.splitlines() or [text] - for line in lines: - remaining = line - if not remaining: - continue - - # Start with a generous chunk size; shrink on demand. - max_chars = min(len(remaining), 512) - while remaining: - take = min(len(remaining), max_chars) - chunk = remaining[:take] - env = make_envelope( - T_NOTICE, - src=self.identity.hash, - room=room, - body=chunk, - ) - payload = encode(env) - if self._packet_would_fit(link, payload): - self._queue_payload(outgoing, link, payload) - remaining = remaining[take:] - max_chars = min(max_chars, 512) - continue - - if max_chars <= 1: - # Nothing we can do; avoid an infinite loop. - self.log.warning( - "NOTICE chunk would not fit MTU; dropping remainder (%s chars)", - len(remaining), - ) - break - - max_chars = max(1, max_chars // 2) - - def _queue_welcome( - self, - outgoing: list[tuple[RNS.Link, bytes]], - link: RNS.Link, - *, - peer_hash: Any, - motd: str | None, - ) -> None: - if self.identity is None: - return - - g = str(motd) if motd else "" - body_w: dict[int, Any] = { - B_WELCOME_HUB: self.config.hub_name, - B_WELCOME_VER: str(__version__), - } - # Capabilities are optional; keep WELCOME minimal unless needed. - - welcome = make_envelope(T_WELCOME, src=self.identity.hash, body=body_w) - welcome_payload = encode(welcome) - - if not self._packet_would_fit(link, welcome_payload): - self.log.warning( - "WELCOME would not fit MTU; cannot welcome peer=%s link_id=%s", - self._fmt_hash(peer_hash), - self._fmt_link_id(link), - ) - return - - self._queue_payload(outgoing, link, welcome_payload) - self.log.debug( - "Queued WELCOME peer=%s link_id=%s", - self._fmt_hash(peer_hash), - self._fmt_link_id(link), - ) - - # The hub MOTD (message of the day) is delivered after WELCOME. - if g: - self._send_text_smart( - link, msg_type=T_NOTICE, text=g, room=None, outgoing=outgoing, kind=RES_KIND_MOTD - ) - - def _inc(self, key: str, delta: int = 1) -> None: - try: - with self._state_lock: - self._counters[key] = int(self._counters.get(key, 0)) + int(delta) - except Exception: - pass - - def _update_nick_index(self, link: RNS.Link, old_nick: str | None, new_nick: str | None) -> None: - """Update nick index when a nick changes. Must be called under _state_lock.""" - # Remove old nick mapping - if old_nick: - old_key = old_nick.strip().lower() - if old_key in self._index_by_nick: - self._index_by_nick[old_key].discard(link) - if not self._index_by_nick[old_key]: - self._index_by_nick.pop(old_key, None) - - # Add new nick mapping - if new_nick: - new_key = new_nick.strip().lower() - self._index_by_nick.setdefault(new_key, set()).add(link) - - # Resource transfer methods - - def _cleanup_expired_expectations(self, link: RNS.Link) -> None: - """Remove expired resource expectations for a link.""" - now = time.time() - exp_dict = self._resource_expectations.get(link) - if not exp_dict: - return - - expired = [rid for rid, exp in exp_dict.items() if exp.expires_at <= now] - for rid in expired: - exp_dict.pop(rid, None) - self.log.debug( - "Expired resource expectation link_id=%s rid=%s", - self._fmt_link_id(link), - rid.hex() if isinstance(rid, bytes) else rid, - ) - - def _cleanup_all_expired_expectations(self) -> None: - """Cleanup expired resource expectations across all links.""" - now = time.time() - with self._state_lock: - for link, exp_dict in list(self._resource_expectations.items()): - if not exp_dict: - continue - - expired = [rid for rid, exp in exp_dict.items() if exp.expires_at <= now] - for rid in expired: - exp_dict.pop(rid, None) - self.log.debug( - "Expired resource expectation link_id=%s rid=%s", - self._fmt_link_id(link), - rid.hex() if isinstance(rid, bytes) else rid, - ) - - def _add_resource_expectation( - self, - link: RNS.Link, - *, - rid: bytes, - kind: str, - size: int, - sha256: bytes | None = None, - encoding: str | None = None, - room: str | None = None, - ) -> bool: - """Add a resource expectation. Returns False if limit exceeded.""" - self._cleanup_expired_expectations(link) - - exp_dict = self._resource_expectations.setdefault(link, {}) - - if len(exp_dict) >= self.config.max_pending_resource_expectations: - self.log.warning( - "Max pending expectations exceeded link_id=%s", - self._fmt_link_id(link), - ) - return False - - now = time.time() - exp = _ResourceExpectation( - id=rid, - kind=kind, - size=size, - sha256=sha256, - encoding=encoding, - created_at=now, - expires_at=now + self.config.resource_expectation_ttl_s, - room=room, - ) - exp_dict[rid] = exp - - self.log.debug( - "Added resource expectation link_id=%s rid=%s kind=%s size=%s", - self._fmt_link_id(link), - rid.hex(), - kind, - size, - ) - return True - - def _find_resource_expectation( - self, link: RNS.Link, size: int - ) -> _ResourceExpectation | None: - """Find a matching resource expectation by size (fallback matching).""" - self._cleanup_expired_expectations(link) - - exp_dict = self._resource_expectations.get(link) - if not exp_dict: - return None - - # Match by size (first match wins) - for exp in exp_dict.values(): - if exp.size == size: - return exp - - return None - - def _get_resource_expectation_by_rid( - self, link: RNS.Link, rid: bytes - ) -> _ResourceExpectation | None: - """Lookup an expectation by RID without removing it.""" - exp_dict = self._resource_expectations.get(link) - if not exp_dict: - return None - return exp_dict.get(rid) - - def _match_resource_expectation( - self, link: RNS.Link, *, rid: bytes | None, size: int, sha256: bytes | None - ) -> _ResourceExpectation | None: - """Find the expectation that should satisfy a completed resource. - - Preference order: - 1) Bound RID (from advertisement) when available. - 2) Exact RID lookup. - 3) Fallback: first size match whose sha256 (if present) matches. - """ - self._cleanup_expired_expectations(link) - - if rid is not None: - exp = self._get_resource_expectation_by_rid(link, rid) - if exp is not None: - return exp - - exp_dict = self._resource_expectations.get(link) - if not exp_dict: - return None - - # Avoid linear scan if nothing matches by size. - for exp in exp_dict.values(): - if exp.size != size: - continue - if exp.sha256 and sha256 and exp.sha256 != sha256: - continue - return exp - return None - - def _pop_resource_expectation( - self, link: RNS.Link, rid: bytes - ) -> _ResourceExpectation | None: - """Remove and return a resource expectation.""" - exp_dict = self._resource_expectations.get(link) - if not exp_dict: - return None - return exp_dict.pop(rid, None) - - def _resource_advertised(self, resource: RNS.Resource) -> bool: - """ - Callback when a Resource is advertised by remote peer. - Returns True to accept, False to reject. - - Minimize lock scope to prevent potential deadlocks with RNS internal locks. - """ - link = resource.link - - # Check config outside lock (immutable during runtime) - if not self.config.enable_resource_transfer: - self.log.debug( - "Rejecting resource (disabled) link_id=%s", - self._fmt_link_id(link), - ) - self._inc("resources_rejected") - return False - - # Check size limit (immutable config) - size = resource.total_size if hasattr(resource, "total_size") else resource.size - if size > self.config.max_resource_bytes: - self.log.warning( - "Rejecting resource (too large: %s > %s) link_id=%s", - size, - self.config.max_resource_bytes, - self._fmt_link_id(link), - ) - self._inc("resources_rejected") - return False - - # Check session exists and find expectation with minimal lock scope - with self._state_lock: - sess = self.sessions.get(link) - if not sess: - self.log.debug( - "Rejecting resource (no session) link_id=%s", - self._fmt_link_id(link), - ) - self._inc("resources_rejected") - return False - - # Find matching expectation - exp = self._find_resource_expectation(link, size) - - # Check expectation outside lock - if not exp: - self.log.warning( - "Rejecting resource (no matching expectation) link_id=%s size=%s", - self._fmt_link_id(link), - size, - ) - self._inc("resources_rejected") - return False - - # Accept and register with minimal lock scope - self.log.info( - "Accepting resource link_id=%s size=%s kind=%s", - self._fmt_link_id(link), - size, - exp.kind, - ) - - with self._state_lock: - self._active_resources.setdefault(link, set()).add(resource) - # Remember which expectation RID this resource was matched to so the - # conclusion handler can verify and pop the correct entry. - self._resource_bindings[resource] = exp.id - - return True - - def _resource_concluded(self, resource: RNS.Resource) -> None: - """Callback when a Resource transfer completes.""" - link = resource.link - - with self._state_lock: - # Remove from active set and retrieve any bound expectation RID. - active_set = self._active_resources.get(link) - if active_set: - active_set.discard(resource) - bound_rid = self._resource_bindings.pop(resource, None) - - if resource.status != RNS.Resource.COMPLETE: - self.log.warning( - "Resource transfer failed link_id=%s status=%s", - self._fmt_link_id(link), - resource.status, - ) - return - - # Get payload outside the lock. - try: - payload = resource.data.read() if hasattr(resource.data, "read") else resource.data - if isinstance(payload, bytearray): - payload = bytes(payload) - except Exception as e: - self.log.error( - "Failed to read resource data link_id=%s: %s", - self._fmt_link_id(link), - e, - ) - return - - size = len(payload) - actual_hash = hashlib.sha256(payload).digest() - - # Find expectation using bound RID first, then RID lookup, then size/sha fallback. - exp = self._match_resource_expectation(link, rid=bound_rid, size=size, sha256=actual_hash) - if not exp: - self.log.warning( - "Received resource without expectation link_id=%s size=%s", - self._fmt_link_id(link), - size, - ) - return - - # Verify SHA256 if provided; keep expectation if mismatch so sender can retry. - if exp.sha256 and actual_hash != exp.sha256: - self.log.error( - "Resource SHA256 mismatch link_id=%s expected=%s actual=%s", - self._fmt_link_id(link), - exp.sha256.hex(), - actual_hash.hex(), - ) - return - - # Pop expectation only after validation succeeds. - self._pop_resource_expectation(link, exp.id) - - self._inc("resources_received") - self._inc("resource_bytes_received", size) - - self.log.info( - "Resource received link_id=%s size=%s kind=%s", - self._fmt_link_id(link), - size, - exp.kind, - ) - - # Dispatch by kind - try: - self._dispatch_received_resource(link, exp, payload) - except Exception as e: - self.log.exception( - "Failed to dispatch resource link_id=%s kind=%s: %s", - self._fmt_link_id(link), - exp.kind, - e, - ) - - def _dispatch_received_resource( - self, link: RNS.Link, exp: _ResourceExpectation, payload: bytes - ) -> None: - """Dispatch a received resource payload to appropriate handler.""" - if exp.kind == RES_KIND_NOTICE: - # Decode as text and deliver as notice - encoding = exp.encoding or "utf-8" - try: - text = payload.decode(encoding) - except Exception as e: - self.log.error( - "Failed to decode notice resource link_id=%s encoding=%s: %s", - self._fmt_link_id(link), - encoding, - e, - ) - return - - self.log.info( - "Received large NOTICE via resource link_id=%s room=%r chars=%s", - self._fmt_link_id(link), - exp.room, - len(text), - ) - - # Forward NOTICE to room members if room is specified - if exp.room and self.identity is not None: - with self._state_lock: - sess = self.sessions.get(link) - peer_hash = sess.get("peer") if sess else None - room_members = self.rooms.get(exp.room, set()) - - if peer_hash and room_members: - notice_env = make_envelope( - T_NOTICE, - src=peer_hash, - room=exp.room, - body=text, - ) - notice_payload = encode(notice_env) - - # Forward to all room members except sender - forwarded = 0 - for other in room_members: - if other != link: - try: - other.packet(notice_payload) - forwarded += 1 - except Exception as e: - self.log.warning( - "Failed to forward NOTICE resource link_id=%s: %s", - self._fmt_link_id(other), - e, - ) - - if forwarded > 0: - self._inc("notices_forwarded") - self.log.debug( - "Forwarded NOTICE resource to %d members room=%s", - forwarded, - exp.room, - ) - - elif exp.kind == RES_KIND_MOTD: - # Similar to NOTICE - encoding = exp.encoding or "utf-8" - try: - text = payload.decode(encoding) - except Exception as e: - self.log.error( - "Failed to decode MOTD resource link_id=%s: %s", - self._fmt_link_id(link), - e, - ) - return - - self.log.info( - "Received MOTD via resource link_id=%s chars=%s", - self._fmt_link_id(link), - len(text), - ) - - elif exp.kind == RES_KIND_BLOB: - # Generic binary data - self.log.info( - "Received BLOB via resource link_id=%s bytes=%s", - self._fmt_link_id(link), - len(payload), - ) - else: - self.log.warning( - "Unknown resource kind link_id=%s kind=%s", - self._fmt_link_id(link), - exp.kind, - ) - - def _send_via_resource( - self, - link: RNS.Link, - *, - kind: str, - payload: bytes, - room: str | None = None, - encoding: str | None = None, - ) -> bool: - """ - Send large payload via Resource. - Returns True if successfully initiated, False otherwise. - """ - if not self.config.enable_resource_transfer: - return False - - size = len(payload) - if size > self.config.max_resource_bytes: - self.log.error( - "Payload too large for resource transfer: %s > %s", - size, - self.config.max_resource_bytes, - ) - return False - - # Generate resource ID - rid = os.urandom(8) - - # Compute SHA256 - sha256 = hashlib.sha256(payload).digest() - - # Send envelope first - if self.identity is None: - return False - - envelope_body = { - B_RES_ID: rid, - B_RES_KIND: kind, - B_RES_SIZE: size, - B_RES_SHA256: sha256, - } - if encoding: - envelope_body[B_RES_ENCODING] = encoding - - envelope = make_envelope( - T_RESOURCE_ENVELOPE, - src=self.identity.hash, - room=room, - body=envelope_body, - ) - - try: - envelope_payload = encode(envelope) - RNS.Packet(link, envelope_payload).send() - self._inc("bytes_out", len(envelope_payload)) - - self.log.debug( - "Sent resource envelope link_id=%s rid=%s kind=%s size=%s", - self._fmt_link_id(link), - rid.hex(), - kind, - size, - ) - except Exception as e: - self.log.error( - "Failed to send resource envelope link_id=%s: %s", - self._fmt_link_id(link), - e, - ) - return False - - # Create and advertise resource - try: - resource = RNS.Resource(payload, link, advertise=True, auto_compress=False) - - with self._state_lock: - self._active_resources.setdefault(link, set()).add(resource) - - self._inc("resources_sent") - self._inc("resource_bytes_sent", size) - - self.log.info( - "Sent resource link_id=%s rid=%s kind=%s size=%s", - self._fmt_link_id(link), - rid.hex(), - kind, - size, - ) - return True - - except Exception as e: - self.log.error( - "Failed to create resource link_id=%s: %s", - self._fmt_link_id(link), - e, - ) - return False - - def _send_text_smart( - self, - link: RNS.Link, - *, - msg_type: int, - text: str, - room: str | None = None, - encoding: str = "utf-8", - outgoing: list[tuple[RNS.Link, bytes]] | None = None, - kind: str | None = None, - ) -> None: - """ - Send text message using best method (packet or resource). - Falls back to chunking if resource transfer fails or is disabled. - - Args: - kind: Resource kind if sent via resource (default: RES_KIND_NOTICE) - """ - if self.identity is None: - return - - # Try encoding as a single packet first - env = make_envelope(msg_type, src=self.identity.hash, room=room, body=text) - payload = encode(env) - - # If it fits, send normally - if self._packet_would_fit(link, payload): - if outgoing is None: - self._send(link, env) - else: - self._queue_env(outgoing, link, env) - return - - # Too large for packet - try resource if enabled and type is NOTICE - if ( - self.config.enable_resource_transfer - and msg_type == T_NOTICE - and len(text.encode(encoding)) <= self.config.max_resource_bytes - ): - text_bytes = text.encode(encoding) - resource_kind = kind if kind is not None else RES_KIND_NOTICE - if self._send_via_resource( - link, - kind=resource_kind, - payload=text_bytes, - room=room, - encoding=encoding, - ): - self.log.debug( - "Sent large text via resource link_id=%s kind=%s chars=%s", - self._fmt_link_id(link), - resource_kind, - len(text), - ) - return - - # Fall back to chunking for NOTICE - if msg_type == T_NOTICE: - if outgoing is None: - outgoing = [] - self._queue_notice_chunks(outgoing, link, room=room, text=text) - for out_link, chunk_payload in outgoing: - self._inc("bytes_out", len(chunk_payload)) - try: - RNS.Packet(out_link, chunk_payload).send() - except Exception as e: - self.log.warning( - "Failed to send chunk link_id=%s: %s", - self._fmt_link_id(out_link), - e, - ) - else: - self._queue_notice_chunks(outgoing, link, room=room, text=text) - else: - # For other message types, just drop or log error - self.log.error( - "Message too large and not NOTICE link_id=%s type=%s", - self._fmt_link_id(link), - msg_type, - ) - def start(self) -> None: self.log.info("Starting Reticulum") - if self._started_wall_time is None: - self._started_wall_time = time.time() - if self._started_monotonic is None: - self._started_monotonic = time.monotonic() + if self.stats_manager.started_wall_time is None: + self.stats_manager.set_start_time() RNS.Reticulum(configdir=self.config.configdir, require_shared_instance=False) if not self.config.identity_path: raise RuntimeError("identity_path is not set") self.identity = self._load_identity(self.config.identity_path) - self._trusted = { - self._parse_identity_hash(h) - for h in (self.config.trusted_identities or ()) - if str(h).strip() - } - self._banned = { - self._parse_identity_hash(h) - for h in (self.config.banned_identities or ()) - if str(h).strip() - } + self.trust_manager.load_from_config( + self.config.trusted_identities, + self.config.banned_identities, + ) self._load_registered_rooms_from_registry() @@ -938,10 +119,11 @@ class HubService: ) self._prune_thread.start() - # Start resource cleanup thread if resource transfer is enabled if self.config.enable_resource_transfer: self._resource_cleanup_thread = threading.Thread( - target=self._resource_cleanup_loop, name="rrcd-resource-cleanup", daemon=True + target=self._resource_cleanup_loop, + name="rrcd-resource-cleanup", + daemon=True, ) self._resource_cleanup_thread.start() @@ -952,7 +134,7 @@ class HubService: self.destination.announce( app_data=encode({"proto": "rrc", "v": 1, "hub": self.config.hub_name}) ) - self._inc("announces") + self.stats_manager.inc("announces") except Exception: self.log.exception("Announce failed") @@ -982,12 +164,9 @@ class HubService: self._shutdown.set() with self._state_lock: - links = list(self.sessions.keys()) - self.sessions.clear() - self.rooms.clear() - self._rate.clear() - self._resource_expectations.clear() - self._active_resources.clear() + links = self.session_manager.clear_all() + self.room_manager.clear_all() + self.resource_manager.clear_all() for link in links: try: @@ -1017,308 +196,7 @@ class HubService: raise ValueError(f"identity hash too short: {text!r}") return b - def _load_toml(self, path: str) -> dict: - import tomllib - - with open(path, "rb") as f: - data = tomllib.load(f) - return data if isinstance(data, dict) else {} - - def _apply_config_data( - self, base: HubRuntimeConfig, data: dict - ) -> HubRuntimeConfig: - hub = data.get("hub") if isinstance(data, dict) else None - if isinstance(hub, dict): - data = {**data, **hub} - - log_table = data.get("logging") if isinstance(data, dict) else None - if isinstance(log_table, dict): - mapped: dict[str, object] = {} - if "level" in log_table: - mapped["log_level"] = log_table.get("level") - if "rns_level" in log_table: - mapped["log_rns_level"] = log_table.get("rns_level") - if "console" in log_table: - mapped["log_console"] = log_table.get("console") - if "file" in log_table: - mapped["log_file"] = log_table.get("file") - if "format" in log_table: - mapped["log_format"] = log_table.get("format") - if "datefmt" in log_table: - mapped["log_datefmt"] = log_table.get("datefmt") - data = {**data, **mapped} - - allowed = set(asdict(base).keys()) - # This identifies where to reload from; do not let the file override it. - allowed.discard("config_path") - - updates = {k: v for k, v in data.items() if k in allowed} - - for list_key in ("trusted_identities", "banned_identities"): - if list_key in updates and isinstance(updates[list_key], list): - updates[list_key] = tuple(str(x) for x in updates[list_key]) - - if "announce" in data and "announce_on_start" not in updates: - try: - updates["announce_on_start"] = bool(data["announce"]) - except Exception: - pass - if "configdir" in updates and updates["configdir"] == "": - updates["configdir"] = None - if "greeting" in updates and updates["greeting"] == "": - updates["greeting"] = None - if "log_file" in updates and updates["log_file"] == "": - updates["log_file"] = None - if "log_datefmt" in updates and updates["log_datefmt"] == "": - updates["log_datefmt"] = None - - return replace(base, **updates) if updates else base - - def _format_reload_value(self, v: Any) -> str: - if v is None: - return "(none)" - if isinstance(v, (bool, int, float)): - return str(v) - if isinstance(v, (tuple, list, set)): - return f"len={len(v)}" - s = str(v) - s = " ".join(s.split()) - if len(s) > 80: - s = s[:77] + "..." - return s - - def _diff_config_summary( - self, old: HubRuntimeConfig, new: HubRuntimeConfig - ) -> list[str]: - old_d = asdict(old) - new_d = asdict(new) - old_d.pop("config_path", None) - new_d.pop("config_path", None) - - changed: list[str] = [] - for k in sorted(new_d.keys()): - if old_d.get(k) == new_d.get(k): - continue - changed.append( - f"{k}: {self._format_reload_value(old_d.get(k))} -> {self._format_reload_value(new_d.get(k))}" - ) - return changed - - def _load_room_registry_from_path( - self, - reg_path: str, - *, - invite_timeout_s: float | None = None, - ) -> tuple[dict[str, dict[str, Any]], str | None]: - if not reg_path: - return {}, "room_registry_path is empty" - if not os.path.exists(reg_path): - return {}, f"room registry file not found: {reg_path}" - try: - from tomlkit import parse # type: ignore - except Exception: - return {}, "missing dependency tomlkit" - - try: - with open(reg_path, encoding="utf-8") as f: - doc = parse(f.read()) - except Exception as e: - return {}, f"failed to parse rooms registry: {e}" - - rooms = doc.get("rooms") - if rooms is None: - return {}, None - if not isinstance(rooms, dict): - return {}, "rooms registry: [rooms] must be a table" - - def _parse_list(cfg: dict[str, Any], name: str) -> set[bytes]: - out: set[bytes] = set() - lst = cfg.get(name) - if isinstance(lst, list): - for item in lst: - if not isinstance(item, str) or not item.strip(): - continue - try: - out.add(self._parse_identity_hash(item)) - except Exception: - continue - return out - - registry: dict[str, dict[str, Any]] = {} - for raw_room, raw_cfg in rooms.items(): - if not isinstance(raw_room, str): - continue - try: - room = self._norm_room(raw_room) - except Exception: - continue - if not isinstance(raw_cfg, dict): - continue - - founder_hex = raw_cfg.get("founder") - founder = None - if isinstance(founder_hex, str) and founder_hex.strip(): - try: - founder = self._parse_identity_hash(founder_hex) - except Exception: - founder = None - - topic = raw_cfg.get("topic") - if not isinstance(topic, str) or not topic.strip(): - topic = None - - moderated = bool(raw_cfg.get("moderated", False)) - - invite_only = bool(raw_cfg.get("invite_only", False)) - topic_ops_only = bool(raw_cfg.get("topic_ops_only", False)) - no_outside_msgs = bool(raw_cfg.get("no_outside_msgs", False)) - - key = raw_cfg.get("key") - if not isinstance(key, str) or not key: - key = None - - last_used_ts = raw_cfg.get("last_used_ts") - try: - last_used_ts = float(last_used_ts) if last_used_ts is not None else None - except Exception: - last_used_ts = None - - ops = _parse_list(raw_cfg, "operators") - voiced = _parse_list(raw_cfg, "voiced") - bans = _parse_list(raw_cfg, "bans") - - invited: dict[bytes, float] = {} - raw_inv = raw_cfg.get("invited") - now = float(time.time()) - ttl_src = invite_timeout_s - if ttl_src is None: - ttl_src = self.config.room_invite_timeout_s - ttl = float(ttl_src) if ttl_src else 0.0 - if ttl <= 0: - ttl = 900.0 - - # New format: invited is a table mapping hex->expiry_ts - if isinstance(raw_inv, dict): - for k, v in raw_inv.items(): - if not isinstance(k, str) or not k.strip(): - continue - try: - h = self._parse_identity_hash(k) - except Exception: - continue - try: - exp = float(v) - except Exception: - continue - if exp > now: - invited[h] = exp - - # Back-compat: invited as a list of identity hashes => grant ttl from now - elif isinstance(raw_inv, list): - for item in raw_inv: - if not isinstance(item, str) or not item.strip(): - continue - try: - h = self._parse_identity_hash(item) - except Exception: - continue - invited[h] = now + ttl - - if founder is not None: - ops.add(founder) - - registry[room] = { - "founder": founder, - "registered": True, - "topic": topic, - "moderated": moderated, - "invite_only": invite_only, - "topic_ops_only": topic_ops_only, - "no_outside_msgs": no_outside_msgs, - "key": key, - "ops": ops, - "voiced": voiced, - "bans": bans, - "invited": invited, - "last_used_ts": last_used_ts, - } - - return registry, None - - def _diff_room_registry_summary( - self, old: dict[str, dict[str, Any]], new: dict[str, dict[str, Any]] - ) -> list[str]: - old_rooms = set(old.keys()) - new_rooms = set(new.keys()) - added = sorted(new_rooms - old_rooms) - removed = sorted(old_rooms - new_rooms) - - lines: list[str] = [] - if added: - preview = ", ".join(added[:10]) - suffix = "" if len(added) <= 10 else f" (+{len(added) - 10} more)" - lines.append(f"rooms_added={len(added)}: {preview}{suffix}") - if removed: - preview = ", ".join(removed[:10]) - suffix = "" if len(removed) <= 10 else f" (+{len(removed) - 10} more)" - lines.append(f"rooms_removed={len(removed)}: {preview}{suffix}") - if not lines: - lines.append(f"rooms_changed=0 (registered_rooms={len(new_rooms)})") - return lines - - def _room_modes(self, room: str) -> dict[str, Any]: - st = self._room_state_ensure(room) - registered = bool(st.get("registered", False)) - moderated = bool(st.get("moderated", False)) - invite_only = bool(st.get("invite_only", False)) - topic_ops_only = bool(st.get("topic_ops_only", False)) - no_outside_msgs = bool(st.get("no_outside_msgs", False)) - private = bool(st.get("private", False)) - key = st.get("key") - has_key = isinstance(key, str) and bool(key) - return { - "registered": registered, - "moderated": moderated, - "invite_only": invite_only, - "topic_ops_only": topic_ops_only, - "no_outside_msgs": no_outside_msgs, - "private": private, - "has_key": has_key, - } - - def _room_mode_string(self, room: str) -> str: - m = self._room_modes(room) - flags: list[str] = [] - # Keep roughly IRC-ish order. - if m.get("invite_only"): - flags.append("i") - if m.get("has_key"): - flags.append("k") - if m.get("moderated"): - flags.append("m") - if m.get("no_outside_msgs"): - flags.append("n") - if m.get("private"): - flags.append("p") - if m.get("registered"): - flags.append("r") - if m.get("topic_ops_only"): - flags.append("t") - return "+" + "".join(flags) if flags else "(none)" - - def _broadcast_room_mode( - self, room: str, outgoing: list[tuple[RNS.Link, bytes]] | None = None - ) -> None: - mode_txt = self._room_mode_string(room) - with self._state_lock: - recipients = list(self.rooms.get(room, set())) - for other in recipients: - self._emit_notice( - outgoing, other, room, f"mode for {room} is now: {mode_txt}" - ) - def _ensure_worker_threads(self) -> None: - # Announce loop if self._announce_thread is None or not self._announce_thread.is_alive(): if ( self.config.announce_period_s @@ -1331,7 +209,6 @@ class HubService: ) self._announce_thread.start() - # Ping loop if self._ping_thread is None or not self._ping_thread.is_alive(): if self.config.ping_interval_s and float(self.config.ping_interval_s) > 0: self._ping_thread = threading.Thread( @@ -1339,7 +216,6 @@ class HubService: ) self._ping_thread.start() - # Prune loop if self._prune_thread is None or not self._prune_thread.is_alive(): if ( self.config.room_registry_prune_interval_s @@ -1354,36 +230,49 @@ class HubService: ) self._prune_thread.start() + def _fmt_hash(self, h: Any, *, prefix: int = 12) -> str: + if isinstance(h, (bytes, bytearray)): + s = bytes(h).hex() + return s if prefix <= 0 else s[: min(prefix, len(s))] + return "-" + + def _fmt_link_id(self, link: RNS.Link) -> str: + lid = getattr(link, "link_id", None) + if isinstance(lid, (bytes, bytearray)): + return bytes(lid).hex() + h = getattr(link, "hash", None) + if isinstance(h, (bytes, bytearray)): + return bytes(h).hex() + return "-" + def _reload_config_and_rooms( self, link: RNS.Link, room: str | None, outgoing: list[tuple[RNS.Link, bytes]] | None = None, ) -> None: - cfg_path = self._config_path_for_writes() + cfg_path = self.config_manager.get_config_path_for_writes() if not cfg_path or not os.path.exists(cfg_path): - self._emit_notice( + self.message_helper.emit_notice( outgoing, link, room, "reload failed: config_path not set or missing" ) return with self._state_lock: old_cfg = self.config - old_trusted = set(self._trusted) - old_banned = set(self._banned) - old_registry = dict(self._room_registry) + old_trusted = set(self.trust_manager._trusted) + old_banned = set(self.trust_manager._banned) + old_registry = dict(self.room_manager._room_registry) - # Stage config parse try: - data = self._load_toml(cfg_path) - new_cfg = self._apply_config_data(old_cfg, data) + data = self.config_manager.load_toml(cfg_path) + new_cfg = self.config_manager.apply_config_data(old_cfg, data) except Exception as e: - self._emit_notice( + self.message_helper.emit_notice( outgoing, link, room, f"reload failed: config parse error: {e}" ) return - # Stage identity lists try: new_trusted = { self._parse_identity_hash(h) @@ -1396,92 +285,44 @@ class HubService: if str(h).strip() } except Exception as e: - self._emit_notice( + self.message_helper.emit_notice( outgoing, link, room, f"reload failed: identity list parse error: {e}" ) return - # Stage room registry parse (strict) reg_path = ( expand_path(str(new_cfg.room_registry_path)) if new_cfg.room_registry_path else "" ) - new_registry, reg_err = self._load_room_registry_from_path( + new_registry, reg_err = self.room_manager.load_registry_from_path( reg_path, invite_timeout_s=new_cfg.room_invite_timeout_s, ) if reg_err is not None: - self._emit_notice(outgoing, link, room, f"reload failed: {reg_err}") + self.message_helper.emit_notice( + outgoing, link, room, f"reload failed: {reg_err}" + ) return with self._state_lock: - # Apply (all-or-nothing) self.config = new_cfg - self._trusted = new_trusted - self._banned = new_banned - self._room_registry = new_registry - - # Merge registry into live per-room state (for active rooms). - # This makes /reload take effect immediately for existing members. - for r, st in list(self._room_state.items()): - if not isinstance(st, dict): - continue - - reg = self._room_registry.get(r) - if reg is None: - # If a room was unregistered on disk, reflect that. - if st.get("registered"): - st["registered"] = False - continue - - st["registered"] = True - - founder = reg.get("founder") - if isinstance(founder, (bytes, bytearray)): - st["founder"] = bytes(founder) - - # Simple scalar fields - for key in ( - "topic", - "moderated", - "invite_only", - "topic_ops_only", - "no_outside_msgs", - "key", - "last_used_ts", - ): - if key in reg: - st[key] = reg.get(key) - - # Set fields - for key in ("ops", "voiced", "bans"): - v = reg.get(key) - if isinstance(v, set): - st[key] = set(v) - - # Invites (dict[bytes, float]) - inv = reg.get("invited") - if isinstance(inv, dict): - st["invited"] = dict(inv) - - # Ensure founder stays op. - founder_st = st.get("founder") - if isinstance(founder_st, (bytes, bytearray)): - ops = st.setdefault("ops", set()) - if isinstance(ops, set): - ops.add(bytes(founder_st)) + self.trust_manager._trusted = new_trusted + self.trust_manager._banned = new_banned + self.room_manager._room_registry = new_registry + self.room_manager.merge_registry_into_state(new_registry) self._ensure_worker_threads() - # Apply logging changes immediately. try: configure_logging(self.config) except Exception: self.log.exception("Failed to reconfigure logging") - cfg_changes = self._diff_config_summary(old_cfg, new_cfg) - room_changes = self._diff_room_registry_summary(old_registry, new_registry) + cfg_changes = self.config_manager.diff_config_summary(old_cfg, new_cfg) + room_changes = self.room_manager.diff_registry_summary( + old_registry, new_registry + ) lines: list[str] = [] lines.append( @@ -1503,161 +344,18 @@ class HubService: lines.append("rooms_changes:") lines.extend(f"- {x}" for x in room_changes) - self._emit_notice(outgoing, link, room, "\n".join(lines)) - - def _room_registry_path_for_writes(self) -> str | None: - p = self.config.room_registry_path - if not p: - return - return expand_path(str(p)) + self.message_helper.emit_notice(outgoing, link, room, "\n".join(lines)) def _load_registered_rooms_from_registry(self) -> None: - reg_path = self._room_registry_path_for_writes() + reg_path = self.room_manager.get_registry_path_for_writes() if not reg_path: return - registry, err = self._load_room_registry_from_path(reg_path) + registry, err = self.room_manager.load_registry_from_path( + reg_path, invite_timeout_s=self.config.room_invite_timeout_s + ) if err is not None: return - self._room_registry = registry - - def _room_state_get(self, room: str) -> dict[str, Any] | None: - return self._room_state.get(room) - - def _room_state_ensure( - self, room: str, *, founder: bytes | None = None - ) -> dict[str, Any]: - st = self._room_state.get(room) - if st is not None: - if st.get("founder") is None and founder is not None: - st["founder"] = founder - st.setdefault("ops", set()).add(founder) - return st - - if room in self._room_registry: - base = self._room_registry[room] - invited = base.get("invited") - invited_dict: dict[bytes, float] = {} - if isinstance(invited, dict): - for k, v in invited.items(): - if isinstance(k, (bytes, bytearray)): - try: - invited_dict[bytes(k)] = float(v) - except Exception: - continue - st = { - "founder": base.get("founder"), - "registered": True, - "topic": base.get("topic"), - "moderated": bool(base.get("moderated", False)), - "invite_only": bool(base.get("invite_only", False)), - "topic_ops_only": bool(base.get("topic_ops_only", False)), - "no_outside_msgs": bool(base.get("no_outside_msgs", False)), - "private": bool(base.get("private", False)), - "key": base.get("key"), - "ops": set(base.get("ops", set())), - "voiced": set(base.get("voiced", set())), - "bans": set(base.get("bans", set())), - "invited": invited_dict, - "last_used_ts": base.get("last_used_ts"), - } - self._room_state[room] = st - return st - - st = { - "founder": founder, - "registered": False, - "topic": None, - "moderated": False, - "invite_only": False, - "topic_ops_only": False, - "no_outside_msgs": False, - "private": False, - "key": None, - "ops": set([founder]) if founder is not None else set(), - "voiced": set(), - "bans": set(), - "invited": {}, - "last_used_ts": None, - } - self._room_state[room] = st - return st - - def _prune_expired_invites(self, st: dict[str, Any]) -> bool: - inv = st.get("invited") - if not isinstance(inv, dict) or not inv: - return False - now = float(time.time()) - removed_any = False - for h, exp in list(inv.items()): - try: - exp_f = float(exp) - except Exception: - exp_f = 0.0 - if exp_f <= now: - inv.pop(h, None) - removed_any = True - return removed_any - - def _is_invited(self, st: dict[str, Any], peer_hash: bytes) -> bool: - inv = st.get("invited") - if not isinstance(inv, dict) or not inv: - return False - now = float(time.time()) - exp = inv.get(peer_hash) - try: - exp_f = float(exp) if exp is not None else 0.0 - except Exception: - exp_f = 0.0 - if exp_f <= now: - inv.pop(peer_hash, None) - return False - return True - - def _touch_room(self, room: str) -> None: - try: - st = self._room_state_ensure(room) - ts = float(time.time()) - st["last_used_ts"] = ts - reg = self._room_registry.get(room) - if isinstance(reg, dict): - reg["last_used_ts"] = ts - except Exception: - pass - - def _is_server_op(self, peer_hash: bytes | None) -> bool: - return self._is_trusted(peer_hash) - - def _is_room_op(self, room: str, peer_hash: bytes | None) -> bool: - if peer_hash is None: - return False - if self._is_server_op(peer_hash): - return True - st = self._room_state_ensure(room) - founder = st.get("founder") - if isinstance(founder, (bytes, bytearray)) and bytes(founder) == peer_hash: - return True - ops = st.get("ops") - return isinstance(ops, set) and peer_hash in ops - - def _is_room_voiced(self, room: str, peer_hash: bytes | None) -> bool: - if peer_hash is None: - return False - if self._is_room_op(room, peer_hash): - return True - st = self._room_state_ensure(room) - voiced = st.get("voiced") - return isinstance(voiced, set) and peer_hash in voiced - - def _is_room_banned(self, room: str, peer_hash: bytes | None) -> bool: - if peer_hash is None: - return False - st = self._room_state_ensure(room) - bans = st.get("bans") - return isinstance(bans, set) and peer_hash in bans - - def _room_moderated(self, room: str) -> bool: - st = self._room_state_ensure(room) - return bool(st.get("moderated", False)) + self.room_manager._room_registry = registry def _resolve_identity_hash( self, token: str, *, room: str | None = None @@ -1667,7 +365,7 @@ class HubService: """ target_link = self._find_target_link(token, room=room) if target_link is not None: - s = self.sessions.get(target_link) + s = self.session_manager.sessions.get(target_link) ph = s.get("peer") if s else None if isinstance(ph, (bytes, bytearray)): return bytes(ph) @@ -1684,181 +382,34 @@ class HubService: Use matches list to provide helpful error messages. """ matches = self._find_target_links(token, room=room) - + if len(matches) == 1: - # Exactly one match - get hash from session - s = self.sessions.get(matches[0]) + s = self.session_manager.sessions.get(matches[0]) ph = s.get("peer") if s else None if isinstance(ph, (bytes, bytearray)): return (bytes(ph), matches) elif len(matches) > 1: - # Ambiguous - return None hash but provide matches for error message return (None, matches) - - # No matches from nick/hash-prefix lookup - try raw hash parse + try: h = self._parse_identity_hash(token) return (h, []) except Exception: return (None, []) - def _persist_room_state_to_registry(self, link: RNS.Link, room: str | None) -> None: - if room is None: - return - reg_path = self._room_registry_path_for_writes() - if not reg_path: - return - st = self._room_state_get(room) - if not st or not st.get("registered"): - return - - try: - from tomlkit import dumps, parse, table # type: ignore - except Exception: - return - - try: - with self._room_registry_write_lock: - file_stat = None - try: - file_stat = os.stat(reg_path) - except Exception: - file_stat = None - - with open(reg_path, encoding="utf-8") as f: - doc = parse(f.read()) - - rooms = doc.get("rooms") - if rooms is None: - rooms = table() - doc["rooms"] = rooms - - room_tbl = rooms.get(room) - if room_tbl is None: - room_tbl = table() - rooms[room] = room_tbl - - founder = st.get("founder") - if isinstance(founder, (bytes, bytearray)): - room_tbl["founder"] = bytes(founder).hex() - - topic = st.get("topic") - if isinstance(topic, str) and topic.strip(): - room_tbl["topic"] = topic - else: - if "topic" in room_tbl: - del room_tbl["topic"] - - room_tbl["moderated"] = bool(st.get("moderated", False)) - - room_tbl["invite_only"] = bool(st.get("invite_only", False)) - room_tbl["topic_ops_only"] = bool(st.get("topic_ops_only", False)) - room_tbl["no_outside_msgs"] = bool(st.get("no_outside_msgs", False)) - - key = st.get("key") - if isinstance(key, str) and key: - room_tbl["key"] = key - else: - if "key" in room_tbl: - del room_tbl["key"] - - last_used_ts = st.get("last_used_ts") - if last_used_ts is None: - last_used_ts = float(time.time()) - try: - room_tbl["last_used_ts"] = float(last_used_ts) - except Exception: - room_tbl["last_used_ts"] = float(time.time()) - - ops = st.get("ops") - if isinstance(ops, set): - room_tbl["operators"] = sorted( - bytes(x).hex() for x in ops if isinstance(x, (bytes, bytearray)) - ) - - voiced = st.get("voiced") - if isinstance(voiced, set): - room_tbl["voiced"] = sorted( - bytes(x).hex() - for x in voiced - if isinstance(x, (bytes, bytearray)) - ) - - bans = st.get("bans") - if isinstance(bans, set): - room_tbl["bans"] = sorted( - bytes(x).hex() - for x in bans - if isinstance(x, (bytes, bytearray)) - ) - - invited = st.get("invited") - if isinstance(invited, dict): - inv_tbl = {} - now = float(time.time()) - for h, exp in invited.items(): - if not isinstance(h, (bytes, bytearray)): - continue - try: - exp_f = float(exp) - except Exception: - continue - if exp_f > now: - inv_tbl[bytes(h).hex()] = exp_f - room_tbl["invited"] = inv_tbl - - new_text = dumps(doc) - with open(reg_path, "w", encoding="utf-8") as f: - f.write(new_text) - - if file_stat is not None: - try: - os.chmod(reg_path, file_stat.st_mode) - except Exception: - pass - except Exception as e: - self._notice_to(link, room, f"room config persist failed: {e}") - - def _delete_room_from_registry(self, link: RNS.Link, room: str) -> None: - reg_path = self._room_registry_path_for_writes() - if not reg_path: - return - try: - from tomlkit import dumps, parse # type: ignore - except Exception: - return - - try: - with self._room_registry_write_lock: - file_stat = None - try: - file_stat = os.stat(reg_path) - except Exception: - file_stat = None - - with open(reg_path, encoding="utf-8") as f: - doc = parse(f.read()) - - rooms = doc.get("rooms") - if isinstance(rooms, dict) and room in rooms: - try: - del rooms[room] - except Exception: - rooms.pop(room, None) - - new_text = dumps(doc) - with open(reg_path, "w", encoding="utf-8") as f: - f.write(new_text) - - if file_stat is not None: - try: - os.chmod(reg_path, file_stat.st_mode) - except Exception: - pass - except Exception as e: - self._notice_to(link, room, f"room unregister persist failed: {e}") + def _resource_cleanup_loop(self) -> None: + """Periodically cleanup expired resource expectations.""" + while not self._shutdown.is_set(): + time.sleep(30.0) + if self._shutdown.is_set(): + break + try: + self.resource_manager.cleanup_all_expired_expectations() + except Exception: + self.log.exception("Resource cleanup failed") def _prune_loop(self) -> None: + """Periodically prune unused registered rooms.""" while not self._shutdown.is_set(): interval = float(self.config.room_registry_prune_interval_s) prune_after = float(self.config.room_registry_prune_after_s) @@ -1870,1347 +421,26 @@ class HubService: if self._shutdown.is_set(): break - now = float(time.time()) - rooms_to_prune: list[str] = [] dummy_link: RNS.Link | None = None with self._state_lock: - dummy_link = next(iter(self.sessions.keys()), None) - - for room, reg in list(self._room_registry.items()): - # Skip active rooms. - if room in self.rooms and self.rooms.get(room): - continue - - last_used = reg.get("last_used_ts") - try: - last_used = float(last_used) if last_used is not None else None - except Exception: - last_used = None - if last_used is None: - # Never-used rooms are eligible after prune_after from process start. - last_used = self._started_wall_time or now - - if (now - float(last_used)) < prune_after: - continue - - # Prune in-memory under lock. - self._room_registry.pop(room, None) - self._room_state.pop(room, None) - rooms_to_prune.append(room) + dummy_link = next(iter(self.session_manager.sessions.keys()), None) + rooms_to_prune = self.room_manager.prune_unused_registered_rooms( + prune_after, self.stats_manager.started_wall_time or time.time() + ) if dummy_link is not None: for room in rooms_to_prune: - self._delete_room_from_registry(dummy_link, room) + self.room_manager.delete_room_from_registry(dummy_link, room) for room in rooms_to_prune: self.log.info("Pruned unused registered room %s", room) - def _resource_cleanup_loop(self) -> None: - """Periodically cleanup expired resource expectations.""" - while not self._shutdown.is_set(): - # Run cleanup every 30 seconds - time.sleep(30.0) - if self._shutdown.is_set(): - break - try: - self._cleanup_all_expired_expectations() - except Exception: - self.log.exception("Resource cleanup failed") - - def _config_path_for_writes(self) -> str | None: - p = self.config.config_path - if not p: - return None - return expand_path(str(p)) - - def _persist_banned_identities_to_config( - self, - link: RNS.Link, - room: str | None, - outgoing: list[tuple[RNS.Link, bytes]] | None = None, - ) -> None: - cfg_path = self._config_path_for_writes() - if not cfg_path: - self._emit_notice( - outgoing, link, room, "ban updated (not persisted; no config_path)" - ) - return - - try: - from tomlkit import dumps, parse, table # type: ignore - except Exception: - self._emit_notice( - outgoing, - link, - room, - "ban updated (not persisted; missing dependency tomlkit)", - ) - return - - try: - with self._config_write_lock: - st = None - try: - st = os.stat(cfg_path) - except Exception: - st = None - - with open(cfg_path, encoding="utf-8") as f: - doc = parse(f.read()) - - hub = doc.get("hub") - if hub is None: - hub = table() - doc["hub"] = hub - - existing = hub.get("banned_identities") - existing_list: list[str] = [] - if isinstance(existing, list): - for x in existing: - if x is None: - continue - sx = str(x).strip().lower() - if sx.startswith("0x"): - sx = sx[2:] - if sx: - existing_list.append(sx) - - merged = set(existing_list) - merged.update(h.hex() for h in sorted(self._banned)) - hub["banned_identities"] = sorted(merged) - - new_text = dumps(doc) - with open(cfg_path, "w", encoding="utf-8") as f: - f.write(new_text) - - if st is not None: - try: - os.chmod(cfg_path, st.st_mode) - except Exception: - pass - except Exception as e: - self._emit_notice( - outgoing, link, room, f"ban updated (persist failed: {e})" - ) - - def _is_trusted(self, peer_hash: bytes | None) -> bool: - if not peer_hash: - return False - with self._state_lock: - return peer_hash in self._trusted - - def _notice_to(self, link: RNS.Link, room: str | None, text: str) -> None: - if self.identity is None: - return - env = make_envelope(T_NOTICE, src=self.identity.hash, room=room, body=text) - self._send(link, env) - - def _queue_payload( - self, outgoing: list[tuple[RNS.Link, bytes]], link: RNS.Link, payload: bytes - ) -> None: - self._inc("bytes_out", len(payload)) - outgoing.append((link, payload)) - - def _queue_env( - self, outgoing: list[tuple[RNS.Link, bytes]], link: RNS.Link, env: dict - ) -> None: - payload = encode(env) - self._queue_payload(outgoing, link, payload) - - def _emit_notice( - self, - outgoing: list[tuple[RNS.Link, bytes]] | None, - link: RNS.Link, - room: str | None, - text: str, - ) -> None: - if self.identity is None: - return - env = make_envelope(T_NOTICE, src=self.identity.hash, room=room, body=text) - if outgoing is None: - self._send(link, env) - else: - self._queue_env(outgoing, link, env) - - def _emit_error( - self, - outgoing: list[tuple[RNS.Link, bytes]] | None, - link: RNS.Link, - *, - src: bytes, - text: str, - room: str | None = None, - ) -> None: - self._inc("errors_sent") - env = make_envelope(T_ERROR, src=src, room=room, body=text) - if outgoing is None: - self._send(link, env) - else: - self._queue_env(outgoing, link, env) - - def _format_stats(self) -> str: - now_mono = time.monotonic() - started_mono = self._started_monotonic - uptime_s = (now_mono - started_mono) if started_mono is not None else 0.0 - - with self._state_lock: - sessions_total = len(self.sessions) - sessions_welcomed = sum( - 1 for s in self.sessions.values() if s.get("welcomed") - ) - sessions_identified = sum( - 1 for s in self.sessions.values() if s.get("peer") is not None - ) - - rooms_total = len(self.rooms) - memberships = sum(len(v) for v in self.rooms.values()) - - top_rooms = sorted( - ((room, len(links)) for room, links in self.rooms.items()), - key=lambda x: (-x[1], x[0]), - )[:5] - - trusted_count = len(self._trusted) - banned_count = len(self._banned) - c = dict(self._counters) - - lines: list[str] = [] - lines.append(f"rrcd {__version__} stats") - lines.append(f"uptime_s={uptime_s:.1f}") - lines.append( - f"clients_total={sessions_total} " - f"clients_identified={sessions_identified} " - f"clients_welcomed={sessions_welcomed}" - ) - lines.append(f"rooms={rooms_total} memberships={memberships}") - - if top_rooms: - lines.append("top_rooms=" + ", ".join(f"{r}:{n}" for r, n in top_rooms)) - - lines.append(f"trust: trusted={trusted_count} banned={banned_count}") - lines.append( - f"limits: rate_limit_msgs_per_minute={self.config.rate_limit_msgs_per_minute} " - f"max_rooms_per_session={self.config.max_rooms_per_session} " - f"max_room_name_len={self.config.max_room_name_len} " - f"nick_max_chars={self.config.nick_max_chars}" - ) - lines.append( - f"features: ping_interval_s={self.config.ping_interval_s} " - f"ping_timeout_s={self.config.ping_timeout_s} " - f"announce_on_start={self.config.announce_on_start} " - f"announce_period_s={self.config.announce_period_s}" - ) - - lines.append( - "io: pkts_in={} pkts_bad={} bytes_in={} bytes_out={}".format( - c.get("pkts_in", 0), - c.get("pkts_bad", 0), - c.get("bytes_in", 0), - c.get("bytes_out", 0), - ) - ) - lines.append( - "events: joins={} parts={} msgs_fwd={} notices_fwd={} errors_sent={} rate_limited={}".format( - c.get("joins", 0), - c.get("parts", 0), - c.get("msgs_forwarded", 0), - c.get("notices_forwarded", 0), - c.get("errors_sent", 0), - c.get("rate_limited", 0), - ) - ) - lines.append( - "pings: in={} out={} pongs: in={} out={}".format( - c.get("pings_in", 0), - c.get("pings_out", 0), - c.get("pongs_in", 0), - c.get("pongs_out", 0), - ) - ) - lines.append( - "resources: sent={} received={} rejected={} bytes_sent={} bytes_received={}".format( - c.get("resources_sent", 0), - c.get("resources_received", 0), - c.get("resources_rejected", 0), - c.get("resource_bytes_sent", 0), - c.get("resource_bytes_received", 0), - ) - ) - - return "".join(lines) - - def _find_target_link(self, token: str, room: str | None = None) -> RNS.Link | None: - """Find a link by nick or identity hash prefix. Uses indexes for O(1) lookups. - Returns the link if exactly one match, None otherwise. - """ - result = self._find_target_links(token, room) - if len(result) == 1: - return result[0] - return None - - def _find_target_links(self, token: str, room: str | None = None) -> list[RNS.Link]: - """Find all links matching a nick or identity hash prefix. - Returns list of matching links (empty if none, multiple if ambiguous). - """ - t = token.strip().lower() - if not t: - return [] - - # If it's hex-like, treat as an identity hash prefix. - hex_candidate = t[2:] if t.startswith("0x") else t - if ( - all(c in "0123456789abcdef" for c in hex_candidate) - and len(hex_candidate) >= 6 - ): - try: - prefix = bytes.fromhex(hex_candidate) - except Exception: - prefix = None - - if prefix is not None: - with self._state_lock: - # Search hash index for matching prefixes - matches: list[RNS.Link] = [] - for peer_hash, candidate_link in self._index_by_hash.items(): - if peer_hash.startswith(prefix): - # Check room membership if specified - if room is not None: - sess = self.sessions.get(candidate_link) - if sess and room not in sess.get("rooms", set()): - continue - matches.append(candidate_link) - - return matches - - # Otherwise treat as nickname - use nick index for O(1) lookup - with self._state_lock: - candidate_links = self._index_by_nick.get(t, set()) - if not candidate_links: - return [] - - # Filter by room membership if specified - if room is not None: - matches = [] - for candidate_link in candidate_links: - sess = self.sessions.get(candidate_link) - if sess and room in sess.get("rooms", set()): - matches.append(candidate_link) - else: - matches = list(candidate_links) - - return matches - - def _format_ambiguous_targets( - self, token: str, matches: list[RNS.Link] - ) -> str: - """Format a helpful message when target lookup is ambiguous.""" - if not matches: - return f"target '{token}' not found" - - with self._state_lock: - items = [] - for match_link in matches: - sess = self.sessions.get(match_link) - if not sess: - continue - peer = sess.get("peer") - nick = sess.get("nick") - hash_str = self._fmt_hash(peer, prefix=16) if peer else "?" - nick_str = f"nick={nick!r}" if nick else "(no nick)" - items.append(f"{hash_str} {nick_str}") - - if len(items) == 0: - return f"target '{token}' not found" - - return ( - f"ambiguous: '{token}' matches {len(items)} identities:\n" - + "\n".join(f" - {item}" for item in items) - + "\nUse full or longer identity hash to disambiguate." - ) - - def _handle_operator_command( - self, - link: RNS.Link, - peer_hash: bytes, - room: str | None, - text: str, - *, - outgoing: list[tuple[RNS.Link, bytes]] | None = None, - ) -> bool: - # Returns True if it was a recognized command (handled). Unknown commands - # return False so the message can be forwarded as normal chat. - cmdline = text.strip() - if not cmdline.startswith("/"): - return False - - parts = [p for p in cmdline[1:].split() if p] - if not parts: - return False - - cmd = parts[0].lower() - - if cmd == "reload": - if not self._is_server_op(peer_hash): - if self.identity is not None: - self._emit_error( - outgoing, - link, - src=self.identity.hash, - text="not authorized", - room=None, - ) - return True - # Hub-level command - send responses without room field - self._reload_config_and_rooms(link, None, outgoing) - return True - - # Global/server-operator commands - if cmd == "stats": - if not self._is_server_op(peer_hash): - if self.identity is not None: - self._emit_error( - outgoing, - link, - src=self.identity.hash, - text="not authorized", - room=None, - ) - return True - # Send response without room field for hub-level command - self._emit_notice(outgoing, link, None, self._format_stats()) - return True - - if cmd == "list": - # List all registered, non-private rooms with their topics - with self._state_lock: - registered_rooms = [] - for room_name, st in self._room_state.items(): - if st.get("registered") and not st.get("private"): - topic = st.get("topic") - registered_rooms.append((room_name, topic)) - - # Also check room registry for rooms not currently in room_state - for room_name, reg in self._room_registry.items(): - if room_name not in self._room_state: - if not reg.get("private"): - topic = reg.get("topic") - registered_rooms.append((room_name, topic)) - - if not registered_rooms: - self._emit_notice(outgoing, link, None, "No public rooms registered") - return True - - # Sort rooms alphabetically - registered_rooms.sort(key=lambda x: x[0]) - - # Format room list with topics - lines = ["Registered public rooms:"] - for room_name, topic in registered_rooms: - if topic: - lines.append(f" {room_name} - {topic}") - else: - lines.append(f" {room_name}") - - self._emit_notice(outgoing, link, None, "\n".join(lines)) - return True - - if cmd in ("who", "names"): - target_room = room - if len(parts) >= 2: - target_room = parts[1] - if not isinstance(target_room, str) or not target_room: - self._emit_notice(outgoing, link, None, "usage: /who [room]") - return True - try: - r = self._norm_room(target_room) - except Exception as e: - self._emit_notice(outgoing, link, None, f"bad room: {e}") - return True - - # Check if room is private - only server operators can see private rooms - st = self._room_state_get(r) - if st and st.get("private"): - if not self._is_server_op(peer_hash): - self._emit_notice(outgoing, link, None, f"room {r} is private") - return True - - members = [] - for other in sorted(self.rooms.get(r, set()), key=lambda x: id(x)): - s = self.sessions.get(other) - if not s: - continue - nick = s.get("nick") - ph = s.get("peer") - ident = bytes(ph).hex() if isinstance(ph, (bytes, bytearray)) else "?" - if isinstance(nick, str) and nick: - members.append(f"{nick} ({ident[:12]})") - else: - members.append(ident) - # Send response without room field for hub-level query - self._emit_notice( - outgoing, - link, - None, - f"members in {r}: " + (", ".join(members) if members else "(none)"), - ) - return True - - if cmd == "kick": - if len(parts) < 3: - self._emit_notice( - outgoing, link, None, "usage: /kick " - ) - return True - target_room = parts[1] - target = parts[2] - try: - r = self._norm_room(target_room) - except Exception as e: - self._emit_notice(outgoing, link, room, f"bad room: {e}") - return True - - if not self._is_room_op(r, peer_hash): - if self.identity is not None: - self._emit_error( - outgoing, - link, - src=self.identity.hash, - text="not authorized", - room=r, - ) - return True - - target_link = self._find_target_link(target, room=r) - if target_link is None: - # Check if ambiguous or just not found - all_matches = self._find_target_links(target, room=r) - self._emit_notice( - outgoing, link, room, self._format_ambiguous_targets(target, all_matches) - ) - return True - - tsess = self.sessions.get(target_link) - if not tsess or r not in tsess.get("rooms", set()): - self._emit_notice(outgoing, link, room, "target not in room") - return True - - tsess["rooms"].discard(r) - if r in self.rooms: - self.rooms[r].discard(target_link) - if not self.rooms[r]: - self.rooms.pop(r, None) - - if self.identity is not None: - self._emit_error( - outgoing, - target_link, - src=self.identity.hash, - text=f"kicked from {r}", - room=r, - ) - self._emit_notice(outgoing, link, room, f"kicked {target} from {r}") - return True - - if cmd == "kline": - if not self._is_server_op(peer_hash): - if self.identity is not None: - self._emit_error( - outgoing, - link, - src=self.identity.hash, - text="not authorized", - room=None, - ) - return True - - # Hub-level command - all responses without room field - if len(parts) < 2: - self._emit_notice( - outgoing, - link, - None, - "usage: /kline add|del|list [nick|hashprefix|hash]", - ) - return True - - op = parts[1].strip().lower() - if op == "list": - items = sorted(h.hex() for h in self._banned) - self._emit_notice( - outgoing, - link, - None, - "klines: " + (", ".join(items) if items else "(none)"), - ) - return True - - if op not in ("add", "del"): - self._emit_notice( - outgoing, - link, - None, - "usage: /kline add|del|list [nick|hashprefix|hash]", - ) - return True - - if len(parts) < 3: - self._emit_notice( - outgoing, link, None, f"usage: /kline {op} " - ) - return True - - target = parts[2] - if op == "add": - target_link = self._find_target_link(target) - if target_link is not None: - tsess = self.sessions.get(target_link) - ph = tsess.get("peer") if tsess else None - if isinstance(ph, (bytes, bytearray)): - self._banned.add(bytes(ph)) - self._persist_banned_identities_to_config(link, None, outgoing) - try: - target_link.teardown() - except Exception: - pass - self._emit_notice(outgoing, link, None, f"kline added for {target}") - return True - - # Not found as active link - check if ambiguous or try as raw hash - all_matches = self._find_target_links(target, room=None) - if all_matches: - # Ambiguous - self._emit_notice( - outgoing, link, None, self._format_ambiguous_targets(target, all_matches) - ) - return True - - # Try as raw hash - try: - h = self._parse_identity_hash(target) - except Exception as e: - self._emit_notice(outgoing, link, None, f"bad identity hash: {e}") - return True - self._banned.add(h) - self._persist_banned_identities_to_config(link, None, outgoing) - self._emit_notice(outgoing, link, None, f"kline added for {h.hex()}") - return True - - # op == "del" - try: - h = self._parse_identity_hash(target) - except Exception as e: - self._emit_notice(outgoing, link, None, f"bad identity hash: {e}") - return True - - if h in self._banned: - self._banned.discard(h) - self._persist_banned_identities_to_config(link, None, outgoing) - self._emit_notice(outgoing, link, None, f"kline removed for {h.hex()}") - else: - self._emit_notice(outgoing, link, None, f"not klined: {h.hex()}") - return True - - # Room-scoped moderation and maintenance - if cmd == "register": - if len(parts) < 2: - self._emit_notice(outgoing, link, None, "usage: /register ") - return True - try: - r = self._norm_room(parts[1]) - except Exception as e: - self._emit_notice(outgoing, link, None, f"bad room: {e}") - return True - # Registration rules: requester must be in the room and must be the founder. - # (No server-op override by design.) - if ( - not room - or self._norm_room(room) != r - or r not in self.sessions.get(link, {}).get("rooms", set()) - ): - self._emit_notice( - outgoing, link, room, "must be present in the room to register it" - ) - return True - - st = self._room_state_ensure(r) - - # Clean up expired invites (best-effort). - if self._prune_expired_invites(st) and bool(st.get("registered")): - self._persist_room_state_to_registry(link, r) - founder = st.get("founder") - if not ( - isinstance(founder, (bytes, bytearray)) and bytes(founder) == peer_hash - ): - if self.identity is not None: - self._emit_error( - outgoing, - link, - src=self.identity.hash, - text="only the room founder can register", - room=r, - ) - return True - - if not self._room_registry_path_for_writes(): - self._emit_notice( - outgoing, link, room, "cannot register room: no room_registry_path" - ) - return True - st["registered"] = True - # Default modes for registered rooms: +nrt - st["no_outside_msgs"] = True - st["topic_ops_only"] = True - if isinstance(founder, (bytes, bytearray)): - st.setdefault("ops", set()).add(bytes(founder)) - self._touch_room(r) - - # Ensure registry mirrors registered rooms. - self._room_registry[r] = { - "founder": bytes(founder) - if isinstance(founder, (bytes, bytearray)) - else None, - "registered": True, - "topic": st.get("topic"), - "moderated": bool(st.get("moderated", False)), - "ops": set(st.get("ops", set())) - if isinstance(st.get("ops"), set) - else set(), - "voiced": set(st.get("voiced", set())) - if isinstance(st.get("voiced"), set) - else set(), - "bans": set(st.get("bans", set())) - if isinstance(st.get("bans"), set) - else set(), - "last_used_ts": st.get("last_used_ts"), - } - - self._persist_room_state_to_registry(link, r) - self._emit_notice(outgoing, link, room, f"registered room {r}") - return True - - if cmd == "unregister": - if len(parts) < 2: - self._emit_notice(outgoing, link, None, "usage: /unregister ") - return True - try: - r = self._norm_room(parts[1]) - except Exception as e: - self._emit_notice(outgoing, link, None, f"bad room: {e}") - return True - - if ( - not room - or self._norm_room(room) != r - or r not in self.sessions.get(link, {}).get("rooms", set()) - ): - self._emit_notice( - outgoing, link, room, "must be present in the room to unregister it" - ) - return True - - st = self._room_state_ensure(r) - founder = st.get("founder") - if not ( - isinstance(founder, (bytes, bytearray)) and bytes(founder) == peer_hash - ): - if self.identity is not None: - self._emit_error( - outgoing, - link, - src=self.identity.hash, - text="only the room founder can unregister", - room=r, - ) - return True - - if not st.get("registered"): - self._emit_notice(outgoing, link, room, f"room {r} is not registered") - return True - - st["registered"] = False - self._room_registry.pop(r, None) - self._delete_room_from_registry(link, r) - # Drop state if empty. - if r not in self.rooms or not self.rooms.get(r): - self._room_state.pop(r, None) - self._emit_notice(outgoing, link, room, f"unregistered room {r}") - return True - - if cmd == "topic": - if len(parts) < 2: - self._emit_notice(outgoing, link, None, "usage: /topic [topic]") - return True - try: - r = self._norm_room(parts[1]) - except Exception as e: - self._emit_notice(outgoing, link, None, f"bad room: {e}") - return True - st = self._room_state_ensure(r) - if len(parts) == 2: - topic = st.get("topic") - self._emit_notice( - outgoing, - link, - room, - f"topic for {r}: {topic if topic else '(none)'}", - ) - return True - - if not self._is_room_op(r, peer_hash): - st = self._room_state_ensure(r) - if bool(st.get("topic_ops_only", False)): - if self.identity is not None: - self._emit_error( - outgoing, - link, - src=self.identity.hash, - text="not authorized (+t)", - room=r, - ) - return True - - topic = " ".join(parts[2:]).strip() - st["topic"] = topic if topic else None - self._touch_room(r) - self._persist_room_state_to_registry(link, r) - # Broadcast topic change to current members. - for other in list(self.rooms.get(r, set())): - self._emit_notice( - outgoing, - other, - r, - f"topic for {r} is now: {topic if topic else '(cleared)'}", - ) - return True - - if cmd in ("op", "deop", "voice", "devoice"): - if len(parts) < 3: - self._emit_notice( - outgoing, link, None, f"usage: /{cmd} " - ) - return True - try: - r = self._norm_room(parts[1]) - except Exception as e: - self._emit_notice(outgoing, link, None, f"bad room: {e}") - return True - if not self._is_room_op(r, peer_hash): - if self.identity is not None: - self._emit_error( - outgoing, - link, - src=self.identity.hash, - text="not authorized", - room=r, - ) - return True - - target_hash, all_matches = self._resolve_identity_hash_with_matches(parts[2], room=r) - if target_hash is None: - self._emit_notice( - outgoing, link, room, self._format_ambiguous_targets(parts[2], all_matches) - ) - return True - - st = self._room_state_ensure(r) - founder = st.get("founder") - founder_b = ( - bytes(founder) if isinstance(founder, (bytes, bytearray)) else None - ) - - if cmd in ("op", "deop"): - ops = st.setdefault("ops", set()) - if not isinstance(ops, set): - ops = set() - st["ops"] = ops - if cmd == "op": - ops.add(target_hash) - self._touch_room(r) - self._persist_room_state_to_registry(link, r) - self._emit_notice(outgoing, link, room, f"op granted in {r}") - return True - else: - if founder_b is not None and target_hash == founder_b: - self._emit_notice(outgoing, link, room, "cannot deop founder") - return True - ops.discard(target_hash) - self._touch_room(r) - self._persist_room_state_to_registry(link, r) - self._emit_notice(outgoing, link, room, f"op removed in {r}") - return True - - voiced = st.setdefault("voiced", set()) - if not isinstance(voiced, set): - voiced = set() - st["voiced"] = voiced - if cmd == "voice": - voiced.add(target_hash) - self._touch_room(r) - self._persist_room_state_to_registry(link, r) - self._emit_notice(outgoing, link, room, f"voice granted in {r}") - return True - else: - voiced.discard(target_hash) - self._touch_room(r) - self._persist_room_state_to_registry(link, r) - self._emit_notice(outgoing, link, room, f"voice removed in {r}") - return True - - if cmd == "mode": - if len(parts) < 3: - self._emit_notice( - outgoing, - link, - None, - "usage: /mode (+m|-m|+i|-i|+t|-t|+n|-n|+p|-p|+k|-k|+r|-r) [key] | /mode (+o|-o|+v|-v) ", - ) - return True - try: - r = self._norm_room(parts[1]) - except Exception as e: - self._emit_notice(outgoing, link, None, f"bad room: {e}") - return True - if not self._is_room_op(r, peer_hash): - if self.identity is not None: - self._emit_error( - outgoing, - link, - src=self.identity.hash, - text="not authorized", - room=r, - ) - return True - flag = parts[2].strip().lower() - st = self._room_state_ensure(r) - - if flag in ("+m", "-m"): - st["moderated"] = flag == "+m" - self._touch_room(r) - self._persist_room_state_to_registry(link, r) - self._broadcast_room_mode(r, outgoing) - return True - - if flag in ("+i", "-i"): - st["invite_only"] = flag == "+i" - self._touch_room(r) - self._persist_room_state_to_registry(link, r) - self._broadcast_room_mode(r, outgoing) - return True - - if flag in ("+t", "-t"): - st["topic_ops_only"] = flag == "+t" - self._touch_room(r) - self._persist_room_state_to_registry(link, r) - self._broadcast_room_mode(r, outgoing) - return True - - if flag in ("+n", "-n"): - st["no_outside_msgs"] = flag == "+n" - self._touch_room(r) - self._persist_room_state_to_registry(link, r) - self._broadcast_room_mode(r, outgoing) - return True - - if flag in ("+p", "-p"): - st["private"] = flag == "+p" - self._touch_room(r) - self._persist_room_state_to_registry(link, r) - self._broadcast_room_mode(r, outgoing) - return True - - if flag in ("+k", "-k"): - if flag == "+k": - if len(parts) < 4: - self._emit_notice( - outgoing, link, room, "usage: /mode +k " - ) - return True - key = " ".join(parts[3:]).strip() - if not key: - self._emit_notice(outgoing, link, room, "key must not be empty") - return True - st["key"] = key - else: - st["key"] = None - self._touch_room(r) - self._persist_room_state_to_registry(link, r) - self._broadcast_room_mode(r, outgoing) - return True - - if flag in ("+r", "-r"): - self._emit_notice( - outgoing, link, room, "use /register or /unregister to change +r" - ) - return True - - if flag in ("+o", "-o", "+v", "-v"): - if len(parts) < 4: - self._emit_notice( - outgoing, - link, - room, - "usage: /mode (+o|-o|+v|-v) ", - ) - return True - - target_hash, all_matches = self._resolve_identity_hash_with_matches(parts[3], room=r) - if target_hash is None: - self._emit_notice( - outgoing, link, room, self._format_ambiguous_targets(parts[3], all_matches) - ) - return True - - founder = st.get("founder") - founder_b = ( - bytes(founder) if isinstance(founder, (bytes, bytearray)) else None - ) - - if flag in ("+o", "-o"): - ops = st.setdefault("ops", set()) - if not isinstance(ops, set): - ops = set() - st["ops"] = ops - - if flag == "+o": - ops.add(target_hash) - else: - if founder_b is not None and target_hash == founder_b: - self._emit_notice( - outgoing, link, room, "cannot deop founder" - ) - return True - ops.discard(target_hash) - - self._touch_room(r) - self._persist_room_state_to_registry(link, r) - for other in list(self.rooms.get(r, set())): - self._emit_notice( - outgoing, - other, - r, - f"mode for {r} is now: {flag} {target_hash.hex()[:12]}", - ) - return True - - voiced = st.setdefault("voiced", set()) - if not isinstance(voiced, set): - voiced = set() - st["voiced"] = voiced - if flag == "+v": - voiced.add(target_hash) - else: - voiced.discard(target_hash) - - self._touch_room(r) - self._persist_room_state_to_registry(link, r) - for other in list(self.rooms.get(r, set())): - self._emit_notice( - outgoing, - other, - r, - f"mode for {r} is now: {flag} {target_hash.hex()[:12]}", - ) - return True - - self._emit_notice( - outgoing, - link, - room, - "supported modes: +m -m +i -i +k -k +t -t +n -n +p -p +r -r +o -o +v -v", - ) - return True - - if cmd == "ban": - if len(parts) < 3: - self._emit_notice( - outgoing, - link, - None, - "usage: /ban add|del|list [nick|hashprefix|hash]", - ) - return True - - try: - r = self._norm_room(parts[1]) - except Exception as e: - self._emit_notice(outgoing, link, None, f"bad room: {e}") - return True - - op = parts[2].strip().lower() - if op == "list": - st = self._room_state_ensure(r) - bans = st.get("bans") - if not isinstance(bans, set) or not bans: - self._emit_notice(outgoing, link, room, f"no bans in {r}") - return True - items = sorted( - bytes(x).hex() for x in bans if isinstance(x, (bytes, bytearray)) - ) - self._emit_notice( - outgoing, link, room, f"bans in {r}: " + ", ".join(items) - ) - return True - - if op not in ("add", "del"): - self._emit_notice( - outgoing, - link, - room, - "usage: /ban add|del|list [nick|hashprefix|hash]", - ) - return True - - if len(parts) < 4: - self._emit_notice( - outgoing, link, room, f"usage: /ban {r} {op} " - ) - return True - - if not self._is_room_op(r, peer_hash): - if self.identity is not None: - self._emit_error( - outgoing, - link, - src=self.identity.hash, - text="not authorized", - room=r, - ) - return True - - target_hash, all_matches = self._resolve_identity_hash_with_matches(parts[3], room=r) - if target_hash is None: - self._emit_notice( - outgoing, link, room, self._format_ambiguous_targets(parts[3], all_matches) - ) - return True - - st = self._room_state_ensure(r) - bans = st.setdefault("bans", set()) - if not isinstance(bans, set): - bans = set() - st["bans"] = bans - - if op == "add": - bans.add(target_hash) - self._touch_room(r) - self._persist_room_state_to_registry(link, r) - - # If currently present in room, remove them. - for other in list(self.rooms.get(r, set())): - s = self.sessions.get(other) - ph = s.get("peer") if s else None - if isinstance(ph, (bytes, bytearray)) and bytes(ph) == target_hash: - s.get("rooms", set()).discard(r) - self.rooms.get(r, set()).discard(other) - if self.identity is not None: - self._emit_error( - outgoing, - other, - src=self.identity.hash, - text=f"banned from {r}", - room=r, - ) - if r in self.rooms and not self.rooms[r]: - self.rooms.pop(r, None) - self._emit_notice(outgoing, link, room, f"ban added in {r}") - return True - - bans.discard(target_hash) - self._touch_room(r) - self._persist_room_state_to_registry(link, r) - self._emit_notice(outgoing, link, room, f"ban removed in {r}") - return True - - if cmd == "invite": - if len(parts) < 3: - self._emit_notice( - outgoing, - link, - None, - "usage: /invite add|del|list [nick|hashprefix|hash]", - ) - return True - - try: - r = self._norm_room(parts[1]) - except Exception as e: - self._emit_notice(outgoing, link, None, f"bad room: {e}") - return True - - if not self._is_room_op(r, peer_hash): - if self.identity is not None: - self._emit_error( - outgoing, - link, - src=self.identity.hash, - text="not authorized", - room=r, - ) - return True - - op = parts[2].strip().lower() - st = self._room_state_ensure(r) - - invited = st.setdefault("invited", {}) - if not isinstance(invited, dict): - invited = {} - st["invited"] = invited - - # Drop expired entries before operating. - pruned = self._prune_expired_invites(st) - - if op == "list": - now = float(time.time()) - items = [] - for h, exp in invited.items(): - if not isinstance(h, (bytes, bytearray)): - continue - try: - exp_f = float(exp) - except Exception: - continue - if exp_f <= now: - continue - items.append(f"{bytes(h).hex()} expires_in={int(exp_f - now)}s") - items.sort() - if pruned: - self._touch_room(r) - self._persist_room_state_to_registry(link, r) - self._emit_notice( - outgoing, - link, - room, - f"invites in {r}: " + (", ".join(items) if items else "(none)"), - ) - return True - - if op not in ("add", "del"): - self._emit_notice( - outgoing, - link, - room, - "usage: /invite add|del|list [nick|hashprefix|hash]", - ) - return True - - if len(parts) < 4: - self._emit_notice( - outgoing, - link, - room, - f"usage: /invite {r} {op} ", - ) - return True - - if op == "add": - token = parts[3] - target_link = self._find_target_link(token, room=None) - if target_link is None: - # Check if ambiguous or just not found - all_matches = self._find_target_links(token, room=None) - if self.identity is not None: - self._emit_error( - outgoing, - link, - src=self.identity.hash, - text=f"invite failed: {self._format_ambiguous_targets(token, all_matches)}", - room=r, - ) - return True - - tsess = self.sessions.get(target_link) - ph = tsess.get("peer") if tsess else None - if not isinstance(ph, (bytes, bytearray)): - if self.identity is not None: - self._emit_error( - outgoing, - link, - src=self.identity.hash, - text="invite failed: target not identified", - room=r, - ) - return True - target_hash = bytes(ph) - - # Always send the invite as a NOTICE so the user can choose to join. - key = st.get("key") - is_keyed = isinstance(key, str) and bool(key) - is_invite_only = bool(st.get("invite_only", False)) - - if is_keyed: - self._emit_notice( - outgoing, - target_link, - r, - f"You have been invited to join {r}. This invite allows joining without the key (+k).", - ) - else: - self._emit_notice( - outgoing, target_link, r, f"You have been invited to join {r}." - ) - - # Persist an expiring invite only when it has semantics: +k bypass and/or +i allow. - if is_keyed or is_invite_only: - ttl = ( - float(self.config.room_invite_timeout_s) - if self.config.room_invite_timeout_s - else 0.0 - ) - if ttl <= 0: - ttl = 900.0 - exp = float(time.time()) + ttl - invited[target_hash] = exp - self._touch_room(r) - self._persist_room_state_to_registry(link, r) - self._emit_notice( - outgoing, - link, - room, - f"invite added in {r} (expires in {int(ttl)}s)", - ) - else: - self._emit_notice( - outgoing, link, room, f"invite sent to {token} for {r}" - ) - return True - - target_hash, all_matches = self._resolve_identity_hash_with_matches(parts[3], room=None) - if target_hash is None: - self._emit_notice( - outgoing, link, room, self._format_ambiguous_targets(parts[3], all_matches) - ) - return True - - if target_hash in invited: - invited.pop(target_hash, None) - self._touch_room(r) - self._persist_room_state_to_registry(link, r) - self._emit_notice(outgoing, link, room, f"invite removed in {r}") - return True - - return False - def _on_link(self, link: RNS.Link) -> None: with self._state_lock: - self.sessions[link] = { - "welcomed": False, - "rooms": set(), - "peer": None, - "nick": None, - "peer_caps": {}, - "awaiting_pong": None, - } - - self._rate[link] = _RateState( - tokens=float(self.config.rate_limit_msgs_per_minute), - last_refill=time.monotonic(), - ) - - # Initialize resource tracking for this link - self._resource_expectations[link] = {} - self._active_resources[link] = set() + self.session_manager.on_link_established(link) + self.resource_manager.on_link_established(link) link.set_packet_callback(lambda data, pkt: self._on_packet(link, data)) link.set_link_closed_callback(lambda closed_link: self._on_close(closed_link)) @@ -3219,23 +449,8 @@ class HubService: identified_link, ident ) ) - - # Set up resource callbacks - if self.config.enable_resource_transfer: - try: - link.set_resource_strategy(RNS.Link.ACCEPT_APP) - link.set_resource_callback(self._resource_advertised) - link.set_resource_concluded_callback(self._resource_concluded) - self.log.debug( - "Resource callbacks configured link_id=%s", - self._fmt_link_id(link), - ) - except Exception as e: - self.log.warning( - "Failed to set resource callbacks link_id=%s: %s", - self._fmt_link_id(link), - e, - ) + + self.resource_manager.configure_link_callbacks(link) self.log.info("Link established link_id=%s", self._fmt_link_id(link)) @@ -3243,18 +458,10 @@ class HubService: self, link: RNS.Link, identity: RNS.Identity | None ) -> None: banned = False + peer_hash = None with self._state_lock: - sess = self.sessions.get(link) - if sess is None: - return - - if identity is not None: - sess["peer"] = identity.hash - - peer_hash = sess.get("peer") - banned = ( - isinstance(peer_hash, (bytes, bytearray)) - and bytes(peer_hash) in self._banned + banned, peer_hash = self.session_manager.on_remote_identified( + link, identity ) if banned: @@ -3265,54 +472,15 @@ class HubService: ) if self.identity is not None: try: - self._error(link, src=self.identity.hash, text="banned") + self.message_helper.error( + link, src=self.identity.hash, text="banned" + ) except Exception: pass try: link.teardown() except Exception: pass - return - - if identity is not None: - self.log.info( - "Remote identified peer=%s link_id=%s", - self._fmt_hash(identity.hash), - self._fmt_link_id(link), - ) - - def _welcome(self, link: RNS.Link, sess: dict[str, Any]) -> None: - if self.identity is None: - return - - sess["welcomed"] = True - # Use the queued path so we can preflight MTU sizing and optionally - # follow up with MOTD via resource or chunks. - outgoing: list[tuple[RNS.Link, bytes]] = [] - self._queue_welcome( - outgoing, - link, - peer_hash=sess.get("peer"), - motd=self.config.greeting, - ) - for out_link, payload in outgoing: - self._inc("bytes_out", len(payload)) - try: - RNS.Packet(out_link, payload).send() - except OSError as e: - self.log.warning( - "Send failed link_id=%s bytes=%s err=%s", - self._fmt_link_id(out_link), - len(payload), - e, - ) - except Exception: - self.log.debug( - "Send failed link_id=%s bytes=%s", - self._fmt_link_id(out_link), - len(payload), - exc_info=True, - ) def _on_close(self, link: RNS.Link) -> None: peer = None @@ -3320,34 +488,8 @@ class HubService: rooms_count = 0 with self._state_lock: - sess = self.sessions.pop(link, None) - self._rate.pop(link, None) - - # Clean up resource state - self._resource_expectations.pop(link, None) - self._active_resources.pop(link, None) - - if not sess: - return - - peer = sess.get("peer") - nick = sess.get("nick") - rooms_count = len(sess.get("rooms") or ()) - - # Clean up indexes - if isinstance(peer, (bytes, bytearray)): - self._index_by_hash.pop(bytes(peer), None) - - if nick: - self._update_nick_index(link, nick, None) - - for room in list(sess["rooms"]): - self.rooms.get(room, set()).discard(link) - if room in self.rooms and not self.rooms[room]: - self.rooms.pop(room, None) - st = self._room_state_get(room) - if st is not None and not st.get("registered"): - self._room_state.pop(room, None) + self.resource_manager.on_link_closed(link) + peer, nick, rooms_count = self.session_manager.on_link_closed(link) self.log.info( "Link closed peer=%s nick=%r rooms=%s link_id=%s", @@ -3357,32 +499,6 @@ class HubService: self._fmt_link_id(link), ) - def _send(self, link: RNS.Link, env: dict) -> None: - payload = encode(env) - self._inc("bytes_out", len(payload)) - try: - RNS.Packet(link, payload).send() - except OSError as e: - # Common failure mode on low-MTU links: packet too large. - self.log.warning( - "Send failed link_id=%s bytes=%s err=%s", - self._fmt_link_id(link), - len(payload), - e, - ) - except Exception: - self.log.debug( - "Send failed link_id=%s bytes=%s", - self._fmt_link_id(link), - len(payload), - exc_info=True, - ) - - def _error( - self, link: RNS.Link, src: bytes, text: str, room: str | None = None - ) -> None: - self._emit_error(None, link, src=src, text=text, room=room) - def _norm_room(self, room: str) -> str: r = room.strip().lower() if not r: @@ -3391,32 +507,10 @@ class HubService: raise ValueError("room name too long") return r - def _refill_and_take(self, link: RNS.Link, cost: float = 1.0) -> bool: - with self._state_lock: - state = self._rate.get(link) - if state is None: - return True - - now = time.monotonic() - per_min = float(max(1, int(self.config.rate_limit_msgs_per_minute))) - rate_per_s = per_min / 60.0 - elapsed = max(0.0, now - state.last_refill) - state.tokens = min(per_min, state.tokens + elapsed * rate_per_s) - state.last_refill = now - - if state.tokens < cost: - return False - - state.tokens -= cost - return True - def _on_packet(self, link: RNS.Link, data: bytes) -> None: - # Packet callbacks can occur concurrently with other link callbacks and - # background worker threads. Keep state mutations under the shared lock, - # but avoid holding the lock while sending packets via RNS. - outgoing: list[tuple[RNS.Link, bytes]] = [] + outgoing: list[tuple[RNS.Link, bytes]] = OutgoingList() with self._state_lock: - self._on_packet_locked(link, data, outgoing) + self.router.route_packet(link, data, outgoing) if self.log.isEnabledFor(logging.DEBUG) and outgoing: self.log.debug( @@ -3426,7 +520,7 @@ class HubService: ) for out_link, payload in outgoing: - self._inc("bytes_out", len(payload)) + self.stats_manager.inc("bytes_out", len(payload)) try: RNS.Packet(out_link, payload).send() except OSError as e: @@ -3444,683 +538,12 @@ class HubService: exc_info=True, ) - def _on_packet_locked( - self, - link: RNS.Link, - data: bytes, - outgoing: list[tuple[RNS.Link, bytes]], - ) -> None: - sess = self.sessions.get(link) - if sess is None: - return - - self._inc("pkts_in") - self._inc("bytes_in", len(data)) - - peer_hash = sess.get("peer") - if peer_hash is None: - ri = link.get_remote_identity() - if ri is None: - # Per spec: the Link is the handshake. Ignore all traffic until it - # is identified. - return - peer_hash = ri.hash - sess["peer"] = peer_hash - - if not self._refill_and_take(link, 1.0): - self._inc("rate_limited") - if self.log.isEnabledFor(logging.DEBUG): - self.log.debug( - "Rate limited peer=%s link_id=%s", - self._fmt_hash(peer_hash), - self._fmt_link_id(link), - ) - if self.identity is not None: - self._emit_error( - outgoing, link, src=self.identity.hash, text="rate limited" - ) - return - - try: - env = decode(data) - validate_envelope(env) - except Exception as e: - self._inc("pkts_bad") - self.log.debug( - "Bad packet peer=%s link_id=%s bytes=%s err=%s", - self._fmt_hash(peer_hash), - self._fmt_link_id(link), - len(data), - e, - ) - if self.identity is not None: - self._emit_error( - outgoing, link, src=self.identity.hash, text=f"bad message: {e}" - ) - return - - t = env.get(K_T) - room = env.get(K_ROOM) - body = env.get(K_BODY) - nick = env.get(K_NICK) - - if self.log.isEnabledFor(logging.DEBUG): - body_len = None - if isinstance(body, (bytes, bytearray)): - body_len = len(body) - elif isinstance(body, str): - body_len = len(body) - self.log.debug( - "RX peer=%s link_id=%s t=%s room=%r bytes=%s body_type=%s body_len=%s", - self._fmt_hash(peer_hash), - self._fmt_link_id(link), - t, - room, - len(data), - type(body).__name__, - body_len, - ) - - if t == T_PONG: - self._inc("pongs_in") - sess["awaiting_pong"] = None - return - - if t == T_RESOURCE_ENVELOPE: - # Handle resource envelope announcement - if not self.config.enable_resource_transfer: - if self.identity is not None: - self._emit_error( - outgoing, - link, - src=self.identity.hash, - text="resource transfer disabled", - room=room, - ) - return - - if not isinstance(body, dict): - if self.identity is not None: - self._emit_error( - outgoing, - link, - src=self.identity.hash, - text="invalid resource envelope body", - room=room, - ) - return - - rid = body.get(B_RES_ID) - kind = body.get(B_RES_KIND) - size = body.get(B_RES_SIZE) - sha256 = body.get(B_RES_SHA256) - encoding = body.get(B_RES_ENCODING) - - # Validate required fields - if not isinstance(rid, (bytes, bytearray)): - if self.identity is not None: - self._emit_error( - outgoing, - link, - src=self.identity.hash, - text="resource envelope missing id", - room=room, - ) - return - - if not isinstance(kind, str) or not kind: - if self.identity is not None: - self._emit_error( - outgoing, - link, - src=self.identity.hash, - text="resource envelope missing kind", - room=room, - ) - return - - if not isinstance(size, int) or size < 0: - if self.identity is not None: - self._emit_error( - outgoing, - link, - src=self.identity.hash, - text="resource envelope invalid size", - room=room, - ) - return - - # Check size limit - if size > self.config.max_resource_bytes: - if self.identity is not None: - self._emit_error( - outgoing, - link, - src=self.identity.hash, - text=f"resource too large: {size} > {self.config.max_resource_bytes}", - room=room, - ) - return - - # Validate optional fields - if sha256 is not None and not isinstance(sha256, (bytes, bytearray)): - if self.identity is not None: - self._emit_error( - outgoing, - link, - src=self.identity.hash, - text="resource envelope invalid sha256", - room=room, - ) - return - - if encoding is not None and not isinstance(encoding, str): - encoding = None - - # Add expectation - if not self._add_resource_expectation( - link, - rid=bytes(rid), - kind=kind, - size=size, - sha256=bytes(sha256) if sha256 else None, - encoding=encoding, - room=room, - ): - if self.identity is not None: - self._emit_error( - outgoing, - link, - src=self.identity.hash, - text="too many pending resource expectations", - room=room, - ) - return - - if not sess["welcomed"]: - if t != T_HELLO: - if self.identity is not None: - self._emit_error( - outgoing, link, src=self.identity.hash, text="send HELLO first" - ) - return - - old_nick = sess.get("nick") - new_nick = None - - if isinstance(nick, str): - n = normalize_nick(nick, max_chars=self.config.nick_max_chars) - if n is not None: - new_nick = n - sess["nick"] = n - - if isinstance(body, dict): - sess["peer_caps"] = self._extract_caps(body) - - # Back-compat: if a legacy client put nick in HELLO body, accept it. - if new_nick is None: - legacy_nick = body.get(B_HELLO_NICK_LEGACY) - n2 = normalize_nick( - legacy_nick, max_chars=self.config.nick_max_chars - ) - if n2 is not None: - new_nick = n2 - sess["nick"] = n2 - - # Update nick index if nick changed - if old_nick != new_nick: - self._update_nick_index(link, old_nick, new_nick) - - self.log.info( - "HELLO peer=%s nick=%r link_id=%s", - self._fmt_hash(peer_hash), - sess.get("nick"), - self._fmt_link_id(link), - ) - - sess["welcomed"] = True - self._queue_welcome( - outgoing, - link, - peer_hash=peer_hash, - motd=self.config.greeting, - ) - return - - if t == T_HELLO: - # Allow re-authentication if client reconnects with same Link ID - # (can happen when client restarts but RNS reuses deterministic link_id) - if self.identity is not None: - # Reset session state and process as new HELLO - old_nick = sess.get("nick") - old_rooms = set(sess.get("rooms", set())) - sess["welcomed"] = False - sess["rooms"] = set() - sess["nick"] = None - sess["peer_caps"] = {} - - # Remove this link from all room membership sets and prune empties. - for r in old_rooms: - self.rooms.get(r, set()).discard(link) - if r in self.rooms and not self.rooms[r]: - self.rooms.pop(r, None) - st = self._room_state_get(r) - if st is not None and not st.get("registered"): - self._room_state.pop(r, None) - - new_nick = None - - # Process the HELLO message - if isinstance(nick, str): - n = normalize_nick(nick, max_chars=self.config.nick_max_chars) - if n is not None: - new_nick = n - sess["nick"] = n - - if isinstance(body, dict): - sess["peer_caps"] = self._extract_caps(body) - if new_nick is None: - legacy_nick = body.get(B_HELLO_NICK_LEGACY) - n2 = normalize_nick( - legacy_nick, max_chars=self.config.nick_max_chars - ) - if n2 is not None: - new_nick = n2 - sess["nick"] = n2 - - # Update nick index if nick changed - if old_nick != new_nick: - self._update_nick_index(link, old_nick, new_nick) - - self.log.info( - "Re-HELLO peer=%s nick=%r link_id=%s", - self._fmt_hash(peer_hash), - sess.get("nick"), - self._fmt_link_id(link), - ) - - sess["welcomed"] = True - self._queue_welcome( - outgoing, - link, - peer_hash=peer_hash, - motd=self.config.greeting, - ) - return - - if t == T_JOIN: - self._inc("joins") - if not isinstance(room, str) or not room: - if self.identity is not None: - self._emit_error( - outgoing, - link, - src=self.identity.hash, - text="JOIN requires room name", - ) - return - - if len(sess["rooms"]) >= int(self.config.max_rooms_per_session): - if self.identity is not None: - self._emit_error( - outgoing, link, src=self.identity.hash, text="too many rooms" - ) - return - - try: - r = self._norm_room(room) - except Exception as e: - if self.identity is not None: - self._emit_error( - outgoing, link, src=self.identity.hash, text=str(e) - ) - return - - # If room is registered, load its state now. - if r in self._room_registry: - self._room_state_ensure(r) - - st = self._room_state_ensure(r) - - # +i invite-only - if bool(st.get("invite_only", False)): - is_invited = self._is_invited(st, peer_hash) - if not self._is_room_op(r, peer_hash) and not is_invited: - if self.identity is not None: - self._emit_error( - outgoing, - link, - src=self.identity.hash, - text="invite-only (+i)", - room=r, - ) - return - - # +k key/password (JOIN body must be the key string) - key = st.get("key") - if isinstance(key, str) and key: - is_invited = self._is_invited(st, peer_hash) - if not self._is_room_op(r, peer_hash) and not is_invited: - provided = body if isinstance(body, str) else None - if provided != key: - if self.identity is not None: - self._emit_error( - outgoing, - link, - src=self.identity.hash, - text="bad key (+k)", - room=r, - ) - return - - # Room bans are room-local and apply to JOIN. - if self._is_room_banned(r, peer_hash): - if self.identity is not None: - self._emit_error( - outgoing, - link, - src=self.identity.hash, - text="banned from room", - room=r, - ) - return - - # If the room doesn't exist yet (in-memory), the first joiner is the founder. - if r not in self.rooms: - self.rooms[r] = set() - self._room_state_ensure(r, founder=peer_hash) - - sess["rooms"].add(r) - self.rooms.setdefault(r, set()).add(link) - - self.log.info( - "JOIN peer=%s nick=%r room=%s link_id=%s", - self._fmt_hash(peer_hash), - sess.get("nick"), - r, - self._fmt_link_id(link), - ) - - self._touch_room(r) - - joined_body = None - if self.config.include_joined_member_list: - members: list[bytes] = [] - for member_link in self.rooms.get(r, set()): - s = self.sessions.get(member_link) - ph = s.get("peer") if s else None - if isinstance(ph, (bytes, bytearray)): - members.append(bytes(ph)) - joined_body = members - - joined = make_envelope( - T_JOINED, src=self.identity.hash, room=r, body=joined_body - ) - self._queue_env(outgoing, link, joined) - - # Consume invite on successful join. - try: - inv = st.get("invited") - if isinstance(inv, dict) and peer_hash in inv: - inv.pop(peer_hash, None) - if bool(st.get("registered")): - self._persist_room_state_to_registry(link, r) - except Exception: - pass - - try: - registered = bool(st.get("registered", False)) - topic = st.get("topic") if isinstance(st.get("topic"), str) else None - mode_txt = self._room_mode_string(r) - topic_txt = topic if topic else "(none)" - reg_txt = "registered" if registered else "unregistered" - self._emit_notice( - outgoing, - link, - r, - f"room {r}: {reg_txt}; mode={mode_txt}; topic={topic_txt}", - ) - except Exception: - pass - return - - if t == T_PART: - self._inc("parts") - if not isinstance(room, str) or not room: - if self.identity is not None: - self._emit_error( - outgoing, - link, - src=self.identity.hash, - text="PART requires room name", - ) - return - - try: - r = self._norm_room(room) - except Exception as e: - if self.identity is not None: - self._emit_error( - outgoing, link, src=self.identity.hash, text=str(e) - ) - return - - sess["rooms"].discard(r) - if r in self.rooms: - self.rooms[r].discard(link) - if not self.rooms[r]: - self.rooms.pop(r, None) - st = self._room_state_get(r) - if st is not None: - self._touch_room(r) - if st.get("registered"): - self._persist_room_state_to_registry(link, r) - if st is not None and not st.get("registered"): - self._room_state.pop(r, None) - - # Per spec: acknowledge PART with PARTED. - parted_body = None - if self.config.include_joined_member_list: - members: list[bytes] = [] - for member_link in self.rooms.get(r, set()): - s = self.sessions.get(member_link) - ph = s.get("peer") if s else None - if isinstance(ph, (bytes, bytearray)): - members.append(bytes(ph)) - parted_body = members - - if self.identity is not None: - parted = make_envelope( - T_PARTED, src=self.identity.hash, room=r, body=parted_body - ) - self._queue_env(outgoing, link, parted) - - self.log.info( - "PART peer=%s nick=%r room=%s link_id=%s", - self._fmt_hash(peer_hash), - sess.get("nick"), - r, - self._fmt_link_id(link), - ) - return - - if t in (T_MSG, T_NOTICE): - # Check for slash commands first, as they may not require a room. - # Per RRC spec, the room field is optional and may be empty. - if isinstance(body, str): - cmdline = body.strip() - if cmdline.startswith("/"): - # It's a slash command - attempt to handle it - if self.log.isEnabledFor(logging.DEBUG): - self.log.debug( - "Slash command peer=%s link_id=%s cmd=%r room=%r", - self._fmt_hash(peer_hash), - self._fmt_link_id(link), - cmdline, - room, - ) - handled = self._handle_operator_command( - link, peer_hash=peer_hash, room=room, text=body, outgoing=outgoing - ) - if handled: - if self.log.isEnabledFor(logging.DEBUG): - self.log.debug( - "Slash command handled, queued=%d responses", - len(outgoing), - ) - return - # Unrecognized slash command - send error - if self.identity is not None: - self._emit_error( - outgoing, - link, - src=self.identity.hash, - text="unrecognized command", - room=room, - ) - return - - # NOTICE messages are informational/non-conversational and don't require a room. - # MSG messages require a room for delivery. - if t == T_MSG: - if not isinstance(room, str) or not room: - if self.identity is not None: - self._emit_error( - outgoing, - link, - src=self.identity.hash, - text="message requires room name", - ) - return - elif t == T_NOTICE: - # NOTICE without a room is allowed - just don't forward it anywhere - if not isinstance(room, str) or not room: - return - - try: - r = self._norm_room(room) - except Exception as e: - if self.identity is not None: - self._emit_error( - outgoing, link, src=self.identity.hash, text=str(e) - ) - return - - if r not in sess["rooms"]: - # +n (no outside messages): when enabled, require membership. - # When disabled (-n), allow sending to existing/registered rooms. - st = None - if r in self._room_registry: - st = self._room_state_ensure(r) - elif r in self.rooms: - st = self._room_state_ensure(r) - - if st is None: - if self.identity is not None: - self._emit_error( - outgoing, - link, - src=self.identity.hash, - text="no such room", - room=r, - ) - return - - if bool(st.get("no_outside_msgs", False)): - if self.identity is not None: - self._emit_error( - outgoing, - link, - src=self.identity.hash, - text="no outside messages (+n)", - room=r, - ) - return - - # Per-room moderation: bans and moderated mode. - if self._is_room_banned(r, peer_hash): - if self.identity is not None: - self._emit_error( - outgoing, - link, - src=self.identity.hash, - text="banned from room", - room=r, - ) - return - if self._room_moderated(r) and not self._is_room_voiced(r, peer_hash): - if self.identity is not None: - self._emit_error( - outgoing, - link, - src=self.identity.hash, - text="room is moderated (+m)", - room=r, - ) - return - - if peer_hash is not None: - env[K_SRC] = ( - bytes(peer_hash) - if isinstance(peer_hash, (bytes, bytearray)) - else peer_hash - ) - env[K_ROOM] = r - - # Preserve the nickname from the incoming envelope if present. - # Fall back to session nickname (from HELLO) if client didn't provide one. - # This allows clients to update their nickname mid-session. - incoming_nick = env.get(K_NICK) - if incoming_nick is not None: - # Client provided a nickname in this message - validate and preserve it - n = normalize_nick(incoming_nick, max_chars=self.config.nick_max_chars) - if n is not None: - # Update session nick and index if it changed - old_session_nick = sess.get("nick") - if old_session_nick != n: - sess["nick"] = n - self._update_nick_index(link, old_session_nick, n) - env[K_NICK] = n - else: - # Invalid nickname provided - remove it - env.pop(K_NICK, None) - else: - # No nickname in message - use session nickname from HELLO if available - nick = sess.get("nick") - n = normalize_nick(nick, max_chars=self.config.nick_max_chars) - if n is not None: - env[K_NICK] = n - - payload = encode(env) - for other in list(self.rooms.get(r, set())): - self._queue_payload(outgoing, other, payload) - - if self.log.isEnabledFor(logging.DEBUG): - self.log.debug( - "Forwarded t=%s peer=%s nick=%r room=%s recipients=%s body_type=%s", - t, - self._fmt_hash(peer_hash), - sess.get("nick"), - r, - len(self.rooms.get(r, set())), - type(body).__name__, - ) - - if t == T_MSG: - self._inc("msgs_forwarded") - else: - self._inc("notices_forwarded") - return - - if t == T_PING: - self._inc("pings_in") - if self.identity is not None: - pong = make_envelope(T_PONG, src=self.identity.hash, body=body) - self._inc("pongs_out") - self._queue_env(outgoing, link, pong) - return - - return + if hasattr(outgoing, "_post_send_callbacks"): + for callback in outgoing._post_send_callbacks: # type: ignore + try: + callback() + except Exception: + self.log.exception("Post-send callback failed") def _ping_loop(self) -> None: while not self._shutdown.is_set(): @@ -4139,7 +562,7 @@ class HubService: to_ping: list[RNS.Link] = [] with self._state_lock: - for link, sess in list(self.sessions.items()): + for link, sess in list(self.session_manager.sessions.items()): if not sess.get("welcomed"): continue @@ -4165,7 +588,7 @@ class HubService: for link in to_ping: ping = make_envelope(T_PING, src=self.identity.hash, body=now) try: - self._inc("pings_out") - self._send(link, ping) + self.stats_manager.inc("pings_out") + self.message_helper.send(link, ping) except Exception: pass diff --git a/rrcd/session.py b/rrcd/session.py new file mode 100644 index 0000000..c2f1302 --- /dev/null +++ b/rrcd/session.py @@ -0,0 +1,264 @@ +from __future__ import annotations + +import logging +import time +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +import RNS + +if TYPE_CHECKING: + from .service import HubService + + +@dataclass +class _RateState: + """Token bucket state for rate limiting.""" + + tokens: float + last_refill: float + + +class SessionManager: + """ + Manages session lifecycle for RRC hub connections. + + This class is responsible for: + - Session creation and initialization + - Session state management (nicknames, rooms, capabilities) + - Nickname indexing for efficient lookups + - Rate limiting with token bucket algorithm + - Session cleanup and teardown + - Remote identity tracking + """ + + def __init__(self, hub: HubService) -> None: + self.hub = hub + self.log = logging.getLogger("rrcd.session") + self.sessions: dict[RNS.Link, dict[str, Any]] = {} + self._rate: dict[RNS.Link, _RateState] = {} + self._index_by_hash: dict[bytes, RNS.Link] = {} # identity hash -> link + self._index_by_nick: dict[str, set[RNS.Link]] = {} # normalized nick -> links + + def on_link_established(self, link: RNS.Link) -> None: + """ + Handle new link establishment. + + Creates session state and sets up callbacks. + Must be called with state lock held. + """ + self.sessions[link] = { + "welcomed": False, + "rooms": set(), + "peer": None, + "nick": None, + "peer_caps": {}, + "awaiting_pong": None, + } + + self._rate[link] = _RateState( + tokens=float(self.hub.config.rate_limit_msgs_per_minute), + last_refill=time.monotonic(), + ) + + self.log.info("Session created link_id=%s", self.hub._fmt_link_id(link)) + + def on_remote_identified( + self, link: RNS.Link, identity: RNS.Identity | None + ) -> tuple[bool, bytes | None]: + """ + Handle remote identity being established. + + Returns: + (is_banned, peer_hash) tuple + Must be called with state lock held. + """ + sess = self.sessions.get(link) + if sess is None: + return False, None + + if identity is not None: + peer_hash = identity.hash + sess["peer"] = peer_hash + + self._index_by_hash[bytes(peer_hash)] = link + + banned = self.hub.trust_manager.is_banned(bytes(peer_hash)) + + if not banned: + self.log.info( + "Remote identified peer=%s link_id=%s", + self.hub._fmt_hash(peer_hash), + self.hub._fmt_link_id(link), + ) + + return banned, peer_hash + + return False, None + + def on_link_closed(self, link: RNS.Link) -> tuple[bytes | None, str | None, int]: + """ + Handle link closure and cleanup. + + Returns: + (peer_hash, nick, rooms_count) for logging + Must be called with state lock held. + """ + sess = self.sessions.pop(link, None) + self._rate.pop(link, None) + + if not sess: + return None, None, 0 + + peer = sess.get("peer") + nick = sess.get("nick") + rooms_count = len(sess.get("rooms") or ()) + + if isinstance(peer, (bytes, bytearray)): + self._index_by_hash.pop(bytes(peer), None) + + if nick: + self.update_nick_index(link, nick, None) + + for room in list(sess["rooms"]): + self.hub.room_manager.remove_member(room, link) + + return peer, nick, rooms_count + + def update_nick_index( + self, link: RNS.Link, old_nick: str | None, new_nick: str | None + ) -> None: + """ + Update nickname index when a nick changes. + + Must be called with state lock held. + """ + if old_nick: + old_key = old_nick.strip().lower() + if old_key in self._index_by_nick: + self._index_by_nick[old_key].discard(link) + if not self._index_by_nick[old_key]: + self._index_by_nick.pop(old_key, None) + + if new_nick: + new_key = new_nick.strip().lower() + self._index_by_nick.setdefault(new_key, set()).add(link) + + def refill_and_take(self, link: RNS.Link, cost: float = 1.0) -> bool: + """ + Token bucket rate limiting. + + Refills tokens based on elapsed time and attempts to take `cost` tokens. + Returns True if tokens were available and taken, False if rate limited. + + Must be called with state lock held. + """ + state = self._rate.get(link) + if state is None: + return True + + now = time.monotonic() + per_min = float(max(1, int(self.hub.config.rate_limit_msgs_per_minute))) + rate_per_s = per_min / 60.0 + elapsed = max(0.0, now - state.last_refill) + state.tokens = min(per_min, state.tokens + elapsed * rate_per_s) + state.last_refill = now + + if state.tokens < cost: + return False + + state.tokens -= cost + return True + + def get_session(self, link: RNS.Link) -> dict[str, Any] | None: + """Get session state for a link.""" + return self.sessions.get(link) + + def get_link_by_hash(self, peer_hash: bytes) -> RNS.Link | None: + """Look up link by peer identity hash (O(1)).""" + return self._index_by_hash.get(bytes(peer_hash)) + + def get_links_by_nick(self, nick: str) -> set[RNS.Link]: + """Look up links by normalized nickname (O(1)).""" + key = nick.strip().lower() + return self._index_by_nick.get(key, set()).copy() + + def clear_all(self) -> list[RNS.Link]: + """ + Clear all sessions and return list of links for teardown. + + Must be called with state lock held. + """ + links = list(self.sessions.keys()) + self.sessions.clear() + self._rate.clear() + self._index_by_hash.clear() + self._index_by_nick.clear() + return links + + def get_stats(self) -> dict[str, Any]: + """Get session statistics for monitoring.""" + total = len(self.sessions) + welcomed = sum(1 for s in self.sessions.values() if s.get("welcomed")) + identified = sum(1 for s in self.sessions.values() if s.get("peer") is not None) + + return { + "total": total, + "welcomed": welcomed, + "identified": identified, + "indexed_by_hash": len(self._index_by_hash), + "indexed_by_nick": len(self._index_by_nick), + } + + def send_welcome( + self, + link: RNS.Link, + outgoing: list[tuple[RNS.Link, bytes]], + *, + peer_hash: bytes, + old_nick: str | None = None, + new_nick: str | None = None, + ) -> None: + """ + Send WELCOME message to a client and optionally MOTD. + + This handles: + - Setting session as welcomed + - Updating nick index if needed + - Queueing WELCOME message + - Setting up MOTD callback for post-send delivery + + Must be called with state lock held. + """ + from .constants import RES_KIND_MOTD, T_NOTICE + + sess = self.sessions.get(link) + if sess is None: + return + + if old_nick != new_nick: + self.update_nick_index(link, old_nick, new_nick) + + sess["welcomed"] = True + + self.hub.message_helper.queue_welcome( + outgoing, + link, + peer_hash=peer_hash, + motd=self.hub.config.greeting, + ) + + if self.hub.config.greeting: + + def send_motd(): + self.hub.message_helper.send_text_smart( + link, + msg_type=T_NOTICE, + text=self.hub.config.greeting, + room=None, + kind=RES_KIND_MOTD, + ) + + if not hasattr(outgoing, "_post_send_callbacks"): + outgoing._post_send_callbacks = [] # type: ignore + outgoing._post_send_callbacks.append(send_motd) # type: ignore diff --git a/rrcd/stats.py b/rrcd/stats.py new file mode 100644 index 0000000..0c01743 --- /dev/null +++ b/rrcd/stats.py @@ -0,0 +1,158 @@ +"""Statistics tracking and reporting for the RRC hub.""" + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .service import HubService + + +class StatsManager: + """ + Manages hub statistics collection and reporting. + + Tracks counters for: + - Bytes in/out + - Packets processed + - Rate limiting events + - Errors sent + - Room joins/parts + - Messages forwarded + - Ping/pong activity + - Announces + - Resource transfers + """ + + def __init__(self, hub: HubService) -> None: + self.hub = hub + self.log = hub.log + + self.started_wall_time: float | None = None + self.started_monotonic: float | None = None + + self._counters: dict[str, int] = { + "bytes_in": 0, + "bytes_out": 0, + "pkts_in": 0, + "pkts_bad": 0, + "rate_limited": 0, + "errors_sent": 0, + "joins": 0, + "parts": 0, + "msgs_forwarded": 0, + "notices_forwarded": 0, + "pings_in": 0, + "pongs_in": 0, + "pings_out": 0, + "pongs_out": 0, + "announces": 0, + "resources_sent": 0, + "resources_received": 0, + "resources_rejected": 0, + "resource_bytes_sent": 0, + "resource_bytes_received": 0, + } + + def set_start_time(self) -> None: + """Set the start time for uptime calculations.""" + self.started_wall_time = time.time() + self.started_monotonic = time.monotonic() + + def inc(self, key: str, delta: int = 1) -> None: + """Increment a counter by the given delta.""" + try: + with self.hub._state_lock: + self._counters[key] = int(self._counters.get(key, 0)) + int(delta) + except Exception: + pass + + def format_stats(self) -> str: + """Format current statistics as a human-readable string.""" + from . import __version__ + + now_mono = time.monotonic() + started_mono = self.started_monotonic + uptime_s = (now_mono - started_mono) if started_mono is not None else 0.0 + + with self.hub._state_lock: + session_stats = self.hub.session_manager.get_stats() + sessions_total = session_stats["total"] + sessions_welcomed = session_stats["welcomed"] + sessions_identified = session_stats["identified"] + + room_stats = self.hub.room_manager.get_stats() + rooms_total = room_stats["rooms_total"] + memberships = room_stats["memberships"] + top_rooms = room_stats["top_rooms"] + + trust_stats = self.hub.trust_manager.get_stats() + trusted_count = trust_stats["trusted_count"] + banned_count = trust_stats["banned_count"] + c = dict(self._counters) + + lines: list[str] = [] + lines.append(f"rrcd {__version__} stats") + lines.append(f"uptime_s={uptime_s:.1f}") + lines.append( + f"clients_total={sessions_total} " + f"clients_identified={sessions_identified} " + f"clients_welcomed={sessions_welcomed}" + ) + lines.append(f"rooms={rooms_total} memberships={memberships}") + + if top_rooms: + lines.append("top_rooms=" + ", ".join(f"{r}:{n}" for r, n in top_rooms)) + + lines.append(f"trust: trusted={trusted_count} banned={banned_count}") + lines.append( + f"limits: rate_limit_msgs_per_minute={self.hub.config.rate_limit_msgs_per_minute} " + f"max_rooms_per_session={self.hub.config.max_rooms_per_session} " + f"max_room_name_len={self.hub.config.max_room_name_len} " + f"nick_max_chars={self.hub.config.nick_max_chars}" + ) + lines.append( + f"features: ping_interval_s={self.hub.config.ping_interval_s} " + f"ping_timeout_s={self.hub.config.ping_timeout_s} " + f"announce_on_start={self.hub.config.announce_on_start} " + f"announce_period_s={self.hub.config.announce_period_s}" + ) + + lines.append( + "io: pkts_in={} pkts_bad={} bytes_in={} bytes_out={}".format( + c.get("pkts_in", 0), + c.get("pkts_bad", 0), + c.get("bytes_in", 0), + c.get("bytes_out", 0), + ) + ) + lines.append( + "events: joins={} parts={} msgs_fwd={} notices_fwd={} errors_sent={} rate_limited={}".format( + c.get("joins", 0), + c.get("parts", 0), + c.get("msgs_forwarded", 0), + c.get("notices_forwarded", 0), + c.get("errors_sent", 0), + c.get("rate_limited", 0), + ) + ) + lines.append( + "pings: in={} out={} pongs: in={} out={}".format( + c.get("pings_in", 0), + c.get("pings_out", 0), + c.get("pongs_in", 0), + c.get("pongs_out", 0), + ) + ) + lines.append( + "resources: sent={} received={} rejected={} bytes_sent={} bytes_received={}".format( + c.get("resources_sent", 0), + c.get("resources_received", 0), + c.get("resources_rejected", 0), + c.get("resource_bytes_sent", 0), + c.get("resource_bytes_received", 0), + ) + ) + + return "".join(lines) diff --git a/rrcd/trust.py b/rrcd/trust.py new file mode 100644 index 0000000..f409124 --- /dev/null +++ b/rrcd/trust.py @@ -0,0 +1,181 @@ +"""Trust and ban management for the RRC hub.""" + +from __future__ import annotations + +import os +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import RNS + + from .service import HubService + + +class TrustManager: + """ + Manages trusted and banned identities for the hub. + + Handles: + - Trusted identity lists (server operators) + - Banned identity lists + - Persistence of ban list to config + - Trust/ban checks + """ + + def __init__(self, hub: HubService) -> None: + self.hub = hub + self.log = hub.log + + self._trusted: set[bytes] = set() + self._banned: set[bytes] = set() + + def load_from_config( + self, trusted_list: list[str] | None, banned_list: list[str] | None + ) -> None: + """Load trusted and banned identities from config lists.""" + self._trusted = { + self.hub._parse_identity_hash(h) + for h in (trusted_list or ()) + if str(h).strip() + } + self._banned = { + self.hub._parse_identity_hash(h) + for h in (banned_list or ()) + if str(h).strip() + } + + def is_trusted(self, peer_hash: bytes | None) -> bool: + """Check if a peer identity is in the trusted list.""" + if not peer_hash: + return False + with self.hub._state_lock: + return peer_hash in self._trusted + + def is_server_op(self, peer_hash: bytes | None) -> bool: + """Check if a peer is a server operator (currently same as trusted).""" + return self.is_trusted(peer_hash) + + def is_banned(self, peer_hash: bytes | None) -> bool: + """Check if a peer identity is in the banned list.""" + if not peer_hash: + return False + with self.hub._state_lock: + return peer_hash in self._banned + + def add_ban(self, peer_hash: bytes) -> None: + """Add a peer identity to the banned list.""" + with self.hub._state_lock: + self._banned.add(peer_hash) + + def remove_ban(self, peer_hash: bytes) -> None: + """Remove a peer identity from the banned list.""" + with self.hub._state_lock: + self._banned.discard(peer_hash) + + def get_stats(self) -> dict[str, int]: + """Get statistics about trusted and banned identities.""" + with self.hub._state_lock: + return { + "trusted_count": len(self._trusted), + "banned_count": len(self._banned), + } + + def update_from_config( + self, trusted_list: list[str] | None, banned_list: list[str] | None + ) -> tuple[set[bytes], set[bytes]]: + """ + Update trusted and banned lists from config. + Returns the old (trusted, banned) sets for comparison. + """ + with self.hub._state_lock: + old_trusted = set(self._trusted) + old_banned = set(self._banned) + + new_trusted = { + self.hub._parse_identity_hash(h) + for h in (trusted_list or ()) + if str(h).strip() + } + new_banned = { + self.hub._parse_identity_hash(h) + for h in (banned_list or ()) + if str(h).strip() + } + + with self.hub._state_lock: + self._trusted = new_trusted + self._banned = new_banned + + return old_trusted, old_banned + + def persist_banned_identities_to_config( + self, + link: RNS.Link, + room: str | None, + outgoing: list[tuple[RNS.Link, bytes]] | None = None, + ) -> None: + """Persist the current banned identities list to the config file.""" + cfg_path = self.hub.config_manager.get_config_path_for_writes() + if not cfg_path: + self.hub.message_helper.emit_notice( + outgoing, link, room, "ban updated (not persisted; no config_path)" + ) + return + + try: + from tomlkit import dumps, parse, table # type: ignore + except Exception: + self.hub.message_helper.emit_notice( + outgoing, + link, + room, + "ban updated (not persisted; missing dependency tomlkit)", + ) + return + + try: + with self.hub.config_manager.get_write_lock(): + st = None + try: + st = os.stat(cfg_path) + except Exception: + st = None + + with open(cfg_path, encoding="utf-8") as f: + doc = parse(f.read()) + + hub = doc.get("hub") + if hub is None: + hub = table() + doc["hub"] = hub + + existing = hub.get("banned_identities") + existing_list: list[str] = [] + if isinstance(existing, list): + for x in existing: + if x is None: + continue + sx = str(x).strip().lower() + if sx.startswith("0x"): + sx = sx[2:] + if sx: + existing_list.append(sx) + + with self.hub._state_lock: + merged = set(existing_list) + merged.update(h.hex() for h in sorted(self._banned)) + hub["banned_identities"] = sorted(merged) + + new_text = dumps(doc) + with open(cfg_path, "w", encoding="utf-8") as f: + f.write(new_text) + + if st is not None: + try: + os.chmod(cfg_path, st.st_mode) + except Exception: + pass + except Exception as e: + self.hub.message_helper.emit_notice( + outgoing, link, room, f"ban updated (persist failed: {e})" + ) diff --git a/rrcd/util.py b/rrcd/util.py index 6e42069..6afbe29 100644 --- a/rrcd/util.py +++ b/rrcd/util.py @@ -25,8 +25,6 @@ def normalize_nick(value, *, max_chars: int = _DEFAULT_NICK_MAX_CHARS) -> str | if limit > 0 and len(s) > limit: return None - # Keep this conservative: avoid embedded newlines or NUL, which frequently - # cause UI/log formatting issues. if "\n" in s or "\r" in s or "\x00" in s: return None