enforce fixed hub destination namespace

This commit is contained in:
kc1awv
2026-05-18 20:48:16 +00:00
parent 0500d393d6
commit f6d7e9d72b
8 changed files with 88 additions and 74 deletions
+2
View File
@@ -11,6 +11,8 @@ This project follows the versioning policy in VERSIONING.md.
the existing-member fanout notifications
- Added focused test coverage for JOINED/PARTED fanout nick hints, including
disconnect-driven `PARTED` notifications
- Hardened hub discovery by fixing the advertised destination namespace to
`rrc.hub` and removing config or CLI overrides for that value
## 0.3.1 - 2026-05-17
+5 -2
View File
@@ -37,8 +37,11 @@ a default identity at `~/.rrcd/hub_identity`, plus a room registry at
To override the default state directory (`~/.rrcd/`), set `RRCD_HOME`, e.g.
`RRCD_HOME=/tmp/rrcd rrcd`.
To specify a custom identity and destination name:
- `rrcd --identity ~/.rrcd/hub_identity --dest-name rrc.hub`
To specify a custom identity path:
- `rrcd --identity ~/.rrcd/hub_identity`
The hub destination namespace is fixed at `rrc.hub` so clients can discover
hubs consistently.
Optional:
+4 -66
View File
@@ -3,12 +3,13 @@ from __future__ import annotations
import argparse
import os
import sys
from dataclasses import asdict, replace
from dataclasses import replace
from pathlib import Path
import RNS
from .config import HubRuntimeConfig
from .constants import HUB_DEST_NAME
from .logging_config import configure_logging
from .paths import (
default_config_path,
@@ -19,62 +20,6 @@ from .paths import (
from .service import HubService
def _load_toml(path: str) -> dict:
import tomllib
with open(path, "rb") as f:
return tomllib.load(f)
def _apply_config_file(cfg: HubRuntimeConfig, path: str) -> HubRuntimeConfig:
data = _load_toml(path)
hub = data.get("hub") if isinstance(data, dict) else None
if isinstance(hub, dict):
data = {**data, **hub}
log_table = data.get("logging") if isinstance(data, dict) else None
if isinstance(log_table, dict):
mapped: dict[str, object] = {}
if "level" in log_table:
mapped["log_level"] = log_table.get("level")
if "rns_level" in log_table:
mapped["log_rns_level"] = log_table.get("rns_level")
if "console" in log_table:
mapped["log_console"] = log_table.get("console")
if "file" in log_table:
mapped["log_file"] = log_table.get("file")
if "format" in log_table:
mapped["log_format"] = log_table.get("format")
if "datefmt" in log_table:
mapped["log_datefmt"] = log_table.get("datefmt")
data = {**data, **mapped}
allowed = set(asdict(cfg).keys())
# This identifies where to reload/persist from; do not let the file override it.
allowed.discard("config_path")
updates = {k: v for k, v in data.items() if k in allowed}
for list_key in ("trusted_identities", "banned_identities"):
if list_key in updates and isinstance(updates[list_key], list):
updates[list_key] = tuple(str(x) for x in updates[list_key])
if "announce" in data and "announce_on_start" not in updates:
try:
updates["announce_on_start"] = bool(data["announce"])
except Exception:
pass
if "configdir" in updates and updates["configdir"] == "":
updates["configdir"] = None
if "greeting" in updates and updates["greeting"] == "":
updates["greeting"] = None
if "log_file" in updates and updates["log_file"] == "":
updates["log_file"] = None
if "log_datefmt" in updates and updates["log_datefmt"] == "":
updates["log_datefmt"] = None
return replace(cfg, **updates) if updates else cfg
def _write_default_config(config_path: str, identity_path: str) -> None:
cfg_dir = os.path.dirname(config_path)
if cfg_dir:
@@ -105,8 +50,8 @@ identity_path = {identity_path!r}
# A running hub can reload both rrcd.toml and rooms.toml with the /reload command.
room_registry_path = {room_registry_path!r}
# Destination name to host the hub on.
dest_name = "rrc.hub"
# The hub destination namespace is fixed for client discovery.
# Hubs always announce on {HUB_DEST_NAME!r}.
# Announcing (Reticulum destination announces)
#
@@ -297,10 +242,6 @@ def _build_arg_parser() -> argparse.ArgumentParser:
default=str(default_room_registry_path()),
help="Path to separate room registry TOML (created on first run)",
)
p.add_argument(
"--dest-name", default=None, help="Destination app name (default: rrc.hub)"
)
p.add_argument(
"--no-announce",
action="store_true",
@@ -414,9 +355,6 @@ def main(argv: list[str] | None = None) -> None:
data = temp_mgr.load_toml(config_path)
cfg = temp_mgr.apply_config_data(cfg, data)
if args.dest_name is not None:
cfg = replace(cfg, dest_name=args.dest_name)
if args.no_announce:
cfg = replace(cfg, announce_on_start=False)
if args.announce_period is not None:
+4 -1
View File
@@ -4,6 +4,8 @@ import threading
from dataclasses import asdict, dataclass, replace
from typing import TYPE_CHECKING, Any
from .constants import HUB_DEST_NAME
if TYPE_CHECKING:
from .service import HubService
@@ -14,7 +16,7 @@ class HubRuntimeConfig:
room_registry_path: str | None = None
configdir: str | None = None
identity_path: str | None = None
dest_name: str = "rrc.hub"
dest_name: str = HUB_DEST_NAME
announce_on_start: bool = True
announce_period_s: float = 0.0
hub_name: str = "rrc"
@@ -94,6 +96,7 @@ class ConfigManager:
allowed = set(asdict(base).keys())
allowed.discard("config_path")
allowed.discard("dest_name")
updates = {k: v for k, v in data.items() if k in allowed}
+1
View File
@@ -1,6 +1,7 @@
# RRC protocol constants (numeric keys and message types)
RRC_VERSION = 1
HUB_DEST_NAME = "rrc.hub"
# Envelope keys
K_V = 0
+10 -5
View File
@@ -5,6 +5,7 @@ import os
import signal
import threading
import time
from dataclasses import replace
from typing import Any
import RNS
@@ -13,6 +14,7 @@ from .codec import encode
from .commands import CommandHandler
from .config import ConfigManager, HubRuntimeConfig
from .constants import (
HUB_DEST_NAME,
T_PING,
)
from .envelope import make_envelope
@@ -29,7 +31,11 @@ from .util import expand_path
class HubService:
def __init__(self, config: HubRuntimeConfig) -> None:
self.config = config
self.config = (
replace(config, dest_name=HUB_DEST_NAME)
if config.dest_name != HUB_DEST_NAME
else config
)
self.log = logging.getLogger("rrcd.hub")
self._state_lock = threading.RLock()
self._shutdown = threading.Event()
@@ -66,9 +72,9 @@ class HubService:
self._load_registered_rooms_from_registry()
parts = [p for p in str(self.config.dest_name).split(".") if p]
parts = [p for p in HUB_DEST_NAME.split(".") if p]
if not parts:
raise ValueError("dest_name must not be empty")
raise ValueError("HUB_DEST_NAME must not be empty")
app_name, aspects = parts[0], parts[1:]
self.destination = RNS.Destination(
@@ -92,8 +98,7 @@ class HubService:
self._announce_thread.start()
self.log.info(
"Hub running dest_name=%s dest_hash=%s",
self.config.dest_name,
"Hub running at dest_hash=%s",
self.destination.hash.hex() if self.destination else "-",
)
self.log.info(
+26
View File
@@ -0,0 +1,26 @@
from __future__ import annotations
from pathlib import Path
import pytest
from rrcd.cli import _build_arg_parser, _write_default_config
from rrcd.constants import HUB_DEST_NAME
def test_arg_parser_rejects_dest_name_override() -> None:
parser = _build_arg_parser()
with pytest.raises(SystemExit):
parser.parse_args(["--dest-name", "custom.hub"])
def test_default_config_does_not_emit_dest_name_field(tmp_path: Path) -> None:
config_path = tmp_path / "rrcd.toml"
identity_path = tmp_path / "hub_identity"
_write_default_config(str(config_path), str(identity_path))
content = config_path.read_text(encoding="utf-8")
assert "dest_name =" not in content
assert f"Hubs always announce on {HUB_DEST_NAME!r}." in content
+36
View File
@@ -0,0 +1,36 @@
from __future__ import annotations
import logging
from rrcd.config import ConfigManager, HubRuntimeConfig
from rrcd.constants import HUB_DEST_NAME
from rrcd.service import HubService
class _FakeHub:
def __init__(self) -> None:
self.log = logging.getLogger("test")
self.config = HubRuntimeConfig()
def test_apply_config_data_ignores_dest_name_override() -> None:
manager = ConfigManager(_FakeHub())
base = HubRuntimeConfig()
updated = manager.apply_config_data(
base,
{
"dest_name": "custom.hub",
"hub": {"dest_name": "custom.hub"},
"hub_name": "custom-name",
},
)
assert updated.dest_name == HUB_DEST_NAME
assert updated.hub_name == "custom-name"
def test_service_normalizes_custom_dest_name() -> None:
service = HubService(HubRuntimeConfig(dest_name="custom.hub"))
assert service.config.dest_name == HUB_DEST_NAME