Files
agessaman 72e874f838 fix(regions): scope flood replies to the request's region
Build a core RegionMap from the node's served regions and wire it into
the dispatcher and every companion bridge, so a flood reply is re-scoped
to the region its request arrived under (or left plain for a wildcard /
direct request) -- matching firmware simple_repeater::sendFloodReply.
Previously replies went out plain, so a reply to a request in region B
was dropped by B-only repeaters.

The map is built once from the node-wide transport_keys table (each named
region -> RegionEntry, flags=REGION_DENY_FLOOD for deny-flood regions; the
'*' wildcard is deliberately not an entry so plain floods reply plain). A
single shared instance reaches the dispatcher and all bridges. Public
regions rely on name-hashing for their key; a stored key is carried only
when it is genuinely custom material the name would not reproduce, keeping
reply-matching aligned with the forwarding transport-code check.

Region edits at runtime (CLI, web API, Glass sync) all funnel through the
transport_keys CRUD methods, which now fire a post-commit change callback;
the daemon rebuilds the map and reassigns a fresh instance to the
dispatcher and every live bridge (atomic rebind, safe against an in-flight
find_match on the RX thread).

Requires openhop_core with Dispatcher.region_map / CompanionBridge.region_map.
2026-07-24 07:52:16 -07:00

125 lines
4.8 KiB
Python

"""Build a core :class:`RegionMap` from the repeater's served transport-key regions.
Core's flood-reply scoping (``region_map.apply_reply_scope``) re-scopes a flood
reply to the region its request arrived under, mirroring firmware
``simple_repeater::sendFloodReply``. For that to engage, the dispatcher and every
companion bridge need a ``RegionMap`` describing the named regions this repeater
serves. This module builds that map from the ``transport_keys`` table — the same
source ``login.LoginHelper._format_region_names`` reads.
Firmware-parity notes:
- The ``*`` wildcard (unscoped flood) is deliberately **not** a region entry. A
plain FLOOD request replies plain, so ``find_match`` must return ``None`` for
it. Wildcard handling lives in ``capture_recv_region`` (route-type based), not
here, and ``mesh.unscoped_flood_allow`` never changes the map contents.
- A deny-flood region carries ``REGION_DENY_FLOOD`` so
``find_match(mask=REGION_DENY_FLOOD)`` skips it => its request replies plain.
- The transport key is derived from the region name via ``get_auto_key_for`` —
the same derivation senders and the repeater's own outgoing floods use. An
explicit stored key is only carried when it is genuinely custom material the
name would not reproduce (a private ``$`` region, or imported key material via
Glass sync); a redundant key that disagreed with the name would silently
re-scope replies to the wrong code.
"""
from __future__ import annotations
import base64
import binascii
import logging
from typing import Optional
from openhop_core.protocol.region_map import REGION_DENY_FLOOD, RegionEntry, RegionMap
from openhop_core.protocol.transport_keys import get_auto_key_for
logger = logging.getLogger("RepeaterRegionMap")
def _decode_stored_key(raw) -> Optional[bytes]:
"""Decode a stored ``transport_key`` to 16 raw bytes, or ``None``.
Keys are stored base64-encoded (see ``SQLiteHandler.generate_transport_key``);
tolerate raw bytes too. Anything that is not exactly 16 bytes is ignored, so a
corrupt or wrong-length key falls back to name hashing rather than breaking
matching.
"""
if not raw:
return None
if isinstance(raw, (bytes, bytearray)):
key = bytes(raw)
else:
try:
key = base64.b64decode(str(raw), validate=True)
except (binascii.Error, ValueError):
return None
return key if len(key) == 16 else None
def build_region_map(config, sqlite_handler) -> RegionMap:
"""Return a :class:`RegionMap` of the named regions this repeater serves.
``config`` is currently unused (the ``*`` wildcard is not a map entry) but is
kept in the signature so a future config-driven region source stays a
drop-in change for every caller.
"""
region_map = RegionMap()
if sqlite_handler is None:
return region_map
try:
records = sqlite_handler.get_transport_keys()
except Exception as exc: # defensive: never let a bad read break startup
logger.warning("Failed to read transport keys for region map: %s", exc)
return region_map
for rec in records or []:
name = (rec.get("name") or "").strip()
# Skip empty names and the wildcard: a plain/unscoped flood replies plain,
# so find_match must not resolve it to a region.
if not name or name == "*":
continue
flood_policy = (rec.get("flood_policy") or "deny").strip().lower()
flags = 0 if flood_policy == "allow" else REGION_DENY_FLOOD
private_keys = None
key_bytes = _decode_stored_key(rec.get("transport_key"))
if key_bytes is not None:
if name.startswith("$"):
# Private region: core never name-hashes a "$" name, so the stored
# key is the only usable key. Without it the region matches nothing.
private_keys = [key_bytes]
else:
# Public region: rely on name hashing unless the stored key is
# genuinely custom material the name would not reproduce.
try:
derived = get_auto_key_for(name)
except ValueError:
derived = None
if derived != key_bytes:
private_keys = [key_bytes]
try:
region_id = int(rec.get("id") or 0)
except (TypeError, ValueError):
region_id = 0
parent_raw = rec.get("parent_id")
try:
parent = int(parent_raw) if parent_raw is not None else 0
except (TypeError, ValueError):
parent = 0
region_map.add_region(
RegionEntry(
id=region_id,
parent=parent,
flags=flags,
name=name,
private_keys=private_keys,
)
)
return region_map