diff --git a/repeater/handler_helpers/login.py b/repeater/handler_helpers/login.py index 6c05a4e..4db7c98 100644 --- a/repeater/handler_helpers/login.py +++ b/repeater/handler_helpers/login.py @@ -6,7 +6,9 @@ This module processes login requests and manages authentication for all identiti import asyncio import logging +import time +from pymc_core.node.handlers.anon_request import AnonRateLimiter, AnonRequestHandler from pymc_core.node.handlers.login_server import LoginServerHandler from pymc_core.protocol.constants import PAYLOAD_TYPE_ANON_REQ @@ -14,15 +16,27 @@ logger = logging.getLogger("LoginHelper") class LoginHelper: - def __init__(self, identity_manager, packet_injector=None, log_fn=None): + def __init__( + self, + identity_manager, + packet_injector=None, + log_fn=None, + sqlite_handler=None, + config=None, + ): self.identity_manager = identity_manager self.packet_injector = packet_injector self.log_fn = log_fn or logger.info + self.sqlite_handler = sqlite_handler + self.config = config or {} self.handlers = {} self.acls = {} # Per-identity ACLs keyed by hash_byte self._pending_tasks = set() + # Shared across all identities so the node's total anon-reply rate is + # bounded (mirrors firmware anon_limiter: ~4 requests / 2 min). + self.anon_limiter = AnonRateLimiter() def _track_task(self, task: asyncio.Task) -> None: self._pending_tasks.add(task) @@ -126,12 +140,88 @@ class LoginHelper: is_room_server=(identity_type == "room_server"), ) - handler.set_send_packet_callback(self._send_packet_with_delay) + # Wrap the login handler in an anon-request dispatcher so anonymous + # regions/owner/basic discovery queries are answered instead of being + # mis-parsed as failed logins (MeshCore 1.16.0 discovery feature). + anon_handler = AnonRequestHandler( + local_identity=identity, + log_fn=self.log_fn, + login_handler=handler, + anon_limiter=self.anon_limiter, + region_names_fn=self._format_region_names, + owner_info_fn=self._make_owner_info_fn(name, config), + features_fn=self._make_features_fn(config), + clock_fn=lambda: int(time.time()), + ) + # Wires the send callback through to both the wrapper and login handler. + anon_handler.set_send_packet_callback(self._send_packet_with_delay) - self.handlers[hash_byte] = handler + self.handlers[hash_byte] = anon_handler logger.info(f"Registered {identity_type} '{name}' login handler: hash=0x{hash_byte:02X}") + def _format_region_names(self) -> str: + """Build the comma-separated region-names string for an anon regions reply. + + Mirrors firmware ``RegionMap::exportNamesTo`` with ``REGION_DENY_FLOOD``: + emit the ``*`` wildcard region first (unless unscoped flood is denied), + then each allow-flood named region with a leading ``#`` stripped, with no + trailing comma. The firmware wildcard is the always-present default flood + scope; pyMC_repeater models that via ``mesh.unscoped_flood_allow`` + (falling back to ``mesh.global_flood_allow``, default allow). + """ + parts = [] + + mesh_cfg = self.config.get("mesh", {}) if isinstance(self.config, dict) else {} + unscoped_allow = mesh_cfg.get( + "unscoped_flood_allow", mesh_cfg.get("global_flood_allow", True) + ) + if unscoped_allow: + parts.append("*") + + if self.sqlite_handler: + try: + keys = self.sqlite_handler.get_transport_keys() + except Exception as e: + logger.warning(f"Failed to read transport keys for regions reply: {e}") + keys = [] + for rec in keys or []: + if rec.get("flood_policy", "deny") != "allow": + continue + name = (rec.get("name") or "").strip() + if not name or name == "*": + continue # wildcard handled above + parts.append(name[1:] if name.startswith("#") else name) + + return ",".join(parts) + + @staticmethod + def _make_owner_info_fn(name: str, config: dict): + """Build an owner-info callback returning ``(node_name, owner_info)``.""" + + def owner_info_fn(): + cfg = config or {} + repeater_cfg = cfg.get("repeater", {}) + node_name = repeater_cfg.get("node_name") or name or "pyMC" + owner = repeater_cfg.get("owner_info", "") or "" + return (node_name, owner) + + return owner_info_fn + + @staticmethod + def _make_features_fn(config: dict): + """Build a feature-flags callback (bit0 = bridge, bit7 = forwarding disabled).""" + + def features_fn(): + cfg = config or {} + mode = cfg.get("repeater", {}).get("mode", "forward") + features = 0 + if mode != "forward": # monitor / no_tx => not forwarding + features |= 0x80 + return features + + return features_fn + async def process_login_packet(self, packet): try: @@ -147,9 +237,16 @@ class LoginHelper: packet.mark_do_not_retransmit() return True else: - # ANON_REQ to other nodes (e.g. owner-info to firmware) is normal; skip log to avoid spam + # ANON_REQ to other nodes (e.g. another repeater's regions/owner + # query overheard on-air) is normal; log at DEBUG so the dest is + # visible when diagnosing "why didn't my repeater answer". ptype = getattr(packet, "get_payload_type", lambda: None)() - if ptype != PAYLOAD_TYPE_ANON_REQ: + if ptype == PAYLOAD_TYPE_ANON_REQ: + logger.debug( + f"ANON_REQ for hash 0x{dest_hash:02X} not addressed to a local " + f"identity ({sorted(f'0x{h:02X}' for h in self.handlers)}); ignoring" + ) + else: logger.debug( f"No login handler registered for hash 0x{dest_hash:02X}, allowing forward" ) diff --git a/repeater/main.py b/repeater/main.py index 8646a1b..8646919 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -268,6 +268,12 @@ class RepeaterDaemon: identity_manager=self.identity_manager, packet_injector=self.router.inject_packet, log_fn=logger.info, + sqlite_handler=( + self.repeater_handler.storage.sqlite_handler + if self.repeater_handler and self.repeater_handler.storage + else None + ), # For anon regions-discovery replies + config=self.config, # For owner-info / feature-flags replies ) # Register default repeater identity diff --git a/tests/test_handler_helpers_trace_discovery_login.py b/tests/test_handler_helpers_trace_discovery_login.py index 593976e..54d21fe 100644 --- a/tests/test_handler_helpers_trace_discovery_login.py +++ b/tests/test_handler_helpers_trace_discovery_login.py @@ -251,12 +251,16 @@ def test_login_register_identity_repeater_creates_acl_and_handler(): identity = FakeIdentity(0x52) acl_obj = MagicMock() handler_obj = MagicMock() + anon_obj = MagicMock() with ( patch("repeater.handler_helpers.acl.ACL", return_value=acl_obj) as acl_cls, patch( "repeater.handler_helpers.login.LoginServerHandler", return_value=handler_obj ) as handler_cls, + patch( + "repeater.handler_helpers.login.AnonRequestHandler", return_value=anon_obj + ) as anon_cls, ): helper.register_identity( name="repeater-main", @@ -271,11 +275,65 @@ def test_login_register_identity_repeater_creates_acl_and_handler(): acl_cls.assert_called_once() handler_cls.assert_called_once() - handler_obj.set_send_packet_callback.assert_called_once() - assert helper.handlers[0x52] is handler_obj + # The login handler is wrapped in an AnonRequestHandler, and that wrapper is + # what gets stored + wired with the send callback. + anon_cls.assert_called_once() + assert anon_cls.call_args.kwargs["login_handler"] is handler_obj + anon_obj.set_send_packet_callback.assert_called_once() + assert helper.handlers[0x52] is anon_obj assert helper.acls[0x52] is acl_obj +class _FakeSqlite: + def __init__(self, keys): + self._keys = keys + + def get_transport_keys(self): + return self._keys + + +def test_format_region_names_filters_and_strips(): + keys = [ + {"name": "#VHF", "flood_policy": "allow"}, + {"name": "USA", "flood_policy": "allow"}, + {"name": "secret", "flood_policy": "deny"}, + {"name": "*", "flood_policy": "allow"}, # duplicate wildcard ignored + {"name": "", "flood_policy": "allow"}, + ] + # Default config => unscoped flood allowed => wildcard '*' present. + helper = LoginHelper(identity_manager=MagicMock(), sqlite_handler=_FakeSqlite(keys)) + # Wildcard first (from policy), '#' stripped, deny + empty + literal '*' excluded. + assert helper._format_region_names() == "*,VHF,USA" + + +def test_format_region_names_wildcard_suppressed_when_unscoped_denied(): + keys = [{"name": "USA", "flood_policy": "allow"}] + helper = LoginHelper( + identity_manager=MagicMock(), + sqlite_handler=_FakeSqlite(keys), + config={"mesh": {"unscoped_flood_allow": False}}, + ) + # No wildcard when unscoped flood is denied (firmware: wildcard deny-flood). + assert helper._format_region_names() == "USA" + + +def test_format_region_names_without_storage_is_just_wildcard(): + # No named regions, but unscoped flood allowed by default => bare wildcard. + helper = LoginHelper(identity_manager=MagicMock(), sqlite_handler=None) + assert helper._format_region_names() == "*" + + +def test_owner_and_features_callbacks_from_config(): + config = {"repeater": {"node_name": "node-x", "owner_info": "me", "mode": "monitor"}} + helper = LoginHelper(identity_manager=MagicMock(), config=config) + + assert helper._make_owner_info_fn("fallback", config)() == ("node-x", "me") + # Non-forward mode sets the forwarding-disabled bit (0x80). + assert helper._make_features_fn(config)() == 0x80 + # Forwarding mode clears it. + assert helper._make_features_fn({"repeater": {"mode": "forward"}})() == 0x00 + + @pytest.mark.asyncio async def test_login_process_packet_routes_to_registered_handler_and_marks_no_retransmit(): helper = LoginHelper(identity_manager=MagicMock(), packet_injector=AsyncMock())