feat: enhance login handler with anonymous request support and region name formatting

- Updated the `LoginHelper` class to wrap the login handler in an `AnonRequestHandler`, allowing for proper handling of anonymous requests.
- Introduced methods for formatting region names based on flood policies and added support for retrieving transport keys from SQLite storage.
- Enhanced the constructor to accept additional parameters for SQLite handler and configuration, improving flexibility for owner-info and feature-flag replies.
- Added tests to validate the new functionality and ensure correct behavior of region name formatting and owner/features callbacks.
This commit is contained in:
agessaman
2026-05-30 16:19:52 -07:00
parent 6295f0fce1
commit 5fcb6255d5
3 changed files with 168 additions and 7 deletions
+102 -5
View File
@@ -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"
)
+6
View File
@@ -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
@@ -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())