diff --git a/CHANGELOG.md b/CHANGELOG.md index 95b7aeb..b210417 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index bcafb16..4941d86 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/rrcd/cli.py b/rrcd/cli.py index f9bdd01..d4cf098 100644 --- a/rrcd/cli.py +++ b/rrcd/cli.py @@ -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: diff --git a/rrcd/config.py b/rrcd/config.py index 00ba6ec..2e065c2 100644 --- a/rrcd/config.py +++ b/rrcd/config.py @@ -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} diff --git a/rrcd/constants.py b/rrcd/constants.py index d4d12df..8b4fb35 100644 --- a/rrcd/constants.py +++ b/rrcd/constants.py @@ -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 diff --git a/rrcd/service.py b/rrcd/service.py index 7f43436..8ccbd6d 100644 --- a/rrcd/service.py +++ b/rrcd/service.py @@ -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( diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..7e585ab --- /dev/null +++ b/tests/test_cli.py @@ -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 \ No newline at end of file diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..2177b33 --- /dev/null +++ b/tests/test_config.py @@ -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 \ No newline at end of file