From e619bc00c21fac02cf7b76173b59858c435abfd3 Mon Sep 17 00:00:00 2001 From: kc1awv Date: Wed, 7 Jan 2026 13:32:13 -0500 Subject: [PATCH] refactor trust management --- rrcd/commands.py | 25 +++---- rrcd/rooms.py | 2 +- rrcd/service.py | 109 ++++------------------------- rrcd/stats.py | 5 +- rrcd/trust.py | 176 +++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 205 insertions(+), 112 deletions(-) create mode 100644 rrcd/trust.py diff --git a/rrcd/commands.py b/rrcd/commands.py index 7e5b222..c08b45a 100644 --- a/rrcd/commands.py +++ b/rrcd/commands.py @@ -45,7 +45,7 @@ class CommandHandler: cmd = parts[0].lower() if cmd == "reload": - if not self.hub._is_server_op(peer_hash): + if not self.hub.trust_manager.is_server_op(peer_hash): if self.hub.identity is not None: self._emit_error( outgoing, @@ -61,7 +61,7 @@ class CommandHandler: # Global/server-operator commands if cmd == "stats": - if not self.hub._is_server_op(peer_hash): + if not self.hub.trust_manager.is_server_op(peer_hash): if self.hub.identity is not None: self._emit_error( outgoing, @@ -125,7 +125,7 @@ class CommandHandler: # Check if room is private - only server operators can see private rooms st = self.hub.room_manager._room_state_get(r) if st and st.get("private"): - if not self.hub._is_server_op(peer_hash): + if not self.hub.trust_manager.is_server_op(peer_hash): self._emit_notice(outgoing, link, None, f"room {r} is private") return True @@ -207,7 +207,7 @@ class CommandHandler: return True if cmd == "kline": - if not self.hub._is_server_op(peer_hash): + if not self.hub.trust_manager.is_server_op(peer_hash): if self.hub.identity is not None: self._emit_error( outgoing, @@ -230,7 +230,8 @@ class CommandHandler: op = parts[1].strip().lower() if op == "list": - items = sorted(h.hex() for h in self.hub._banned) + with self.hub._state_lock: + items = sorted(h.hex() for h in self.hub.trust_manager._banned) self._emit_notice( outgoing, link, @@ -261,8 +262,8 @@ class CommandHandler: tsess = self.hub.session_manager.sessions.get(target_link) ph = tsess.get("peer") if tsess else None if isinstance(ph, (bytes, bytearray)): - self.hub._banned.add(bytes(ph)) - self.hub._persist_banned_identities_to_config(link, None, outgoing) + 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: @@ -285,8 +286,8 @@ class CommandHandler: except Exception as e: self._emit_notice(outgoing, link, None, f"bad identity hash: {e}") return True - self.hub._banned.add(h) - self.hub._persist_banned_identities_to_config(link, None, outgoing) + self.hub.trust_manager.add_ban(h) + self.hub.trust_manager.persist_banned_identities_to_config(link, None, outgoing) self._emit_notice(outgoing, link, None, f"kline added for {h.hex()}") return True @@ -297,9 +298,9 @@ class CommandHandler: self._emit_notice(outgoing, link, None, f"bad identity hash: {e}") return True - if h in self.hub._banned: - self.hub._banned.discard(h) - self.hub._persist_banned_identities_to_config(link, None, outgoing) + 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._emit_notice(outgoing, link, None, f"kline removed for {h.hex()}") else: self._emit_notice(outgoing, link, None, f"not klined: {h.hex()}") diff --git a/rrcd/rooms.py b/rrcd/rooms.py index bc91283..f27401c 100644 --- a/rrcd/rooms.py +++ b/rrcd/rooms.py @@ -238,7 +238,7 @@ class RoomManager: """Check if peer is a room operator.""" if peer_hash is None: return False - if self.hub._is_server_op(peer_hash): + if self.hub.trust_manager.is_server_op(peer_hash): return True st = self._room_state_ensure(room) founder = st.get("founder") diff --git a/rrcd/service.py b/rrcd/service.py index cb33d7a..b81c85c 100644 --- a/rrcd/service.py +++ b/rrcd/service.py @@ -31,6 +31,7 @@ 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 @@ -63,14 +64,13 @@ class HubService: # Stats manager for metrics and reporting self.stats_manager = StatsManager(self) + + # Trust manager for trusted/banned identities + self.trust_manager = TrustManager(self) self.identity: RNS.Identity | None = None self.destination: RNS.Destination | None = None - - self._trusted: set[bytes] = set() - self._banned: set[bytes] = set() - self._prune_thread: threading.Thread | None = None self._ping_thread: threading.Thread | None = None @@ -331,16 +331,10 @@ class HubService: 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() @@ -612,8 +606,8 @@ class HubService: with self._state_lock: old_cfg = self.config - old_trusted = set(self._trusted) - old_banned = set(self._banned) + 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 @@ -661,8 +655,8 @@ class HubService: with self._state_lock: # Apply (all-or-nothing) self.config = new_cfg - self._trusted = new_trusted - self._banned = new_banned + self.trust_manager._trusted = new_trusted + self.trust_manager._banned = new_banned self.room_manager._room_registry = new_registry # Merge registry into live per-room state (for active rooms). @@ -713,9 +707,6 @@ class HubService: return self.room_manager._room_registry = registry - def _is_server_op(self, peer_hash: bytes | None) -> bool: - return self._is_trusted(peer_hash) - def _resolve_identity_hash( self, token: str, *, room: str | None = None ) -> bytes | None: @@ -806,82 +797,6 @@ class HubService: 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 diff --git a/rrcd/stats.py b/rrcd/stats.py index 4548e81..7733914 100644 --- a/rrcd/stats.py +++ b/rrcd/stats.py @@ -90,8 +90,9 @@ class StatsManager: memberships = room_stats["memberships"] top_rooms = room_stats["top_rooms"] - trusted_count = len(self.hub._trusted) - banned_count = len(self.hub._banned) + 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] = [] diff --git a/rrcd/trust.py b/rrcd/trust.py new file mode 100644 index 0000000..d49b9f9 --- /dev/null +++ b/rrcd/trust.py @@ -0,0 +1,176 @@ +"""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_path_for_writes() + if not cfg_path: + self.hub._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._emit_notice( + outgoing, + link, + room, + "ban updated (not persisted; missing dependency tomlkit)", + ) + return + + try: + with self.hub._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) + + 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._emit_notice( + outgoing, link, room, f"ban updated (persist failed: {e})" + )