diff --git a/repeater/handler_helpers/mesh_cli.py b/repeater/handler_helpers/mesh_cli.py new file mode 100644 index 0000000..444e6c2 --- /dev/null +++ b/repeater/handler_helpers/mesh_cli.py @@ -0,0 +1,578 @@ +import logging +from typing import Optional, Dict, Any, Callable +import yaml +from pathlib import Path +import time + +logger = logging.getLogger(__name__) + + +class MeshCLI: + + def __init__( + self, + config_path: str, + config: Dict[str, Any], + save_config_callback: Callable, + identity_type: str = "repeater", + enable_regions: bool = True, + send_advert_callback: Optional[Callable] = None + ): + + self.config_path = Path(config_path) + self.config = config + self.save_config = save_config_callback + self.identity_type = identity_type + self.enable_regions = enable_regions + self.send_advert_callback = send_advert_callback + + # Get repeater config shortcut + self.repeater_config = config.get('repeater', {}) + + def handle_command(self, sender_pubkey: bytes, command: str, is_admin: bool) -> str: + + # Check admin permission first + 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) + + # Add prefix back to reply if present + if prefix: + return prefix + reply + return reply + + def _route_command(self, command: str) -> str: + + # System commands + if command == "reboot": + return self._cmd_reboot() + elif command == "advert": + return self._cmd_advert() + elif command.startswith("clock"): + return self._cmd_clock(command) + elif command.startswith("time "): + return self._cmd_time(command) + elif command == "start ota": + return "Error: OTA not supported in Python repeater" + elif command.startswith("password "): + return self._cmd_password(command) + elif command == "clear stats": + return self._cmd_clear_stats() + elif command == "ver": + return self._cmd_version() + + # Get commands + elif command.startswith("get "): + return self._cmd_get(command[4:]) + + # Set commands + elif command.startswith("set "): + return self._cmd_set(command[4:]) + + # ACL commands + elif command.startswith("setperm "): + return self._cmd_setperm(command) + elif command == "get acl": + return "Error: Use 'get acl' via serial console only" + + # Region commands (repeaters only) + elif command.startswith("region"): + if self.enable_regions: + return self._cmd_region(command) + else: + return "Error: Region commands not available for room servers" + + # Neighbor commands + elif command == "neighbors": + return self._cmd_neighbors() + elif command.startswith("neighbor.remove "): + return self._cmd_neighbor_remove(command) + + # Temporary radio params + elif command.startswith("tempradio "): + return self._cmd_tempradio(command) + + # Sensor commands + elif command.startswith("sensor "): + return "Error: Sensor commands not implemented in Python repeater" + + # GPS commands + elif command.startswith("gps"): + return "Error: GPS commands not implemented in Python repeater" + + # Logging commands + elif command.startswith("log "): + return self._cmd_log(command) + + # Statistics commands + elif command.startswith("stats-"): + return "Error: Stats commands not fully implemented yet" + + else: + return "Unknown command" + + # ==================== System Commands ==================== + + def _cmd_reboot(self) -> str: + """Reboot the repeater process.""" + logger.warning("Reboot command received - not implemented (use systemctl restart)") + return "Error: Use systemctl restart pymc-repeater" + + def _cmd_advert(self) -> str: + """Send self advertisement.""" + if not self.send_advert_callback: + logger.warning("Advert command received but no callback configured") + return "Error: Advert functionality not configured" + + try: + # Call the async callback synchronously (will be awaited by caller if needed) + import asyncio + loop = asyncio.get_event_loop() + if loop.is_running(): + # Schedule the coroutine and don't wait for it + asyncio.create_task(self.send_advert_callback()) + logger.info("Advert send scheduled") + return "OK - Advert sent" + else: + # Run synchronously if no loop + result = loop.run_until_complete(self.send_advert_callback()) + if result: + return "OK - Advert sent" + else: + return "Error: Failed to send advert" + except Exception as e: + logger.error(f"Failed to send advert: {e}", exc_info=True) + return f"Error: {e}" + + def _cmd_clock(self, command: str) -> str: + """Handle clock commands.""" + if command == "clock": + # Display current time + import datetime + dt = datetime.datetime.utcnow() + return f"{dt.hour:02d}:{dt.minute:02d} - {dt.day}/{dt.month}/{dt.year} UTC" + elif command == "clock sync": + # Clock sync happens automatically via sender_timestamp in protocol + return "OK - clock sync not needed (system time used)" + else: + return "Unknown clock command" + + def _cmd_time(self, command: str) -> str: + """Set time - not supported in Python (use system time).""" + return "Error: Time setting not supported (system time is used)" + + def _cmd_password(self, command: str) -> str: + """Change admin password.""" + new_password = command[9:].strip() + + if not new_password: + return "Error: Password cannot be empty" + + # Update security config + if 'security' not in self.config: + self.config['security'] = {} + + self.config['security']['password'] = new_password + + # Save config + try: + self.save_config() + return f"password now: {new_password}" + except Exception as e: + logger.error(f"Failed to save password: {e}") + return "Error: Failed to save password" + + def _cmd_clear_stats(self) -> str: + """Clear statistics.""" + # TODO: Implement stats clearing + return "Error: Not yet implemented" + + def _cmd_version(self) -> str: + """Get version information.""" + role = "room_server" if self.identity_type == "room_server" else "repeater" + version = self.config.get('version', '1.0.0') + return f"pyMC_{role} v{version}" + + # ==================== Get Commands ==================== + + 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) + return f"> {af}" + + elif param == "name": + name = self.repeater_config.get('name', 'Unknown') + return f"> {name}" + + elif param == "repeat": + disabled = self.repeater_config.get('disable_forward', False) + return f"> {'off' if disabled else 'on'}" + + elif param == "lat": + lat = self.repeater_config.get('latitude', 0.0) + return f"> {lat}" + + elif param == "lon": + lon = self.repeater_config.get('longitude', 0.0) + return f"> {lon}" + + elif param == "radio": + radio = self.config.get('radio', {}) + freq_hz = radio.get('frequency', 915000000) + bw_hz = radio.get('bandwidth', 125000) + sf = radio.get('spreading_factor', 7) + cr = radio.get('coding_rate', 5) + # Convert Hz to MHz for freq, Hz to kHz for bandwidth (match C++ ftoa output) + freq_mhz = freq_hz / 1_000_000.0 + bw_khz = bw_hz / 1_000.0 + return f"> {freq_mhz},{bw_khz},{sf},{cr}" + + elif param == "freq": + freq_hz = self.config.get('radio', {}).get('frequency', 915000000) + freq_mhz = freq_hz / 1_000_000.0 + return f"> {freq_mhz}" + + elif param == "tx": + power = self.config.get('radio', {}).get('tx_power', 20) + return f"> {power}" + + elif param == "public.key": + # TODO: Get from identity + return "Error: Not yet implemented" + + elif param == "role": + role = "room_server" if self.identity_type == "room_server" else "repeater" + return f"> {role}" + + elif param == "guest.password": + guest_pw = self.config.get('security', {}).get('guest_password', '') + return f"> {guest_pw}" + + elif param == "allow.read.only": + allow = self.config.get('security', {}).get('allow_read_only', False) + return f"> {'on' if allow else 'off'}" + + elif param == "advert.interval": + interval = self.repeater_config.get('advert_interval_minutes', 120) + return f"> {interval}" + + elif param == "flood.advert.interval": + interval = self.repeater_config.get('flood_advert_interval_hours', 24) + return f"> {interval}" + + elif param == "flood.max": + max_flood = self.repeater_config.get('max_flood_hops', 3) + return f"> {max_flood}" + + elif param == "rxdelay": + delay = self.repeater_config.get('rx_delay_base', 0.0) + return f"> {delay}" + + elif param == "txdelay": + delay = self.repeater_config.get('tx_delay_factor', 1.0) + return f"> {delay}" + + elif param == "direct.txdelay": + delay = self.repeater_config.get('direct_tx_delay_factor', 0.5) + return f"> {delay}" + + elif param == "multi.acks": + acks = self.repeater_config.get('multi_acks', 0) + return f"> {acks}" + + elif param == "int.thresh": + thresh = self.repeater_config.get('interference_threshold', -120) + return f"> {thresh}" + + elif param == "agc.reset.interval": + interval = self.repeater_config.get('agc_reset_interval', 0) + return f"> {interval}" + + else: + return f"??: {param}" + + # ==================== Set Commands ==================== + + def _cmd_set(self, param: str) -> str: + """Handle set commands.""" + parts = param.split(None, 1) + if len(parts) < 2: + return "Error: Missing value" + + key, value = parts[0], parts[1] + + try: + if key == "af": + self.repeater_config['airtime_factor'] = float(value) + self.save_config() + return "OK" + + elif key == "name": + self.repeater_config['name'] = value + self.save_config() + return "OK" + + elif key == "repeat": + disabled = value.lower() == "off" + self.repeater_config['disable_forward'] = disabled + self.save_config() + return f"OK - repeat is now {'OFF' if disabled else 'ON'}" + + elif key == "lat": + self.repeater_config['latitude'] = float(value) + self.save_config() + return "OK" + + elif key == "lon": + self.repeater_config['longitude'] = float(value) + self.save_config() + return "OK" + + elif key == "radio": + # Format: freq bw sf cr + radio_parts = value.split() + if len(radio_parts) != 4: + return "Error: Expected freq bw sf cr" + + if 'radio' not in self.config: + self.config['radio'] = {} + + self.config['radio']['frequency'] = float(radio_parts[0]) + self.config['radio']['bandwidth'] = float(radio_parts[1]) + self.config['radio']['spreading_factor'] = int(radio_parts[2]) + self.config['radio']['coding_rate'] = int(radio_parts[3]) + self.save_config() + return "OK - restart repeater to apply" + + elif key == "freq": + if 'radio' not in self.config: + self.config['radio'] = {} + self.config['radio']['frequency'] = float(value) + self.save_config() + return "OK - restart repeater to apply" + + elif key == "tx": + if 'radio' not in self.config: + self.config['radio'] = {} + self.config['radio']['tx_power'] = int(value) + self.save_config() + return "OK" + + elif key == "guest.password": + if 'security' not in self.config: + self.config['security'] = {} + self.config['security']['guest_password'] = value + self.save_config() + return "OK" + + elif key == "allow.read.only": + if 'security' not in self.config: + self.config['security'] = {} + self.config['security']['allow_read_only'] = value.lower() == "on" + self.save_config() + return "OK" + + elif key == "advert.interval": + mins = int(value) + if mins > 0 and (mins < 60 or mins > 240): + return "Error: interval range is 60-240 minutes" + self.repeater_config['advert_interval_minutes'] = mins + self.save_config() + return "OK" + + elif key == "flood.advert.interval": + hours = int(value) + if (hours > 0 and hours < 3) or hours > 48: + return "Error: interval range is 3-48 hours" + self.repeater_config['flood_advert_interval_hours'] = hours + self.save_config() + return "OK" + + elif key == "flood.max": + max_val = int(value) + if max_val > 64: + return "Error: max 64" + self.repeater_config['max_flood_hops'] = max_val + self.save_config() + return "OK" + + elif key == "rxdelay": + delay = float(value) + if delay < 0: + return "Error: cannot be negative" + self.repeater_config['rx_delay_base'] = delay + self.save_config() + return "OK" + + elif key == "txdelay": + delay = float(value) + if delay < 0: + return "Error: cannot be negative" + self.repeater_config['tx_delay_factor'] = delay + self.save_config() + return "OK" + + elif key == "direct.txdelay": + delay = float(value) + if delay < 0: + return "Error: cannot be negative" + self.repeater_config['direct_tx_delay_factor'] = delay + self.save_config() + return "OK" + + elif key == "multi.acks": + self.repeater_config['multi_acks'] = int(value) + self.save_config() + return "OK" + + elif key == "int.thresh": + self.repeater_config['interference_threshold'] = int(value) + self.save_config() + return "OK" + + elif key == "agc.reset.interval": + interval = int(value) + # Round to nearest multiple of 4 + rounded = (interval // 4) * 4 + self.repeater_config['agc_reset_interval'] = rounded + self.save_config() + return f"OK - interval rounded to {rounded}" + + else: + return f"unknown config: {key}" + + except ValueError as e: + return f"Error: invalid value - {e}" + except Exception as e: + logger.error(f"Set command error: {e}") + return f"Error: {e}" + + # ==================== ACL Commands ==================== + + def _cmd_setperm(self, command: str) -> str: + """Set permissions for a public key.""" + # Format: setperm {pubkey-hex} {permissions-int} + parts = command[8:].split() + if len(parts) < 2: + return "Err - bad params" + + pubkey_hex = parts[0] + try: + permissions = int(parts[1]) + except ValueError: + return "Err - invalid permissions" + + # TODO: Apply permissions via ACL + logger.info(f"setperm command: {pubkey_hex} -> {permissions}") + return "Error: Not yet implemented - use config file" + + # ==================== Region Commands ==================== + + def _cmd_region(self, command: str) -> str: + """Handle region commands.""" + parts = command.split() + + if len(parts) == 1: + return "Error: Region commands not implemented in Python repeater" + + subcommand = parts[1] + + if subcommand == "load": + return "Error: Region commands not implemented" + elif subcommand == "save": + return "Error: Region commands not implemented" + elif subcommand in ("allowf", "denyf", "get", "home", "put", "remove"): + return "Error: Region commands not implemented" + else: + return "Err - ??" + + # ==================== Neighbor Commands ==================== + + def _cmd_neighbors(self) -> str: + """List neighbors.""" + # TODO: Get neighbors from routing table + return "Error: Not yet implemented" + + def _cmd_neighbor_remove(self, command: str) -> str: + """Remove a neighbor.""" + pubkey_hex = command[16:].strip() + + if not pubkey_hex: + return "ERR: Missing pubkey" + + # TODO: Remove neighbor from routing table + logger.info(f"neighbor.remove: {pubkey_hex}") + return "Error: Not yet implemented" + + # ==================== Temporary Radio Commands ==================== + + def _cmd_tempradio(self, command: str) -> str: + """Apply temporary radio parameters.""" + # Format: tempradio {freq} {bw} {sf} {cr} {timeout_mins} + parts = command[10:].split() + + if len(parts) < 5: + return "Error: Expected freq bw sf cr timeout_mins" + + try: + freq = float(parts[0]) + bw = float(parts[1]) + sf = int(parts[2]) + cr = int(parts[3]) + timeout_mins = int(parts[4]) + + # Validate + if not (300.0 <= freq <= 2500.0): + return "Error: invalid frequency" + if not (7.0 <= bw <= 500.0): + return "Error: invalid bandwidth" + if not (5 <= sf <= 12): + return "Error: invalid spreading factor" + if not (5 <= cr <= 8): + return "Error: invalid coding rate" + if timeout_mins <= 0: + return "Error: invalid timeout" + + # TODO: Apply temporary radio parameters + logger.info(f"tempradio: {freq}MHz {bw}kHz SF{sf} CR4/{cr} for {timeout_mins}min") + return "Error: Not yet implemented" + + except ValueError: + return "Error, invalid params" + + # ==================== Logging Commands ==================== + + def _cmd_log(self, command: str) -> str: + """Handle log commands.""" + if command == "log start": + # TODO: Enable logging + return "Error: Not yet implemented" + elif command == "log stop": + # TODO: Disable logging + return "Error: Not yet implemented" + elif command == "log erase": + # TODO: Clear log file + return "Error: Not yet implemented" + elif command == "log": + return "Error: Use journalctl to view logs" + else: + return "Unknown log command" diff --git a/repeater/handler_helpers/repeater_cli.py b/repeater/handler_helpers/repeater_cli.py index 7812cd9..4e79d5f 100644 --- a/repeater/handler_helpers/repeater_cli.py +++ b/repeater/handler_helpers/repeater_cli.py @@ -1,6 +1,6 @@ """ -Repeater CLI Handler -Handles administrative commands sent to the repeater via TXT_MSG packets. +Mesh CLI Handler +Handles administrative commands sent to repeaters and room servers via TXT_MSG packets. Only users with admin permissions (via ACL) can execute these commands. """ @@ -13,14 +13,21 @@ import time logger = logging.getLogger(__name__) -class RepeaterCLI: +class MeshCLI: """ - CLI command handler for repeater administration. + CLI command handler for mesh node administration (repeaters and room servers). Commands follow the format: XX|command params where XX is an optional sequence number that gets echoed in the reply. """ - def __init__(self, config_path: str, config: Dict[str, Any], save_config_callback: Callable): + def __init__( + self, + config_path: str, + config: Dict[str, Any], + save_config_callback: Callable, + identity_type: str = "repeater", + enable_regions: bool = True + ): """ Initialize the CLI handler. @@ -28,10 +35,14 @@ class RepeaterCLI: config_path: Path to the config.yaml file config: Current configuration dictionary save_config_callback: Callback to save config changes + identity_type: Type of identity ('repeater' or 'room_server') + enable_regions: Whether to enable region commands (only for repeaters) """ self.config_path = Path(config_path) self.config = config self.save_config = save_config_callback + self.identity_type = identity_type + self.enable_regions = enable_regions # Get repeater config shortcut self.repeater_config = config.get('repeater', {}) @@ -108,9 +119,12 @@ class RepeaterCLI: elif command == "get acl": return "Error: Use 'get acl' via serial console only" - # Region commands + # Region commands (repeaters only) elif command.startswith("region"): - return self._cmd_region(command) + if self.enable_regions: + return self._cmd_region(command) + else: + return "Error: Region commands not available for room servers" # Neighbor commands elif command == "neighbors": @@ -199,8 +213,9 @@ class RepeaterCLI: def _cmd_version(self) -> str: """Get version information.""" + role = "room_server" if self.identity_type == "room_server" else "repeater" version = self.config.get('version', '1.0.0') - return f"pyMC_Repeater v{version}" + return f"pyMC_{role} v{version}" # ==================== Get Commands ==================== @@ -254,7 +269,8 @@ class RepeaterCLI: return "Error: Not yet implemented" elif param == "role": - return "> repeater" + role = "room_server" if self.identity_type == "room_server" else "repeater" + return f"> {role}" elif param == "guest.password": guest_pw = self.config.get('security', {}).get('guest_password', '') @@ -569,3 +585,7 @@ class RepeaterCLI: return "Error: Use journalctl to view logs" else: return "Unknown log command" + + +# Backward compatibility alias +RepeaterCLI = MeshCLI diff --git a/repeater/handler_helpers/room_server.py b/repeater/handler_helpers/room_server.py index 178ba15..98064b4 100644 --- a/repeater/handler_helpers/room_server.py +++ b/repeater/handler_helpers/room_server.py @@ -79,7 +79,11 @@ class RoomServer: sqlite_handler, packet_injector, acl, - max_posts: int = 32 + max_posts: int = 32, + config_path: str = None, + config: dict = None, + save_config_callback = None, + send_advert_callback = None ): self.room_hash = room_hash @@ -89,6 +93,67 @@ class RoomServer: self.packet_injector = packet_injector self.acl = acl + # Create send_advert callback for this room server + async def send_room_advert(): + """Send advertisement for this specific room server.""" + if not packet_injector or not local_identity: + logger.error(f"Room '{room_name}': Cannot send advert - missing injector or identity") + return False + + try: + from pymc_core.protocol import PacketBuilder + from pymc_core.protocol.constants import ADVERT_FLAG_HAS_NAME, ADVERT_FLAG_IS_ROOM_SERVER + + # Get room config + room_config = config.get('identities', {}).get('room_servers', []) + room_settings = {} + for rs in room_config: + if rs.get('name') == room_name: + room_settings = rs.get('settings', {}) + break + + # Use room-specific name and location + node_name = room_settings.get('room_name', room_name) + latitude = room_settings.get('latitude', 0.0) + longitude = room_settings.get('longitude', 0.0) + + flags = ADVERT_FLAG_IS_ROOM_SERVER | ADVERT_FLAG_HAS_NAME + + packet = PacketBuilder.create_advert( + local_identity=local_identity, + name=node_name, + lat=latitude, + lon=longitude, + feature1=0, + feature2=0, + flags=flags, + route_type="flood", + ) + + # Send via packet injector + await packet_injector(packet, wait_for_ack=False) + + logger.info(f"Room '{room_name}': Sent flood advert '{node_name}' at ({latitude:.6f}, {longitude:.6f})") + return True + + except Exception as e: + logger.error(f"Room '{room_name}': Failed to send advert: {e}", exc_info=True) + return False + + # Initialize CLI handler for room server commands + self.cli = None + if config_path and config and save_config_callback: + from .mesh_cli import MeshCLI + self.cli = MeshCLI( + config_path, + config, + save_config_callback, + identity_type="room_server", + enable_regions=False, # Room servers don't support region commands + send_advert_callback=send_room_advert + ) + logger.info(f"Room '{room_name}': Initialized CLI handler") + # Enforce hard limit (match C++ MAX_UNSYNCED_POSTS) if max_posts > MAX_UNSYNCED_POSTS: logger.warning( diff --git a/repeater/handler_helpers/text.py b/repeater/handler_helpers/text.py index cf66736..4c4c271 100644 --- a/repeater/handler_helpers/text.py +++ b/repeater/handler_helpers/text.py @@ -11,7 +11,7 @@ import logging import struct from pymc_core.node.handlers.text import TextMessageHandler -from .repeater_cli import RepeaterCLI +from .mesh_cli import MeshCLI from .room_server import RoomServer logger = logging.getLogger("TextHelper") @@ -21,13 +21,14 @@ class TextHelper: def __init__(self, identity_manager, packet_injector=None, acl_dict=None, log_fn=None, config_path: str = None, config: dict = None, save_config_callback=None, - sqlite_handler=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 = {} @@ -38,10 +39,22 @@ class TextHelper: # Track repeater identity for CLI commands self.repeater_hash = None + # Store config for later use + self.config_path = config_path + self.config = config + self.save_config_callback = save_config_callback + # Initialize CLI handler if config provided self.cli = None if config_path and config and save_config_callback: - self.cli = RepeaterCLI(config_path, config, save_config_callback) + self.cli = MeshCLI( + config_path, + config, + save_config_callback, + identity_type="repeater", + enable_regions=True, + send_advert_callback=send_advert_callback + ) logger.info("Initialized CLI handler for repeater commands") def register_identity( @@ -109,7 +122,10 @@ class TextHelper: sqlite_handler=self.sqlite_handler, packet_injector=self.packet_injector, acl=identity_acl, - max_posts=max_posts + max_posts=max_posts, + config_path=self.config_path, + config=self.config, + save_config_callback=self.save_config_callback ) self.room_servers[hash_byte] = room_server @@ -252,14 +268,52 @@ class TextHelper: except Exception as e: logger.error(f"Failed to store room message: {e}", exc_info=True) - # Room messages don't need further processing + # Check if this is a CLI command to the room server + if self._is_cli_command(message_text): + room_server = self.room_servers.get(dest_hash) + if room_server and room_server.cli: + try: + # Check admin permission + is_admin = self._check_admin_permission_for_identity(src_hash, 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 + identity_acl = self.acl_dict.get(dest_hash) + sender_pubkey = bytes([src_hash]) + b'\x00' * 31 # Default + if identity_acl: + for client_info in identity_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 = 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) + + except Exception as e: + logger.error(f"Error processing room server CLI command: {e}", exc_info=True) + + # Room messages (non-CLI) don't need further processing 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: # Check admin permission - is_admin = self._check_admin_permission(src_hash) + is_admin = self._check_admin_permission_for_identity(src_hash, self.repeater_hash) # If not admin, log and return without sending reply if not is_admin: @@ -347,14 +401,18 @@ 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 0x02).""" - # Get the repeater's ACL - repeater_acl = self.acl_dict.get(self.repeater_hash) - if not repeater_acl: + """Check if sender has admin permissions for repeater (legacy method).""" + return self._check_admin_permission_for_identity(src_hash, self.repeater_hash) + + def _check_admin_permission_for_identity(self, src_hash: int, identity_hash: int) -> bool: + """Check if sender has admin permissions (bit 0x02) for a specific identity.""" + # Get the identity's ACL + identity_acl = self.acl_dict.get(identity_hash) + if not identity_acl: return False # Get client by hash byte - clients = repeater_acl.get_all_clients() + clients = identity_acl.get_all_clients() for client_info in clients: pubkey = client_info.id.get_public_key() if pubkey[0] == src_hash: diff --git a/repeater/main.py b/repeater/main.py index 0855d76..b959cc7 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -196,6 +196,7 @@ class RepeaterDaemon: config=self.config, # For CLI to read/modify settings save_config_callback=lambda: self._save_config(getattr(self, 'config_path', '/tmp/config.yaml')), # For CLI to persist changes sqlite_handler=self.repeater_handler.storage.sqlite_handler if self.repeater_handler and self.repeater_handler.storage else None, # For room server database + send_advert_callback=self.send_advert, # For CLI advert command ) # Register default repeater identity for text messages