mirror of
https://github.com/kc1awv/rrcd.git
synced 2026-07-15 04:38:11 -07:00
cleanup, consolidation, remove unneeded delegations
This commit is contained in:
+227
-131
@@ -30,7 +30,7 @@ class CommandHandler:
|
|||||||
outgoing: list[tuple[RNS.Link, bytes]] | None = None,
|
outgoing: list[tuple[RNS.Link, bytes]] | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Handle an operator command.
|
"""Handle an operator command.
|
||||||
|
|
||||||
Returns True if it was a recognized command (handled). Unknown commands
|
Returns True if it was a recognized command (handled). Unknown commands
|
||||||
return False so the message can be forwarded as normal chat.
|
return False so the message can be forwarded as normal chat.
|
||||||
"""
|
"""
|
||||||
@@ -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,20 +69,19 @@ 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():
|
||||||
if st.get("registered") and not st.get("private"):
|
if st.get("registered") and not st.get("private"):
|
||||||
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,20 +89,20 @@ 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:
|
||||||
lines.append(f" {room_name} - {topic}")
|
lines.append(f" {room_name} - {topic}")
|
||||||
else:
|
else:
|
||||||
lines.append(f" {room_name}")
|
lines.append(f" {room_name}")
|
||||||
|
|
||||||
self.hub.message_helper.emit_notice(outgoing, link, None, "\n".join(lines))
|
self.hub.message_helper.emit_notice(outgoing, link, None, "\n".join(lines))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -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:
|
||||||
@@ -500,14 +555,19 @@ class CommandHandler:
|
|||||||
room=r,
|
room=r,
|
||||||
)
|
)
|
||||||
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
|
||||||
|
|
||||||
st = self.hub.room_manager._room_state_ensure(r)
|
st = self.hub.room_manager._room_state_ensure(r)
|
||||||
founder = st.get("founder")
|
founder = st.get("founder")
|
||||||
founder_b = (
|
founder_b = (
|
||||||
@@ -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)
|
||||||
@@ -1014,29 +1111,30 @@ class CommandHandler:
|
|||||||
prefix = bytes.fromhex(hex_candidate)
|
prefix = bytes.fromhex(hex_candidate)
|
||||||
except Exception:
|
except Exception:
|
||||||
prefix = None
|
prefix = None
|
||||||
|
|
||||||
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:
|
||||||
@@ -1045,16 +1143,14 @@ class CommandHandler:
|
|||||||
matches.append(candidate_link)
|
matches.append(candidate_link)
|
||||||
else:
|
else:
|
||||||
matches = list(candidate_links)
|
matches = list(candidate_links)
|
||||||
|
|
||||||
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"
|
||||||
|
|
||||||
with self.hub._state_lock:
|
with self.hub._state_lock:
|
||||||
items = []
|
items = []
|
||||||
for match_link in matches:
|
for match_link in matches:
|
||||||
@@ -1066,10 +1162,10 @@ class CommandHandler:
|
|||||||
hash_str = self.hub._fmt_hash(peer, prefix=16) if peer else "?"
|
hash_str = self.hub._fmt_hash(peer, prefix=16) if peer else "?"
|
||||||
nick_str = f"nick={nick!r}" if nick else "(no nick)"
|
nick_str = f"nick={nick!r}" if nick else "(no nick)"
|
||||||
items.append(f"{hash_str} {nick_str}")
|
items.append(f"{hash_str} {nick_str}")
|
||||||
|
|
||||||
if len(items) == 0:
|
if len(items) == 0:
|
||||||
return f"target '{token}' not found"
|
return f"target '{token}' not found"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
f"ambiguous: '{token}' matches {len(items)} identities:\n"
|
f"ambiguous: '{token}' matches {len(items)} identities:\n"
|
||||||
+ "\n".join(f" - {item}" for item in items)
|
+ "\n".join(f" - {item}" for item in items)
|
||||||
@@ -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)
|
||||||
|
|||||||
+4
-8
@@ -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
|
||||||
@@ -47,7 +46,7 @@ class HubRuntimeConfig:
|
|||||||
class ConfigManager:
|
class ConfigManager:
|
||||||
"""
|
"""
|
||||||
Manages hub configuration loading, reloading, and persistence.
|
Manages hub configuration loading, reloading, and persistence.
|
||||||
|
|
||||||
Handles:
|
Handles:
|
||||||
- Loading TOML configuration files
|
- Loading TOML configuration files
|
||||||
- Applying configuration updates
|
- Applying configuration updates
|
||||||
@@ -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}
|
||||||
@@ -155,7 +151,7 @@ class ConfigManager:
|
|||||||
def get_config_path_for_writes(self) -> str | None:
|
def get_config_path_for_writes(self) -> str | None:
|
||||||
"""Get the resolved config file path for write operations."""
|
"""Get the resolved config file path for write operations."""
|
||||||
from .util import expand_path
|
from .util import expand_path
|
||||||
|
|
||||||
p = self.hub.config.config_path
|
p = self.hub.config.config_path
|
||||||
if not p:
|
if not p:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+11
-20
@@ -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:
|
||||||
@@ -17,7 +17,7 @@ if TYPE_CHECKING:
|
|||||||
class MessageHelper:
|
class MessageHelper:
|
||||||
"""
|
"""
|
||||||
Helper methods for sending and queueing messages.
|
Helper methods for sending and queueing messages.
|
||||||
|
|
||||||
Handles:
|
Handles:
|
||||||
- Message queueing (outgoing lists)
|
- Message queueing (outgoing lists)
|
||||||
- Notice chunking for large messages
|
- Notice chunking for large messages
|
||||||
@@ -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),
|
||||||
@@ -120,13 +114,11 @@ class MessageHelper:
|
|||||||
return
|
return
|
||||||
|
|
||||||
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)
|
||||||
@@ -163,13 +155,15 @@ class MessageHelper:
|
|||||||
- Chunked messages otherwise
|
- Chunked messages otherwise
|
||||||
"""
|
"""
|
||||||
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
|
||||||
@@ -200,8 +194,7 @@ class MessageHelper:
|
|||||||
"Resource send failed, falling back to chunks link_id=%s",
|
"Resource send failed, falling back to chunks link_id=%s",
|
||||||
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),
|
||||||
|
|||||||
+59
-83
@@ -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:
|
||||||
@@ -74,7 +74,7 @@ class ResourceManager:
|
|||||||
"""Set up resource callbacks for a link if resource transfer is enabled."""
|
"""Set up resource callbacks for a link if resource transfer is enabled."""
|
||||||
if not self.hub.config.enable_resource_transfer:
|
if not self.hub.config.enable_resource_transfer:
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
link.set_resource_strategy(RNS.Link.ACCEPT_APP)
|
link.set_resource_strategy(RNS.Link.ACCEPT_APP)
|
||||||
link.set_resource_callback(self._resource_advertised)
|
link.set_resource_callback(self._resource_advertised)
|
||||||
@@ -90,15 +90,13 @@ 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()
|
||||||
exp_dict = self._resource_expectations.get(link)
|
exp_dict = self._resource_expectations.get(link)
|
||||||
if not exp_dict:
|
if not exp_dict:
|
||||||
return
|
return
|
||||||
|
|
||||||
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)
|
||||||
@@ -115,8 +113,10 @@ class ResourceManager:
|
|||||||
for link, exp_dict in list(self._resource_expectations.items()):
|
for link, exp_dict in list(self._resource_expectations.items()):
|
||||||
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(
|
||||||
@@ -138,16 +138,16 @@ class ResourceManager:
|
|||||||
) -> bool:
|
) -> bool:
|
||||||
"""Add a resource expectation. Returns False if limit exceeded."""
|
"""Add a resource expectation. Returns False if limit exceeded."""
|
||||||
self.cleanup_expired_expectations(link)
|
self.cleanup_expired_expectations(link)
|
||||||
|
|
||||||
exp_dict = self._resource_expectations.setdefault(link, {})
|
exp_dict = self._resource_expectations.setdefault(link, {})
|
||||||
|
|
||||||
if len(exp_dict) >= self.hub.config.max_pending_resource_expectations:
|
if len(exp_dict) >= self.hub.config.max_pending_resource_expectations:
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
"Max pending expectations exceeded link_id=%s",
|
"Max pending expectations exceeded link_id=%s",
|
||||||
self.hub._fmt_link_id(link),
|
self.hub._fmt_link_id(link),
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
exp = _ResourceExpectation(
|
exp = _ResourceExpectation(
|
||||||
id=rid,
|
id=rid,
|
||||||
@@ -160,7 +160,7 @@ class ResourceManager:
|
|||||||
room=room,
|
room=room,
|
||||||
)
|
)
|
||||||
exp_dict[rid] = exp
|
exp_dict[rid] = exp
|
||||||
|
|
||||||
self.log.debug(
|
self.log.debug(
|
||||||
"Added resource expectation link_id=%s rid=%s kind=%s size=%s",
|
"Added resource expectation link_id=%s rid=%s kind=%s size=%s",
|
||||||
self.hub._fmt_link_id(link),
|
self.hub._fmt_link_id(link),
|
||||||
@@ -175,16 +175,15 @@ class ResourceManager:
|
|||||||
) -> _ResourceExpectation | None:
|
) -> _ResourceExpectation | None:
|
||||||
"""Find a matching resource expectation by size (fallback matching)."""
|
"""Find a matching resource expectation by size (fallback matching)."""
|
||||||
self.cleanup_expired_expectations(link)
|
self.cleanup_expired_expectations(link)
|
||||||
|
|
||||||
exp_dict = self._resource_expectations.get(link)
|
exp_dict = self._resource_expectations.get(link)
|
||||||
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
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_resource_expectation_by_rid(
|
def get_resource_expectation_by_rid(
|
||||||
@@ -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,18 +233,15 @@ 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.
|
||||||
Returns True to accept, False to reject.
|
Returns True to accept, False to reject.
|
||||||
|
|
||||||
Minimize lock scope to prevent potential deadlocks with RNS internal locks.
|
Minimize lock scope to prevent potential deadlocks with RNS internal locks.
|
||||||
"""
|
"""
|
||||||
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",
|
||||||
@@ -254,8 +249,7 @@ 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(
|
||||||
@@ -266,8 +260,7 @@ 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:
|
||||||
@@ -277,11 +270,9 @@ 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",
|
||||||
@@ -290,29 +281,25 @@ 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),
|
||||||
size,
|
size,
|
||||||
exp.kind,
|
exp.kind,
|
||||||
)
|
)
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
def _resource_concluded(self, resource: RNS.Resource) -> None:
|
def _resource_concluded(self, resource: RNS.Resource) -> None:
|
||||||
"""Callback when a Resource transfer completes."""
|
"""Callback when a Resource transfer completes."""
|
||||||
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,20 +352,18 @@ 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")
|
||||||
self.hub.stats_manager.inc("resource_bytes_received", size)
|
self.hub.stats_manager.inc("resource_bytes_received", size)
|
||||||
|
|
||||||
self.log.info(
|
self.log.info(
|
||||||
"Resource received link_id=%s size=%s kind=%s",
|
"Resource received link_id=%s size=%s kind=%s",
|
||||||
self.hub._fmt_link_id(link),
|
self.hub._fmt_link_id(link),
|
||||||
size,
|
size,
|
||||||
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)
|
||||||
@@ -403,21 +390,20 @@ class ResourceManager:
|
|||||||
e,
|
e,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
self.log.info(
|
self.log.info(
|
||||||
"Received large NOTICE via resource link_id=%s room=%r chars=%s",
|
"Received large NOTICE via resource link_id=%s room=%r chars=%s",
|
||||||
self.hub._fmt_link_id(link),
|
self.hub._fmt_link_id(link),
|
||||||
exp.room,
|
exp.room,
|
||||||
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)
|
||||||
peer_hash = sess.get("peer") if sess else None
|
peer_hash = sess.get("peer") if sess else None
|
||||||
room_members = self.hub.room_manager.get_room_members(exp.room)
|
room_members = self.hub.room_manager.get_room_members(exp.room)
|
||||||
|
|
||||||
if peer_hash and room_members:
|
if peer_hash and room_members:
|
||||||
notice_env = make_envelope(
|
notice_env = make_envelope(
|
||||||
T_NOTICE,
|
T_NOTICE,
|
||||||
@@ -426,8 +412,7 @@ class ResourceManager:
|
|||||||
body=text,
|
body=text,
|
||||||
)
|
)
|
||||||
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:
|
||||||
@@ -440,7 +425,7 @@ class ResourceManager:
|
|||||||
self.hub._fmt_link_id(other),
|
self.hub._fmt_link_id(other),
|
||||||
e,
|
e,
|
||||||
)
|
)
|
||||||
|
|
||||||
if forwarded > 0:
|
if forwarded > 0:
|
||||||
self.hub.stats_manager.inc("notices_forwarded")
|
self.hub.stats_manager.inc("notices_forwarded")
|
||||||
self.log.debug(
|
self.log.debug(
|
||||||
@@ -448,9 +433,8 @@ class ResourceManager:
|
|||||||
forwarded,
|
forwarded,
|
||||||
exp.room,
|
exp.room,
|
||||||
)
|
)
|
||||||
|
|
||||||
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)
|
||||||
@@ -461,15 +445,14 @@ class ResourceManager:
|
|||||||
e,
|
e,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
self.log.info(
|
self.log.info(
|
||||||
"Received MOTD via resource link_id=%s chars=%s",
|
"Received MOTD via resource link_id=%s chars=%s",
|
||||||
self.hub._fmt_link_id(link),
|
self.hub._fmt_link_id(link),
|
||||||
len(text),
|
len(text),
|
||||||
)
|
)
|
||||||
|
|
||||||
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),
|
||||||
@@ -494,14 +477,14 @@ class ResourceManager:
|
|||||||
"""
|
"""
|
||||||
Send large payload via Resource.
|
Send large payload via Resource.
|
||||||
Returns True if successfully initiated, False otherwise.
|
Returns True if successfully initiated, False otherwise.
|
||||||
|
|
||||||
Note: This sends the resource envelope immediately, then creates
|
Note: This sends the resource envelope immediately, then creates
|
||||||
and advertises the resource. Should only be called when immediate
|
and advertises the resource. Should only be called when immediate
|
||||||
sending is desired (not when batching messages).
|
sending is desired (not when batching messages).
|
||||||
"""
|
"""
|
||||||
if not self.hub.config.enable_resource_transfer:
|
if not self.hub.config.enable_resource_transfer:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
size = len(payload)
|
size = len(payload)
|
||||||
if size > self.hub.config.max_resource_bytes:
|
if size > self.hub.config.max_resource_bytes:
|
||||||
self.log.error(
|
self.log.error(
|
||||||
@@ -510,17 +493,13 @@ class ResourceManager:
|
|||||||
self.hub.config.max_resource_bytes,
|
self.hub.config.max_resource_bytes,
|
||||||
)
|
)
|
||||||
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
|
||||||
|
|
||||||
envelope_body = {
|
envelope_body = {
|
||||||
B_RES_ID: rid,
|
B_RES_ID: rid,
|
||||||
B_RES_KIND: kind,
|
B_RES_KIND: kind,
|
||||||
@@ -529,19 +508,19 @@ class ResourceManager:
|
|||||||
}
|
}
|
||||||
if encoding:
|
if encoding:
|
||||||
envelope_body[B_RES_ENCODING] = encoding
|
envelope_body[B_RES_ENCODING] = encoding
|
||||||
|
|
||||||
envelope = make_envelope(
|
envelope = make_envelope(
|
||||||
T_RESOURCE_ENVELOPE,
|
T_RESOURCE_ENVELOPE,
|
||||||
src=self.hub.identity.hash,
|
src=self.hub.identity.hash,
|
||||||
room=room,
|
room=room,
|
||||||
body=envelope_body,
|
body=envelope_body,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
envelope_payload = encode(envelope)
|
envelope_payload = encode(envelope)
|
||||||
RNS.Packet(link, envelope_payload).send()
|
RNS.Packet(link, envelope_payload).send()
|
||||||
self.hub.stats_manager.inc("bytes_out", len(envelope_payload))
|
self.hub.stats_manager.inc("bytes_out", len(envelope_payload))
|
||||||
|
|
||||||
self.log.debug(
|
self.log.debug(
|
||||||
"Sent resource envelope link_id=%s rid=%s kind=%s size=%s",
|
"Sent resource envelope link_id=%s rid=%s kind=%s size=%s",
|
||||||
self.hub._fmt_link_id(link),
|
self.hub._fmt_link_id(link),
|
||||||
@@ -556,19 +535,16 @@ class ResourceManager:
|
|||||||
e,
|
e,
|
||||||
)
|
)
|
||||||
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)
|
||||||
|
|
||||||
self.hub.stats_manager.inc("resources_sent")
|
self.hub.stats_manager.inc("resources_sent")
|
||||||
self.hub.stats_manager.inc("resource_bytes_sent", size)
|
self.hub.stats_manager.inc("resource_bytes_sent", size)
|
||||||
|
|
||||||
self.log.info(
|
self.log.info(
|
||||||
"Sent resource link_id=%s rid=%s kind=%s size=%s",
|
"Sent resource link_id=%s rid=%s kind=%s size=%s",
|
||||||
self.hub._fmt_link_id(link),
|
self.hub._fmt_link_id(link),
|
||||||
@@ -577,7 +553,7 @@ class ResourceManager:
|
|||||||
size,
|
size,
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log.error(
|
self.log.error(
|
||||||
"Failed to create resource link_id=%s: %s",
|
"Failed to create resource link_id=%s: %s",
|
||||||
|
|||||||
+24
-32
@@ -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
|
||||||
@@ -446,7 +438,7 @@ class RoomManager:
|
|||||||
def get_registry_path_for_writes(self) -> str | None:
|
def get_registry_path_for_writes(self) -> str | None:
|
||||||
"""Get path to room registry file for write operations."""
|
"""Get path to room registry file for write operations."""
|
||||||
from .util import expand_path
|
from .util import expand_path
|
||||||
|
|
||||||
p = self.hub.config.room_registry_path
|
p = self.hub.config.room_registry_path
|
||||||
if not p:
|
if not p:
|
||||||
return None
|
return None
|
||||||
@@ -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,21 +601,22 @@ 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
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""
|
"""
|
||||||
Prune registered rooms that haven't been used recently.
|
Prune registered rooms that haven't been used recently.
|
||||||
|
|
||||||
Returns list of pruned room names.
|
Returns list of pruned room names.
|
||||||
"""
|
"""
|
||||||
now = float(time.time())
|
now = float(time.time())
|
||||||
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)
|
||||||
@@ -647,7 +640,7 @@ class RoomManager:
|
|||||||
def merge_registry_into_state(self, registry: dict[str, dict[str, Any]]) -> None:
|
def merge_registry_into_state(self, registry: dict[str, dict[str, Any]]) -> None:
|
||||||
"""
|
"""
|
||||||
Merge registry into live room state.
|
Merge registry into live room state.
|
||||||
|
|
||||||
Updates in-memory state for active rooms with registry data.
|
Updates in-memory state for active rooms with registry data.
|
||||||
"""
|
"""
|
||||||
for r, st in list(self._room_state.items()):
|
for r, st in list(self._room_state.items()):
|
||||||
@@ -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
|
||||||
|
|||||||
+17
-49
@@ -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
|
||||||
|
|
||||||
@@ -46,7 +47,7 @@ if TYPE_CHECKING:
|
|||||||
class MessageRouter:
|
class MessageRouter:
|
||||||
"""
|
"""
|
||||||
Handles message routing and dispatching for the RRC hub.
|
Handles message routing and dispatching for the RRC hub.
|
||||||
|
|
||||||
This class is responsible for:
|
This class is responsible for:
|
||||||
- Decoding and validating incoming packets
|
- Decoding and validating incoming packets
|
||||||
- Dispatching messages by type (HELLO, JOIN, PART, MSG, NOTICE, PING, etc.)
|
- Dispatching messages by type (HELLO, JOIN, PART, MSG, NOTICE, PING, etc.)
|
||||||
@@ -67,7 +68,7 @@ class MessageRouter:
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Main entry point for routing an incoming packet.
|
Main entry point for routing an incoming packet.
|
||||||
|
|
||||||
This method should be called with the state lock held.
|
This method should be called with the state lock held.
|
||||||
"""
|
"""
|
||||||
sess = self.hub.session_manager.sessions.get(link)
|
sess = self.hub.session_manager.sessions.get(link)
|
||||||
@@ -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:
|
||||||
|
|||||||
+41
-167
@@ -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()
|
||||||
@@ -453,18 +382,15 @@ class HubService:
|
|||||||
Use matches list to provide helpful error messages.
|
Use matches list to provide helpful error messages.
|
||||||
"""
|
"""
|
||||||
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
|
||||||
@@ -524,8 +449,7 @@ class HubService:
|
|||||||
identified_link, ident
|
identified_link, ident
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# 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(
|
||||||
@@ -649,28 +537,14 @@ class HubService:
|
|||||||
len(payload),
|
len(payload),
|
||||||
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
|
||||||
|
|||||||
+23
-38
@@ -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
|
||||||
|
|
||||||
@@ -21,7 +22,7 @@ class _RateState:
|
|||||||
class SessionManager:
|
class SessionManager:
|
||||||
"""
|
"""
|
||||||
Manages session lifecycle for RRC hub connections.
|
Manages session lifecycle for RRC hub connections.
|
||||||
|
|
||||||
This class is responsible for:
|
This class is responsible for:
|
||||||
- Session creation and initialization
|
- Session creation and initialization
|
||||||
- Session state management (nicknames, rooms, capabilities)
|
- Session state management (nicknames, rooms, capabilities)
|
||||||
@@ -34,21 +35,15 @@ 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
|
||||||
|
|
||||||
def on_link_established(self, link: RNS.Link) -> None:
|
def on_link_established(self, link: RNS.Link) -> None:
|
||||||
"""
|
"""
|
||||||
Handle new link establishment.
|
Handle new link establishment.
|
||||||
|
|
||||||
Creates session state and sets up callbacks.
|
Creates session state and sets up callbacks.
|
||||||
Must be called with state lock held.
|
Must be called with state lock held.
|
||||||
"""
|
"""
|
||||||
@@ -73,7 +68,7 @@ class SessionManager:
|
|||||||
) -> tuple[bool, bytes | None]:
|
) -> tuple[bool, bytes | None]:
|
||||||
"""
|
"""
|
||||||
Handle remote identity being established.
|
Handle remote identity being established.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(is_banned, peer_hash) tuple
|
(is_banned, peer_hash) tuple
|
||||||
Must be called with state lock held.
|
Must be called with state lock held.
|
||||||
@@ -85,20 +80,18 @@ class SessionManager:
|
|||||||
if identity is not None:
|
if identity is not None:
|
||||||
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:
|
||||||
self.log.info(
|
self.log.info(
|
||||||
"Remote identified peer=%s link_id=%s",
|
"Remote identified peer=%s link_id=%s",
|
||||||
self.hub._fmt_hash(peer_hash),
|
self.hub._fmt_hash(peer_hash),
|
||||||
self.hub._fmt_link_id(link),
|
self.hub._fmt_link_id(link),
|
||||||
)
|
)
|
||||||
|
|
||||||
return banned, peer_hash
|
return banned, peer_hash
|
||||||
|
|
||||||
return False, None
|
return False, None
|
||||||
@@ -106,7 +99,7 @@ class SessionManager:
|
|||||||
def on_link_closed(self, link: RNS.Link) -> tuple[bytes | None, str | None, int]:
|
def on_link_closed(self, link: RNS.Link) -> tuple[bytes | None, str | None, int]:
|
||||||
"""
|
"""
|
||||||
Handle link closure and cleanup.
|
Handle link closure and cleanup.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(peer_hash, nick, rooms_count) for logging
|
(peer_hash, nick, rooms_count) for logging
|
||||||
Must be called with state lock held.
|
Must be called with state lock held.
|
||||||
@@ -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)
|
||||||
|
|
||||||
@@ -139,10 +130,9 @@ class SessionManager:
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Update nickname index when a nick changes.
|
Update nickname index when a nick changes.
|
||||||
|
|
||||||
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)
|
||||||
@@ -158,10 +147,10 @@ class SessionManager:
|
|||||||
def refill_and_take(self, link: RNS.Link, cost: float = 1.0) -> bool:
|
def refill_and_take(self, link: RNS.Link, cost: float = 1.0) -> bool:
|
||||||
"""
|
"""
|
||||||
Token bucket rate limiting.
|
Token bucket rate limiting.
|
||||||
|
|
||||||
Refills tokens based on elapsed time and attempts to take `cost` tokens.
|
Refills tokens based on elapsed time and attempts to take `cost` tokens.
|
||||||
Returns True if tokens were available and taken, False if rate limited.
|
Returns True if tokens were available and taken, False if rate limited.
|
||||||
|
|
||||||
Must be called with state lock held.
|
Must be called with state lock held.
|
||||||
"""
|
"""
|
||||||
state = self._rate.get(link)
|
state = self._rate.get(link)
|
||||||
@@ -197,7 +186,7 @@ class SessionManager:
|
|||||||
def clear_all(self) -> list[RNS.Link]:
|
def clear_all(self) -> list[RNS.Link]:
|
||||||
"""
|
"""
|
||||||
Clear all sessions and return list of links for teardown.
|
Clear all sessions and return list of links for teardown.
|
||||||
|
|
||||||
Must be called with state lock held.
|
Must be called with state lock held.
|
||||||
"""
|
"""
|
||||||
links = list(self.sessions.keys())
|
links = list(self.sessions.keys())
|
||||||
@@ -212,7 +201,7 @@ class SessionManager:
|
|||||||
total = len(self.sessions)
|
total = len(self.sessions)
|
||||||
welcomed = sum(1 for s in self.sessions.values() if s.get("welcomed"))
|
welcomed = sum(1 for s in self.sessions.values() if s.get("welcomed"))
|
||||||
identified = sum(1 for s in self.sessions.values() if s.get("peer") is not None)
|
identified = sum(1 for s in self.sessions.values() if s.get("peer") is not None)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total": total,
|
"total": total,
|
||||||
"welcomed": welcomed,
|
"welcomed": welcomed,
|
||||||
@@ -232,39 +221,35 @@ class SessionManager:
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Send WELCOME message to a client and optionally MOTD.
|
Send WELCOME message to a client and optionally MOTD.
|
||||||
|
|
||||||
This handles:
|
This handles:
|
||||||
- Setting session as welcomed
|
- Setting session as welcomed
|
||||||
- Updating nick index if needed
|
- Updating nick index if needed
|
||||||
- Queueing WELCOME message
|
- Queueing WELCOME message
|
||||||
- Setting up MOTD callback for post-send delivery
|
- Setting up MOTD callback for post-send delivery
|
||||||
|
|
||||||
Must be called with state lock held.
|
Must be called with state lock held.
|
||||||
"""
|
"""
|
||||||
from .constants import RES_KIND_MOTD, T_NOTICE
|
from .constants import RES_KIND_MOTD, T_NOTICE
|
||||||
|
|
||||||
sess = self.sessions.get(link)
|
sess = self.sessions.get(link)
|
||||||
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,
|
||||||
peer_hash=peer_hash,
|
peer_hash=peer_hash,
|
||||||
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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+9
-4
@@ -7,13 +7,14 @@ from typing import TYPE_CHECKING
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
import RNS
|
import RNS
|
||||||
|
|
||||||
from .service import HubService
|
from .service import HubService
|
||||||
|
|
||||||
|
|
||||||
class TrustManager:
|
class TrustManager:
|
||||||
"""
|
"""
|
||||||
Manages trusted and banned identities for the hub.
|
Manages trusted and banned identities for the hub.
|
||||||
|
|
||||||
Handles:
|
Handles:
|
||||||
- Trusted identity lists (server operators)
|
- Trusted identity lists (server operators)
|
||||||
- Banned identity lists
|
- Banned identity lists
|
||||||
@@ -24,11 +25,13 @@ class TrustManager:
|
|||||||
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._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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user