diff --git a/repeater/config_manager.py b/repeater/config_manager.py index 9356a30..68c35f7 100644 --- a/repeater/config_manager.py +++ b/repeater/config_manager.py @@ -354,6 +354,15 @@ class ConfigManager: self.daemon.dispatcher.set_default_path_hash_mode(path_hash_mode) logger.info(f"Reloaded path hash mode: mesh.path_hash_mode={path_hash_mode}") + # mesh.default_region is firmware's default_scope, which decides + # the REPLY_SCOPE_DEFAULT row. Re-resolve it here rather than + # leaving it to the transport_keys hook: setting the default region + # over the web API writes the config *after* it creates the region, + # so that hook has already run against the previous value. + refresh_default_scope = getattr(self.daemon, "refresh_default_flood_scope", None) + if callable(refresh_default_scope): + refresh_default_scope() + if "radio_type" in sections: logger.info("radio_type change detected; service restart required") live_update_ok = False diff --git a/repeater/main.py b/repeater/main.py index e1e67f8..8454ac2 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -48,7 +48,7 @@ from repeater.identity_manager import IdentityConfigurationError, IdentityManage from repeater.logging_utils import normalize_log_level from repeater.neighbors_publisher import NeighborsPublisher from repeater.packet_router import PacketRouter -from repeater.region_map_builder import build_region_map +from repeater.region_map_builder import build_region_map, resolve_default_scope_key from repeater.sensors import SensorManager from repeater.utils_packet import create_scoped_advert_packet from repeater.web.http_server import HTTPStatsServer, _log_buffer @@ -256,6 +256,7 @@ class RepeaterDaemon: self._region_map = build_region_map(self.config, sqlite_handler) if self.dispatcher is not None: self.dispatcher.region_map = self._region_map + self.refresh_default_flood_scope() if sqlite_handler is not None and hasattr( sqlite_handler, "set_transport_keys_changed_callback" ): @@ -265,6 +266,50 @@ class RepeaterDaemon: len(self._region_map.regions), ) + def refresh_default_flood_scope(self) -> None: + """Re-resolve ``mesh.default_region`` onto the dispatcher's default scope. + + Firmware's ``simple_repeater`` holds a ``default_scope`` TransportKey and + answers the ``REPLY_SCOPE_DEFAULT`` row with + ``sendFloodScoped(default_scope, ...)``. Core expresses that key as + ``Dispatcher.default_flood_transport_key``, which its send-time resolver + reads for any flood packet no earlier layer decided -- so this is the half + that makes a reply whose request scope was unknowable go out scoped rather + than plain. See ``region_map_builder.resolve_default_scope_key`` for which + replies those are, and why plain ones die at hop 0. + + Re-run from three places, because three separate things change the answer: + boot (``_init_region_map``), a transport_keys edit that adds, removes or + re-keys the default region (``refresh_region_map``), and a runtime + ``mesh.default_region`` change (``ConfigManager.live_update_daemon``). + + Companion bridges are covered by this one assignment rather than needing + their own: a deferred reply from a bridge's login server reaches the shared + dispatcher unmarked, so it resolves here. That mirrors firmware + ``simple_room_server``, which keeps its own ``default_scope`` for exactly + these rows. A bridge's *outgoing requests* are a different matter -- those + take the bridge's own companion scope, which its app owns over the frame + protocol, as ``_prefs.default_scope_key`` does on a firmware companion. + """ + if self.dispatcher is None: + return + region_map = self._region_map + if region_map is None: + self.dispatcher.default_flood_transport_key = None + return + try: + key = resolve_default_scope_key(self.config, region_map) + except Exception: + # Never let a bad default-region value break TX: an unresolved default + # is a plain flood, which is firmware's null-default row. + logger.debug("Failed to resolve mesh.default_region", exc_info=True) + key = None + self.dispatcher.default_flood_transport_key = key + logger.info( + "Default flood scope %s", + "resolved from mesh.default_region" if key else "unset (replies flood un-scoped)", + ) + def refresh_region_map(self) -> None: """Rebuild the RegionMap and reassign it to the dispatcher and all bridges. @@ -285,6 +330,10 @@ class RepeaterDaemon: bridge.region_map = new_map except Exception: logger.debug("Failed to update region map on a companion bridge", exc_info=True) + # After the rebind, so the default resolves against the map that is now + # live: this hook is exactly where the default region gains, loses or + # re-keys its entry. + self.refresh_default_flood_scope() logger.info("Region map refreshed with %d served region(s)", len(new_map.regions)) async def initialize(self): diff --git a/repeater/region_map_builder.py b/repeater/region_map_builder.py index 052d329..17c90e7 100644 --- a/repeater/region_map_builder.py +++ b/repeater/region_map_builder.py @@ -122,3 +122,71 @@ def build_region_map(config, sqlite_handler) -> RegionMap: ) return region_map + + +def resolve_default_scope_key(config, region_map: RegionMap) -> Optional[bytes]: + """Return the transport key for ``mesh.default_region``, or ``None``. + + This is firmware's ``default_scope``. ``simple_repeater`` resolves it once at + boot from ``region_map.getDefaultRegion()`` via + ``getTransportKeysFor(*r, &default_scope, 1)``, and ``sendFloodReply`` uses it + for the ``REPLY_SCOPE_DEFAULT`` row: a reply whose request scope is + *unknowable* rather than un-scoped -- a DIRECT request we hold no return path + for, or a transport code that matched no served region. Firmware's comment on + that branch is why it matters:: + + // un-scoped would be dropped at hop 0 by repeaters running flood.max.unscoped=0 + + ``None`` is firmware's ``default_scope.isNull()``, which ``chooseReplyScope`` + turns into ``REPLY_SCOPE_NONE`` -- a plain flood. That is the correct answer + for an unset default and for the ``*`` wildcard, which is deliberately not a + region entry (see the module docstring). + + Resolution goes through the built map rather than hashing the configured name + directly, for parity with ``getDefaultRegion()``, which can only ever return a + region the map holds: + + - a ``$private`` default resolves to its stored key material, which its name + cannot reproduce; + - a default naming a region this repeater does not serve resolves to nothing, + rather than to a scope no local Region would match on the way back in; + - a deny-flood default still resolves, because ``REGION_DENY_FLOOD`` gates + *inbound* ``find_match``, not what this node may scope its own replies with. + + The name is matched case-insensitively against each region's display name (the + stored ``#`` stripped), as ``web.api_endpoints.default_region`` matches it when + auto-creating the region. The key itself always comes from the matched entry, + so it derives from the name the table actually holds. + """ + mesh_cfg = config.get("mesh", {}) if isinstance(config, dict) else {} + if not isinstance(mesh_cfg, dict): + return None + + raw = mesh_cfg.get("default_region") + name = str(raw).strip() if raw not in (None, "") else "" + if name.startswith("#"): + name = name[1:].strip() + if not name or name == "*": + return None + + needle = name.lower() + for region in region_map.regions: + display = (region.name or "").strip() + if display.startswith("#"): + display = display[1:] + if display.lower() == needle: + key = region_map.first_key_for(region) + if key is None: + logger.warning( + "mesh.default_region '%s' resolves to no usable transport key; " + "replies with an unknowable request scope will flood un-scoped", + name, + ) + return key + + logger.warning( + "mesh.default_region '%s' is not a served region; replies with an " + "unknowable request scope will flood un-scoped", + name, + ) + return None diff --git a/tests/test_region_map_wiring.py b/tests/test_region_map_wiring.py index 6a9d1cd..ee8ba0a 100644 --- a/tests/test_region_map_wiring.py +++ b/tests/test_region_map_wiring.py @@ -16,13 +16,32 @@ import base64 from types import SimpleNamespace from unittest.mock import MagicMock -from openhop_core.protocol.packet import Packet -from openhop_core.protocol.region_map import REGION_DENY_FLOOD -from openhop_core.protocol.transport_keys import get_auto_key_for, scope_packet +import pytest +from openhop_core.node.dispatcher import Dispatcher +from openhop_core.protocol.constants import ( + ROUTE_TYPE_DIRECT, + ROUTE_TYPE_FLOOD, + ROUTE_TYPE_TRANSPORT_FLOOD, +) +from openhop_core.protocol.packet import Packet +from openhop_core.protocol.region_map import ( + REGION_DENY_FLOOD, + RegionEntry, + RegionMap, + apply_reply_scope, + capture_recv_region, +) +from openhop_core.protocol.transport_keys import ( + calc_transport_code, + get_auto_key_for, + scope_packet, +) + +from repeater.config_manager import ConfigManager from repeater.data_acquisition.sqlite_handler import SQLiteHandler from repeater.main import RepeaterDaemon -from repeater.region_map_builder import build_region_map +from repeater.region_map_builder import build_region_map, resolve_default_scope_key class _FakeHandler: @@ -290,3 +309,253 @@ def test_daemon_wires_and_refreshes_region_map(tmp_path): handler.delete_transport_key(eu["id"]) assert [r.name for r in daemon.dispatcher.region_map.regions] == ["#usa"] assert daemon.companion_bridges[1].region_map is daemon.dispatcher.region_map + + +# --------------------------------------------------------------------------- +# resolve_default_scope_key: mesh.default_region -> firmware's default_scope +# --------------------------------------------------------------------------- +def _served_map(*names_and_policies): + """A RegionMap of ``(name, policy)`` pairs, as build_region_map would yield.""" + rm = RegionMap() + for i, (name, policy) in enumerate(names_and_policies, start=1): + flags = 0 if policy == "allow" else REGION_DENY_FLOOD + rm.add_region(RegionEntry(id=i, parent=0, flags=flags, name=name)) + return rm + + +def test_unset_default_region_resolves_to_no_key(): + """Firmware's ``default_scope.isNull()`` row: chooseReplyScope -> NONE, plain.""" + rm = _served_map(("#usa", "allow")) + assert resolve_default_scope_key({"mesh": {}}, rm) is None + assert resolve_default_scope_key({}, rm) is None + assert resolve_default_scope_key({"mesh": {"default_region": ""}}, rm) is None + + +def test_wildcard_default_region_resolves_to_no_key(): + """``*`` is not a region entry, so it carries no key: replies flood plain.""" + rm = _served_map(("#usa", "allow")) + assert resolve_default_scope_key({"mesh": {"default_region": "*"}}, rm) is None + + +def test_served_region_resolves_to_its_key(): + rm = _served_map(("#usa", "allow")) + assert resolve_default_scope_key({"mesh": {"default_region": "usa"}}, rm) == get_auto_key_for( + "#usa" + ) + + +def test_leading_hash_and_case_are_tolerated(): + """The web API matches on the display name case-insensitively; match it here. + + The key still comes from the matched entry, so it derives from the name the + transport_keys table actually holds -- not from the spelling in config. + """ + rm = _served_map(("#usa", "allow")) + expected = get_auto_key_for("#usa") + for spelling in ("#usa", "USA", " #UsA "): + assert resolve_default_scope_key({"mesh": {"default_region": spelling}}, rm) == expected + + +def test_unserved_default_region_resolves_to_no_key(): + """A default naming a region we do not serve must not invent a scope. + + Firmware cannot reach this state -- ``getDefaultRegion()`` only ever returns a + region the map holds -- so the closest parity is to resolve nothing rather + than to scope replies with a code no local Region would match on the way back. + """ + rm = _served_map(("#usa", "allow")) + assert resolve_default_scope_key({"mesh": {"default_region": "eu"}}, rm) is None + + +def test_private_default_region_uses_its_stored_key(): + """A ``$`` region is never name-hashed, so only stored material can resolve it.""" + custom = bytes(range(16)) + rm = RegionMap() + rm.add_region(RegionEntry(id=1, parent=0, flags=0, name="$vip", private_keys=[custom])) + assert resolve_default_scope_key({"mesh": {"default_region": "$vip"}}, rm) == custom + + +def test_private_default_region_without_a_key_resolves_to_nothing(): + rm = RegionMap() + rm.add_region(RegionEntry(id=1, parent=0, flags=0, name="$vip", private_keys=None)) + assert resolve_default_scope_key({"mesh": {"default_region": "$vip"}}, rm) is None + + +def test_deny_flood_default_region_still_resolves(): + """``REGION_DENY_FLOOD`` gates inbound ``find_match``, not our own replies. + + Firmware's ``default_scope`` is read straight from the region via + ``getTransportKeysFor``, with no flags test, and its web API forces the + default region to allow-flood anyway -- so a deny-flood default is a + misconfiguration, not a reason to strand replies un-scoped. + """ + rm = _served_map(("#usa", "deny")) + assert resolve_default_scope_key({"mesh": {"default_region": "usa"}}, rm) == get_auto_key_for( + "#usa" + ) + + +# --------------------------------------------------------------------------- +# The behaviour this exists for: the REPLY_SCOPE_DEFAULT row goes out scoped +# --------------------------------------------------------------------------- +def _core_defers_the_default_row() -> bool: + """Whether the installed core leaves rows 3/4 for the send layer. + + The wiring in this module is only *observable* on a core that defers + ``REPLY_SCOPE_DEFAULT``. An older core resolved every captured case inside + ``apply_reply_scope`` and marked the reply final, so both send-layer resolvers + skipped it and ``default_flood_transport_key`` was never consulted -- the + assignment was correct but inert. + + Probed by behaviour rather than by attribute, so this reports what the reply + path actually does. The two tests below skip on an older core; the rest of the + module does not depend on it. + """ + rm = _served_map(("#usa", "allow")) + req = Packet() + req.payload = bytearray(b"req-body") + req.header = ROUTE_TYPE_DIRECT + capture_recv_region(rm, req) + + reply = Packet() + reply.payload = bytearray(b"reply-body") + reply.header = ROUTE_TYPE_FLOOD + try: + apply_reply_scope(reply, req) + except Exception: + return False + return not getattr(reply, "_flood_scope_applied", False) + + +requires_deferred_default = pytest.mark.skipif( + not _core_defers_the_default_row(), + reason="installed openhop_core resolves REPLY_SCOPE_DEFAULT at RX and marks the " + "reply final, so the dispatcher default is never consulted; needs the core " + "reply-scope deferral", +) + + +def _direct_request(): + """A DIRECT request: firmware leaves recv_pkt_region NULL -> scope unknowable.""" + pkt = Packet() + pkt.payload = bytearray(b"req-body") + pkt.header = ROUTE_TYPE_DIRECT + return pkt + + +def _resolve_default_row(default_key): + """Drive a DEFAULT-row reply through core exactly as the repeater does. + + Capture on a DIRECT request resolves nothing, ``apply_reply_scope`` defers + (firmware rows 3/4 turn on whether a default is configured, which only the + send layer knows), and the dispatcher's resolver answers at TX. + """ + rm = _served_map(("#usa", "allow")) + req = _direct_request() + capture_recv_region(rm, req) + assert req._recv_region_key is None and req._recv_region_unscoped is False + + reply = Packet() + reply.payload = bytearray(b"reply-body") + reply.header = ROUTE_TYPE_FLOOD + apply_reply_scope(reply, req) + assert reply._flood_scope_applied is False, "rows 3/4 must defer to the send layer" + + dispatcher = Dispatcher(MagicMock()) + dispatcher.region_map = rm + dispatcher.default_flood_transport_key = default_key + dispatcher._apply_flood_scope(reply) + return reply + + +@requires_deferred_default +def test_default_row_reply_is_scoped_once_the_default_is_wired(): + """[the fix] Firmware answers this row with sendFloodScoped(default_scope, ...).""" + key = get_auto_key_for("#usa") + reply = _resolve_default_row(key) + + assert reply.get_route_type() == ROUTE_TYPE_TRANSPORT_FLOOD + assert reply.transport_codes[0] == calc_transport_code(key, reply) + + +@requires_deferred_default +def test_default_row_reply_floods_plain_without_the_wiring(): + """[the bug] Un-scoped here dies at hop 0 on repeaters with flood.max.unscoped=0. + + Pins what the wiring buys: the same reply, with no default resolved, stays a + plain flood -- which is correct for an unset default (firmware's null row) and + a deliverability failure when a default region *is* configured but never + reaches the dispatcher. + """ + reply = _resolve_default_row(None) + + assert reply.get_route_type() == ROUTE_TYPE_FLOOD + assert reply.transport_codes == [0, 0] + + +# --------------------------------------------------------------------------- +# Daemon + config wiring: the three places the answer can change +# --------------------------------------------------------------------------- +def test_daemon_wires_default_scope_at_boot_and_on_region_changes(tmp_path): + handler = SQLiteHandler(tmp_path) + handler.create_transport_key("#usa", "allow") + + daemon = RepeaterDaemon({"logging": {}, "mesh": {"default_region": "usa"}}) + daemon.repeater_handler = SimpleNamespace(storage=SimpleNamespace(sqlite_handler=handler)) + daemon.dispatcher = SimpleNamespace(region_map=None, default_flood_transport_key=None) + daemon.companion_bridges = {} + + daemon._init_region_map() + assert daemon.dispatcher.default_flood_transport_key == get_auto_key_for("#usa") + + # Deleting the default region leaves nothing to resolve: the storage hook + # reruns the resolve, so the stale key must not linger. + usa = next(r for r in handler.get_transport_keys() if r["name"] == "#usa") + handler.delete_transport_key(usa["id"]) + assert daemon.dispatcher.default_flood_transport_key is None + + # Re-creating it resolves again. + handler.create_transport_key("#usa", "allow") + assert daemon.dispatcher.default_flood_transport_key == get_auto_key_for("#usa") + + +def test_daemon_default_scope_is_none_when_no_default_region_configured(tmp_path): + handler = SQLiteHandler(tmp_path) + handler.create_transport_key("#usa", "allow") + + daemon = RepeaterDaemon({"logging": {}, "mesh": {}}) + daemon.repeater_handler = SimpleNamespace(storage=SimpleNamespace(sqlite_handler=handler)) + daemon.dispatcher = SimpleNamespace(region_map=None, default_flood_transport_key="stale") + daemon.companion_bridges = {} + + daemon._init_region_map() + assert daemon.dispatcher.default_flood_transport_key is None + + +def test_live_config_update_reresolves_the_default_scope(tmp_path): + """A runtime ``mesh.default_region`` change must not need a restart. + + The web API creates the region *before* writing the config, so the + transport_keys hook has already run against the previous value; the mesh + branch of ``live_update_daemon`` is what closes that gap. + """ + handler = SQLiteHandler(tmp_path) + handler.create_transport_key("#usa", "allow") + + daemon = RepeaterDaemon({"logging": {}, "mesh": {}}) + daemon.repeater_handler = SimpleNamespace(storage=SimpleNamespace(sqlite_handler=handler)) + daemon.dispatcher = SimpleNamespace( + region_map=None, + default_flood_transport_key=None, + rx_delay_base=0.0, + set_default_path_hash_mode=lambda mode: None, + ) + daemon.companion_bridges = {} + daemon._init_region_map() + assert daemon.dispatcher.default_flood_transport_key is None + + # The web API's order: region already exists, then config, then live update. + cm = ConfigManager("unused.yaml", {"mesh": {"default_region": "usa"}}, daemon) + cm.live_update_daemon(["mesh"]) + + assert daemon.dispatcher.default_flood_transport_key == get_auto_key_for("#usa")