fix(region-map): resolve mesh.default_region onto the dispatcher default scope

Firmware's ``simple_repeater`` resolves a ``default_scope`` TransportKey at boot
from ``region_map.getDefaultRegion()`` and answers the ``REPLY_SCOPE_DEFAULT``
row with ``sendFloodScoped(default_scope, ...)``. The comment on that branch is
the whole point of it::

    // un-scoped would be dropped at hop 0 by repeaters running flood.max.unscoped=0

We had the config for it -- ``mesh.default_region``, which the web API already
auto-creates as an allow-flood region -- but nothing carried it to
``Dispatcher.default_flood_transport_key``, so it only ever reached adverts and
the neighbours publisher. Core's send-time resolver therefore found no default
and left the reply a plain flood.

The row is reached whenever the request's scope is *unknowable* rather than
un-scoped: a DIRECT request we hold no return path for, or a TRANSPORT_FLOOD
whose code matched no served region. Both are ordinary traffic, and on a mesh
running ``flood.max.unscoped = 0`` both replies died at hop 0 while firmware
would have delivered them.

``resolve_default_scope_key`` resolves through the built RegionMap rather than
hashing the configured name, for parity with ``getDefaultRegion()``, which can
only return a region the map holds: a ``$private`` default gets its stored key
material, and a default naming an unserved region resolves to nothing rather
than to a scope no local Region would match on the way back in. An unset default
and the ``*`` wildcard resolve to None, which is firmware's
``default_scope.isNull()`` row -- a plain flood.

Re-resolved from the three places that change the answer: boot, the
transport_keys change hook, and a runtime ``mesh.default_region`` edit. That last
one matters because the web API creates the region *before* writing the config,
so the storage hook has already run against the previous value.

Companion bridges need no separate wiring: a deferred reply from a bridge's
login server reaches the shared dispatcher unmarked, so it resolves here, which
is what firmware ``simple_room_server`` does with its own ``default_scope``.

The two end-to-end tests skip on a core that still resolves REPLY_SCOPE_DEFAULT
at RX and marks the reply final, since the assignment is correct but inert
there. They are gated on the behaviour rather than on a version, so they start
running by themselves once that core lands.
This commit is contained in:
agessaman authored and Rightup committed 2026-09-07 22:27:50 +01:00
1 parent 0cecb17060
commit 6ca305fa6a
4 files changed
+400 -5

No files matched your search

+9
View File
@@ -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
+50 -1
View File
@@ -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):
+68
View File
@@ -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
+273 -4
View File
@@ -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")