diff --git a/repeater/handler_helpers/login.py b/repeater/handler_helpers/login.py index 893480b..9bfd9d3 100644 --- a/repeater/handler_helpers/login.py +++ b/repeater/handler_helpers/login.py @@ -13,42 +13,56 @@ logger = logging.getLogger("LoginHelper") class LoginHelper: - def __init__(self, identity_manager, packet_injector=None, acl=None, log_fn=None): + def __init__(self, identity_manager, packet_injector=None, log_fn=None): self.identity_manager = identity_manager self.packet_injector = packet_injector self.log_fn = log_fn or logger.info - self.acl = acl self.handlers = {} + self.acls = {} # Per-identity ACLs keyed by hash_byte def register_identity(self, name: str, identity, identity_type: str = "room_server", config: dict = None): - if not self.acl: - logger.warning(f"Cannot register identity '{name}': no ACL configured") - return - config = config or {} - # Validate room servers have their own passwords + hash_byte = identity.get_public_key()[0] + + # Create ACL for this identity + from repeater.handler_helpers.acl import ACL + + # Get security config for this identity if identity_type == "room_server": security = config.get("security", {}) + # Validate room servers have their own passwords if not security.get("admin_password") and not security.get("guest_password"): logger.error( f"Room server '{name}' MUST have security.admin_password or " f"security.guest_password configured. Skipping registration." ) return + else: + # Repeater uses security from its config + security = config.get("security", {}) - hash_byte = identity.get_public_key()[0] + # Create ACL for this identity + identity_acl = ACL( + max_clients=security.get("max_clients", 50), + admin_password=security.get("admin_password", "admin123"), + guest_password=security.get("guest_password", "guest123"), + allow_read_only=security.get("allow_read_only", True), + ) - # Create auth callback that includes identity context + self.acls[hash_byte] = identity_acl + logger.info(f"Created ACL for {identity_type} '{name}': hash=0x{hash_byte:02X}") + + # Create auth callback that uses this identity's ACL def auth_callback_with_context(client_identity, shared_secret, password, timestamp): - return self.acl.authenticate_client( + return identity_acl.authenticate_client( client_identity=client_identity, shared_secret=shared_secret, password=password, timestamp=timestamp, - target_identity_hash=hash_byte, # Which identity is being logged into + target_identity_hash=hash_byte, target_identity_name=name, target_identity_config=config ) @@ -105,10 +119,22 @@ class LoginHelper: except Exception as e: logger.error(f"Error sending login response: {e}") - def get_acl(self): - return self.acl + def get_acl_dict(self): + """Return dictionary of ACLs keyed by identity hash.""" + return self.acls + + def get_acl_for_identity(self, hash_byte: int): + """Get ACL for a specific identity.""" + return self.acls.get(hash_byte) - def list_authenticated_clients(self): - if self.acl: - return self.acl.get_all_clients() - return [] + def list_authenticated_clients(self, hash_byte: int = None): + """List authenticated clients for a specific identity or all identities.""" + if hash_byte is not None: + acl = self.acls.get(hash_byte) + return acl.get_all_clients() if acl else [] + + # Return clients from all ACLs + all_clients = [] + for acl in self.acls.values(): + all_clients.extend(acl.get_all_clients()) + return all_clients diff --git a/repeater/handler_helpers/repeater_cli.py b/repeater/handler_helpers/repeater_cli.py new file mode 100644 index 0000000..316ce84 --- /dev/null +++ b/repeater/handler_helpers/repeater_cli.py @@ -0,0 +1,562 @@ +""" +Repeater CLI Handler +Handles administrative commands sent to the repeater via TXT_MSG packets. +Only users with admin permissions (via ACL) can execute these commands. +""" + +import logging +from typing import Optional, Dict, Any, Callable +import yaml +from pathlib import Path +import time + +logger = logging.getLogger(__name__) + + +class RepeaterCLI: + """ + CLI command handler for repeater administration. + 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): + """ + Initialize the CLI handler. + + Args: + config_path: Path to the config.yaml file + config: Current configuration dictionary + save_config_callback: Callback to save config changes + """ + self.config_path = Path(config_path) + self.config = config + self.save_config = save_config_callback + + # Get repeater config shortcut + self.repeater_config = config.get('repeater', {}) + + def handle_command(self, sender_pubkey: bytes, command: str, is_admin: bool) -> str: + """ + Handle an incoming command from a client. + + Args: + sender_pubkey: Public key of sender + command: Command string (may include XX| prefix) + is_admin: Whether sender has admin permissions + + Returns: + Reply string to send back to sender + """ + # Check admin permission first + if not is_admin: + return "Error: Admin permission required" + + # Extract optional sequence prefix (XX|) + prefix = "" + if len(command) > 4 and command[2] == '|': + prefix = command[:3] + command = command[3:] + + # Strip leading/trailing whitespace + command = command.strip() + + # 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: + """Route command to appropriate handler method.""" + + # 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 + elif command.startswith("region"): + return self._cmd_region(command) + + # 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.""" + logger.info("Advert command received") + # TODO: Trigger advertisement through packet handler + return "Error: Not yet implemented" + + 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.""" + version = self.config.get('version', '1.0.0') + return f"pyMC_Repeater v{version}" + + # ==================== Get Commands ==================== + + def _cmd_get(self, param: str) -> str: + """Handle get commands.""" + param = param.strip() + + 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 = radio.get('frequency', 915.0) + bw = radio.get('bandwidth', 125.0) + sf = radio.get('spreading_factor', 7) + cr = radio.get('coding_rate', 5) + return f"> {freq},{bw},{sf},{cr}" + + elif param == "freq": + freq = self.config.get('radio', {}).get('frequency', 915.0) + return f"> {freq}" + + 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": + return "> repeater" + + 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/text.py b/repeater/handler_helpers/text.py index 2cab5e9..d9576ab 100644 --- a/repeater/handler_helpers/text.py +++ b/repeater/handler_helpers/text.py @@ -3,27 +3,39 @@ Text message (TXT_MSG) handling helper for pyMC 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 from pymc_core.node.handlers.text import TextMessageHandler +from .repeater_cli import RepeaterCLI logger = logging.getLogger("TextHelper") class TextHelper: - def __init__(self, identity_manager, packet_injector=None, acl=None, log_fn=None): + 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): self.identity_manager = identity_manager self.packet_injector = packet_injector self.log_fn = log_fn or logger.info - self.acl = acl + self.acl_dict = acl_dict or {} # Per-identity ACLs keyed by hash_byte # Dictionary of handlers keyed by dest_hash self.handlers = {} + + # Track repeater identity for CLI commands + self.repeater_hash = None + + # 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) + logger.info("Initialized CLI handler for repeater commands") def register_identity( self, @@ -33,12 +45,16 @@ class TextHelper: radio_config=None ): - if not self.acl: - logger.warning(f"Cannot register identity '{name}': no ACL configured") + 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 ACL - acl_contacts = self._create_acl_contacts_wrapper() + # 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( @@ -58,15 +74,20 @@ class TextHelper: "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}") + logger.info( f"Registered {identity_type} '{name}' text handler: hash=0x{hash_byte:02X}" ) - def _create_acl_contacts_wrapper(self): + def _create_acl_contacts_wrapper(self, acl): class ACLContactsWrapper: - def __init__(self, acl): - self._acl = acl + def __init__(self, identity_acl): + self._acl = identity_acl @property def contacts(self): @@ -81,7 +102,7 @@ class TextHelper: contact_list.append(ContactProxy(client_info)) return contact_list - return ACLContactsWrapper(self.acl) + return ACLContactsWrapper(acl) async def process_text_packet(self, packet): @@ -99,7 +120,42 @@ class TextHelper: f"dest=0x{dest_hash:02X}, src=0x{src_hash:02X}" ) - # Call the handler + # 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 + ) + + # Send reply through text handler + logger.info(f"CLI command from 0x{src_hash:02X}: {message_text[:50]} -> {reply[:100]}") + + # TODO: Send reply packet + # For now, just log it + + 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 await handler_info["handler"](packet) # Call placeholder for custom processing @@ -172,3 +228,37 @@ class TextHelper: } for hash_byte, info in self.handlers.items() ] + + 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 ", + "password ", "clear ", "ver", "board", "neighbors", "neighbor.", + "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 (bit 0x01).""" + # Get the repeater's ACL + repeater_acl = self.acl_dict.get(self.repeater_hash) + if not repeater_acl: + return False + + # Get client by hash byte + clients = repeater_acl.get_all_clients() + for client_info in clients: + pubkey = client_info.id.get_public_key() + if pubkey[0] == src_hash: + # Check admin bit (0x01) + permissions = getattr(client_info, 'permissions', 0) + return (permissions & 0x01) != 0 + + return False diff --git a/repeater/main.py b/repeater/main.py index 5f3c4fb..ecbafd7 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -158,30 +158,15 @@ class RepeaterDaemon: else: logger.info("Discovery response handler disabled") - # Create shared ACL for all identities - from repeater.handler_helpers.acl import ACL - - repeater_config = self.config.get("repeater", {}) - security_config = repeater_config.get("security", {}) - - shared_acl = ACL( - max_clients=security_config.get("max_clients", 50), - admin_password=security_config.get("admin_password", "admin123"), - guest_password=security_config.get("guest_password", "guest123"), - allow_read_only=security_config.get("allow_read_only", True), - ) - self.acl = shared_acl - logger.info("Shared ACL initialized") - - # Create login helper with shared ACL + # Create login helper (will create per-identity ACLs) self.login_helper = LoginHelper( identity_manager=self.identity_manager, packet_injector=self.router.inject_packet, - acl=shared_acl, log_fn=logger.info, ) # Register default repeater identity + repeater_config = self.config.get("repeater", {}) self.login_helper.register_identity( name="repeater", identity=self.local_identity, @@ -200,12 +185,15 @@ class RepeaterDaemon: logger.info("Login processing helper initialized") - # Initialize text message helper (uses shared ACL as contacts) + # Initialize text message helper with per-identity ACLs self.text_helper = TextHelper( identity_manager=self.identity_manager, packet_injector=self.router.inject_packet, - acl=shared_acl, # Use shared ACL as contacts database + acl_dict=self.login_helper.get_acl_dict(), # Per-identity ACLs log_fn=logger.info, + config_path=getattr(self, 'config_path', None), # For CLI to save changes + 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 ) # Register default repeater identity for text messages @@ -230,6 +218,17 @@ class RepeaterDaemon: except Exception as e: logger.error(f"Failed to initialize dispatcher: {e}") raise + + def _save_config(self, config_path: str): + """Save configuration to file (called by CLI when settings change).""" + import yaml + try: + with open(config_path, 'w') as f: + yaml.dump(self.config, f, default_flow_style=False) + logger.info(f"Configuration saved to {config_path}") + except Exception as e: + logger.error(f"Failed to save config: {e}") + raise async def _load_additional_identities(self): from pymc_core import LocalIdentity