diff --git a/README.md b/README.md index 6b73ad0..2f79d37 100644 --- a/README.md +++ b/README.md @@ -80,15 +80,22 @@ too large for the current Reticulum link MTU. Mitigations: -- Keep `greeting` reasonably short if you want it to appear inside `WELCOME`. -- If `WELCOME` would exceed MTU, `rrcd` automatically sends a minimal `WELCOME` - and then delivers the full greeting as one or more `NOTICE` messages sized to - fit the link MTU. +- `WELCOME` sent by `rrcd` is intentionally minimal (hub name/version/caps). +- The hub `greeting` is delivered after `WELCOME` via one or more `NOTICE` + messages chunked to fit the link MTU. ## Compatibility `rrcd` implements the core RRC protocol as described in the RRC docs. +Protocol alignment notes (for implementers): + +- `HELLO` and `WELCOME` bodies are CBOR maps with unsigned integer keys. +- Capabilities are carried in body key `2` as a CBOR map (not a bitmask). Keys + inside the capabilities map are unsigned integers; values are advisory. +- `WELCOME` is intentionally minimal (hub name/version/caps only). Any hub + greeting text is delivered after `WELCOME` via one or more `NOTICE` messages. + Extensions beyond core RRC will be documented in the Extensions section of this README as they are added. @@ -120,10 +127,10 @@ Wire-level extensions (backwards-compatible): 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. + The hub learns this value from the client's optional envelope nickname field + `K_NICK = 7` on inbound messages, 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. Nicknames are advisory only; clients should treat them as display hints. The hub may ignore, sanitize, replace, or omit them. diff --git a/rrcd/cli.py b/rrcd/cli.py index 7a83999..404b831 100644 --- a/rrcd/cli.py +++ b/rrcd/cli.py @@ -117,14 +117,12 @@ dest_name = "rrc.hub" announce_on_start = true announce_period_s = 0.0 -# WELCOME message fields. +# Hub identity fields. hub_name = "rrc" greeting = "" -# Note: Some Reticulum links have low MTU. If `greeting` is very long, the hub -# may be unable to include it inside the initial WELCOME. In that case, rrcd -# will send a minimal WELCOME and then deliver the greeting afterward via NOTICE -# messages. +# Note: The hub greeting is delivered after WELCOME via one or more NOTICE +# messages. NOTICE payloads are chunked as needed to fit the Link MTU. # Operator / moderation # @@ -296,7 +294,11 @@ def _build_arg_parser() -> argparse.ArgumentParser: ) p.add_argument("--hub-name", default=None, help="Hub name in WELCOME") - p.add_argument("--greeting", default=None, help="Greeting in WELCOME") + p.add_argument( + "--greeting", + default=None, + help="Greeting delivered via NOTICE after WELCOME", + ) p.add_argument( "--include-joined-member-list", action="store_true", diff --git a/rrcd/constants.py b/rrcd/constants.py index 7c3db3b..1cf43ef 100644 --- a/rrcd/constants.py +++ b/rrcd/constants.py @@ -30,12 +30,19 @@ T_PONG = 31 T_ERROR = 40 # HELLO body keys -B_HELLO_NICK = 0 -B_HELLO_NAME = 1 -B_HELLO_VER = 2 -B_HELLO_CAPS = 3 +# Per spec: key assignments are fixed. +B_HELLO_NAME = 0 +B_HELLO_VER = 1 +B_HELLO_CAPS = 2 + +# Legacy / pre-spec implementations may have sent nick in HELLO body. +# Prefer the envelope-level nickname field (K_NICK=7) going forward. +B_HELLO_NICK_LEGACY = 64 # WELCOME body keys B_WELCOME_HUB = 0 -B_WELCOME_GREETING = 1 +B_WELCOME_VER = 1 B_WELCOME_CAPS = 2 + +# Capabilities map keys (values are advisory). Keep these small and numeric. +CAP_RESOURCE_ENVELOPE = 0 diff --git a/rrcd/service.py b/rrcd/service.py index 7f262aa..d46fc26 100644 --- a/rrcd/service.py +++ b/rrcd/service.py @@ -14,9 +14,11 @@ from . import __version__ from .codec import decode, encode from .config import HubRuntimeConfig from .constants import ( - B_HELLO_NICK, - B_WELCOME_GREETING, + B_HELLO_CAPS, + B_HELLO_NICK_LEGACY, B_WELCOME_HUB, + B_WELCOME_VER, + B_WELCOME_CAPS, K_BODY, K_NICK, K_ROOM, @@ -101,6 +103,12 @@ class HubService: "announces": 0, } + def _extract_caps(self, body: Any) -> dict[int, Any]: + if not isinstance(body, dict): + return {} + caps = body.get(B_HELLO_CAPS) + return caps if isinstance(caps, dict) else {} + def _fmt_hash(self, h: Any, *, prefix: int = 12) -> str: if isinstance(h, (bytes, bytearray)): s = bytes(h).hex() @@ -185,43 +193,31 @@ class HubService: return g = str(greeting) if greeting else "" - - body_w: dict[int, Any] = {B_WELCOME_HUB: self.config.hub_name} - if g: - body_w[B_WELCOME_GREETING] = g + body_w: dict[int, Any] = { + B_WELCOME_HUB: self.config.hub_name, + B_WELCOME_VER: str(__version__), + } + # Capabilities are optional; keep WELCOME minimal unless needed. welcome = make_envelope(T_WELCOME, src=self.identity.hash, body=body_w) welcome_payload = encode(welcome) - if self._packet_would_fit(link, welcome_payload): - self._queue_payload(outgoing, link, welcome_payload) - self.log.debug( - "Queued WELCOME (with greeting) peer=%s link_id=%s", - self._fmt_hash(peer_hash), - self._fmt_link_id(link), - ) - return - - # Fallback: send a minimal WELCOME, then send the greeting as NOTICE - # chunks that each fit within the link MTU. - body_min: dict[int, Any] = {B_WELCOME_HUB: self.config.hub_name} - welcome_min = make_envelope(T_WELCOME, src=self.identity.hash, body=body_min) - welcome_min_payload = encode(welcome_min) - - if not self._packet_would_fit(link, welcome_min_payload): + if not self._packet_would_fit(link, welcome_payload): self.log.warning( - "WELCOME would not fit MTU even without greeting; cannot welcome peer=%s link_id=%s", + "WELCOME would not fit MTU; cannot welcome peer=%s link_id=%s", self._fmt_hash(peer_hash), self._fmt_link_id(link), ) return - self.log.warning( - "WELCOME too large for MTU; sending minimal WELCOME + NOTICE chunks peer=%s link_id=%s", + self._queue_payload(outgoing, link, welcome_payload) + self.log.debug( + "Queued WELCOME peer=%s link_id=%s", self._fmt_hash(peer_hash), self._fmt_link_id(link), ) - self._queue_payload(outgoing, link, welcome_min_payload) + + # The hub greeting is delivered as NOTICE after WELCOME. if g: self._queue_notice_chunks(outgoing, link, room=None, text=g) @@ -2133,6 +2129,7 @@ class HubService: "rooms": set(), "peer": None, "nick": None, + "peer_caps": {}, "awaiting_pong": None, } @@ -2196,7 +2193,8 @@ class HubService: return sess["welcomed"] = True - # Prefer the queued WELCOME path so we can preflight MTU sizing. + # Use the queued path so we can preflight MTU sizing and optionally + # follow up with NOTICE chunks (e.g. greeting). outgoing: list[tuple[RNS.Link, bytes]] = [] self._queue_welcome( outgoing, @@ -2205,8 +2203,16 @@ class HubService: greeting=self.config.greeting, ) for out_link, payload in outgoing: + self._inc("bytes_out", len(payload)) try: RNS.Packet(out_link, payload).send() + except OSError as e: + self.log.warning( + "Send failed link_id=%s bytes=%s err=%s", + self._fmt_link_id(out_link), + len(payload), + e, + ) except Exception: self.log.debug( "Send failed link_id=%s bytes=%s", @@ -2308,6 +2314,7 @@ class HubService: self._on_packet_locked(link, data, outgoing) for out_link, payload in outgoing: + self._inc("bytes_out", len(payload)) try: RNS.Packet(out_link, payload).send() except OSError as e: @@ -2379,6 +2386,7 @@ class HubService: t = env.get(K_T) room = env.get(K_ROOM) body = env.get(K_BODY) + nick = env.get(K_NICK) if self.log.isEnabledFor(logging.DEBUG): body_len = None @@ -2408,12 +2416,21 @@ class HubService: self._emit_error(outgoing, link, src=self.identity.hash, text="send HELLO first") return - if isinstance(body, dict): - nick = body.get(B_HELLO_NICK) + if isinstance(nick, str): n = normalize_nick(nick, max_chars=self.config.nick_max_chars) if n is not None: sess["nick"] = n + if isinstance(body, dict): + sess["peer_caps"] = self._extract_caps(body) + + # Back-compat: if a legacy client put nick in HELLO body, accept it. + if sess.get("nick") is None: + legacy_nick = body.get(B_HELLO_NICK_LEGACY) + n2 = normalize_nick(legacy_nick, max_chars=self.config.nick_max_chars) + if n2 is not None: + sess["nick"] = n2 + self.log.info( "HELLO peer=%s nick=%r link_id=%s", self._fmt_hash(peer_hash), @@ -2438,14 +2455,22 @@ class HubService: sess["welcomed"] = False sess["rooms"] = set() sess["nick"] = None + sess["peer_caps"] = {} # Process the HELLO message - if isinstance(body, dict): - nick = body.get(B_HELLO_NICK) + if isinstance(nick, str): n = normalize_nick(nick, max_chars=self.config.nick_max_chars) if n is not None: sess["nick"] = n + if isinstance(body, dict): + sess["peer_caps"] = self._extract_caps(body) + if sess.get("nick") is None: + legacy_nick = body.get(B_HELLO_NICK_LEGACY) + n2 = normalize_nick(legacy_nick, max_chars=self.config.nick_max_chars) + if n2 is not None: + sess["nick"] = n2 + self.log.info( "Re-HELLO peer=%s nick=%r link_id=%s", self._fmt_hash(peer_hash), diff --git a/tests/test_envelope.py b/tests/test_envelope.py index afdaa57..e8b8310 100644 --- a/tests/test_envelope.py +++ b/tests/test_envelope.py @@ -1,7 +1,7 @@ import pytest from rrcd.constants import ( - B_HELLO_NICK, + B_HELLO_NICK_LEGACY, K_BODY, K_ID, K_NICK, @@ -16,7 +16,7 @@ from rrcd.envelope import make_envelope, validate_envelope def test_validate_accepts_make_envelope() -> None: - env = make_envelope(T_HELLO, src=b"peer", body={B_HELLO_NICK: "alice"}) + env = make_envelope(T_HELLO, src=b"peer", body={B_HELLO_NICK_LEGACY: "alice"}) validate_envelope(env)