align protocol constants and update handling of HELLO/WELCOME messages

This commit is contained in:
kc1awv
2025-12-31 16:01:58 -05:00
parent 8afacd8727
commit c0e72c7f11
5 changed files with 93 additions and 52 deletions

View File

@@ -80,15 +80,22 @@ too large for the current Reticulum link MTU.
Mitigations: Mitigations:
- Keep `greeting` reasonably short if you want it to appear inside `WELCOME`. - `WELCOME` sent by `rrcd` is intentionally minimal (hub name/version/caps).
- If `WELCOME` would exceed MTU, `rrcd` automatically sends a minimal `WELCOME` - The hub `greeting` is delivered after `WELCOME` via one or more `NOTICE`
and then delivers the full greeting as one or more `NOTICE` messages sized to messages chunked to fit the link MTU.
fit the link MTU.
## Compatibility ## Compatibility
`rrcd` implements the core RRC protocol as described in the RRC docs. `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 Extensions beyond core RRC will be documented in the Extensions section of this
README as they are added. 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 hint associated with `K_SRC` so clients can display a human-friendly
nickname instead of only the sender identity hash. nickname instead of only the sender identity hash.
The hub learns this value from the client's `HELLO` body key The hub learns this value from the client's optional envelope nickname field
`B_HELLO_NICK = 0` and treats it as the authoritative nickname for that `K_NICK = 7` on inbound messages, and treats it as the authoritative nickname
link. Clients should treat `K_NICK` as optional and fall back to `K_SRC` for that link. Clients should treat `K_NICK` as optional and fall back to
when it is missing. `K_SRC` when it is missing.
Nicknames are advisory only; clients should treat them as display hints. Nicknames are advisory only; clients should treat them as display hints.
The hub may ignore, sanitize, replace, or omit them. The hub may ignore, sanitize, replace, or omit them.

View File

@@ -117,14 +117,12 @@ dest_name = "rrc.hub"
announce_on_start = true announce_on_start = true
announce_period_s = 0.0 announce_period_s = 0.0
# WELCOME message fields. # Hub identity fields.
hub_name = "rrc" hub_name = "rrc"
greeting = "" greeting = ""
# Note: Some Reticulum links have low MTU. If `greeting` is very long, the hub # Note: The hub greeting is delivered after WELCOME via one or more NOTICE
# may be unable to include it inside the initial WELCOME. In that case, rrcd # messages. NOTICE payloads are chunked as needed to fit the Link MTU.
# will send a minimal WELCOME and then deliver the greeting afterward via NOTICE
# messages.
# Operator / moderation # 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("--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( p.add_argument(
"--include-joined-member-list", "--include-joined-member-list",
action="store_true", action="store_true",

View File

@@ -30,12 +30,19 @@ T_PONG = 31
T_ERROR = 40 T_ERROR = 40
# HELLO body keys # HELLO body keys
B_HELLO_NICK = 0 # Per spec: key assignments are fixed.
B_HELLO_NAME = 1 B_HELLO_NAME = 0
B_HELLO_VER = 2 B_HELLO_VER = 1
B_HELLO_CAPS = 3 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 # WELCOME body keys
B_WELCOME_HUB = 0 B_WELCOME_HUB = 0
B_WELCOME_GREETING = 1 B_WELCOME_VER = 1
B_WELCOME_CAPS = 2 B_WELCOME_CAPS = 2
# Capabilities map keys (values are advisory). Keep these small and numeric.
CAP_RESOURCE_ENVELOPE = 0

View File

@@ -14,9 +14,11 @@ from . import __version__
from .codec import decode, encode from .codec import decode, encode
from .config import HubRuntimeConfig from .config import HubRuntimeConfig
from .constants import ( from .constants import (
B_HELLO_NICK, B_HELLO_CAPS,
B_WELCOME_GREETING, B_HELLO_NICK_LEGACY,
B_WELCOME_HUB, B_WELCOME_HUB,
B_WELCOME_VER,
B_WELCOME_CAPS,
K_BODY, K_BODY,
K_NICK, K_NICK,
K_ROOM, K_ROOM,
@@ -101,6 +103,12 @@ class HubService:
"announces": 0, "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: def _fmt_hash(self, h: Any, *, prefix: int = 12) -> str:
if isinstance(h, (bytes, bytearray)): if isinstance(h, (bytes, bytearray)):
s = bytes(h).hex() s = bytes(h).hex()
@@ -185,43 +193,31 @@ class HubService:
return return
g = str(greeting) if greeting else "" g = str(greeting) if greeting else ""
body_w: dict[int, Any] = {
body_w: dict[int, Any] = {B_WELCOME_HUB: self.config.hub_name} B_WELCOME_HUB: self.config.hub_name,
if g: B_WELCOME_VER: str(__version__),
body_w[B_WELCOME_GREETING] = g }
# Capabilities are optional; keep WELCOME minimal unless needed.
welcome = make_envelope(T_WELCOME, src=self.identity.hash, body=body_w) welcome = make_envelope(T_WELCOME, src=self.identity.hash, body=body_w)
welcome_payload = encode(welcome) welcome_payload = encode(welcome)
if self._packet_would_fit(link, welcome_payload): if not 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):
self.log.warning( 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_hash(peer_hash),
self._fmt_link_id(link), self._fmt_link_id(link),
) )
return return
self.log.warning( self._queue_payload(outgoing, link, welcome_payload)
"WELCOME too large for MTU; sending minimal WELCOME + NOTICE chunks peer=%s link_id=%s", self.log.debug(
"Queued WELCOME peer=%s link_id=%s",
self._fmt_hash(peer_hash), self._fmt_hash(peer_hash),
self._fmt_link_id(link), self._fmt_link_id(link),
) )
self._queue_payload(outgoing, link, welcome_min_payload)
# The hub greeting is delivered as NOTICE after WELCOME.
if g: if g:
self._queue_notice_chunks(outgoing, link, room=None, text=g) self._queue_notice_chunks(outgoing, link, room=None, text=g)
@@ -2133,6 +2129,7 @@ class HubService:
"rooms": set(), "rooms": set(),
"peer": None, "peer": None,
"nick": None, "nick": None,
"peer_caps": {},
"awaiting_pong": None, "awaiting_pong": None,
} }
@@ -2196,7 +2193,8 @@ class HubService:
return return
sess["welcomed"] = True 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]] = [] outgoing: list[tuple[RNS.Link, bytes]] = []
self._queue_welcome( self._queue_welcome(
outgoing, outgoing,
@@ -2205,8 +2203,16 @@ class HubService:
greeting=self.config.greeting, greeting=self.config.greeting,
) )
for out_link, payload in outgoing: for out_link, payload in outgoing:
self._inc("bytes_out", len(payload))
try: try:
RNS.Packet(out_link, payload).send() 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: except Exception:
self.log.debug( self.log.debug(
"Send failed link_id=%s bytes=%s", "Send failed link_id=%s bytes=%s",
@@ -2308,6 +2314,7 @@ class HubService:
self._on_packet_locked(link, data, outgoing) self._on_packet_locked(link, data, outgoing)
for out_link, payload in outgoing: for out_link, payload in outgoing:
self._inc("bytes_out", len(payload))
try: try:
RNS.Packet(out_link, payload).send() RNS.Packet(out_link, payload).send()
except OSError as e: except OSError as e:
@@ -2379,6 +2386,7 @@ class HubService:
t = env.get(K_T) t = env.get(K_T)
room = env.get(K_ROOM) room = env.get(K_ROOM)
body = env.get(K_BODY) body = env.get(K_BODY)
nick = env.get(K_NICK)
if self.log.isEnabledFor(logging.DEBUG): if self.log.isEnabledFor(logging.DEBUG):
body_len = None body_len = None
@@ -2408,12 +2416,21 @@ class HubService:
self._emit_error(outgoing, link, src=self.identity.hash, text="send HELLO first") self._emit_error(outgoing, link, src=self.identity.hash, text="send HELLO first")
return return
if isinstance(body, dict): if isinstance(nick, str):
nick = body.get(B_HELLO_NICK)
n = normalize_nick(nick, max_chars=self.config.nick_max_chars) n = normalize_nick(nick, max_chars=self.config.nick_max_chars)
if n is not None: if n is not None:
sess["nick"] = n 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( self.log.info(
"HELLO peer=%s nick=%r link_id=%s", "HELLO peer=%s nick=%r link_id=%s",
self._fmt_hash(peer_hash), self._fmt_hash(peer_hash),
@@ -2438,14 +2455,22 @@ class HubService:
sess["welcomed"] = False sess["welcomed"] = False
sess["rooms"] = set() sess["rooms"] = set()
sess["nick"] = None sess["nick"] = None
sess["peer_caps"] = {}
# Process the HELLO message # Process the HELLO message
if isinstance(body, dict): if isinstance(nick, str):
nick = body.get(B_HELLO_NICK)
n = normalize_nick(nick, max_chars=self.config.nick_max_chars) n = normalize_nick(nick, max_chars=self.config.nick_max_chars)
if n is not None: if n is not None:
sess["nick"] = n 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( self.log.info(
"Re-HELLO peer=%s nick=%r link_id=%s", "Re-HELLO peer=%s nick=%r link_id=%s",
self._fmt_hash(peer_hash), self._fmt_hash(peer_hash),

View File

@@ -1,7 +1,7 @@
import pytest import pytest
from rrcd.constants import ( from rrcd.constants import (
B_HELLO_NICK, B_HELLO_NICK_LEGACY,
K_BODY, K_BODY,
K_ID, K_ID,
K_NICK, K_NICK,
@@ -16,7 +16,7 @@ from rrcd.envelope import make_envelope, validate_envelope
def test_validate_accepts_make_envelope() -> None: 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) validate_envelope(env)