Merge pull request #13 from kc1awv/message_limits

Message limits and packet size
This commit is contained in:
Steve Miller
2026-01-15 16:25:38 -05:00
committed by GitHub
12 changed files with 138 additions and 42 deletions
+9
View File
@@ -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
View File
@@ -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
View File
@@ -1,3 +1,3 @@
__all__ = ["__version__"]
__version__ = "0.2.1"
__version__ = "0.2.2"
+31 -8
View File
@@ -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
View File
@@ -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
+8
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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