Files
pyMC_Repeater/repeater/handler_helpers/text.py
T
agessaman 8875177088 fix(router): consume packets only on authenticated local handling (#353)
Route login, text, and protocol-request packets through a single
_consume_via_local_candidates fan-out that offers each one to every local
candidate sharing the one-byte destination hash — the companion bridge and the
room-server/repeater identity — and consumes it only when one MAC-verifies it.
A prefix collision with a remote node (or a forged packet) is left for the
forwarding engine instead of being swallowed.

- gate processed_by_injection on the core HandlerResult.authenticated
- protocol-request now forwards on collision instead of always marking handled
- add real-crypto integration tests (text, room-server login, protocol-request)
  and router fan-out tests covering companion + room-server collisions
2026-07-13 14:46:56 -07:00

702 lines
27 KiB
Python

"""
Text message (TXT_MSG) handling helper for openHop Repeater.
This module processes incoming text messages for all managed identities
(repeater identity + identity manager identities).
Also handles CLI commands for admin users on the repeater identity.
"""
import asyncio
import logging
import time
from openhop_core.node.handlers.text import TextMessageHandler
from openhop_core.protocol import CryptoUtils, Identity
from .mesh_cli import MeshCLI
from .room_server import RoomServer
logger = logging.getLogger("TextHelper")
# Text message type flags
TXT_TYPE_PLAIN = 0x00
TXT_TYPE_CLI_DATA = 0x01
class TextHelper:
def __init__(
self,
identity_manager,
packet_injector=None,
acl_dict=None,
log_fn=None,
config_path: str | None = None,
config: dict | None = None,
config_manager=None,
sqlite_handler=None,
send_advert_callback=None,
):
self.identity_manager = identity_manager
self.packet_injector = packet_injector
self.log_fn = log_fn or logger.info
self.acl_dict = acl_dict or {} # Per-identity ACLs keyed by hash_byte
self.sqlite_handler = sqlite_handler # For room server database operations
self.send_advert_callback = send_advert_callback # Callback to send repeater advert
# Dictionary of handlers keyed by dest_hash
self.handlers = {}
# Dictionary of room servers keyed by dest_hash
self.room_servers = {}
# Track repeater identity for CLI commands
self.repeater_hash = None
# Store config for later use
self.config_path = config_path
self.config = config
self.config_manager = config_manager
# Store for later CLI initialization (needs identity and storage)
self.config_path = config_path
self.config = config
# Initialize CLI handler later when repeater identity is registered
self.cli = None
self._pending_tasks = set()
self._loop: asyncio.AbstractEventLoop | None = None
def _track_task(self, task: asyncio.Task) -> None:
self._pending_tasks.add(task)
def _on_done(done_task: asyncio.Task) -> None:
self._pending_tasks.discard(done_task)
try:
done_task.result()
except asyncio.CancelledError:
pass
except Exception as e:
logger.error(f"Background text task failed: {e}", exc_info=True)
task.add_done_callback(_on_done)
def register_identity(
self, name: str, identity, identity_type: str = "room_server", radio_config=None
):
hash_byte = identity.get_public_key()[0]
# Get ACL for this identity
identity_acl = self.acl_dict.get(hash_byte)
if not identity_acl:
logger.warning(f"Cannot register identity '{name}': no ACL for hash 0x{hash_byte:02X}")
return
# Create a contacts wrapper from this identity's ACL
acl_contacts = self._create_acl_contacts_wrapper(identity_acl)
# Create TextMessageHandler for this identity
handler = TextMessageHandler(
local_identity=identity,
contacts=acl_contacts,
log_fn=self.log_fn,
send_packet_fn=self._send_packet,
radio_config=radio_config,
)
# Register by dest hash
hash_byte = identity.get_public_key()[0]
self.handlers[hash_byte] = {
"handler": handler,
"identity": identity,
"name": name,
"type": identity_type,
}
# Track repeater identity for CLI commands
if identity_type == "repeater":
self.repeater_hash = hash_byte
logger.info(f"Set repeater hash for CLI: 0x{hash_byte:02X}")
# Initialize CLI handler now that we have the repeater identity
if self.config_path and self.config and self.config_manager:
self.cli = MeshCLI(
self.config_path,
self.config,
self.config_manager,
identity_type="repeater",
enable_regions=True,
send_advert_callback=self.send_advert_callback,
identity=identity,
storage_handler=self.sqlite_handler,
)
logger.info(
"Initialized CLI handler for repeater commands with identity and storage"
)
# Create RoomServer instance for room_server identities
if identity_type == "room_server" and self.sqlite_handler:
try:
from .room_server import MAX_UNSYNCED_POSTS
room_config = radio_config or {}
max_posts = room_config.get("max_posts", MAX_UNSYNCED_POSTS)
# Enforce hard limit
if max_posts > MAX_UNSYNCED_POSTS:
logger.warning(
f"Room '{name}': Configured max_posts={max_posts} exceeds hard limit "
f"of {MAX_UNSYNCED_POSTS}, capping to {MAX_UNSYNCED_POSTS}"
)
max_posts = MAX_UNSYNCED_POSTS
room_server = RoomServer(
room_hash=hash_byte,
room_name=name,
local_identity=identity,
sqlite_handler=self.sqlite_handler,
packet_injector=self.packet_injector,
acl=identity_acl,
max_posts=max_posts,
config_path=self.config_path or "",
config=self.config or {},
config_manager=self.config_manager,
)
self.room_servers[hash_byte] = room_server
# Start sync loop — may be called from a non-async HTTP handler thread
try:
loop = asyncio.get_running_loop()
start_task = loop.create_task(room_server.start())
self._track_task(start_task)
except RuntimeError:
# No running event loop in this thread
if self._loop and self._loop.is_running():
future = asyncio.run_coroutine_threadsafe(room_server.start(), self._loop)
future.add_done_callback(
lambda f: (
logger.error(
f"Room server '{name}' failed: {f.exception()}",
exc_info=f.exception(),
)
if not f.cancelled() and f.exception()
else None
)
)
else:
logger.error(f"Cannot start room server '{name}': no event loop available")
logger.info(
f"Registered room server '{name}': hash=0x{hash_byte:02X}, "
f"max_posts={max_posts}"
)
except Exception as e:
logger.error(f"Failed to create room server '{name}': {e}", exc_info=True)
logger.info(f"Registered {identity_type} '{name}' text handler: hash=0x{hash_byte:02X}")
def _create_acl_contacts_wrapper(self, acl):
class ACLContactsWrapper:
def __init__(self, identity_acl):
self._acl = identity_acl
@property
def contacts(self):
contact_list = []
for client_info in self._acl.get_all_clients():
# Create a minimal contact object that TextMessageHandler needs
class ContactProxy:
def __init__(self, client):
self.public_key = client.id.get_public_key().hex()
self.name = f"client_{self.public_key[:8]}"
contact_list.append(ContactProxy(client_info))
return contact_list
return ACLContactsWrapper(acl)
async def process_text_packet(self, packet):
try:
if len(packet.payload) < 2:
return False
dest_hash = packet.payload[0]
src_hash = packet.payload[1]
handler_info = self.handlers.get(dest_hash)
if handler_info:
logger.debug(
f"Routing text message to '{handler_info['name']}': "
f"dest=0x{dest_hash:02X}, src=0x{src_hash:02X}"
)
# Let the handler attempt decryption. It authenticates only when
# the message actually decrypted for this identity. Otherwise the
# dest hash collided with ours (common with a 1-byte hash) but the
# packet is not really for us — do not consume it, so the engine can
# still forward/re-flood it (#353).
result = await handler_info["handler"](packet)
if not result.authenticated:
logger.debug(
f"TXT_MSG dest 0x{dest_hash:02X} did not decrypt for "
f"'{handler_info['name']}' (hash collision), allowing forward"
)
return False
# Call placeholder for custom processing
await self._on_message_received(
identity_name=handler_info["name"],
identity_type=handler_info["type"],
packet=packet,
dest_hash=dest_hash,
src_hash=src_hash,
)
# Mark packet as handled
packet.mark_do_not_retransmit()
return True
else:
logger.debug(f"No text handler for hash 0x{dest_hash:02X}, allowing forward")
return False
except Exception as e:
logger.error(f"Error processing text packet: {e}")
return False
async def _on_message_received(
self,
identity_name: str,
identity_type: str,
packet,
dest_hash: int,
src_hash: int,
):
# Placeholder - can be overridden or callback can be added
logger.debug(
f"Message received for {identity_type} '{identity_name}' from 0x{src_hash:02X}"
)
# Extract decrypted message if available
if hasattr(packet, "decrypted") and packet.decrypted:
message_text = packet.decrypted.get("text", "<unknown>")
sender_client = self._resolve_sender_client(dest_hash, src_hash, packet)
# Clean message text - remove null bytes and trailing whitespace
message_text = message_text.rstrip("\x00").rstrip()
logger.info(f"[{identity_type}:{identity_name}] Message: {message_text}")
# Handle room server messages
if identity_type == "room_server" and dest_hash in self.room_servers:
room_server = self.room_servers[dest_hash]
# Check if this is a CLI command FIRST (before storing as post)
if self._is_cli_command(message_text):
# Handle CLI command - do NOT store as post
if room_server and room_server.cli:
try:
# Check admin permission
is_admin = self._check_admin_permission_for_identity(
sender_client, dest_hash
)
if not is_admin:
logger.warning(
f"Room '{identity_name}': CLI command denied from 0x{src_hash:02X} (not admin)"
)
return
# Get sender's full pubkey
sender_pubkey = bytes([src_hash]) + b"\x00" * 31 # Default
if sender_client is not None:
sender_pubkey = sender_client.id.get_public_key()
# Handle CLI command
reply = room_server.cli.handle_command(
sender_pubkey=sender_pubkey, command=message_text, is_admin=is_admin
)
logger.info(
f"Room '{identity_name}': CLI command from 0x{src_hash:02X}: {message_text[:50]} -> {reply[:100]}"
)
# Send reply back to sender
handler_info = self.handlers.get(dest_hash)
if handler_info:
await self._send_cli_reply(
packet,
reply,
handler_info,
sender_client=sender_client,
)
except Exception as e:
logger.error(
f"Error processing room server CLI command: {e}", exc_info=True
)
# CLI command handled, don't store as post
return
# NOT a CLI command - store as regular room post
try:
# Get sender's full pubkey
sender_pubkey = bytes([src_hash]) + b"\x00" * 31 # Default
if sender_client is not None:
sender_pubkey = sender_client.id.get_public_key()
# Store message as post
sender_timestamp = int(time.time())
success = await room_server.add_post(
client_pubkey=sender_pubkey,
message_text=message_text,
sender_timestamp=sender_timestamp,
txt_type=TXT_TYPE_PLAIN,
)
if success:
logger.info(
f"Room '{identity_name}': New post from {sender_pubkey[:4].hex()}: {message_text[:50]}"
)
except Exception as e:
logger.error(f"Error storing room post: {e}", exc_info=True)
return
# Check if this is a CLI command to the repeater (AFTER decryption)
if dest_hash == self.repeater_hash and self.cli and self._is_cli_command(message_text):
try:
repeater_hash = self.repeater_hash
if repeater_hash is None:
return
# Check admin permission
is_admin = self._check_admin_permission_for_identity(
sender_client, repeater_hash
)
# If not admin, log and return without sending reply
if not is_admin:
logger.warning(
f"CLI command denied from 0x{src_hash:02X} (not admin): {message_text[:50]}"
)
return
# Get client for full public key
sender_pubkey = bytes([src_hash]) + b"\x00" * 31 # Default
if sender_client is not None:
sender_pubkey = sender_client.id.get_public_key()
# Handle CLI command
reply = self.cli.handle_command(
sender_pubkey=sender_pubkey, command=message_text, is_admin=is_admin
)
logger.info(
f"CLI command from 0x{src_hash:02X}: {message_text[:50]} -> {reply[:100]}"
)
# Send reply back to sender
handler_info = self.handlers.get(dest_hash)
if handler_info:
await self._send_cli_reply(
packet,
reply,
handler_info,
sender_client=sender_client,
)
except Exception as e:
logger.error(f"Error processing CLI command: {e}", exc_info=True)
async def _send_packet(self, packet, wait_for_ack: bool = False):
if self.packet_injector:
try:
return await self.packet_injector(packet, wait_for_ack=wait_for_ack)
except Exception as e:
logger.error(f"Error sending packet: {e}")
return False
else:
logger.error("No packet injector configured, cannot send packet")
return False
def set_message_callback(self, callback):
self._message_callback = callback
def list_registered_identities(self):
return [
{
"hash": hash_byte,
"name": info["name"],
"type": info["type"],
}
for hash_byte, info in self.handlers.items()
]
async def cleanup(self):
"""Cleanup room servers and handlers."""
# Stop all room server sync loops
for room_server in self.room_servers.values():
try:
await room_server.stop()
except Exception as e:
logger.error(f"Error stopping room server: {e}")
logger.info("TextHelper cleanup complete")
def _is_cli_command(self, message: str) -> bool:
"""Check if message looks like a CLI command."""
# Strip optional sequence prefix (XX|)
if len(message) > 4 and message[2] == "|":
message = message[3:].strip()
# Check for known command prefixes
command_prefixes = [
"get ",
"set ",
"reboot",
"advert",
"clock",
"time ",
"http ",
"password ",
"clear ",
"ver",
"board",
"neighbors",
"neighbor.",
"discover.",
"tempradio ",
"setperm ",
"region",
"sensor ",
"gps",
"log ",
"stats-",
"start ota",
]
return any(message.startswith(prefix) for prefix in command_prefixes)
def _check_admin_permission(self, src_hash: int) -> bool:
"""Check if sender has admin permissions for repeater (legacy method)."""
repeater_hash = self.repeater_hash
if repeater_hash is None:
return False
identity_acl = self.acl_dict.get(repeater_hash)
if not identity_acl:
return False
for client_info in identity_acl.get_all_clients():
pubkey = client_info.id.get_public_key()
if pubkey[0] == src_hash:
return self._check_admin_permission_for_identity(client_info, repeater_hash)
return False
def _check_admin_permission_for_identity(self, sender_client, identity_hash: int) -> bool:
"""Check admin permissions for a specific identity.
Accepts either a resolved ACL client object (preferred) or a legacy
``src_hash`` int for backwards-compatible helper/unit-test calls.
"""
# Get the identity's ACL
identity_acl = self.acl_dict.get(identity_hash)
if not identity_acl or sender_client is None:
return False
# Legacy compatibility: caller provided src_hash int instead of client.
if isinstance(sender_client, int):
src_hash = sender_client & 0xFF
for client_info in identity_acl.get_all_clients():
pubkey = client_info.id.get_public_key()
if pubkey[0] == src_hash:
permissions = getattr(client_info, "permissions", 0)
PERM_ACL_ADMIN = 0x02
return (permissions & 0x02) == PERM_ACL_ADMIN
return False
sender_pubkey = sender_client.id.get_public_key()
for client_info in identity_acl.get_all_clients():
if client_info.id.get_public_key() == sender_pubkey:
# Check admin bit (0x02 = PERM_ACL_ADMIN)
permissions = getattr(client_info, "permissions", 0)
PERM_ACL_ADMIN = 0x02
return (permissions & 0x02) == PERM_ACL_ADMIN
return False
def _get_shared_secret_for_client(self, client_info, identity) -> bytes:
"""Return shared secret for a client, deriving it when ACL cache is absent."""
shared_secret = getattr(client_info, "shared_secret", b"") or b""
if shared_secret:
return bytes(shared_secret)
if not identity:
return b""
try:
peer_pubkey = client_info.id.get_public_key()
peer_identity = Identity(peer_pubkey)
return peer_identity.calc_shared_secret(identity.get_private_key())
except Exception:
return b""
def _resolve_sender_client(
self,
identity_hash: int,
src_hash: int,
packet,
local_identity=None,
allow_hash_fallback: bool = False,
):
"""Resolve sender client by trying hash-collision candidates until decrypt succeeds."""
identity_acl = self.acl_dict.get(identity_hash)
if local_identity is None:
handler_info = self.handlers.get(identity_hash)
local_identity = handler_info.get("identity") if handler_info else None
if not identity_acl:
return None
# Optional compatibility path for direct helper calls/tests where we don't
# have decryptable payload bytes available; only accept a UNIQUE hash match.
if allow_hash_fallback and (not local_identity or len(packet.payload) < 4):
matches = [
c for c in identity_acl.get_all_clients() if c.id.get_public_key()[0] == src_hash
]
return matches[0] if len(matches) == 1 else None
if not local_identity or len(packet.payload) < 4:
return None
encrypted_data = bytes(packet.payload[2:])
for client_info in identity_acl.get_all_clients():
pubkey = client_info.id.get_public_key()
if pubkey[0] != src_hash:
continue
shared_secret = self._get_shared_secret_for_client(client_info, local_identity)
if len(shared_secret) < 16:
continue
try:
CryptoUtils.mac_then_decrypt(shared_secret[:16], shared_secret, encrypted_data)
return client_info
except Exception: # nosec B112
continue
return None
async def _send_cli_reply(
self,
original_packet,
reply_text: str,
handler_info: dict,
sender_client=None,
):
"""
Send CLI reply back to sender using TXT_MSG datagram.
Follows the C++ pattern (lines 603-609 in MyMesh.cpp):
- Creates TXT_MSG datagram with TXT_TYPE_CLI_DATA flag
- Encrypts with shared secret from ACL client
- Uses client->out_path_len to decide routing:
* if out_path_len < 0: sendFlood()
* else: sendDirect() with stored out_path
"""
import time
from openhop_core.protocol import PacketBuilder
from openhop_core.protocol.constants import PAYLOAD_TYPE_TXT_MSG
try:
src_hash = original_packet.payload[1]
dest_hash = original_packet.payload[0]
incoming_route = original_packet.get_route_type()
logger.debug(
f"CLI reply: original packet dest=0x{dest_hash:02X}, src=0x{src_hash:02X}, incoming_route={incoming_route}"
)
# Find the client in the DESTINATION identity's ACL (not always repeater!)
# dest_hash is the identity that received the command (repeater OR room server)
identity_acl = self.acl_dict.get(dest_hash)
if not identity_acl:
logger.error(f"No ACL found for identity 0x{dest_hash:02X} for CLI reply")
return
client = sender_client or self._resolve_sender_client(
dest_hash,
src_hash,
original_packet,
local_identity=handler_info.get("identity"),
allow_hash_fallback=True,
)
if not client:
logger.error(
f"Client 0x{src_hash:02X} not found in identity 0x{dest_hash:02X} ACL for CLI reply"
)
return
# Get shared secret from client
shared_secret = client.shared_secret
if not shared_secret or len(shared_secret) == 0:
logger.error(f"No shared secret for client 0x{src_hash:02X}")
return
# Build reply packet payload
# Format: timestamp(4) + flags(1) + reply_text
timestamp = int(time.time())
TXT_TYPE_CLI_DATA = 0x01
flags = TXT_TYPE_CLI_DATA << 2 # Upper 6 bits are txt_type
reply_bytes = reply_text.encode("utf-8")
plaintext = timestamp.to_bytes(4, "little") + bytes([flags]) + reply_bytes
# Decide routing based on client->out_path_len (C++ pattern)
# out_path is populated by PATH packets, NOT from incoming text message route
route_type = "flood" if client.out_path_len < 0 else "direct"
logger.debug(
f"CLI reply: client.out_path_len={client.out_path_len}, using route_type={route_type}"
)
reply_packet = PacketBuilder.create_datagram(
ptype=PAYLOAD_TYPE_TXT_MSG,
dest=client.id,
local_identity=handler_info["identity"],
secret=shared_secret,
plaintext=plaintext,
route_type=route_type,
)
# Add path for direct routing if available from PATH packets
if client.out_path_len >= 0 and len(client.out_path) > 0:
reply_packet.path = bytearray(client.out_path[: client.out_path_len])
reply_packet.path_len = client.out_path_len
logger.debug(
f"CLI reply: Added stored out_path - path_len={reply_packet.path_len}, path={[hex(b) for b in reply_packet.path]}"
)
# Send with delay (CLI_REPLY_DELAY_MILLIS = 600ms in C++)
CLI_REPLY_DELAY_MS = 600
await asyncio.sleep(CLI_REPLY_DELAY_MS / 1000.0)
await self._send_packet(reply_packet, wait_for_ack=False)
logger.info(
f"CLI reply sent to 0x{src_hash:02X} via {route_type.upper()}: {reply_text[:50]}"
)
except Exception as e:
logger.error(f"Error sending CLI reply: {e}", exc_info=True)