From 82f1a20f4431a5721b95c3301f31665f6ec597da Mon Sep 17 00:00:00 2001 From: Lloyd Date: Wed, 17 Dec 2025 14:06:16 +0000 Subject: [PATCH] Add PathHelper for processing PATH packets and update routing logic --- repeater/handler_helpers/__init__.py | 3 +- repeater/handler_helpers/path.py | 89 +++++++++++++++++ repeater/handler_helpers/repeater_cli.py | 5 + repeater/handler_helpers/text.py | 119 ++++++++++++++--------- repeater/main.py | 10 +- repeater/packet_router.py | 7 ++ 6 files changed, 184 insertions(+), 49 deletions(-) create mode 100644 repeater/handler_helpers/path.py diff --git a/repeater/handler_helpers/__init__.py b/repeater/handler_helpers/__init__.py index 9b6458b..e0e0990 100644 --- a/repeater/handler_helpers/__init__.py +++ b/repeater/handler_helpers/__init__.py @@ -5,5 +5,6 @@ from .discovery import DiscoveryHelper from .advert import AdvertHelper from .login import LoginHelper from .text import TextHelper +from .path import PathHelper -__all__ = ["TraceHelper", "DiscoveryHelper", "AdvertHelper", "LoginHelper", "TextHelper"] +__all__ = ["TraceHelper", "DiscoveryHelper", "AdvertHelper", "LoginHelper", "TextHelper", "PathHelper"] diff --git a/repeater/handler_helpers/path.py b/repeater/handler_helpers/path.py new file mode 100644 index 0000000..f9534b3 --- /dev/null +++ b/repeater/handler_helpers/path.py @@ -0,0 +1,89 @@ +import logging +import time + +logger = logging.getLogger("PathHelper") + + +class PathHelper: + def __init__(self, acl_dict=None, log_fn=None): + + self.acl_dict = acl_dict or {} + self.log_fn = log_fn or logger.info + + async def process_path_packet(self, packet): + + from pymc_core.protocol.crypto import CryptoUtils + + try: + if len(packet.payload) < 2: + return False + + dest_hash = packet.payload[0] + src_hash = packet.payload[1] + + # Get the ACL for this destination identity + identity_acl = self.acl_dict.get(dest_hash) + if not identity_acl: + logger.debug(f"No ACL for dest 0x{dest_hash:02X}, allowing forward") + return False + + # Find the client by source hash + client = None + for client_info in identity_acl.get_all_clients(): + pubkey = client_info.id.get_public_key() + if pubkey[0] == src_hash: + client = client_info + break + + if not client: + logger.debug(f"PATH packet from unknown client 0x{src_hash:02X}, allowing forward") + return False + + # Get shared secret for decryption + shared_secret = client.shared_secret + if not shared_secret or len(shared_secret) == 0: + logger.debug(f"No shared secret for client 0x{src_hash:02X}, cannot decrypt PATH") + return False + + # Decrypt the PATH packet payload + # Payload format: dest_hash(1) + src_hash(1) + mac(2) + encrypted_data + if len(packet.payload) < 4: + logger.debug(f"PATH packet too short: {len(packet.payload)} bytes") + return False + + mac_and_data = packet.payload[2:] # Skip dest_hash and src_hash + decrypted = CryptoUtils.mac_then_decrypt(shared_secret, mac_and_data) + + if not decrypted: + logger.debug(f"Failed to decrypt PATH packet from 0x{src_hash:02X}") + return False + + # Parse decrypted PATH data + # Format: path_len(1) + path[path_len] + extra_type(1) + extra[...] + if len(decrypted) < 1: + logger.debug(f"Decrypted PATH data too short") + return False + + path_len = decrypted[0] + if len(decrypted) < 1 + path_len: + logger.debug(f"PATH data truncated: need {1 + path_len} bytes, got {len(decrypted)}") + return False + + path_data = decrypted[1:1 + path_len] + + # Update client's out_path (same as C++ memcpy) + client.out_path = bytearray(path_data) + client.out_path_len = path_len + client.last_activity = int(time.time()) + + logger.info( + f"Updated out_path for client 0x{src_hash:02X} -> 0x{dest_hash:02X}: " + f"path_len={path_len}, path={[hex(b) for b in path_data]}" + ) + + # Don't mark as do_not_retransmit - let it forward normally + return False + + except Exception as e: + logger.error(f"Error processing PATH packet: {e}", exc_info=True) + return False diff --git a/repeater/handler_helpers/repeater_cli.py b/repeater/handler_helpers/repeater_cli.py index 316ce84..72487ee 100644 --- a/repeater/handler_helpers/repeater_cli.py +++ b/repeater/handler_helpers/repeater_cli.py @@ -52,14 +52,18 @@ class RepeaterCLI: if not is_admin: return "Error: Admin permission required" + logger.debug(f"handle_command received: '{command}' (len={len(command)})") + # Extract optional sequence prefix (XX|) prefix = "" if len(command) > 4 and command[2] == '|': prefix = command[:3] command = command[3:] + logger.debug(f"Extracted prefix: '{prefix}', remaining command: '{command}'") # Strip leading/trailing whitespace command = command.strip() + logger.debug(f"After strip: '{command}'") # Route to appropriate handler reply = self._route_command(command) @@ -203,6 +207,7 @@ class RepeaterCLI: def _cmd_get(self, param: str) -> str: """Handle get commands.""" param = param.strip() + logger.debug(f"_cmd_get called with param: '{param}' (len={len(param)})") if param == "af": af = self.repeater_config.get('airtime_factor', 1.0) diff --git a/repeater/handler_helpers/text.py b/repeater/handler_helpers/text.py index 12c7fea..bbd3fdf 100644 --- a/repeater/handler_helpers/text.py +++ b/repeater/handler_helpers/text.py @@ -120,41 +120,7 @@ class TextHelper: f"dest=0x{dest_hash:02X}, src=0x{src_hash:02X}" ) - # Check if this is a CLI command to the repeater - if dest_hash == self.repeater_hash and self.cli: - # Try to extract message text - try: - # Assume payload format: [dest_hash, src_hash, ...message...] - message_bytes = packet.payload[2:] - message_text = message_bytes.decode('utf-8', errors='ignore').strip() - - # Check if it's a CLI command - if self._is_cli_command(message_text): - # Check admin permission - is_admin = self._check_admin_permission(src_hash) - - # Get sender's public key for CLI - sender_pubkey = bytes([src_hash]) + b'\x00' * 31 # Placeholder - - # 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 using TXT_MSG - await self._send_cli_reply(packet, reply, handler_info) - - packet.mark_do_not_retransmit() - return True - except Exception as e: - logger.error(f"Error processing CLI command: {e}") - # Fall through to normal text handling - - # Normal text message handling + # Let handler decrypt the message first await handler_info["handler"](packet) # Call placeholder for custom processing @@ -194,12 +160,53 @@ class TextHelper: f"from 0x{src_hash:02X}" ) - # Example: Extract decrypted message if available + # Extract decrypted message if available if hasattr(packet, "decrypted") and packet.decrypted: message_text = packet.decrypted.get("text", "") + + # 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}" ) + + # 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: + # Check admin permission + is_admin = self._check_admin_permission(src_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 + repeater_acl = self.acl_dict.get(self.repeater_hash) + sender_pubkey = bytes([src_hash]) + b'\x00' * 31 # Default + if repeater_acl: + for client_info in repeater_acl.get_all_clients(): + if client_info.id.get_public_key()[0] == src_hash: + sender_pubkey = client_info.id.get_public_key() + break + + # 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) + + 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): @@ -245,7 +252,7 @@ class TextHelper: 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 (bit 0x01).""" + """Check if sender has admin permissions (bit 0x02).""" # Get the repeater's ACL repeater_acl = self.acl_dict.get(self.repeater_hash) if not repeater_acl: @@ -256,9 +263,10 @@ class TextHelper: for client_info in clients: pubkey = client_info.id.get_public_key() if pubkey[0] == src_hash: - # Check admin bit (0x01) + # Check admin bit (0x02 = PERM_ACL_ADMIN) permissions = getattr(client_info, 'permissions', 0) - return (permissions & 0x01) != 0 + PERM_ACL_ADMIN = 0x02 + return (permissions & 0x02) == PERM_ACL_ADMIN return False @@ -266,10 +274,12 @@ class TextHelper: """ Send CLI reply back to sender using TXT_MSG datagram. - Follows the C++ pattern: + 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 - - Sends via flood or direct depending on client's out_path + - Uses client->out_path_len to decide routing: + * if out_path_len < 0: sendFlood() + * else: sendDirect() with stored out_path """ from pymc_core.protocol import PacketBuilder, Identity from pymc_core.protocol.constants import PAYLOAD_TYPE_TXT_MSG @@ -277,6 +287,10 @@ class TextHelper: 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 repeater's ACL to get shared secret repeater_acl = self.acl_dict.get(self.repeater_hash) @@ -310,27 +324,38 @@ class TextHelper: reply_bytes = reply_text.encode('utf-8') plaintext = timestamp.to_bytes(4, 'little') + bytes([flags]) + reply_bytes - # Create datagram using PacketBuilder + # 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="flood" if client.out_path_len < 0 else "direct" + route_type=route_type ) - # Add path for direct routing if available + # Debug reply packet structure + if len(reply_packet.payload) >= 2: + reply_dest_hash = reply_packet.payload[0] + reply_src_hash = reply_packet.payload[1] + logger.debug(f"CLI reply: Packet created - dest=0x{reply_dest_hash:02X}, src=0x{reply_src_hash:02X}, route={reply_packet.get_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 = 1500ms in C++) - CLI_REPLY_DELAY_MS = 1500 + # 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}: {reply_text[:50]}") + 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) diff --git a/repeater/main.py b/repeater/main.py index ecbafd7..280a024 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -6,7 +6,7 @@ import sys from repeater.config import get_radio_for_board, load_config from repeater.engine import RepeaterHandler from repeater.web.http_server import HTTPStatsServer, _log_buffer -from repeater.handler_helpers import TraceHelper, DiscoveryHelper, AdvertHelper, LoginHelper, TextHelper +from repeater.handler_helpers import TraceHelper, DiscoveryHelper, AdvertHelper, LoginHelper, TextHelper, PathHelper from repeater.packet_router import PacketRouter from repeater.identity_manager import IdentityManager @@ -30,6 +30,7 @@ class RepeaterDaemon: self.discovery_helper = None self.login_helper = None self.text_helper = None + self.path_helper = None self.acl = None self.router = None @@ -214,6 +215,13 @@ class RepeaterDaemon: ) logger.info("Text message processing helper initialized") + + # Initialize PATH packet helper for updating client out_path + self.path_helper = PathHelper( + acl_dict=self.login_helper.get_acl_dict(), # Per-identity ACLs + log_fn=logger.info, + ) + logger.info("PATH packet processing helper initialized") except Exception as e: logger.error(f"Failed to initialize dispatcher: {e}") diff --git a/repeater/packet_router.py b/repeater/packet_router.py index 7ef613a..08c8381 100644 --- a/repeater/packet_router.py +++ b/repeater/packet_router.py @@ -6,6 +6,7 @@ from pymc_core.node.handlers.control import ControlHandler from pymc_core.node.handlers.advert import AdvertHandler from pymc_core.node.handlers.login_server import LoginServerHandler from pymc_core.node.handlers.text import TextMessageHandler +from pymc_core.node.handlers.path import PathHandler logger = logging.getLogger("PacketRouter") @@ -108,6 +109,12 @@ class PacketRouter: if handled: processed_by_injection = True + elif payload_type == PathHandler.payload_type(): + # Process PATH packet to update client out_path for direct routing + if self.daemon.path_helper: + await self.daemon.path_helper.process_path_packet(packet) + # Note: process_path_packet returns False to allow forwarding + # Only pass to repeater engine if not already processed by injection if self.daemon.repeater_handler and not processed_by_injection: metadata = {