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:
- 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.

View File

@@ -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",

View File

@@ -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

View File

@@ -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),

View File

@@ -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)