From f111cf545f45eb982acf95b0058fa43e9b8fb233 Mon Sep 17 00:00:00 2001 From: kc1awv Date: Tue, 30 Dec 2025 09:43:18 -0500 Subject: [PATCH] Add nickname support to envelope protocol and update version to 0.1.1 --- CHANGELOG.md | 4 ++++ README.md | 15 +++++++++++++++ pyproject.toml | 2 +- rrcd/__init__.py | 2 +- rrcd/constants.py | 2 ++ rrcd/envelope.py | 38 +++++++++++++++++++++++++++++++++++++- rrcd/service.py | 17 ++++++++++++++--- rrcd/util.py | 26 ++++++++++++++++++++++++++ tests/test_envelope.py | 26 ++++++++++++++++++++++++++ 9 files changed, 126 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b56d40a..4d1fb7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,3 +13,7 @@ Initial public release. - Persistent config + room registry in TOML (`rrcd.toml`, `rooms.toml`) - Reduced lock contention by flushing outbound packets outside the shared state lock - Added small packaging metadata and README polish + +## 0.1.1 - 2025-12-30 + +- Protocol extension: hub may attach an optional nickname (`K_NICK = 7`) to forwarded `MSG`/`NOTICE` envelopes based on the nickname provided in `HELLO`. diff --git a/README.md b/README.md index 3fa7797..bd94e39 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,21 @@ use a hub-local convention: if a client sends a `MSG`/`NOTICE` whose body is a string beginning with `/`, and the command is recognized, the hub treats it as a command and does not forward it. +Wire-level extensions (backwards-compatible): + +- **Optional envelope nickname**: the hub may include an additional envelope key + `K_NICK = 7` (string) when forwarding `MSG`/`NOTICE`. This is an optional + hint associated with `K_SRC` so clients can display a human-friendly + nickname instead of only the sender identity hash. + + The hub learns this value from the client's `HELLO` body key + `B_HELLO_NICK = 0` and treats it as the authoritative nickname for that + link. Clients should treat `K_NICK` as optional and fall back to `K_SRC` + when it is missing. + + Nickname policy (current implementation): trimmed Unicode string, UTF-8 + encodable on the wire, maximum 32 characters. + Configure trusted operators and banned identities in the TOML config: - `trusted_identities`: list of Reticulum Identity hashes (hex) allowed to run diff --git a/pyproject.toml b/pyproject.toml index 0363f66..494f451 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "rrcd" -version = "0.1.0" +version = "0.1.1" description = "Reticulum Relay Chat daemon (hub service)" readme = "README.md" license = { file = "LICENSE" } diff --git a/rrcd/__init__.py b/rrcd/__init__.py index a05eb9a..fd9a4ec 100644 --- a/rrcd/__init__.py +++ b/rrcd/__init__.py @@ -1,3 +1,3 @@ __all__ = ["__version__"] -__version__ = "0.1.0" +__version__ = "0.1.1" diff --git a/rrcd/constants.py b/rrcd/constants.py index bdc0816..683a513 100644 --- a/rrcd/constants.py +++ b/rrcd/constants.py @@ -10,6 +10,8 @@ K_TS = 3 K_SRC = 4 K_ROOM = 5 K_BODY = 6 +K_NICK = 7 +NICK_MAX_CHARS = 32 # Message types T_HELLO = 1 diff --git a/rrcd/envelope.py b/rrcd/envelope.py index 2edeafe..9e99d06 100644 --- a/rrcd/envelope.py +++ b/rrcd/envelope.py @@ -3,7 +3,19 @@ from __future__ import annotations import os import time -from .constants import K_BODY, K_ID, K_ROOM, K_SRC, K_T, K_TS, K_V, RRC_VERSION +from .constants import ( + K_BODY, + K_ID, + K_NICK, + K_ROOM, + K_SRC, + K_T, + K_TS, + K_V, + NICK_MAX_CHARS, + RRC_VERSION, +) +from .util import normalize_nick def now_ms() -> int: @@ -20,6 +32,7 @@ def make_envelope( src: bytes, room: str | None = None, body=None, + nick: str | None = None, mid: bytes | None = None, ts: int | None = None, ) -> dict: @@ -34,6 +47,10 @@ def make_envelope( env[K_ROOM] = room if body is not None: env[K_BODY] = body + if nick is not None: + n = normalize_nick(nick) + if n is not None: + env[K_NICK] = n return env @@ -81,3 +98,22 @@ def validate_envelope(env: dict) -> None: raise TypeError("room name must be a string") if room == "": raise ValueError("room name must not be empty") + + if K_NICK in env: + nick = env[K_NICK] + if not isinstance(nick, str): + raise TypeError("nickname must be a string") + if nick.strip() == "": + raise ValueError("nickname must not be empty") + + # Require normalized form on the wire. + if nick != nick.strip(): + raise ValueError("nickname must not have leading/trailing whitespace") + if len(nick) > int(NICK_MAX_CHARS): + raise ValueError("nickname too long") + if "\n" in nick or "\r" in nick or "\x00" in nick: + raise ValueError("nickname contains control characters") + try: + nick.encode("utf-8", "strict") + except UnicodeError as e: + raise ValueError(f"nickname is not valid UTF-8: {e}") from e diff --git a/rrcd/service.py b/rrcd/service.py index 3b9d0c0..d852140 100644 --- a/rrcd/service.py +++ b/rrcd/service.py @@ -18,6 +18,7 @@ from .constants import ( B_WELCOME_GREETING, B_WELCOME_HUB, K_BODY, + K_NICK, K_ROOM, K_SRC, K_T, @@ -33,7 +34,7 @@ from .constants import ( T_WELCOME, ) from .envelope import make_envelope, validate_envelope -from .util import expand_path +from .util import expand_path, normalize_nick @dataclass @@ -2150,8 +2151,9 @@ class HubService: if isinstance(body, dict): nick = body.get(B_HELLO_NICK) - if isinstance(nick, str) and nick.strip(): - sess["nick"] = nick.strip() + n = normalize_nick(nick) + if n is not None: + sess["nick"] = n if self.identity is not None: sess["welcomed"] = True @@ -2347,6 +2349,15 @@ class HubService: env[K_SRC] = bytes(peer_hash) if isinstance(peer_hash, (bytes, bytearray)) else peer_hash env[K_ROOM] = r + # Backwards-compatible extension: hub can attach the nickname learned + # from HELLO so clients can render a human-friendly name. + nick = sess.get("nick") + if isinstance(nick, str) and nick.strip(): + env[K_NICK] = nick.strip() + else: + # Prevent client-supplied spoofed nicknames. + env.pop(K_NICK, None) + payload = encode(env) for other in list(self.rooms.get(r, set())): if other is link: diff --git a/rrcd/util.py b/rrcd/util.py index d39c494..a2abae6 100644 --- a/rrcd/util.py +++ b/rrcd/util.py @@ -2,6 +2,32 @@ from __future__ import annotations import os +from .constants import NICK_MAX_CHARS + def expand_path(p: str) -> str: return os.path.expanduser(os.path.expandvars(p)) + + +def normalize_nick(value) -> str | None: + if not isinstance(value, str): + return None + + s = value.strip() + if not s: + return None + + if len(s) > int(NICK_MAX_CHARS): + 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: + return None + + try: + s.encode("utf-8", "strict") + except UnicodeError: + return None + + return s diff --git a/tests/test_envelope.py b/tests/test_envelope.py index 39998c5..1dedb3c 100644 --- a/tests/test_envelope.py +++ b/tests/test_envelope.py @@ -4,6 +4,7 @@ from rrcd.constants import ( B_HELLO_NICK, K_BODY, K_ID, + K_NICK, K_SRC, K_T, K_TS, @@ -19,6 +20,26 @@ def test_validate_accepts_make_envelope() -> None: validate_envelope(env) +def test_validate_accepts_optional_nick_extension() -> None: + env = make_envelope(T_HELLO, src=b"peer", body=None, nick="alice") + assert env[K_NICK] == "alice" + validate_envelope(env) + + +def test_validate_rejects_nick_too_long() -> None: + env = make_envelope(T_HELLO, src=b"peer", body=None) + env[K_NICK] = "a" * 33 + with pytest.raises(ValueError): + validate_envelope(env) + + +def test_validate_rejects_nick_with_whitespace() -> None: + env = make_envelope(T_HELLO, src=b"peer", body=None) + env[K_NICK] = " alice " + with pytest.raises(ValueError): + validate_envelope(env) + + def test_validate_rejects_missing_required_key() -> None: env = make_envelope(T_HELLO, src=b"peer", body=None) env.pop(K_TS) @@ -67,3 +88,8 @@ def test_validate_rejects_wrong_field_types() -> None: env[K_TS] = "not-int" with pytest.raises(TypeError): validate_envelope(env) + + env = make_envelope(T_HELLO, src=b"peer", body=None) + env[K_NICK] = 123 + with pytest.raises(TypeError): + validate_envelope(env)