cleanup, consolidation, remove unneeded delegations

This commit is contained in:
kc1awv
2026-01-07 15:42:34 -05:00
parent 48c1b132d0
commit 8dcbfadbca
13 changed files with 415 additions and 541 deletions
+214 -118
View File
@@ -55,11 +55,9 @@ class CommandHandler:
room=None, room=None,
) )
return True return True
# Hub-level command - send responses without room field
self.hub._reload_config_and_rooms(link, None, outgoing) self.hub._reload_config_and_rooms(link, None, outgoing)
return True return True
# Global/server-operator commands
if cmd == "stats": if cmd == "stats":
if not self.hub.trust_manager.is_server_op(peer_hash): if not self.hub.trust_manager.is_server_op(peer_hash):
if self.hub.identity is not None: if self.hub.identity is not None:
@@ -71,12 +69,12 @@ class CommandHandler:
room=None, room=None,
) )
return True return True
# Send response without room field for hub-level command self.hub.message_helper.emit_notice(
self.hub.message_helper.emit_notice(outgoing, link, None, self.hub.stats_manager.format_stats()) outgoing, link, None, self.hub.stats_manager.format_stats()
)
return True return True
if cmd == "list": if cmd == "list":
# List all registered, non-private rooms with their topics
with self.hub._state_lock: with self.hub._state_lock:
registered_rooms = [] registered_rooms = []
for room_name, st in self.hub.room_manager._room_state.items(): for room_name, st in self.hub.room_manager._room_state.items():
@@ -84,7 +82,6 @@ class CommandHandler:
topic = st.get("topic") topic = st.get("topic")
registered_rooms.append((room_name, topic)) registered_rooms.append((room_name, topic))
# Also check room registry for rooms not currently in room_state
for room_name, reg in self.hub.room_manager._room_registry.items(): for room_name, reg in self.hub.room_manager._room_registry.items():
if room_name not in self.hub.room_manager._room_state: if room_name not in self.hub.room_manager._room_state:
if not reg.get("private"): if not reg.get("private"):
@@ -92,13 +89,13 @@ class CommandHandler:
registered_rooms.append((room_name, topic)) registered_rooms.append((room_name, topic))
if not registered_rooms: if not registered_rooms:
self.hub.message_helper.emit_notice(outgoing, link, None, "No public rooms registered") self.hub.message_helper.emit_notice(
outgoing, link, None, "No public rooms registered"
)
return True return True
# Sort rooms alphabetically
registered_rooms.sort(key=lambda x: x[0]) registered_rooms.sort(key=lambda x: x[0])
# Format room list with topics
lines = ["Registered public rooms:"] lines = ["Registered public rooms:"]
for room_name, topic in registered_rooms: for room_name, topic in registered_rooms:
if topic: if topic:
@@ -114,23 +111,30 @@ class CommandHandler:
if len(parts) >= 2: if len(parts) >= 2:
target_room = parts[1] target_room = parts[1]
if not isinstance(target_room, str) or not target_room: if not isinstance(target_room, str) or not target_room:
self.hub.message_helper.emit_notice(outgoing, link, None, "usage: /who [room]") self.hub.message_helper.emit_notice(
outgoing, link, None, "usage: /who [room]"
)
return True return True
try: try:
r = self.hub._norm_room(target_room) r = self.hub._norm_room(target_room)
except Exception as e: except Exception as e:
self.hub.message_helper.emit_notice(outgoing, link, None, f"bad room: {e}") self.hub.message_helper.emit_notice(
outgoing, link, None, f"bad room: {e}"
)
return True return True
# Check if room is private - only server operators can see private rooms
st = self.hub.room_manager._room_state_get(r) st = self.hub.room_manager._room_state_get(r)
if st and st.get("private"): if st and st.get("private"):
if not self.hub.trust_manager.is_server_op(peer_hash): 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") self.hub.message_helper.emit_notice(
outgoing, link, None, f"room {r} is private"
)
return True return True
members = [] members = []
for other in sorted(self.hub.room_manager.get_room_members(r), key=lambda x: id(x)): 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) s = self.hub.session_manager.sessions.get(other)
if not s: if not s:
continue continue
@@ -141,7 +145,6 @@ class CommandHandler:
members.append(f"{nick} ({ident[:12]})") members.append(f"{nick} ({ident[:12]})")
else: else:
members.append(ident) members.append(ident)
# Send response without room field for hub-level query
self.hub.message_helper.emit_notice( self.hub.message_helper.emit_notice(
outgoing, outgoing,
link, link,
@@ -161,7 +164,9 @@ class CommandHandler:
try: try:
r = self.hub._norm_room(target_room) r = self.hub._norm_room(target_room)
except Exception as e: except Exception as e:
self.hub.message_helper.emit_notice(outgoing, link, room, f"bad room: {e}") self.hub.message_helper.emit_notice(
outgoing, link, room, f"bad room: {e}"
)
return True return True
if not self.hub.room_manager.is_room_op(r, peer_hash): if not self.hub.room_manager.is_room_op(r, peer_hash):
@@ -177,23 +182,27 @@ class CommandHandler:
target_link = self._find_target_link(target, room=r) target_link = self._find_target_link(target, room=r)
if target_link is None: if target_link is None:
# Check if ambiguous or just not found
all_matches = self._find_target_links(target, room=r) all_matches = self._find_target_links(target, room=r)
self.hub.message_helper.emit_notice( self.hub.message_helper.emit_notice(
outgoing, link, room, self._format_ambiguous_targets(target, all_matches) outgoing,
link,
room,
self._format_ambiguous_targets(target, all_matches),
) )
return True return True
tsess = self.hub.session_manager.sessions.get(target_link) tsess = self.hub.session_manager.sessions.get(target_link)
if not tsess or r not in tsess.get("rooms", set()): if not tsess or r not in tsess.get("rooms", set()):
self.hub.message_helper.emit_notice(outgoing, link, room, "target not in room") self.hub.message_helper.emit_notice(
outgoing, link, room, "target not in room"
)
return True return True
tsess["rooms"].discard(r) tsess["rooms"].discard(r)
if self.hub.room_manager.get_room_members(r): if self.hub.room_manager.get_room_members(r):
self.hub.room_manager.rooms[r].discard(target_link) self.hub.room_manager.rooms[r].discard(target_link)
if not self.hub.room_manager.rooms[r]: if not self.hub.room_manager.rooms[r]:
pass # room cleanup handled by room_manager pass
if self.hub.identity is not None: if self.hub.identity is not None:
self._emit_error( self._emit_error(
@@ -203,7 +212,9 @@ class CommandHandler:
text=f"kicked from {r}", text=f"kicked from {r}",
room=r, room=r,
) )
self.hub.message_helper.emit_notice(outgoing, link, room, f"kicked {target} from {r}") self.hub.message_helper.emit_notice(
outgoing, link, room, f"kicked {target} from {r}"
)
return True return True
if cmd == "kline": if cmd == "kline":
@@ -218,7 +229,6 @@ class CommandHandler:
) )
return True return True
# Hub-level command - all responses without room field
if len(parts) < 2: if len(parts) < 2:
self.hub.message_helper.emit_notice( self.hub.message_helper.emit_notice(
outgoing, outgoing,
@@ -263,65 +273,86 @@ class CommandHandler:
ph = tsess.get("peer") if tsess else None ph = tsess.get("peer") if tsess else None
if isinstance(ph, (bytes, bytearray)): if isinstance(ph, (bytes, bytearray)):
self.hub.trust_manager.add_ban(bytes(ph)) self.hub.trust_manager.add_ban(bytes(ph))
self.hub.trust_manager.persist_banned_identities_to_config(link, None, outgoing) self.hub.trust_manager.persist_banned_identities_to_config(
link, None, outgoing
)
try: try:
target_link.teardown() target_link.teardown()
except Exception: except Exception:
pass pass
self.hub.message_helper.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.hub.message_helper.emit_notice( self.hub.message_helper.emit_notice(
outgoing, link, None, self._format_ambiguous_targets(target, all_matches) 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 return True
# Try as raw hash
try: try:
h = self.hub._parse_identity_hash(target) h = self.hub._parse_identity_hash(target)
except Exception as e: except Exception as e:
self.hub.message_helper.emit_notice(outgoing, link, None, f"bad identity hash: {e}") self.hub.message_helper.emit_notice(
outgoing, link, None, f"bad identity hash: {e}"
)
return True return True
self.hub.trust_manager.add_ban(h) self.hub.trust_manager.add_ban(h)
self.hub.trust_manager.persist_banned_identities_to_config(link, None, outgoing) self.hub.trust_manager.persist_banned_identities_to_config(
self.hub.message_helper.emit_notice(outgoing, link, None, f"kline added for {h.hex()}") link, None, outgoing
)
self.hub.message_helper.emit_notice(
outgoing, link, None, f"kline added for {h.hex()}"
)
return True return True
# op == "del"
try: try:
h = self.hub._parse_identity_hash(target) h = self.hub._parse_identity_hash(target)
except Exception as e: except Exception as e:
self.hub.message_helper.emit_notice(outgoing, link, None, f"bad identity hash: {e}") self.hub.message_helper.emit_notice(
outgoing, link, None, f"bad identity hash: {e}"
)
return True return True
if self.hub.trust_manager.is_banned(h): if self.hub.trust_manager.is_banned(h):
self.hub.trust_manager.remove_ban(h) self.hub.trust_manager.remove_ban(h)
self.hub.trust_manager.persist_banned_identities_to_config(link, None, outgoing) self.hub.trust_manager.persist_banned_identities_to_config(
self.hub.message_helper.emit_notice(outgoing, link, None, f"kline removed for {h.hex()}") link, None, outgoing
)
self.hub.message_helper.emit_notice(
outgoing, link, None, f"kline removed for {h.hex()}"
)
else: else:
self.hub.message_helper.emit_notice(outgoing, link, None, f"not klined: {h.hex()}") self.hub.message_helper.emit_notice(
outgoing, link, None, f"not klined: {h.hex()}"
)
return True return True
# Room-scoped moderation and maintenance
if cmd == "register": if cmd == "register":
if len(parts) < 2: if len(parts) < 2:
self.hub.message_helper.emit_notice(outgoing, link, None, "usage: /register <room>") self.hub.message_helper.emit_notice(
outgoing, link, None, "usage: /register <room>"
)
return True return True
try: try:
r = self.hub._norm_room(parts[1]) r = self.hub._norm_room(parts[1])
except Exception as e: except Exception as e:
self.hub.message_helper.emit_notice(outgoing, link, None, f"bad room: {e}") self.hub.message_helper.emit_notice(
outgoing, link, None, f"bad room: {e}"
)
return True return True
# Registration rules: requester must be in the room and must be the founder.
# (No server-op override by design.)
if ( if (
not room not room
or self.hub._norm_room(room) != r or self.hub._norm_room(room) != r
or r not in self.hub.session_manager.sessions.get(link, {}).get("rooms", set()) or r
not in self.hub.session_manager.sessions.get(link, {}).get(
"rooms", set()
)
): ):
self.hub.message_helper.emit_notice( self.hub.message_helper.emit_notice(
outgoing, link, room, "must be present in the room to register it" outgoing, link, room, "must be present in the room to register it"
@@ -330,8 +361,9 @@ class CommandHandler:
st = self.hub.room_manager._room_state_ensure(r) st = self.hub.room_manager._room_state_ensure(r)
# Clean up expired invites (best-effort). if self.hub.room_manager.prune_expired_invites(r) and bool(
if self.hub.room_manager.prune_expired_invites(r) and bool(st.get("registered")): st.get("registered")
):
self.hub.room_manager.persist_room_state(link, r) self.hub.room_manager.persist_room_state(link, r)
founder = st.get("founder") founder = st.get("founder")
if not ( if not (
@@ -353,51 +385,64 @@ class CommandHandler:
) )
return True return True
st["registered"] = True st["registered"] = True
# Default modes for registered rooms: +nrt
st["no_outside_msgs"] = True st["no_outside_msgs"] = True
st["topic_ops_only"] = True st["topic_ops_only"] = True
if isinstance(founder, (bytes, bytearray)): if isinstance(founder, (bytes, bytearray)):
st.setdefault("ops", set()).add(bytes(founder)) st.setdefault("ops", set()).add(bytes(founder))
self.hub.room_manager.touch_room(r) self.hub.room_manager.touch_room(r)
# Ensure registry mirrors registered rooms.
self.hub.room_manager._room_registry[r] = { self.hub.room_manager._room_registry[r] = {
"founder": bytes(founder) "founder": (
if isinstance(founder, (bytes, bytearray)) bytes(founder) if isinstance(founder, (bytes, bytearray)) else None
else None, ),
"registered": True, "registered": True,
"topic": st.get("topic"), "topic": st.get("topic"),
"moderated": bool(st.get("moderated", False)), "moderated": bool(st.get("moderated", False)),
"ops": set(st.get("ops", set())) "ops": (
if isinstance(st.get("ops"), set) set(st.get("ops", set()))
else set(), if isinstance(st.get("ops"), set)
"voiced": set(st.get("voiced", set())) else set()
if isinstance(st.get("voiced"), set) ),
else set(), "voiced": (
"bans": set(st.get("bans", set())) set(st.get("voiced", set()))
if isinstance(st.get("bans"), set) if isinstance(st.get("voiced"), set)
else 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"), "last_used_ts": st.get("last_used_ts"),
} }
self.hub.room_manager.persist_room_state(link, r) self.hub.room_manager.persist_room_state(link, r)
self.hub.message_helper.emit_notice(outgoing, link, room, f"registered room {r}") self.hub.message_helper.emit_notice(
outgoing, link, room, f"registered room {r}"
)
return True return True
if cmd == "unregister": if cmd == "unregister":
if len(parts) < 2: if len(parts) < 2:
self.hub.message_helper.emit_notice(outgoing, link, None, "usage: /unregister <room>") self.hub.message_helper.emit_notice(
outgoing, link, None, "usage: /unregister <room>"
)
return True return True
try: try:
r = self.hub._norm_room(parts[1]) r = self.hub._norm_room(parts[1])
except Exception as e: except Exception as e:
self.hub.message_helper.emit_notice(outgoing, link, None, f"bad room: {e}") self.hub.message_helper.emit_notice(
outgoing, link, None, f"bad room: {e}"
)
return True return True
if ( if (
not room not room
or self.hub._norm_room(room) != r or self.hub._norm_room(room) != r
or r not in self.hub.session_manager.sessions.get(link, {}).get("rooms", set()) or r
not in self.hub.session_manager.sessions.get(link, {}).get(
"rooms", set()
)
): ):
self.hub.message_helper.emit_notice( self.hub.message_helper.emit_notice(
outgoing, link, room, "must be present in the room to unregister it" outgoing, link, room, "must be present in the room to unregister it"
@@ -420,26 +465,35 @@ class CommandHandler:
return True return True
if not st.get("registered"): if not st.get("registered"):
self.hub.message_helper.emit_notice(outgoing, link, room, f"room {r} is not registered") self.hub.message_helper.emit_notice(
outgoing, link, room, f"room {r} is not registered"
)
return True return True
st["registered"] = False st["registered"] = False
self.hub.room_manager._room_registry.pop(r, None) self.hub.room_manager._room_registry.pop(r, None)
self.hub._delete_room_from_registry(link, r) self.hub.room_manager.delete_room_from_registry(link, r)
# Drop state if empty. if not self.hub.room_manager.get_room_members(
if not self.hub.room_manager.get_room_members(r) or not self.hub.room_manager.get_room_members(r): r
) or not self.hub.room_manager.get_room_members(r):
self.hub.room_manager._room_state.pop(r, None) self.hub.room_manager._room_state.pop(r, None)
self.hub.message_helper.emit_notice(outgoing, link, room, f"unregistered room {r}") self.hub.message_helper.emit_notice(
outgoing, link, room, f"unregistered room {r}"
)
return True return True
if cmd == "topic": if cmd == "topic":
if len(parts) < 2: if len(parts) < 2:
self.hub.message_helper.emit_notice(outgoing, link, None, "usage: /topic <room> [topic]") self.hub.message_helper.emit_notice(
outgoing, link, None, "usage: /topic <room> [topic]"
)
return True return True
try: try:
r = self.hub._norm_room(parts[1]) r = self.hub._norm_room(parts[1])
except Exception as e: except Exception as e:
self.hub.message_helper.emit_notice(outgoing, link, None, f"bad room: {e}") self.hub.message_helper.emit_notice(
outgoing, link, None, f"bad room: {e}"
)
return True return True
st = self.hub.room_manager._room_state_ensure(r) st = self.hub.room_manager._room_state_ensure(r)
if len(parts) == 2: if len(parts) == 2:
@@ -469,7 +523,6 @@ class CommandHandler:
st["topic"] = topic if topic else None st["topic"] = topic if topic else None
self.hub.room_manager.touch_room(r) self.hub.room_manager.touch_room(r)
self.hub.room_manager.persist_room_state(link, r) self.hub.room_manager.persist_room_state(link, r)
# Broadcast topic change to current members.
for other in list(self.hub.room_manager.get_room_members(r)): for other in list(self.hub.room_manager.get_room_members(r)):
self.hub.message_helper.emit_notice( self.hub.message_helper.emit_notice(
outgoing, outgoing,
@@ -488,7 +541,9 @@ class CommandHandler:
try: try:
r = self.hub._norm_room(parts[1]) r = self.hub._norm_room(parts[1])
except Exception as e: except Exception as e:
self.hub.message_helper.emit_notice(outgoing, link, None, f"bad room: {e}") self.hub.message_helper.emit_notice(
outgoing, link, None, f"bad room: {e}"
)
return True return True
if not self.hub.room_manager.is_room_op(r, peer_hash): if not self.hub.room_manager.is_room_op(r, peer_hash):
if self.hub.identity is not None: if self.hub.identity is not None:
@@ -501,10 +556,15 @@ class CommandHandler:
) )
return True return True
target_hash, all_matches = self.hub._resolve_identity_hash_with_matches(parts[2], room=r) target_hash, all_matches = self.hub._resolve_identity_hash_with_matches(
parts[2], room=r
)
if target_hash is None: if target_hash is None:
self.hub.message_helper.emit_notice( self.hub.message_helper.emit_notice(
outgoing, link, room, self._format_ambiguous_targets(parts[2], all_matches) outgoing,
link,
room,
self._format_ambiguous_targets(parts[2], all_matches),
) )
return True return True
@@ -523,16 +583,22 @@ class CommandHandler:
ops.add(target_hash) ops.add(target_hash)
self.hub.room_manager.touch_room(r) self.hub.room_manager.touch_room(r)
self.hub.room_manager.persist_room_state(link, r) self.hub.room_manager.persist_room_state(link, r)
self.hub.message_helper.emit_notice(outgoing, link, room, f"op granted in {r}") self.hub.message_helper.emit_notice(
outgoing, link, room, f"op granted in {r}"
)
return True return True
else: else:
if founder_b is not None and target_hash == founder_b: if founder_b is not None and target_hash == founder_b:
self.hub.message_helper.emit_notice(outgoing, link, room, "cannot deop founder") self.hub.message_helper.emit_notice(
outgoing, link, room, "cannot deop founder"
)
return True return True
ops.discard(target_hash) ops.discard(target_hash)
self.hub.room_manager.touch_room(r) self.hub.room_manager.touch_room(r)
self.hub.room_manager.persist_room_state(link, r) self.hub.room_manager.persist_room_state(link, r)
self.hub.message_helper.emit_notice(outgoing, link, room, f"op removed in {r}") self.hub.message_helper.emit_notice(
outgoing, link, room, f"op removed in {r}"
)
return True return True
voiced = st.setdefault("voiced", set()) voiced = st.setdefault("voiced", set())
@@ -543,13 +609,17 @@ class CommandHandler:
voiced.add(target_hash) voiced.add(target_hash)
self.hub.room_manager.touch_room(r) self.hub.room_manager.touch_room(r)
self.hub.room_manager.persist_room_state(link, r) self.hub.room_manager.persist_room_state(link, r)
self.hub.message_helper.emit_notice(outgoing, link, room, f"voice granted in {r}") self.hub.message_helper.emit_notice(
outgoing, link, room, f"voice granted in {r}"
)
return True return True
else: else:
voiced.discard(target_hash) voiced.discard(target_hash)
self.hub.room_manager.touch_room(r) self.hub.room_manager.touch_room(r)
self.hub.room_manager.persist_room_state(link, r) self.hub.room_manager.persist_room_state(link, r)
self.hub.message_helper.emit_notice(outgoing, link, room, f"voice removed in {r}") self.hub.message_helper.emit_notice(
outgoing, link, room, f"voice removed in {r}"
)
return True return True
if cmd == "mode": if cmd == "mode":
@@ -564,7 +634,9 @@ class CommandHandler:
try: try:
r = self.hub._norm_room(parts[1]) r = self.hub._norm_room(parts[1])
except Exception as e: except Exception as e:
self.hub.message_helper.emit_notice(outgoing, link, None, f"bad room: {e}") self.hub.message_helper.emit_notice(
outgoing, link, None, f"bad room: {e}"
)
return True return True
if not self.hub.room_manager.is_room_op(r, peer_hash): if not self.hub.room_manager.is_room_op(r, peer_hash):
if self.hub.identity is not None: if self.hub.identity is not None:
@@ -623,7 +695,9 @@ class CommandHandler:
return True return True
key = " ".join(parts[3:]).strip() key = " ".join(parts[3:]).strip()
if not key: if not key:
self.hub.message_helper.emit_notice(outgoing, link, room, "key must not be empty") self.hub.message_helper.emit_notice(
outgoing, link, room, "key must not be empty"
)
return True return True
st["key"] = key st["key"] = key
else: else:
@@ -649,10 +723,15 @@ class CommandHandler:
) )
return True return True
target_hash, all_matches = self.hub._resolve_identity_hash_with_matches(parts[3], room=r) target_hash, all_matches = self.hub._resolve_identity_hash_with_matches(
parts[3], room=r
)
if target_hash is None: if target_hash is None:
self.hub.message_helper.emit_notice( self.hub.message_helper.emit_notice(
outgoing, link, room, self._format_ambiguous_targets(parts[3], all_matches) outgoing,
link,
room,
self._format_ambiguous_targets(parts[3], all_matches),
) )
return True return True
@@ -729,7 +808,9 @@ class CommandHandler:
try: try:
r = self.hub._norm_room(parts[1]) r = self.hub._norm_room(parts[1])
except Exception as e: except Exception as e:
self.hub.message_helper.emit_notice(outgoing, link, None, f"bad room: {e}") self.hub.message_helper.emit_notice(
outgoing, link, None, f"bad room: {e}"
)
return True return True
op = parts[2].strip().lower() op = parts[2].strip().lower()
@@ -737,7 +818,9 @@ class CommandHandler:
st = self.hub.room_manager._room_state_ensure(r) st = self.hub.room_manager._room_state_ensure(r)
bans = st.get("bans") bans = st.get("bans")
if not isinstance(bans, set) or not bans: if not isinstance(bans, set) or not bans:
self.hub.message_helper.emit_notice(outgoing, link, room, f"no bans in {r}") self.hub.message_helper.emit_notice(
outgoing, link, room, f"no bans in {r}"
)
return True return True
items = sorted( items = sorted(
bytes(x).hex() for x in bans if isinstance(x, (bytes, bytearray)) bytes(x).hex() for x in bans if isinstance(x, (bytes, bytearray))
@@ -773,10 +856,15 @@ class CommandHandler:
) )
return True return True
target_hash, all_matches = self.hub._resolve_identity_hash_with_matches(parts[3], room=r) target_hash, all_matches = self.hub._resolve_identity_hash_with_matches(
parts[3], room=r
)
if target_hash is None: if target_hash is None:
self.hub.message_helper.emit_notice( self.hub.message_helper.emit_notice(
outgoing, link, room, self._format_ambiguous_targets(parts[3], all_matches) outgoing,
link,
room,
self._format_ambiguous_targets(parts[3], all_matches),
) )
return True return True
@@ -791,7 +879,6 @@ class CommandHandler:
self.hub.room_manager.touch_room(r) self.hub.room_manager.touch_room(r)
self.hub.room_manager.persist_room_state(link, r) self.hub.room_manager.persist_room_state(link, r)
# If currently present in room, remove them.
for other in list(self.hub.room_manager.get_room_members(r)): for other in list(self.hub.room_manager.get_room_members(r)):
s = self.hub.session_manager.sessions.get(other) s = self.hub.session_manager.sessions.get(other)
ph = s.get("peer") if s else None ph = s.get("peer") if s else None
@@ -806,15 +893,22 @@ class CommandHandler:
text=f"banned from {r}", text=f"banned from {r}",
room=r, room=r,
) )
if self.hub.room_manager.get_room_members(r) and not self.hub.room_manager.rooms[r]: if (
pass # room cleanup handled by room_manager self.hub.room_manager.get_room_members(r)
self.hub.message_helper.emit_notice(outgoing, link, room, f"ban added in {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 return True
bans.discard(target_hash) bans.discard(target_hash)
self.hub.room_manager.touch_room(r) self.hub.room_manager.touch_room(r)
self.hub.room_manager.persist_room_state(link, r) self.hub.room_manager.persist_room_state(link, r)
self.hub.message_helper.emit_notice(outgoing, link, room, f"ban removed in {r}") self.hub.message_helper.emit_notice(
outgoing, link, room, f"ban removed in {r}"
)
return True return True
if cmd == "invite": if cmd == "invite":
@@ -830,7 +924,9 @@ class CommandHandler:
try: try:
r = self.hub._norm_room(parts[1]) r = self.hub._norm_room(parts[1])
except Exception as e: except Exception as e:
self.hub.message_helper.emit_notice(outgoing, link, None, f"bad room: {e}") self.hub.message_helper.emit_notice(
outgoing, link, None, f"bad room: {e}"
)
return True return True
if not self.hub.room_manager.is_room_op(r, peer_hash): if not self.hub.room_manager.is_room_op(r, peer_hash):
@@ -852,7 +948,6 @@ class CommandHandler:
invited = {} invited = {}
st["invited"] = invited st["invited"] = invited
# Drop expired entries before operating.
pruned = self.hub.room_manager.prune_expired_invites(r) pruned = self.hub.room_manager.prune_expired_invites(r)
if op == "list": if op == "list":
@@ -902,7 +997,6 @@ class CommandHandler:
token = parts[3] token = parts[3]
target_link = self._find_target_link(token, room=None) target_link = self._find_target_link(token, room=None)
if target_link is None: if target_link is None:
# Check if ambiguous or just not found
all_matches = self._find_target_links(token, room=None) all_matches = self._find_target_links(token, room=None)
if self.hub.identity is not None: if self.hub.identity is not None:
self._emit_error( self._emit_error(
@@ -928,7 +1022,6 @@ class CommandHandler:
return True return True
target_hash = bytes(ph) target_hash = bytes(ph)
# Always send the invite as a NOTICE so the user can choose to join.
key = st.get("key") key = st.get("key")
is_keyed = isinstance(key, str) and bool(key) is_keyed = isinstance(key, str) and bool(key)
is_invite_only = bool(st.get("invite_only", False)) is_invite_only = bool(st.get("invite_only", False))
@@ -945,7 +1038,6 @@ class CommandHandler:
outgoing, target_link, r, f"You have been invited to join {r}." 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: if is_keyed or is_invite_only:
ttl = ( ttl = (
float(self.hub.config.room_invite_timeout_s) float(self.hub.config.room_invite_timeout_s)
@@ -970,10 +1062,15 @@ class CommandHandler:
) )
return True return True
target_hash, all_matches = self.hub._resolve_identity_hash_with_matches(parts[3], room=None) target_hash, all_matches = self.hub._resolve_identity_hash_with_matches(
parts[3], room=None
)
if target_hash is None: if target_hash is None:
self.hub.message_helper.emit_notice( self.hub.message_helper.emit_notice(
outgoing, link, room, self._format_ambiguous_targets(parts[3], all_matches) outgoing,
link,
room,
self._format_ambiguous_targets(parts[3], all_matches),
) )
return True return True
@@ -981,12 +1078,13 @@ class CommandHandler:
invited.pop(target_hash, None) invited.pop(target_hash, None)
self.hub.room_manager.touch_room(r) self.hub.room_manager.touch_room(r)
self.hub.room_manager.persist_room_state(link, r) self.hub.room_manager.persist_room_state(link, r)
self.hub.message_helper.emit_notice(outgoing, link, room, f"invite removed in {r}") self.hub.message_helper.emit_notice(
outgoing, link, room, f"invite removed in {r}"
)
return True return True
return False return False
# Helper methods
def _find_target_link(self, token: str, room: str | None = None) -> RNS.Link | None: 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. """Find a link by nick or identity hash prefix. Uses indexes for O(1) lookups.
Returns the link if exactly one match, None otherwise. Returns the link if exactly one match, None otherwise.
@@ -1004,7 +1102,6 @@ class CommandHandler:
if not t: if not t:
return [] return []
# If it's hex-like, treat as an identity hash prefix.
hex_candidate = t[2:] if t.startswith("0x") else t hex_candidate = t[2:] if t.startswith("0x") else t
if ( if (
all(c in "0123456789abcdef" for c in hex_candidate) all(c in "0123456789abcdef" for c in hex_candidate)
@@ -1017,26 +1114,27 @@ class CommandHandler:
if prefix is not None: if prefix is not None:
with self.hub._state_lock: with self.hub._state_lock:
# Search hash index for matching prefixes
matches: list[RNS.Link] = [] matches: list[RNS.Link] = []
for peer_hash, candidate_link in self.hub.session_manager._index_by_hash.items(): for (
peer_hash,
candidate_link,
) in self.hub.session_manager._index_by_hash.items():
if peer_hash.startswith(prefix): if peer_hash.startswith(prefix):
# Check room membership if specified
if room is not None: if room is not None:
sess = self.hub.session_manager.sessions.get(candidate_link) sess = self.hub.session_manager.sessions.get(
candidate_link
)
if sess and room not in sess.get("rooms", set()): if sess and room not in sess.get("rooms", set()):
continue continue
matches.append(candidate_link) matches.append(candidate_link)
return matches return matches
# Otherwise treat as nickname - use nick index for O(1) lookup
with self.hub._state_lock: with self.hub._state_lock:
candidate_links = self.hub.session_manager._index_by_nick.get(t, set()) candidate_links = self.hub.session_manager._index_by_nick.get(t, set())
if not candidate_links: if not candidate_links:
return [] return []
# Filter by room membership if specified
if room is not None: if room is not None:
matches = [] matches = []
for candidate_link in candidate_links: for candidate_link in candidate_links:
@@ -1048,9 +1146,7 @@ class CommandHandler:
return matches return matches
def _format_ambiguous_targets( def _format_ambiguous_targets(self, token: str, matches: list[RNS.Link]) -> str:
self, token: str, matches: list[RNS.Link]
) -> str:
"""Format a helpful message when target lookup is ambiguous.""" """Format a helpful message when target lookup is ambiguous."""
if not matches: if not matches:
return f"target '{token}' not found" return f"target '{token}' not found"
@@ -1087,9 +1183,9 @@ class CommandHandler:
return return
env = make_envelope(T_NOTICE, src=self.hub.identity.hash, room=room, body=text) env = make_envelope(T_NOTICE, src=self.hub.identity.hash, room=room, body=text)
if outgoing is None: if outgoing is None:
self.hub._send(link, env) self.hub.message_helper.send(link, env)
else: else:
self.hub._queue_env(outgoing, link, env) self.hub.message_helper.queue_env(outgoing, link, env)
def _emit_error( def _emit_error(
self, self,
@@ -1103,6 +1199,6 @@ class CommandHandler:
self.hub.stats_manager.inc("errors_sent") self.hub.stats_manager.inc("errors_sent")
env = make_envelope(T_ERROR, src=src, room=room, body=text) env = make_envelope(T_ERROR, src=src, room=room, body=text)
if outgoing is None: if outgoing is None:
self.hub._send(link, env) self.hub.message_helper.send(link, env)
else: else:
self.hub._queue_env(outgoing, link, env) self.hub.message_helper.queue_env(outgoing, link, env)
+2 -6
View File
@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import os
import threading import threading
from dataclasses import asdict, dataclass, replace from dataclasses import asdict, dataclass, replace
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
@@ -32,7 +31,7 @@ class HubRuntimeConfig:
rate_limit_msgs_per_minute: int = 240 rate_limit_msgs_per_minute: int = 240
ping_interval_s: float = 0.0 ping_interval_s: float = 0.0
ping_timeout_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 max_pending_resource_expectations: int = 8
resource_expectation_ttl_s: float = 30.0 resource_expectation_ttl_s: float = 30.0
enable_resource_transfer: bool = True enable_resource_transfer: bool = True
@@ -69,9 +68,7 @@ class ConfigManager:
data = tomllib.load(f) data = tomllib.load(f)
return data if isinstance(data, dict) else {} return data if isinstance(data, dict) else {}
def apply_config_data( def apply_config_data(self, base: HubRuntimeConfig, data: dict) -> HubRuntimeConfig:
self, base: HubRuntimeConfig, data: dict
) -> HubRuntimeConfig:
"""Apply configuration data from TOML to a runtime config instance.""" """Apply configuration data from TOML to a runtime config instance."""
hub = data.get("hub") if isinstance(data, dict) else None hub = data.get("hub") if isinstance(data, dict) else None
if isinstance(hub, dict): if isinstance(hub, dict):
@@ -95,7 +92,6 @@ class ConfigManager:
data = {**data, **mapped} data = {**data, **mapped}
allowed = set(asdict(base).keys()) allowed = set(asdict(base).keys())
# This identifies where to reload from; do not let the file override it.
allowed.discard("config_path") allowed.discard("config_path")
updates = {k: v for k, v in data.items() if k in allowed} updates = {k: v for k, v in data.items() if k in allowed}
-3
View File
@@ -85,11 +85,8 @@ def validate_envelope(env: dict) -> None:
room = env[K_ROOM] room = env[K_ROOM]
if not isinstance(room, str): if not isinstance(room, str):
raise TypeError("room name must be a string") 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: if K_NICK in env:
nick = env[K_NICK] nick = env[K_NICK]
if not isinstance(nick, str): if not isinstance(nick, str):
raise TypeError("nickname must be a string") 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.
-1
View File
@@ -99,7 +99,6 @@ def configure_logging(
root.setLevel(level) root.setLevel(level)
# Library loggers
logging.getLogger("RNS").setLevel(rns_level) logging.getLogger("RNS").setLevel(rns_level)
logging.captureWarnings(True) logging.captureWarnings(True)
+7 -16
View File
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any
import RNS import RNS
from .codec import encode from .codec import encode
from .constants import T_ERROR, T_NOTICE, T_WELCOME, B_WELCOME_HUB, B_WELCOME_VER from .constants import B_WELCOME_HUB, B_WELCOME_VER, T_ERROR, T_NOTICE, T_WELCOME
from .envelope import make_envelope from .envelope import make_envelope
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -33,10 +33,8 @@ class MessageHelper:
def packet_would_fit(self, link: RNS.Link, payload: bytes) -> bool: def packet_would_fit(self, link: RNS.Link, payload: bytes) -> bool:
"""Check if payload fits within link MDU without creating/packing packets.""" """Check if payload fits within link MDU without creating/packing packets."""
try: try:
# Query link MDU directly if available (more efficient than packing) if hasattr(link, "MDU") and link.MDU is not None:
if hasattr(link, 'MDU') and link.MDU is not None:
return len(payload) <= link.MDU return len(payload) <= link.MDU
# Fall back to packet creation if MDU not available
pkt = RNS.Packet(link, payload) pkt = RNS.Packet(link, payload)
pkt.pack() pkt.pack()
return True return True
@@ -71,15 +69,12 @@ class MessageHelper:
if not text: if not text:
return 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] lines = text.splitlines() or [text]
for line in lines: for line in lines:
remaining = line remaining = line
if not remaining: if not remaining:
continue continue
# Start with a generous chunk size; shrink on demand.
max_chars = min(len(remaining), 512) max_chars = min(len(remaining), 512)
while remaining: while remaining:
take = min(len(remaining), max_chars) take = min(len(remaining), max_chars)
@@ -98,7 +93,6 @@ class MessageHelper:
continue continue
if max_chars <= 1: if max_chars <= 1:
# Nothing we can do; avoid an infinite loop.
self.log.warning( self.log.warning(
"NOTICE chunk would not fit MTU; dropping remainder (%s chars)", "NOTICE chunk would not fit MTU; dropping remainder (%s chars)",
len(remaining), len(remaining),
@@ -121,12 +115,10 @@ class MessageHelper:
from . import __version__ from . import __version__
g = str(motd) if motd else ""
body_w: dict[int, Any] = { body_w: dict[int, Any] = {
B_WELCOME_HUB: self.hub.config.hub_name, B_WELCOME_HUB: self.hub.config.hub_name,
B_WELCOME_VER: str(__version__), B_WELCOME_VER: str(__version__),
} }
# Capabilities are optional; keep WELCOME minimal unless needed.
welcome = make_envelope(T_WELCOME, src=self.hub.identity.hash, body=body_w) welcome = make_envelope(T_WELCOME, src=self.hub.identity.hash, body=body_w)
welcome_payload = encode(welcome) welcome_payload = encode(welcome)
@@ -164,12 +156,14 @@ class MessageHelper:
""" """
from .constants import RES_KIND_MOTD, RES_KIND_NOTICE from .constants import RES_KIND_MOTD, RES_KIND_NOTICE
# Determine resource kind if not specified
resource_kind = kind resource_kind = kind
if resource_kind is None: if resource_kind is None:
resource_kind = RES_KIND_MOTD if msg_type == T_NOTICE and room is None else RES_KIND_NOTICE resource_kind = (
RES_KIND_MOTD
if msg_type == T_NOTICE and room is None
else RES_KIND_NOTICE
)
# Try resource transfer if enabled, outgoing is None, and message is large enough
if ( if (
self.hub.config.enable_resource_transfer self.hub.config.enable_resource_transfer
and outgoing is None and outgoing is None
@@ -201,7 +195,6 @@ class MessageHelper:
self.hub._fmt_link_id(link), self.hub._fmt_link_id(link),
) )
# Fall back to chunking for NOTICE
if msg_type == T_NOTICE: if msg_type == T_NOTICE:
self.log.debug( self.log.debug(
"Falling back to chunking link_id=%s outgoing_is_none=%s", "Falling back to chunking link_id=%s outgoing_is_none=%s",
@@ -224,7 +217,6 @@ class MessageHelper:
else: else:
self.queue_notice_chunks(outgoing, link, room=room, text=text) self.queue_notice_chunks(outgoing, link, room=room, text=text)
else: else:
# For other message types, just drop or log error
self.log.error( self.log.error(
"Message too large and not NOTICE link_id=%s type=%s", "Message too large and not NOTICE link_id=%s type=%s",
self.hub._fmt_link_id(link), self.hub._fmt_link_id(link),
@@ -284,7 +276,6 @@ class MessageHelper:
try: try:
RNS.Packet(link, payload).send() RNS.Packet(link, payload).send()
except OSError as e: except OSError as e:
# Common failure mode on low-MTU links: packet too large.
self.log.warning( self.log.warning(
"Send failed link_id=%s bytes=%s err=%s", "Send failed link_id=%s bytes=%s err=%s",
self.hub._fmt_link_id(link), self.hub._fmt_link_id(link),
+16 -40
View File
@@ -32,6 +32,7 @@ if TYPE_CHECKING:
@dataclass @dataclass
class _ResourceExpectation: class _ResourceExpectation:
"""Tracks an expected incoming Resource transfer.""" """Tracks an expected incoming Resource transfer."""
id: bytes id: bytes
kind: str kind: str
size: int size: int
@@ -48,11 +49,10 @@ class ResourceManager:
def __init__(self, hub: HubService) -> None: def __init__(self, hub: HubService) -> None:
self.hub = hub self.hub = hub
self.log = hub.log self.log = hub.log
self._resource_expectations: dict[
# Resource state RNS.Link, dict[bytes, _ResourceExpectation]
self._resource_expectations: dict[RNS.Link, dict[bytes, _ResourceExpectation]] = {} ] = {}
self._active_resources: dict[RNS.Link, set[RNS.Resource]] = {} 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._resource_bindings: dict[RNS.Resource, bytes] = {}
def on_link_established(self, link: RNS.Link) -> None: def on_link_established(self, link: RNS.Link) -> None:
@@ -90,8 +90,6 @@ class ResourceManager:
e, e,
) )
# Resource expectation management
def cleanup_expired_expectations(self, link: RNS.Link) -> None: def cleanup_expired_expectations(self, link: RNS.Link) -> None:
"""Remove expired resource expectations for a link.""" """Remove expired resource expectations for a link."""
now = time.time() now = time.time()
@@ -116,7 +114,9 @@ class ResourceManager:
if not exp_dict: if not exp_dict:
continue continue
expired = [rid for rid, exp in exp_dict.items() if exp.expires_at <= now] expired = [
rid for rid, exp in exp_dict.items() if exp.expires_at <= now
]
for rid in expired: for rid in expired:
exp_dict.pop(rid, None) exp_dict.pop(rid, None)
self.log.debug( self.log.debug(
@@ -180,7 +180,6 @@ class ResourceManager:
if not exp_dict: if not exp_dict:
return None return None
# Match by size (first match wins)
for exp in exp_dict.values(): for exp in exp_dict.values():
if exp.size == size: if exp.size == size:
return exp return exp
@@ -217,7 +216,6 @@ class ResourceManager:
if not exp_dict: if not exp_dict:
return None return None
# Avoid linear scan if nothing matches by size.
for exp in exp_dict.values(): for exp in exp_dict.values():
if exp.size != size: if exp.size != size:
continue continue
@@ -235,8 +233,6 @@ class ResourceManager:
return None return None
return exp_dict.pop(rid, None) return exp_dict.pop(rid, None)
# Resource transfer callbacks
def _resource_advertised(self, resource: RNS.Resource) -> bool: def _resource_advertised(self, resource: RNS.Resource) -> bool:
""" """
Callback when a Resource is advertised by remote peer. Callback when a Resource is advertised by remote peer.
@@ -246,7 +242,6 @@ class ResourceManager:
""" """
link = resource.link link = resource.link
# Check config outside lock (immutable during runtime)
if not self.hub.config.enable_resource_transfer: if not self.hub.config.enable_resource_transfer:
self.log.debug( self.log.debug(
"Rejecting resource (disabled) link_id=%s", "Rejecting resource (disabled) link_id=%s",
@@ -255,7 +250,6 @@ class ResourceManager:
self.hub.stats_manager.inc("resources_rejected") self.hub.stats_manager.inc("resources_rejected")
return False return False
# Check size limit (immutable config)
size = resource.total_size if hasattr(resource, "total_size") else resource.size size = resource.total_size if hasattr(resource, "total_size") else resource.size
if size > self.hub.config.max_resource_bytes: if size > self.hub.config.max_resource_bytes:
self.log.warning( self.log.warning(
@@ -267,7 +261,6 @@ class ResourceManager:
self.hub.stats_manager.inc("resources_rejected") self.hub.stats_manager.inc("resources_rejected")
return False return False
# Check session exists and find expectation with minimal lock scope
with self.hub._state_lock: with self.hub._state_lock:
sess = self.hub.session_manager.sessions.get(link) sess = self.hub.session_manager.sessions.get(link)
if not sess: if not sess:
@@ -278,10 +271,8 @@ class ResourceManager:
self.hub.stats_manager.inc("resources_rejected") self.hub.stats_manager.inc("resources_rejected")
return False return False
# Find matching expectation
exp = self.find_resource_expectation(link, size) exp = self.find_resource_expectation(link, size)
# Check expectation outside lock
if not exp: if not exp:
self.log.warning( self.log.warning(
"Rejecting resource (no matching expectation) link_id=%s size=%s", "Rejecting resource (no matching expectation) link_id=%s size=%s",
@@ -291,7 +282,6 @@ class ResourceManager:
self.hub.stats_manager.inc("resources_rejected") self.hub.stats_manager.inc("resources_rejected")
return False return False
# Accept and register with minimal lock scope
self.log.info( self.log.info(
"Accepting resource link_id=%s size=%s kind=%s", "Accepting resource link_id=%s size=%s kind=%s",
self.hub._fmt_link_id(link), self.hub._fmt_link_id(link),
@@ -301,8 +291,6 @@ class ResourceManager:
with self.hub._state_lock: with self.hub._state_lock:
self._active_resources.setdefault(link, set()).add(resource) 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 self._resource_bindings[resource] = exp.id
return True return True
@@ -312,7 +300,6 @@ class ResourceManager:
link = resource.link link = resource.link
with self.hub._state_lock: with self.hub._state_lock:
# Remove from active set and retrieve any bound expectation RID.
active_set = self._active_resources.get(link) active_set = self._active_resources.get(link)
if active_set: if active_set:
active_set.discard(resource) active_set.discard(resource)
@@ -326,9 +313,12 @@ class ResourceManager:
) )
return return
# Get payload outside the lock.
try: try:
payload = resource.data.read() if hasattr(resource.data, "read") else resource.data payload = (
resource.data.read()
if hasattr(resource.data, "read")
else resource.data
)
if isinstance(payload, bytearray): if isinstance(payload, bytearray):
payload = bytes(payload) payload = bytes(payload)
except Exception as e: except Exception as e:
@@ -342,8 +332,9 @@ class ResourceManager:
size = len(payload) size = len(payload)
actual_hash = hashlib.sha256(payload).digest() actual_hash = hashlib.sha256(payload).digest()
# Find expectation using bound RID first, then RID lookup, then size/sha fallback. exp = self.match_resource_expectation(
exp = self.match_resource_expectation(link, rid=bound_rid, size=size, sha256=actual_hash) link, rid=bound_rid, size=size, sha256=actual_hash
)
if not exp: if not exp:
self.log.warning( self.log.warning(
"Received resource without expectation link_id=%s size=%s", "Received resource without expectation link_id=%s size=%s",
@@ -352,7 +343,6 @@ class ResourceManager:
) )
return return
# Verify SHA256 if provided; keep expectation if mismatch so sender can retry.
if exp.sha256 and actual_hash != exp.sha256: if exp.sha256 and actual_hash != exp.sha256:
self.log.error( self.log.error(
"Resource SHA256 mismatch link_id=%s expected=%s actual=%s", "Resource SHA256 mismatch link_id=%s expected=%s actual=%s",
@@ -362,7 +352,6 @@ class ResourceManager:
) )
return return
# Pop expectation only after validation succeeds.
self.pop_resource_expectation(link, exp.id) self.pop_resource_expectation(link, exp.id)
self.hub.stats_manager.inc("resources_received") self.hub.stats_manager.inc("resources_received")
@@ -375,7 +364,6 @@ class ResourceManager:
exp.kind, exp.kind,
) )
# Dispatch by kind
try: try:
self._dispatch_received_resource(link, exp, payload) self._dispatch_received_resource(link, exp, payload)
except Exception as e: except Exception as e:
@@ -391,7 +379,6 @@ class ResourceManager:
) -> None: ) -> None:
"""Dispatch a received resource payload to appropriate handler.""" """Dispatch a received resource payload to appropriate handler."""
if exp.kind == RES_KIND_NOTICE: if exp.kind == RES_KIND_NOTICE:
# Decode as text and deliver as notice
encoding = exp.encoding or "utf-8" encoding = exp.encoding or "utf-8"
try: try:
text = payload.decode(encoding) text = payload.decode(encoding)
@@ -411,7 +398,6 @@ class ResourceManager:
len(text), len(text),
) )
# Forward NOTICE to room members if room is specified
if exp.room and self.hub.identity is not None: if exp.room and self.hub.identity is not None:
with self.hub._state_lock: with self.hub._state_lock:
sess = self.hub.session_manager.sessions.get(link) sess = self.hub.session_manager.sessions.get(link)
@@ -427,7 +413,6 @@ class ResourceManager:
) )
notice_payload = encode(notice_env) notice_payload = encode(notice_env)
# Forward to all room members except sender
forwarded = 0 forwarded = 0
for other in room_members: for other in room_members:
if other != link: if other != link:
@@ -450,7 +435,6 @@ class ResourceManager:
) )
elif exp.kind == RES_KIND_MOTD: elif exp.kind == RES_KIND_MOTD:
# Similar to NOTICE
encoding = exp.encoding or "utf-8" encoding = exp.encoding or "utf-8"
try: try:
text = payload.decode(encoding) text = payload.decode(encoding)
@@ -469,7 +453,6 @@ class ResourceManager:
) )
elif exp.kind == RES_KIND_BLOB: elif exp.kind == RES_KIND_BLOB:
# Generic binary data
self.log.info( self.log.info(
"Received BLOB via resource link_id=%s bytes=%s", "Received BLOB via resource link_id=%s bytes=%s",
self.hub._fmt_link_id(link), self.hub._fmt_link_id(link),
@@ -511,13 +494,9 @@ class ResourceManager:
) )
return False return False
# Generate resource ID
rid = os.urandom(8) rid = os.urandom(8)
# Compute SHA256
sha256 = hashlib.sha256(payload).digest() sha256 = hashlib.sha256(payload).digest()
# Send envelope first
if self.hub.identity is None: if self.hub.identity is None:
return False return False
@@ -557,11 +536,8 @@ class ResourceManager:
) )
return False return False
# Create and advertise resource
try: try:
resource = RNS.Resource( resource = RNS.Resource(payload, link, advertise=True, auto_compress=False)
payload, link, advertise=True, auto_compress=False
)
with self.hub._state_lock: with self.hub._state_lock:
self._active_resources.setdefault(link, set()).add(resource) self._active_resources.setdefault(link, set()).add(resource)
+21 -29
View File
@@ -28,13 +28,7 @@ class RoomManager:
def __init__(self, hub: HubService) -> None: def __init__(self, hub: HubService) -> None:
self.hub = hub self.hub = hub
self.log = logging.getLogger("rrcd.rooms") self.log = logging.getLogger("rrcd.rooms")
# Room memberships: room name -> set of links in that room
self.rooms: dict[str, set[RNS.Link]] = {} self.rooms: dict[str, set[RNS.Link]] = {}
# 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_state: dict[str, dict[str, Any]] = {}
self._room_registry: dict[str, dict[str, Any]] = {} self._room_registry: dict[str, dict[str, Any]] = {}
@@ -50,7 +44,9 @@ class RoomManager:
"""Get set of links currently in a room.""" """Get set of links currently in a room."""
return self.rooms.get(room, set()) return self.rooms.get(room, set())
def add_member(self, room: str, link: RNS.Link, *, founder: bytes | None = None) -> None: 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.""" """Add a link to a room, creating the room if needed."""
if room not in self.rooms: if room not in self.rooms:
self.rooms[room] = set() self.rooms[room] = set()
@@ -65,7 +61,6 @@ class RoomManager:
if not self.rooms[room]: if not self.rooms[room]:
self.rooms.pop(room, None) self.rooms.pop(room, None)
st = self._room_state_get(room) st = self._room_state_get(room)
# Clean up unregistered empty rooms
if st is not None and not st.get("registered"): if st is not None and not st.get("registered"):
self._room_state.pop(room, None) self._room_state.pop(room, None)
@@ -94,8 +89,6 @@ class RoomManager:
"top_rooms": top_rooms, "top_rooms": top_rooms,
} }
# Room state management
def _room_state_get(self, room: str) -> dict[str, Any] | None: def _room_state_get(self, room: str) -> dict[str, Any] | None:
"""Get room state dict if it exists.""" """Get room state dict if it exists."""
return self._room_state.get(room) return self._room_state.get(room)
@@ -111,7 +104,6 @@ class RoomManager:
st.setdefault("ops", set()).add(founder) st.setdefault("ops", set()).add(founder)
return st return st
# Load from registry if registered
if room in self._room_registry: if room in self._room_registry:
base = self._room_registry[room] base = self._room_registry[room]
invited = base.get("invited") invited = base.get("invited")
@@ -142,7 +134,6 @@ class RoomManager:
self._room_state[room] = st self._room_state[room] = st
return st return st
# Create new unregistered room
st = { st = {
"founder": founder, "founder": founder,
"registered": False, "registered": False,
@@ -174,8 +165,6 @@ class RoomManager:
except Exception: except Exception:
pass pass
# Room modes and permissions
def get_room_modes(self, room: str) -> dict[str, Any]: def get_room_modes(self, room: str) -> dict[str, Any]:
"""Get dict of room mode flags.""" """Get dict of room mode flags."""
st = self._room_state_ensure(room) st = self._room_state_ensure(room)
@@ -201,7 +190,6 @@ class RoomManager:
"""Get IRC-style mode string for a room.""" """Get IRC-style mode string for a room."""
m = self.get_room_modes(room) m = self.get_room_modes(room)
flags: list[str] = [] flags: list[str] = []
# Keep roughly IRC-ish order.
if m.get("invite_only"): if m.get("invite_only"):
flags.append("i") flags.append("i")
if m.get("has_key"): if m.get("has_key"):
@@ -265,8 +253,6 @@ class RoomManager:
bans = st.get("bans") bans = st.get("bans")
return isinstance(bans, set) and peer_hash in bans return isinstance(bans, set) and peer_hash in bans
# Invite management
def is_invited(self, room: str, peer_hash: bytes) -> bool: def is_invited(self, room: str, peer_hash: bytes) -> bool:
"""Check if peer has a valid (non-expired) invite.""" """Check if peer has a valid (non-expired) invite."""
st = self._room_state_ensure(room) st = self._room_state_ensure(room)
@@ -302,8 +288,6 @@ class RoomManager:
removed_any = True removed_any = True
return removed_any return removed_any
# Room registry persistence
def load_registry_from_path( def load_registry_from_path(
self, path: str, *, invite_timeout_s: float self, path: str, *, invite_timeout_s: float
) -> tuple[dict[str, dict[str, Any]], str | None]: ) -> tuple[dict[str, dict[str, Any]], str | None]:
@@ -360,7 +344,9 @@ class RoomManager:
for op in operators: for op in operators:
if isinstance(op, str): if isinstance(op, str):
try: try:
ops.add(bytes.fromhex(op.strip().lower().removeprefix("0x"))) ops.add(
bytes.fromhex(op.strip().lower().removeprefix("0x"))
)
except Exception: except Exception:
continue continue
@@ -370,7 +356,9 @@ class RoomManager:
for v in voiced_list: for v in voiced_list:
if isinstance(v, str): if isinstance(v, str):
try: try:
voiced.add(bytes.fromhex(v.strip().lower().removeprefix("0x"))) voiced.add(
bytes.fromhex(v.strip().lower().removeprefix("0x"))
)
except Exception: except Exception:
continue continue
@@ -380,7 +368,9 @@ class RoomManager:
for b in bans_list: for b in bans_list:
if isinstance(b, str): if isinstance(b, str):
try: try:
bans.add(bytes.fromhex(b.strip().lower().removeprefix("0x"))) bans.add(
bytes.fromhex(b.strip().lower().removeprefix("0x"))
)
except Exception: except Exception:
continue continue
@@ -390,7 +380,9 @@ class RoomManager:
for h, exp in invited_dict.items(): for h, exp in invited_dict.items():
if isinstance(h, str): if isinstance(h, str):
try: try:
h_bytes = bytes.fromhex(h.strip().lower().removeprefix("0x")) h_bytes = bytes.fromhex(
h.strip().lower().removeprefix("0x")
)
exp_f = float(exp) exp_f = float(exp)
if exp_f > now: if exp_f > now:
invited[h_bytes] = exp_f invited[h_bytes] = exp_f
@@ -567,7 +559,9 @@ class RoomManager:
except Exception: except Exception:
pass pass
except Exception as e: except Exception as e:
self.hub.message_helper.notice_to(link, room, f"room config persist failed: {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: def delete_room_from_registry(self, link: RNS.Link, room: str) -> None:
"""Remove a room from the registry TOML file.""" """Remove a room from the registry TOML file."""
@@ -607,7 +601,9 @@ class RoomManager:
except Exception: except Exception:
pass pass
except Exception as e: except Exception as e:
self.hub.message_helper.notice_to(link, room, f"room unregister persist failed: {e}") self.hub.message_helper.notice_to(
link, room, f"room unregister persist failed: {e}"
)
def prune_unused_registered_rooms( def prune_unused_registered_rooms(
self, prune_after_s: float, started_wall_time: float self, prune_after_s: float, started_wall_time: float
@@ -621,7 +617,6 @@ class RoomManager:
rooms_to_prune: list[str] = [] rooms_to_prune: list[str] = []
for room, reg in list(self._room_registry.items()): for room, reg in list(self._room_registry.items()):
# Skip active rooms
if room in self.rooms and self.rooms.get(room): if room in self.rooms and self.rooms.get(room):
continue continue
@@ -631,13 +626,11 @@ class RoomManager:
except Exception: except Exception:
last_used = None last_used = None
if last_used is None: if last_used is None:
# Never-used rooms are eligible after prune_after from process start
last_used = started_wall_time last_used = started_wall_time
if (now - float(last_used)) < prune_after_s: if (now - float(last_used)) < prune_after_s:
continue continue
# Prune in-memory
self._room_registry.pop(room, None) self._room_registry.pop(room, None)
self._room_state.pop(room, None) self._room_state.pop(room, None)
rooms_to_prune.append(room) rooms_to_prune.append(room)
@@ -656,7 +649,6 @@ class RoomManager:
reg = registry.get(r) reg = registry.get(r)
if reg is None: if reg is None:
# If a room was unregistered on disk, reflect that
if st.get("registered"): if st.get("registered"):
st["registered"] = False st["registered"] = False
continue continue
+15 -47
View File
@@ -5,11 +5,6 @@ from typing import TYPE_CHECKING, Any
import RNS import RNS
class OutgoingList(list):
"""Custom list that allows attaching callback attributes."""
pass
from .codec import decode, encode from .codec import decode, encode
from .constants import ( from .constants import (
B_HELLO_CAPS, B_HELLO_CAPS,
@@ -24,7 +19,6 @@ from .constants import (
K_ROOM, K_ROOM,
K_SRC, K_SRC,
K_T, K_T,
RES_KIND_MOTD,
T_HELLO, T_HELLO,
T_JOIN, T_JOIN,
T_JOINED, T_JOINED,
@@ -39,6 +33,13 @@ from .constants import (
from .envelope import make_envelope, validate_envelope from .envelope import make_envelope, validate_envelope
from .util import normalize_nick from .util import normalize_nick
class OutgoingList(list):
"""Custom list that allows attaching callback attributes."""
pass
if TYPE_CHECKING: if TYPE_CHECKING:
from .service import HubService from .service import HubService
@@ -81,13 +82,11 @@ class MessageRouter:
if peer_hash is None: if peer_hash is None:
ri = link.get_remote_identity() ri = link.get_remote_identity()
if ri is None: if ri is None:
# Per spec: the Link is the handshake. Ignore all traffic until it
# is identified.
return return
peer_hash = ri.hash peer_hash = ri.hash
sess["peer"] = peer_hash sess["peer"] = peer_hash
if not self.hub._refill_and_take(link, 1.0): if not self.hub.session_manager.refill_and_take(link, 1.0):
self.hub.stats_manager.inc("rate_limited") self.hub.stats_manager.inc("rate_limited")
if self.log.isEnabledFor(logging.DEBUG): if self.log.isEnabledFor(logging.DEBUG):
self.log.debug( self.log.debug(
@@ -140,7 +139,6 @@ class MessageRouter:
body_len, body_len,
) )
# Dispatch by message type
if t == T_PONG: if t == T_PONG:
self._handle_pong(link, sess) self._handle_pong(link, sess)
elif t == T_RESOURCE_ENVELOPE: elif t == T_RESOURCE_ENVELOPE:
@@ -202,7 +200,6 @@ class MessageRouter:
sha256 = body.get(B_RES_SHA256) sha256 = body.get(B_RES_SHA256)
encoding = body.get(B_RES_ENCODING) encoding = body.get(B_RES_ENCODING)
# Validate required fields
if not isinstance(rid, (bytes, bytearray)): if not isinstance(rid, (bytes, bytearray)):
if self.hub.identity is not None: if self.hub.identity is not None:
self.hub.message_helper.emit_error( self.hub.message_helper.emit_error(
@@ -236,7 +233,6 @@ class MessageRouter:
) )
return return
# Check size limit
if size > self.hub.config.max_resource_bytes: if size > self.hub.config.max_resource_bytes:
if self.hub.identity is not None: if self.hub.identity is not None:
self.hub.message_helper.emit_error( self.hub.message_helper.emit_error(
@@ -248,7 +244,6 @@ class MessageRouter:
) )
return return
# Validate optional fields
if sha256 is not None and not isinstance(sha256, (bytes, bytearray)): if sha256 is not None and not isinstance(sha256, (bytes, bytearray)):
if self.hub.identity is not None: if self.hub.identity is not None:
self.hub.message_helper.emit_error( self.hub.message_helper.emit_error(
@@ -263,7 +258,6 @@ class MessageRouter:
if encoding is not None and not isinstance(encoding, str): if encoding is not None and not isinstance(encoding, str):
encoding = None encoding = None
# Add expectation
if not self.hub.resource_manager.add_resource_expectation( if not self.hub.resource_manager.add_resource_expectation(
link, link,
rid=bytes(rid), rid=bytes(rid),
@@ -314,7 +308,6 @@ class MessageRouter:
if isinstance(body, dict): if isinstance(body, dict):
sess["peer_caps"] = self._extract_caps(body) 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: if new_nick is None:
legacy_nick = body.get(B_HELLO_NICK_LEGACY) legacy_nick = body.get(B_HELLO_NICK_LEGACY)
n2 = normalize_nick( n2 = normalize_nick(
@@ -331,7 +324,6 @@ class MessageRouter:
self.hub._fmt_link_id(link), self.hub._fmt_link_id(link),
) )
# Send welcome message and MOTD
self.hub.session_manager.send_welcome( self.hub.session_manager.send_welcome(
link, link,
outgoing, outgoing,
@@ -355,7 +347,6 @@ class MessageRouter:
if self.hub.identity is None: if self.hub.identity is None:
return return
# Reset session state and process as new HELLO
old_nick = sess.get("nick") old_nick = sess.get("nick")
old_rooms = set(sess.get("rooms", set())) old_rooms = set(sess.get("rooms", set()))
sess["welcomed"] = False sess["welcomed"] = False
@@ -363,13 +354,11 @@ class MessageRouter:
sess["nick"] = None sess["nick"] = None
sess["peer_caps"] = {} sess["peer_caps"] = {}
# Remove this link from all room membership sets and prune empties.
for r in old_rooms: for r in old_rooms:
self.hub.room_manager.remove_member(r, link) self.hub.room_manager.remove_member(r, link)
new_nick = None new_nick = None
# Process the HELLO message
if isinstance(nick, str): if isinstance(nick, str):
n = normalize_nick(nick, max_chars=self.hub.config.nick_max_chars) n = normalize_nick(nick, max_chars=self.hub.config.nick_max_chars)
if n is not None: if n is not None:
@@ -394,7 +383,6 @@ class MessageRouter:
self.hub._fmt_link_id(link), self.hub._fmt_link_id(link),
) )
# Send welcome message and MOTD
self.hub.session_manager.send_welcome( self.hub.session_manager.send_welcome(
link, link,
outgoing, outgoing,
@@ -442,13 +430,11 @@ class MessageRouter:
) )
return return
# If room is registered, load its state now.
if r in self.hub.room_manager._room_registry: if r in self.hub.room_manager._room_registry:
self.hub.room_manager._room_state_ensure(r) self.hub.room_manager._room_state_ensure(r)
st = self.hub.room_manager._room_state_ensure(r) st = self.hub.room_manager._room_state_ensure(r)
# +i invite-only
if bool(st.get("invite_only", False)): if bool(st.get("invite_only", False)):
is_invited = self.hub.room_manager.is_invited(r, peer_hash) 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 not self.hub.room_manager.is_room_op(r, peer_hash) and not is_invited:
@@ -462,7 +448,6 @@ class MessageRouter:
) )
return return
# +k key/password (JOIN body must be the key string)
key = st.get("key") key = st.get("key")
if isinstance(key, str) and key: if isinstance(key, str) and key:
is_invited = self.hub.room_manager.is_invited(r, peer_hash) is_invited = self.hub.room_manager.is_invited(r, peer_hash)
@@ -479,7 +464,6 @@ class MessageRouter:
) )
return return
# Room bans are room-local and apply to JOIN.
if self.hub.room_manager.is_room_banned(r, peer_hash): if self.hub.room_manager.is_room_banned(r, peer_hash):
if self.hub.identity is not None: if self.hub.identity is not None:
self.hub.message_helper.emit_error( self.hub.message_helper.emit_error(
@@ -491,9 +475,8 @@ class MessageRouter:
) )
return return
# If the room doesn't exist yet (in-memory), the first joiner is the founder.
if not self.hub.room_manager.get_room_members(r): if not self.hub.room_manager.get_room_members(r):
pass # room created by add_member pass
self.hub.room_manager._room_state_ensure(r, founder=peer_hash) self.hub.room_manager._room_state_ensure(r, founder=peer_hash)
sess["rooms"].add(r) sess["rooms"].add(r)
@@ -524,7 +507,6 @@ class MessageRouter:
) )
self.hub.message_helper.queue_env(outgoing, link, joined) self.hub.message_helper.queue_env(outgoing, link, joined)
# Consume invite on successful join.
try: try:
inv = st.get("invited") inv = st.get("invited")
if isinstance(inv, dict) and peer_hash in inv: if isinstance(inv, dict) and peer_hash in inv:
@@ -593,7 +575,6 @@ class MessageRouter:
if st is not None and not st.get("registered"): if st is not None and not st.get("registered"):
self.hub.room_manager._room_state.pop(r, None) self.hub.room_manager._room_state.pop(r, None)
# Per spec: acknowledge PART with PARTED.
parted_body = None parted_body = None
if self.hub.config.include_joined_member_list: if self.hub.config.include_joined_member_list:
members: list[bytes] = [] members: list[bytes] = []
@@ -631,12 +612,9 @@ class MessageRouter:
room = env.get(K_ROOM) room = env.get(K_ROOM)
body = env.get(K_BODY) body = env.get(K_BODY)
# 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): if isinstance(body, str):
cmdline = body.strip() cmdline = body.strip()
if cmdline.startswith("/"): if cmdline.startswith("/"):
# It's a slash command - attempt to handle it
if self.log.isEnabledFor(logging.DEBUG): if self.log.isEnabledFor(logging.DEBUG):
self.log.debug( self.log.debug(
"Slash command peer=%s link_id=%s cmd=%r room=%r", "Slash command peer=%s link_id=%s cmd=%r room=%r",
@@ -655,7 +633,6 @@ class MessageRouter:
len(outgoing), len(outgoing),
) )
return return
# Unrecognized slash command - send error
if self.hub.identity is not None: if self.hub.identity is not None:
self.hub.message_helper.emit_error( self.hub.message_helper.emit_error(
outgoing, outgoing,
@@ -666,8 +643,6 @@ class MessageRouter:
) )
return 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 t == T_MSG:
if not isinstance(room, str) or not room: if not isinstance(room, str) or not room:
if self.hub.identity is not None: if self.hub.identity is not None:
@@ -679,7 +654,6 @@ class MessageRouter:
) )
return return
elif t == T_NOTICE: elif t == T_NOTICE:
# NOTICE without a room is allowed - just don't forward it anywhere
if not isinstance(room, str) or not room: if not isinstance(room, str) or not room:
return return
@@ -693,8 +667,6 @@ class MessageRouter:
return return
if r not in sess["rooms"]: 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 st = None
if r in self.hub.room_manager._room_registry: if r in self.hub.room_manager._room_registry:
st = self.hub.room_manager._room_state_ensure(r) st = self.hub.room_manager._room_state_ensure(r)
@@ -723,7 +695,6 @@ class MessageRouter:
) )
return return
# Per-room moderation: bans and moderated mode.
if self.hub.room_manager.is_room_banned(r, peer_hash): if self.hub.room_manager.is_room_banned(r, peer_hash):
if self.hub.identity is not None: if self.hub.identity is not None:
self.hub.message_helper.emit_error( self.hub.message_helper.emit_error(
@@ -734,7 +705,9 @@ class MessageRouter:
room=r, room=r,
) )
return 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.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: if self.hub.identity is not None:
self.hub.message_helper.emit_error( self.hub.message_helper.emit_error(
outgoing, outgoing,
@@ -753,25 +726,20 @@ class MessageRouter:
) )
env[K_ROOM] = r 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) incoming_nick = env.get(K_NICK)
if incoming_nick is not None: 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.hub.config.nick_max_chars) n = normalize_nick(incoming_nick, max_chars=self.hub.config.nick_max_chars)
if n is not None: if n is not None:
# Update session nick and index if it changed
old_session_nick = sess.get("nick") old_session_nick = sess.get("nick")
if old_session_nick != n: if old_session_nick != n:
sess["nick"] = n sess["nick"] = n
self.hub.session_manager.update_nick_index(link, old_session_nick, n) self.hub.session_manager.update_nick_index(
link, old_session_nick, n
)
env[K_NICK] = n env[K_NICK] = n
else: else:
# Invalid nickname provided - remove it
env.pop(K_NICK, None) env.pop(K_NICK, None)
else: else:
# No nickname in message - use session nickname from HELLO if available
nick = sess.get("nick") nick = sess.get("nick")
n = normalize_nick(nick, max_chars=self.hub.config.nick_max_chars) n = normalize_nick(nick, max_chars=self.hub.config.nick_max_chars)
if n is not None: if n is not None:
+37 -163
View File
@@ -5,24 +5,15 @@ import os
import signal import signal
import threading import threading
import time import time
from dataclasses import asdict, replace
from typing import Any from typing import Any
import RNS import RNS
from . import __version__
from .codec import encode from .codec import encode
from .commands import CommandHandler from .commands import CommandHandler
from .config import ConfigManager, HubRuntimeConfig from .config import ConfigManager, HubRuntimeConfig
from .constants import ( from .constants import (
B_WELCOME_HUB,
B_WELCOME_VER,
RES_KIND_MOTD,
RES_KIND_NOTICE,
T_ERROR,
T_NOTICE,
T_PING, T_PING,
T_WELCOME,
) )
from .envelope import make_envelope from .envelope import make_envelope
from .logging_config import configure_logging from .logging_config import configure_logging
@@ -40,95 +31,24 @@ class HubService:
def __init__(self, config: HubRuntimeConfig) -> None: def __init__(self, config: HubRuntimeConfig) -> None:
self.config = config self.config = config
self.log = logging.getLogger("rrcd.hub") 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._state_lock = threading.RLock()
self._shutdown = threading.Event() self._shutdown = threading.Event()
# Message router for handling protocol messages
self.router = MessageRouter(self) self.router = MessageRouter(self)
# Session manager for connection lifecycle
self.session_manager = SessionManager(self) self.session_manager = SessionManager(self)
# Command handler for operator commands
self.command_handler = CommandHandler(self) self.command_handler = CommandHandler(self)
# Resource manager for file/data transfers
self.resource_manager = ResourceManager(self) self.resource_manager = ResourceManager(self)
# Room manager for room memberships and permissions
self.room_manager = RoomManager(self) self.room_manager = RoomManager(self)
# Stats manager for metrics and reporting
self.stats_manager = StatsManager(self) self.stats_manager = StatsManager(self)
# Trust manager for trusted/banned identities
self.trust_manager = TrustManager(self) self.trust_manager = TrustManager(self)
# Config manager for configuration loading and reloading
self.config_manager = ConfigManager(self) self.config_manager = ConfigManager(self)
# Message helper for sending and queueing messages
self.message_helper = MessageHelper(self) self.message_helper = MessageHelper(self)
self.identity: RNS.Identity | None = None self.identity: RNS.Identity | None = None
self.destination: RNS.Destination | None = None self.destination: RNS.Destination | None = None
self._prune_thread: threading.Thread | None = None self._prune_thread: threading.Thread | None = None
self._ping_thread: threading.Thread | None = None self._ping_thread: threading.Thread | None = None
self._announce_thread: threading.Thread | None = None self._announce_thread: threading.Thread | None = None
self._resource_cleanup_thread: threading.Thread | None = None self._resource_cleanup_thread: threading.Thread | None = None
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 _update_nick_index(self, link: RNS.Link, old_nick: str | None, new_nick: str | None) -> None:
"""Update nick index when a nick changes. Delegates to SessionManager."""
self.session_manager.update_nick_index(link, old_nick, new_nick)
# Resource transfer methods - delegates to message_helper for smart sending
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:
"""Delegate to message_helper for smart text sending."""
self.message_helper.send_text_smart(
link,
msg_type=msg_type,
text=text,
room=room,
kind=kind,
outgoing=outgoing,
encoding=encoding,
)
def start(self) -> None: def start(self) -> None:
self.log.info("Starting Reticulum") self.log.info("Starting Reticulum")
if self.stats_manager.started_wall_time is None: if self.stats_manager.started_wall_time is None:
@@ -199,10 +119,11 @@ class HubService:
) )
self._prune_thread.start() self._prune_thread.start()
# Start resource cleanup thread if resource transfer is enabled
if self.config.enable_resource_transfer: if self.config.enable_resource_transfer:
self._resource_cleanup_thread = threading.Thread( 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() self._resource_cleanup_thread.start()
@@ -276,7 +197,6 @@ class HubService:
return b return b
def _ensure_worker_threads(self) -> None: def _ensure_worker_threads(self) -> None:
# Announce loop
if self._announce_thread is None or not self._announce_thread.is_alive(): if self._announce_thread is None or not self._announce_thread.is_alive():
if ( if (
self.config.announce_period_s self.config.announce_period_s
@@ -289,7 +209,6 @@ class HubService:
) )
self._announce_thread.start() self._announce_thread.start()
# Ping loop
if self._ping_thread is None or not self._ping_thread.is_alive(): 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: if self.config.ping_interval_s and float(self.config.ping_interval_s) > 0:
self._ping_thread = threading.Thread( self._ping_thread = threading.Thread(
@@ -297,7 +216,6 @@ class HubService:
) )
self._ping_thread.start() self._ping_thread.start()
# Prune loop
if self._prune_thread is None or not self._prune_thread.is_alive(): if self._prune_thread is None or not self._prune_thread.is_alive():
if ( if (
self.config.room_registry_prune_interval_s self.config.room_registry_prune_interval_s
@@ -312,6 +230,21 @@ class HubService:
) )
self._prune_thread.start() 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( def _reload_config_and_rooms(
self, self,
link: RNS.Link, link: RNS.Link,
@@ -320,7 +253,7 @@ class HubService:
) -> None: ) -> None:
cfg_path = self.config_manager.get_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): 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" outgoing, link, room, "reload failed: config_path not set or missing"
) )
return return
@@ -331,17 +264,15 @@ class HubService:
old_banned = set(self.trust_manager._banned) old_banned = set(self.trust_manager._banned)
old_registry = dict(self.room_manager._room_registry) old_registry = dict(self.room_manager._room_registry)
# Stage config parse
try: try:
data = self.config_manager.load_toml(cfg_path) data = self.config_manager.load_toml(cfg_path)
new_cfg = self.config_manager.apply_config_data(old_cfg, data) new_cfg = self.config_manager.apply_config_data(old_cfg, data)
except Exception as e: except Exception as e:
self._emit_notice( self.message_helper.emit_notice(
outgoing, link, room, f"reload failed: config parse error: {e}" outgoing, link, room, f"reload failed: config parse error: {e}"
) )
return return
# Stage identity lists
try: try:
new_trusted = { new_trusted = {
self._parse_identity_hash(h) self._parse_identity_hash(h)
@@ -354,12 +285,11 @@ class HubService:
if str(h).strip() if str(h).strip()
} }
except Exception as e: except Exception as e:
self._emit_notice( self.message_helper.emit_notice(
outgoing, link, room, f"reload failed: identity list parse error: {e}" outgoing, link, room, f"reload failed: identity list parse error: {e}"
) )
return return
# Stage room registry parse (strict)
reg_path = ( reg_path = (
expand_path(str(new_cfg.room_registry_path)) expand_path(str(new_cfg.room_registry_path))
if new_cfg.room_registry_path if new_cfg.room_registry_path
@@ -370,30 +300,29 @@ class HubService:
invite_timeout_s=new_cfg.room_invite_timeout_s, invite_timeout_s=new_cfg.room_invite_timeout_s,
) )
if reg_err is not None: 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 return
with self._state_lock: with self._state_lock:
# Apply (all-or-nothing)
self.config = new_cfg self.config = new_cfg
self.trust_manager._trusted = new_trusted self.trust_manager._trusted = new_trusted
self.trust_manager._banned = new_banned self.trust_manager._banned = new_banned
self.room_manager._room_registry = new_registry self.room_manager._room_registry = new_registry
# Merge registry into live per-room state (for active rooms).
# This makes /reload take effect immediately for existing members.
self.room_manager.merge_registry_into_state(new_registry) self.room_manager.merge_registry_into_state(new_registry)
self._ensure_worker_threads() self._ensure_worker_threads()
# Apply logging changes immediately.
try: try:
configure_logging(self.config) configure_logging(self.config)
except Exception: except Exception:
self.log.exception("Failed to reconfigure logging") self.log.exception("Failed to reconfigure logging")
cfg_changes = self.config_manager.diff_config_summary(old_cfg, new_cfg) cfg_changes = self.config_manager.diff_config_summary(old_cfg, new_cfg)
room_changes = self.room_manager.diff_registry_summary(old_registry, new_registry) room_changes = self.room_manager.diff_registry_summary(
old_registry, new_registry
)
lines: list[str] = [] lines: list[str] = []
lines.append( lines.append(
@@ -415,7 +344,7 @@ class HubService:
lines.append("rooms_changes:") lines.append("rooms_changes:")
lines.extend(f"- {x}" for x in room_changes) lines.extend(f"- {x}" for x in room_changes)
self._emit_notice(outgoing, link, room, "\n".join(lines)) self.message_helper.emit_notice(outgoing, link, room, "\n".join(lines))
def _load_registered_rooms_from_registry(self) -> None: def _load_registered_rooms_from_registry(self) -> None:
reg_path = self.room_manager.get_registry_path_for_writes() reg_path = self.room_manager.get_registry_path_for_writes()
@@ -455,16 +384,13 @@ class HubService:
matches = self._find_target_links(token, room=room) matches = self._find_target_links(token, room=room)
if len(matches) == 1: if len(matches) == 1:
# Exactly one match - get hash from session
s = self.session_manager.sessions.get(matches[0]) s = self.session_manager.sessions.get(matches[0])
ph = s.get("peer") if s else None ph = s.get("peer") if s else None
if isinstance(ph, (bytes, bytearray)): if isinstance(ph, (bytes, bytearray)):
return (bytes(ph), matches) return (bytes(ph), matches)
elif len(matches) > 1: elif len(matches) > 1:
# Ambiguous - return None hash but provide matches for error message
return (None, matches) return (None, matches)
# No matches from nick/hash-prefix lookup - try raw hash parse
try: try:
h = self._parse_identity_hash(token) h = self._parse_identity_hash(token)
return (h, []) return (h, [])
@@ -474,7 +400,6 @@ class HubService:
def _resource_cleanup_loop(self) -> None: def _resource_cleanup_loop(self) -> None:
"""Periodically cleanup expired resource expectations.""" """Periodically cleanup expired resource expectations."""
while not self._shutdown.is_set(): while not self._shutdown.is_set():
# Run cleanup every 30 seconds
time.sleep(30.0) time.sleep(30.0)
if self._shutdown.is_set(): if self._shutdown.is_set():
break break
@@ -525,7 +450,6 @@ class HubService:
) )
) )
# Set up resource callbacks
self.resource_manager.configure_link_callbacks(link) self.resource_manager.configure_link_callbacks(link)
self.log.info("Link established link_id=%s", self._fmt_link_id(link)) self.log.info("Link established link_id=%s", self._fmt_link_id(link))
@@ -536,7 +460,9 @@ class HubService:
banned = False banned = False
peer_hash = None peer_hash = None
with self._state_lock: with self._state_lock:
banned, peer_hash = self.session_manager.on_remote_identified(link, identity) banned, peer_hash = self.session_manager.on_remote_identified(
link, identity
)
if banned: if banned:
self.log.warning( self.log.warning(
@@ -546,7 +472,9 @@ class HubService:
) )
if self.identity is not None: if self.identity is not None:
try: try:
self._error(link, src=self.identity.hash, text="banned") self.message_helper.error(
link, src=self.identity.hash, text="banned"
)
except Exception: except Exception:
pass pass
try: try:
@@ -560,7 +488,6 @@ class HubService:
rooms_count = 0 rooms_count = 0
with self._state_lock: with self._state_lock:
# Clean up resource and session state
self.resource_manager.on_link_closed(link) self.resource_manager.on_link_closed(link)
peer, nick, rooms_count = self.session_manager.on_link_closed(link) peer, nick, rooms_count = self.session_manager.on_link_closed(link)
@@ -572,38 +499,6 @@ class HubService:
self._fmt_link_id(link), self._fmt_link_id(link),
) )
def _send(self, link: RNS.Link, env: dict) -> None:
"""Delegate to message_helper for sending."""
self.message_helper.send(link, env)
def _error(
self, link: RNS.Link, src: bytes, text: str, room: str | None = None
) -> None:
"""Delegate to message_helper for error sending."""
self.message_helper.error(link, src, text, room)
def _emit_error(
self,
outgoing: list[tuple[RNS.Link, bytes]] | None,
link: RNS.Link,
*,
src: bytes,
text: str,
room: str | None = None,
) -> None:
"""Delegate to message_helper for error emission."""
self.message_helper.emit_error(outgoing, link, src=src, text=text, room=room)
def _emit_notice(
self,
outgoing: list[tuple[RNS.Link, bytes]] | None,
link: RNS.Link,
room: str | None,
text: str,
) -> None:
"""Delegate to message_helper for notice emission."""
self.message_helper.emit_notice(outgoing, link, room, text)
def _norm_room(self, room: str) -> str: def _norm_room(self, room: str) -> str:
r = room.strip().lower() r = room.strip().lower()
if not r: if not r:
@@ -612,17 +507,10 @@ class HubService:
raise ValueError("room name too long") raise ValueError("room name too long")
return r return r
def _refill_and_take(self, link: RNS.Link, cost: float = 1.0) -> bool:
"""Token bucket rate limiting. Delegates to SessionManager."""
return self.session_manager.refill_and_take(link, cost)
def _on_packet(self, link: RNS.Link, data: bytes) -> None: 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]] = OutgoingList() outgoing: list[tuple[RNS.Link, bytes]] = OutgoingList()
with self._state_lock: 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: if self.log.isEnabledFor(logging.DEBUG) and outgoing:
self.log.debug( self.log.debug(
@@ -650,27 +538,13 @@ class HubService:
exc_info=True, exc_info=True,
) )
# Execute any post-send callbacks (e.g., for MOTD after WELCOME) if hasattr(outgoing, "_post_send_callbacks"):
if hasattr(outgoing, '_post_send_callbacks'):
for callback in outgoing._post_send_callbacks: # type: ignore for callback in outgoing._post_send_callbacks: # type: ignore
try: try:
callback() callback()
except Exception: except Exception:
self.log.exception("Post-send callback failed") self.log.exception("Post-send callback failed")
def _on_packet_locked(
self,
link: RNS.Link,
data: bytes,
outgoing: list[tuple[RNS.Link, bytes]],
) -> None:
"""
Handle incoming packet with state lock held.
Delegates to MessageRouter for message routing and dispatching.
"""
self.router.route_packet(link, data, outgoing)
def _ping_loop(self) -> None: def _ping_loop(self) -> None:
while not self._shutdown.is_set(): while not self._shutdown.is_set():
interval = float(self.config.ping_interval_s) interval = float(self.config.ping_interval_s)
@@ -715,6 +589,6 @@ class HubService:
ping = make_envelope(T_PING, src=self.identity.hash, body=now) ping = make_envelope(T_PING, src=self.identity.hash, body=now)
try: try:
self.stats_manager.inc("pings_out") self.stats_manager.inc("pings_out")
self._send(link, ping) self.message_helper.send(link, ping)
except Exception: except Exception:
pass pass
+4 -19
View File
@@ -14,6 +14,7 @@ if TYPE_CHECKING:
@dataclass @dataclass
class _RateState: class _RateState:
"""Token bucket state for rate limiting.""" """Token bucket state for rate limiting."""
tokens: float tokens: float
last_refill: float last_refill: float
@@ -34,14 +35,8 @@ class SessionManager:
def __init__(self, hub: HubService) -> None: def __init__(self, hub: HubService) -> None:
self.hub = hub self.hub = hub
self.log = logging.getLogger("rrcd.session") self.log = logging.getLogger("rrcd.session")
# Session state storage (keyed by RNS.Link)
self.sessions: dict[RNS.Link, dict[str, Any]] = {} self.sessions: dict[RNS.Link, dict[str, Any]] = {}
# Rate limiting state
self._rate: dict[RNS.Link, _RateState] = {} self._rate: dict[RNS.Link, _RateState] = {}
# Secondary indexes for efficient lookups
self._index_by_hash: dict[bytes, RNS.Link] = {} # identity hash -> link self._index_by_hash: dict[bytes, RNS.Link] = {} # identity hash -> link
self._index_by_nick: dict[str, set[RNS.Link]] = {} # normalized nick -> links self._index_by_nick: dict[str, set[RNS.Link]] = {} # normalized nick -> links
@@ -86,10 +81,8 @@ class SessionManager:
peer_hash = identity.hash peer_hash = identity.hash
sess["peer"] = peer_hash sess["peer"] = peer_hash
# Update hash index
self._index_by_hash[bytes(peer_hash)] = link self._index_by_hash[bytes(peer_hash)] = link
# Check if banned
banned = self.hub.trust_manager.is_banned(bytes(peer_hash)) banned = self.hub.trust_manager.is_banned(bytes(peer_hash))
if not banned: if not banned:
@@ -121,14 +114,12 @@ class SessionManager:
nick = sess.get("nick") nick = sess.get("nick")
rooms_count = len(sess.get("rooms") or ()) rooms_count = len(sess.get("rooms") or ())
# Clean up indexes
if isinstance(peer, (bytes, bytearray)): if isinstance(peer, (bytes, bytearray)):
self._index_by_hash.pop(bytes(peer), None) self._index_by_hash.pop(bytes(peer), None)
if nick: if nick:
self.update_nick_index(link, nick, None) self.update_nick_index(link, nick, None)
# Clean up room memberships
for room in list(sess["rooms"]): for room in list(sess["rooms"]):
self.hub.room_manager.remove_member(room, link) self.hub.room_manager.remove_member(room, link)
@@ -142,7 +133,6 @@ class SessionManager:
Must be called with state lock held. Must be called with state lock held.
""" """
# Remove old nick mapping
if old_nick: if old_nick:
old_key = old_nick.strip().lower() old_key = old_nick.strip().lower()
if old_key in self._index_by_nick: if old_key in self._index_by_nick:
@@ -150,7 +140,6 @@ class SessionManager:
if not self._index_by_nick[old_key]: if not self._index_by_nick[old_key]:
self._index_by_nick.pop(old_key, None) self._index_by_nick.pop(old_key, None)
# Add new nick mapping
if new_nick: if new_nick:
new_key = new_nick.strip().lower() new_key = new_nick.strip().lower()
self._index_by_nick.setdefault(new_key, set()).add(link) self._index_by_nick.setdefault(new_key, set()).add(link)
@@ -247,14 +236,11 @@ class SessionManager:
if sess is None: if sess is None:
return return
# Update nick index if nick changed
if old_nick != new_nick: if old_nick != new_nick:
self.update_nick_index(link, old_nick, new_nick) self.update_nick_index(link, old_nick, new_nick)
# Mark session as welcomed
sess["welcomed"] = True sess["welcomed"] = True
# Queue WELCOME message
self.hub.message_helper.queue_welcome( self.hub.message_helper.queue_welcome(
outgoing, outgoing,
link, link,
@@ -262,9 +248,8 @@ class SessionManager:
motd=self.hub.config.greeting, motd=self.hub.config.greeting,
) )
# Send MOTD after WELCOME (outside of outgoing queue to enable resource transfer)
# The outgoing queue will be sent first, then this callback will send the MOTD
if self.hub.config.greeting: if self.hub.config.greeting:
def send_motd(): def send_motd():
self.hub.message_helper.send_text_smart( self.hub.message_helper.send_text_smart(
link, link,
@@ -273,7 +258,7 @@ class SessionManager:
room=None, room=None,
kind=RES_KIND_MOTD, kind=RES_KIND_MOTD,
) )
# Store callback to be executed after outgoing packets are sent
if not hasattr(outgoing, '_post_send_callbacks'): if not hasattr(outgoing, "_post_send_callbacks"):
outgoing._post_send_callbacks = [] # type: ignore outgoing._post_send_callbacks = [] # type: ignore
outgoing._post_send_callbacks.append(send_motd) # type: ignore outgoing._post_send_callbacks.append(send_motd) # type: ignore
-3
View File
@@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import threading
import time import time
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -33,8 +32,6 @@ class StatsManager:
self.started_wall_time: float | None = None self.started_wall_time: float | None = None
self.started_monotonic: 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] = { self._counters: dict[str, int] = {
"bytes_in": 0, "bytes_in": 0,
"bytes_out": 0, "bytes_out": 0,
+7 -2
View File
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
import RNS import RNS
from .service import HubService from .service import HubService
@@ -28,7 +29,9 @@ class TrustManager:
self._trusted: set[bytes] = set() self._trusted: set[bytes] = set()
self._banned: set[bytes] = set() self._banned: set[bytes] = set()
def load_from_config(self, trusted_list: list[str] | None, banned_list: list[str] | None) -> None: def load_from_config(
self, trusted_list: list[str] | None, banned_list: list[str] | None
) -> None:
"""Load trusted and banned identities from config lists.""" """Load trusted and banned identities from config lists."""
self._trusted = { self._trusted = {
self.hub._parse_identity_hash(h) self.hub._parse_identity_hash(h)
@@ -77,7 +80,9 @@ class TrustManager:
"banned_count": len(self._banned), "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]]: 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. Update trusted and banned lists from config.
Returns the old (trusted, banned) sets for comparison. Returns the old (trusted, banned) sets for comparison.
-2
View File
@@ -25,8 +25,6 @@ def normalize_nick(value, *, max_chars: int = _DEFAULT_NICK_MAX_CHARS) -> str |
if limit > 0 and len(s) > limit: if limit > 0 and len(s) > limit:
return None 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: if "\n" in s or "\r" in s or "\x00" in s:
return None return None