mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-10-31 04:01:59 +01:00
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:
1 parent
0cecb17060
commit
6ca305fa6a
4 files changed
+400
-5
No files matched your search
@@ -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")
|
||||
Reference in new issue
Block a user