mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-08-05 08:23:12 +02:00
Refactor LoginHelper and TextHelper to implement per-identity ACLs add CLI command handling
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
+18
-19
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user