mirror of
https://github.com/kc1awv/rrcd.git
synced 2026-06-08 14:11:53 -07:00
Merge pull request #13 from kc1awv/message_limits
Message limits and packet size
This commit is contained in:
@@ -2,6 +2,15 @@
|
||||
|
||||
This project follows the versioning policy in VERSIONING.md.
|
||||
|
||||
## 0.2.2 - 2026-01-09
|
||||
|
||||
- **Protocol constants and welcome message limits**: Added new constants for hub
|
||||
limits in welcome messages and updated message construction accordingly
|
||||
- Added `max_nick_bytes` configuration option to specify maximum nickname size in UTF-8 bytes
|
||||
- Updated CLI to allow overriding `max_nick_bytes` via command-line argument
|
||||
- Updated documentation to reflect new nickname size limit configuration
|
||||
-
|
||||
|
||||
## 0.2.1 - 2026-01-08
|
||||
|
||||
- **JOINED/PARTED room notifications**: Existing room members now receive real-time notifications when users join or leave
|
||||
|
||||
+3
-3
@@ -23,7 +23,7 @@ classifiers = [
|
||||
]
|
||||
dependencies = [
|
||||
"cbor2>=5.6.0",
|
||||
"rns>=0.8.0",
|
||||
"rns>=0.9.6",
|
||||
"tomlkit>=0.13.2",
|
||||
]
|
||||
|
||||
@@ -34,8 +34,8 @@ Issues = "https://github.com/kc1awv/rrcd/issues"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"black>=24.0.0",
|
||||
"pytest>=9.0.2",
|
||||
"black>=25.12.0",
|
||||
"ruff>=0.6.0",
|
||||
"mypy>=1.0.0",
|
||||
]
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.2.1"
|
||||
__version__ = "0.2.2"
|
||||
|
||||
+31
-8
@@ -146,13 +146,17 @@ room_invite_timeout_s = 900.0
|
||||
# Optional behaviors.
|
||||
include_joined_member_list = false
|
||||
|
||||
# Nickname policy.
|
||||
# Maximum accepted nickname length (Unicode characters). 0 disables length limiting.
|
||||
nick_max_chars = 32
|
||||
|
||||
# Limits.
|
||||
# These limits help mitigate abuse and resource exhaustion, but can be adjusted
|
||||
# based on your use case.
|
||||
#
|
||||
# N.B. max_msg_body_bytes should not allow messages so large that they cannot
|
||||
# fit within the link MTU after UTF-8 encoding and envelope overhead. The
|
||||
# default of 350 bytes is a safe choice for the default Reticulum MTU of 500.
|
||||
max_nick_bytes = 32
|
||||
max_room_name_bytes = 64
|
||||
max_msg_body_bytes = 350
|
||||
max_rooms_per_session = 32
|
||||
max_room_name_len = 64
|
||||
rate_limit_msgs_per_minute = 240
|
||||
|
||||
# Hub-initiated liveness checks (0 disables).
|
||||
@@ -323,7 +327,16 @@ def _build_arg_parser() -> argparse.ArgumentParser:
|
||||
|
||||
p.add_argument("--max-rooms", type=int, default=None, help="Max rooms per session")
|
||||
p.add_argument(
|
||||
"--max-room-name-len", type=int, default=None, help="Max room name length"
|
||||
"--max-nick-bytes",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Max nickname size in UTF-8 bytes",
|
||||
)
|
||||
p.add_argument(
|
||||
"--max-room-name-bytes",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Max room name size in UTF-8 bytes",
|
||||
)
|
||||
|
||||
p.add_argument(
|
||||
@@ -332,6 +345,12 @@ def _build_arg_parser() -> argparse.ArgumentParser:
|
||||
default=None,
|
||||
help="Per-link message rate limit",
|
||||
)
|
||||
p.add_argument(
|
||||
"--max-msg-body-bytes",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Maximum message body size in UTF-8 bytes",
|
||||
)
|
||||
|
||||
p.add_argument(
|
||||
"--ping-interval",
|
||||
@@ -413,13 +432,17 @@ def main(argv: list[str] | None = None) -> None:
|
||||
|
||||
if args.max_rooms is not None:
|
||||
cfg = replace(cfg, max_rooms_per_session=int(args.max_rooms))
|
||||
if args.max_room_name_len is not None:
|
||||
cfg = replace(cfg, max_room_name_len=int(args.max_room_name_len))
|
||||
if args.max_nick_bytes is not None:
|
||||
cfg = replace(cfg, max_nick_bytes=int(args.max_nick_bytes))
|
||||
if args.max_room_name_bytes is not None:
|
||||
cfg = replace(cfg, max_room_name_bytes=int(args.max_room_name_bytes))
|
||||
|
||||
if args.rate_limit_msgs_per_minute is not None:
|
||||
cfg = replace(
|
||||
cfg, rate_limit_msgs_per_minute=int(args.rate_limit_msgs_per_minute)
|
||||
)
|
||||
if args.max_msg_body_bytes is not None:
|
||||
cfg = replace(cfg, max_msg_body_bytes=int(args.max_msg_body_bytes))
|
||||
|
||||
if args.ping_interval is not None:
|
||||
cfg = replace(cfg, ping_interval_s=float(args.ping_interval))
|
||||
|
||||
+3
-2
@@ -25,9 +25,10 @@ class HubRuntimeConfig:
|
||||
room_registry_prune_interval_s: float = 3600.0
|
||||
room_invite_timeout_s: float = 900.0
|
||||
include_joined_member_list: bool = False
|
||||
nick_max_chars: int = 32
|
||||
max_nick_bytes: int = 32
|
||||
max_rooms_per_session: int = 32
|
||||
max_room_name_len: int = 64
|
||||
max_room_name_bytes: int = 64
|
||||
max_msg_body_bytes: int = 350
|
||||
rate_limit_msgs_per_minute: int = 240
|
||||
ping_interval_s: float = 0.0
|
||||
ping_timeout_s: float = 0.0
|
||||
|
||||
@@ -45,6 +45,14 @@ B_HELLO_NICK_LEGACY = 64
|
||||
B_WELCOME_HUB = 0
|
||||
B_WELCOME_VER = 1
|
||||
B_WELCOME_CAPS = 2
|
||||
B_WELCOME_LIMITS = 3
|
||||
|
||||
# Hub Limits map keys (within B_WELCOME_LIMITS)
|
||||
B_LIMIT_MAX_NICK_BYTES = 0
|
||||
B_LIMIT_MAX_ROOM_NAME_BYTES = 1
|
||||
B_LIMIT_MAX_MSG_BODY_BYTES = 2
|
||||
B_LIMIT_MAX_ROOMS_PER_SESSION = 3
|
||||
B_LIMIT_RATE_LIMIT_MSGS_PER_MINUTE = 4
|
||||
|
||||
# Capabilities map keys (values are advisory). Keep these small and numeric.
|
||||
CAP_RESOURCE_ENVELOPE = 0
|
||||
|
||||
+23
-1
@@ -7,7 +7,19 @@ from typing import TYPE_CHECKING, Any
|
||||
import RNS
|
||||
|
||||
from .codec import encode
|
||||
from .constants import B_WELCOME_HUB, B_WELCOME_VER, T_ERROR, T_NOTICE, T_WELCOME
|
||||
from .constants import (
|
||||
B_LIMIT_MAX_MSG_BODY_BYTES,
|
||||
B_LIMIT_MAX_NICK_BYTES,
|
||||
B_LIMIT_MAX_ROOM_NAME_BYTES,
|
||||
B_LIMIT_MAX_ROOMS_PER_SESSION,
|
||||
B_LIMIT_RATE_LIMIT_MSGS_PER_MINUTE,
|
||||
B_WELCOME_HUB,
|
||||
B_WELCOME_LIMITS,
|
||||
B_WELCOME_VER,
|
||||
T_ERROR,
|
||||
T_NOTICE,
|
||||
T_WELCOME,
|
||||
)
|
||||
from .envelope import make_envelope
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -118,9 +130,19 @@ class MessageHelper:
|
||||
|
||||
from . import __version__
|
||||
|
||||
# Build hub limits map with integer keys per spec
|
||||
limits: dict[int, int] = {
|
||||
B_LIMIT_MAX_NICK_BYTES: self.hub.config.max_nick_bytes,
|
||||
B_LIMIT_MAX_ROOM_NAME_BYTES: self.hub.config.max_room_name_bytes,
|
||||
B_LIMIT_MAX_MSG_BODY_BYTES: self.hub.config.max_msg_body_bytes,
|
||||
B_LIMIT_MAX_ROOMS_PER_SESSION: self.hub.config.max_rooms_per_session,
|
||||
B_LIMIT_RATE_LIMIT_MSGS_PER_MINUTE: self.hub.config.rate_limit_msgs_per_minute,
|
||||
}
|
||||
|
||||
body_w: dict[int, Any] = {
|
||||
B_WELCOME_HUB: self.hub.config.hub_name,
|
||||
B_WELCOME_VER: str(__version__),
|
||||
B_WELCOME_LIMITS: limits,
|
||||
}
|
||||
|
||||
welcome = make_envelope(T_WELCOME, src=self.hub.identity.hash, body=body_w)
|
||||
|
||||
+26
-6
@@ -300,7 +300,7 @@ class MessageRouter:
|
||||
new_nick = None
|
||||
|
||||
if isinstance(nick, str):
|
||||
n = normalize_nick(nick, max_chars=self.hub.config.nick_max_chars)
|
||||
n = normalize_nick(nick, max_bytes=self.hub.config.max_nick_bytes)
|
||||
if n is not None:
|
||||
new_nick = n
|
||||
sess["nick"] = n
|
||||
@@ -311,7 +311,7 @@ class MessageRouter:
|
||||
if new_nick is None:
|
||||
legacy_nick = body.get(B_HELLO_NICK_LEGACY)
|
||||
n2 = normalize_nick(
|
||||
legacy_nick, max_chars=self.hub.config.nick_max_chars
|
||||
legacy_nick, max_bytes=self.hub.config.max_nick_bytes
|
||||
)
|
||||
if n2 is not None:
|
||||
new_nick = n2
|
||||
@@ -360,7 +360,7 @@ class MessageRouter:
|
||||
new_nick = None
|
||||
|
||||
if isinstance(nick, str):
|
||||
n = normalize_nick(nick, max_chars=self.hub.config.nick_max_chars)
|
||||
n = normalize_nick(nick, max_bytes=self.hub.config.max_nick_bytes)
|
||||
if n is not None:
|
||||
new_nick = n
|
||||
sess["nick"] = n
|
||||
@@ -370,7 +370,7 @@ class MessageRouter:
|
||||
if new_nick is None:
|
||||
legacy_nick = body.get(B_HELLO_NICK_LEGACY)
|
||||
n2 = normalize_nick(
|
||||
legacy_nick, max_chars=self.hub.config.nick_max_chars
|
||||
legacy_nick, max_bytes=self.hub.config.max_nick_bytes
|
||||
)
|
||||
if n2 is not None:
|
||||
new_nick = n2
|
||||
@@ -686,6 +686,26 @@ class MessageRouter:
|
||||
text="message requires room name",
|
||||
)
|
||||
return
|
||||
|
||||
# Validate message body size (UTF-8 bytes)
|
||||
if isinstance(body, str):
|
||||
body_bytes = len(body.encode("utf-8", errors="replace"))
|
||||
if body_bytes > self.hub.config.max_msg_body_bytes:
|
||||
if self.hub.identity is not None:
|
||||
self.hub.message_helper.emit_error(
|
||||
outgoing,
|
||||
link,
|
||||
src=self.hub.identity.hash,
|
||||
text=f"message too large: {body_bytes} bytes > {self.hub.config.max_msg_body_bytes} bytes",
|
||||
)
|
||||
self.log.info(
|
||||
"Rejected oversized message peer=%s nick=%r body_bytes=%s limit=%s",
|
||||
self.hub._fmt_hash(peer_hash),
|
||||
sess.get("nick"),
|
||||
body_bytes,
|
||||
self.hub.config.max_msg_body_bytes,
|
||||
)
|
||||
return
|
||||
elif t == T_NOTICE:
|
||||
if not isinstance(room, str) or not room:
|
||||
return
|
||||
@@ -761,7 +781,7 @@ class MessageRouter:
|
||||
|
||||
incoming_nick = env.get(K_NICK)
|
||||
if incoming_nick is not None:
|
||||
n = normalize_nick(incoming_nick, max_chars=self.hub.config.nick_max_chars)
|
||||
n = normalize_nick(incoming_nick, max_bytes=self.hub.config.max_nick_bytes)
|
||||
if n is not None:
|
||||
old_session_nick = sess.get("nick")
|
||||
if old_session_nick != n:
|
||||
@@ -774,7 +794,7 @@ class MessageRouter:
|
||||
env.pop(K_NICK, None)
|
||||
else:
|
||||
nick = sess.get("nick")
|
||||
n = normalize_nick(nick, max_chars=self.hub.config.nick_max_chars)
|
||||
n = normalize_nick(nick, max_bytes=self.hub.config.max_nick_bytes)
|
||||
if n is not None:
|
||||
env[K_NICK] = n
|
||||
|
||||
|
||||
+10
-6
@@ -97,10 +97,10 @@ class HubService:
|
||||
self.destination.hash.hex() if self.destination else "-",
|
||||
)
|
||||
self.log.info(
|
||||
"Policy nick_max_chars=%s max_rooms=%s max_room_name_len=%s rate_limit_msgs_per_minute=%s",
|
||||
self.config.nick_max_chars,
|
||||
"Policy max_nick_bytes=%s max_rooms=%s max_room_name_bytes=%s rate_limit_msgs_per_minute=%s",
|
||||
self.config.max_nick_bytes,
|
||||
self.config.max_rooms_per_session,
|
||||
self.config.max_room_name_len,
|
||||
self.config.max_room_name_bytes,
|
||||
self.config.rate_limit_msgs_per_minute,
|
||||
)
|
||||
|
||||
@@ -330,7 +330,7 @@ class HubService:
|
||||
f"banned={len(old_banned)}->{len(new_banned)} "
|
||||
f"registered_rooms={len(old_registry)}->{len(new_registry)}"
|
||||
)
|
||||
lines.append(f"policy: nick_max_chars={new_cfg.nick_max_chars}")
|
||||
lines.append(f"policy: max_nick_bytes={new_cfg.max_nick_bytes}")
|
||||
|
||||
if cfg_changes:
|
||||
lines.append("config_changes:")
|
||||
@@ -503,8 +503,12 @@ class HubService:
|
||||
r = room.strip().lower()
|
||||
if not r:
|
||||
raise ValueError("room name must not be empty")
|
||||
if len(r) > int(self.config.max_room_name_len):
|
||||
raise ValueError("room name too long")
|
||||
# Check UTF-8 byte length
|
||||
room_bytes = len(r.encode("utf-8", errors="replace"))
|
||||
if room_bytes > int(self.config.max_room_name_bytes):
|
||||
raise ValueError(
|
||||
f"room name too long: {room_bytes} bytes > {self.config.max_room_name_bytes} bytes"
|
||||
)
|
||||
return r
|
||||
|
||||
def _on_packet(self, link: RNS.Link, data: bytes) -> None:
|
||||
|
||||
+11
-3
@@ -135,16 +135,24 @@ class SessionManager:
|
||||
self.hub.room_manager.remove_member(room, link)
|
||||
|
||||
if remaining_members and peer_hash and self.hub.identity:
|
||||
notification_body = [peer_hash] if self.hub.config.include_joined_member_list else None
|
||||
notification_body = (
|
||||
[peer_hash] if self.hub.config.include_joined_member_list else None
|
||||
)
|
||||
member_notification = make_envelope(
|
||||
T_PARTED, src=self.hub.identity.hash, room=room, body=notification_body
|
||||
T_PARTED,
|
||||
src=self.hub.identity.hash,
|
||||
room=room,
|
||||
body=notification_body,
|
||||
)
|
||||
member_notification_payload = encode(member_notification)
|
||||
for member_link in remaining_members:
|
||||
try:
|
||||
import RNS
|
||||
|
||||
RNS.Packet(member_link, member_notification_payload).send()
|
||||
self.hub.stats_manager.inc("bytes_out", len(member_notification_payload))
|
||||
self.hub.stats_manager.inc(
|
||||
"bytes_out", len(member_notification_payload)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
+2
-2
@@ -109,8 +109,8 @@ class StatsManager:
|
||||
lines.append(
|
||||
f"limits: rate_limit_msgs_per_minute={self.hub.config.rate_limit_msgs_per_minute} "
|
||||
f"max_rooms_per_session={self.hub.config.max_rooms_per_session} "
|
||||
f"max_room_name_len={self.hub.config.max_room_name_len} "
|
||||
f"nick_max_chars={self.hub.config.nick_max_chars}"
|
||||
f"max_room_name_bytes={self.hub.config.max_room_name_bytes} "
|
||||
f"max_nick_bytes={self.hub.config.max_nick_bytes}"
|
||||
)
|
||||
lines.append(
|
||||
f"features: ping_interval_s={self.hub.config.ping_interval_s} "
|
||||
|
||||
+11
-10
@@ -2,14 +2,14 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
_DEFAULT_NICK_MAX_CHARS = 32
|
||||
_DEFAULT_NICK_MAX_BYTES = 32
|
||||
|
||||
|
||||
def expand_path(p: str) -> str:
|
||||
return os.path.expanduser(os.path.expandvars(p))
|
||||
|
||||
|
||||
def normalize_nick(value, *, max_chars: int = _DEFAULT_NICK_MAX_CHARS) -> str | None:
|
||||
def normalize_nick(value, *, max_bytes: int = _DEFAULT_NICK_MAX_BYTES) -> str | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
|
||||
@@ -18,19 +18,20 @@ def normalize_nick(value, *, max_chars: int = _DEFAULT_NICK_MAX_CHARS) -> str |
|
||||
return None
|
||||
|
||||
try:
|
||||
limit = int(max_chars)
|
||||
limit = int(max_bytes)
|
||||
except Exception:
|
||||
limit = int(_DEFAULT_NICK_MAX_CHARS)
|
||||
limit = int(_DEFAULT_NICK_MAX_BYTES)
|
||||
|
||||
if limit > 0 and len(s) > limit:
|
||||
# Check UTF-8 byte length
|
||||
try:
|
||||
encoded = s.encode("utf-8", "strict")
|
||||
except UnicodeError:
|
||||
return None
|
||||
|
||||
if limit > 0 and len(encoded) > limit:
|
||||
return None
|
||||
|
||||
if "\n" in s or "\r" in s or "\x00" in s:
|
||||
return None
|
||||
|
||||
try:
|
||||
s.encode("utf-8", "strict")
|
||||
except UnicodeError:
|
||||
return None
|
||||
|
||||
return s
|
||||
|
||||
Reference in New Issue
Block a user