mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-10-31 04:01:59 +01:00
Two things firmware does that openHop did not. Dispatch by type, not by text. simple_room_server::onPeerDataRecv routes CLI_DATA/CLI_COMMAND to the admin CLI and turns PLAIN into a post; simple_repeater has no chat function, so it runs every accepted type through handleCommand with no text test at all. openHop asked _is_cli_command(message_text) for both, and so got both wrong: a PLAIN "set ..." ran as a command instead of being posted, a command whose text was not in the prefix list was published to the room, and an admin's mistyped repeater command vanished silently instead of being answered. Guard replays. Both servers open the branch with `sender_timestamp >= client->last_timestamp`, treat equality as a retry they accept but do not re-run, and drop anything older. openHop checked neither, so replaying an admin command with the same timestamp and different attempt bits re-executed it -- packet dedup does not catch that because the bytes differ. Note ACL._is_replay is not reusable here: its strict <= rejects the equal case this branch accepts. Both read from packet.decrypted, which openhop_core now publishes. A core too old to report them falls back to the previous text-prefix behaviour and logs a warning rather than dropping every message.
849 lines
34 KiB
Python
849 lines
34 KiB
Python
"""
|
|
Text message (TXT_MSG) handling helper for openHop Repeater.
|
|
|
|
This module processes incoming text messages for all managed identities
|
|
(repeater identity + identity manager identities).
|
|
Also handles CLI commands for admin users on the repeater identity.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import time
|
|
|
|
from openhop_core.node.handlers.text import TextMessageHandler
|
|
from openhop_core.protocol import CryptoUtils, Identity
|
|
|
|
from .acl import PERM_ACL_GUEST, PERM_ACL_ROLE_MASK, is_admin_permissions
|
|
from .mesh_cli import MeshCLI
|
|
from .room_server import RoomServer
|
|
|
|
logger = logging.getLogger("TextHelper")
|
|
|
|
|
|
# Text message type flags (firmware TxtDataHelpers.h)
|
|
TXT_TYPE_PLAIN = 0x00
|
|
TXT_TYPE_CLI_DATA = 0x01
|
|
TXT_TYPE_SIGNED_PLAIN = 0x02
|
|
TXT_TYPE_CLI_COMMAND = 0x03
|
|
|
|
# The text types a server acts on. Both simple_repeater::onPeerDataRecv and
|
|
# simple_room_server::onPeerDataRecv open their TXT_MSG branch with
|
|
#
|
|
# if (!(flags == TXT_TYPE_PLAIN || flags == TXT_TYPE_CLI_DATA
|
|
# || flags == TXT_TYPE_CLI_COMMAND)) {
|
|
# MESH_DEBUG_PRINTLN("unsupported text type received: flags=%02x", flags);
|
|
# } else if (...)
|
|
#
|
|
# so anything else -- SIGNED_PLAIN included -- is logged and dropped: no
|
|
# command run, no post stored, no reply. CLI_COMMAND joined the set when
|
|
# firmware split "a CLI command" out of CLI_DATA (MeshCore 2c0ace25); CLI_DATA
|
|
# stays in it because that is what every released client still sends.
|
|
SERVER_TXT_TYPES = frozenset((TXT_TYPE_PLAIN, TXT_TYPE_CLI_DATA, TXT_TYPE_CLI_COMMAND))
|
|
|
|
|
|
def _accept_once(client, sender_timestamp) -> tuple[bool, bool]:
|
|
"""Firmware's TXT_MSG replay guard. Returns ``(accepted, is_retry)``.
|
|
|
|
``simple_repeater`` and ``simple_room_server`` both open the branch with
|
|
``sender_timestamp >= client->last_timestamp``: an older timestamp is a
|
|
replay and is dropped whole, an equal one is a *retry* — accepted, activity
|
|
refreshed, but the command not re-run and the post not re-added — and a
|
|
newer one is fresh work. The watermark advances either way.
|
|
|
|
Note the deliberate difference from ``ACL._is_replay``, which rejects the
|
|
equal case: that matches the REQ branch (``timestamp > last_timestamp``),
|
|
not this one.
|
|
|
|
An unresolved client or a core too old to publish the timestamp yields
|
|
``(True, False)`` — no watermark to check against, so behave as before.
|
|
"""
|
|
if client is None or sender_timestamp is None:
|
|
return True, False
|
|
last = int(getattr(client, "last_timestamp", 0) or 0)
|
|
if sender_timestamp < last:
|
|
logger.warning(
|
|
f"Possible replay: text timestamp={sender_timestamp} < last={last}; dropping"
|
|
)
|
|
return False, False
|
|
is_retry = sender_timestamp == last
|
|
client.last_timestamp = sender_timestamp
|
|
client.last_activity = int(time.time())
|
|
return True, is_retry
|
|
|
|
|
|
def _is_guest_client(client) -> bool:
|
|
"""True when ``client`` holds the GUEST role (firmware ``PERM_ACL_GUEST``).
|
|
|
|
Falls back to the role bits for a client object with no ``is_guest``
|
|
helper, so an older ACL shape still fails closed.
|
|
"""
|
|
is_guest = getattr(client, "is_guest", None)
|
|
if callable(is_guest):
|
|
return bool(is_guest())
|
|
return (getattr(client, "permissions", 0) & PERM_ACL_ROLE_MASK) == PERM_ACL_GUEST
|
|
|
|
|
|
class TextHelper:
|
|
def __init__(
|
|
self,
|
|
identity_manager,
|
|
packet_injector=None,
|
|
acl_dict=None,
|
|
log_fn=None,
|
|
config_path: str | None = None,
|
|
config: dict | None = None,
|
|
config_manager=None,
|
|
sqlite_handler=None,
|
|
send_advert_callback=None,
|
|
):
|
|
|
|
self.identity_manager = identity_manager
|
|
self.packet_injector = packet_injector
|
|
self.log_fn = log_fn or logger.info
|
|
self.acl_dict = acl_dict or {} # Per-identity ACLs keyed by hash_byte
|
|
self.sqlite_handler = sqlite_handler # For room server database operations
|
|
self.send_advert_callback = send_advert_callback # Callback to send repeater advert
|
|
|
|
# Dictionary of handlers keyed by dest_hash
|
|
self.handlers = {}
|
|
|
|
# Dictionary of room servers keyed by dest_hash
|
|
self.room_servers = {}
|
|
|
|
# Track repeater identity for CLI commands
|
|
self.repeater_hash = None
|
|
|
|
# Store config for later use
|
|
self.config_path = config_path
|
|
self.config = config
|
|
self.config_manager = config_manager
|
|
|
|
# Store for later CLI initialization (needs identity and storage)
|
|
self.config_path = config_path
|
|
self.config = config
|
|
|
|
# Initialize CLI handler later when repeater identity is registered
|
|
self.cli = None
|
|
self._pending_tasks = set()
|
|
self._loop: asyncio.AbstractEventLoop | None = None
|
|
|
|
def _track_task(self, task: asyncio.Task) -> None:
|
|
self._pending_tasks.add(task)
|
|
|
|
def _on_done(done_task: asyncio.Task) -> None:
|
|
self._pending_tasks.discard(done_task)
|
|
try:
|
|
done_task.result()
|
|
except asyncio.CancelledError:
|
|
pass
|
|
except Exception as e:
|
|
logger.error(f"Background text task failed: {e}", exc_info=True)
|
|
|
|
task.add_done_callback(_on_done)
|
|
|
|
def register_identity(
|
|
self, name: str, identity, identity_type: str = "room_server", radio_config=None
|
|
):
|
|
|
|
hash_byte = identity.get_public_key()[0]
|
|
|
|
# Get ACL for this identity
|
|
identity_acl = self.acl_dict.get(hash_byte)
|
|
if not identity_acl:
|
|
logger.warning(f"Cannot register identity '{name}': no ACL for hash 0x{hash_byte:02X}")
|
|
return
|
|
|
|
# Create a contacts wrapper from this identity's ACL
|
|
acl_contacts = self._create_acl_contacts_wrapper(identity_acl)
|
|
|
|
# Create TextMessageHandler for this identity
|
|
handler = TextMessageHandler(
|
|
local_identity=identity,
|
|
contacts=acl_contacts,
|
|
log_fn=self.log_fn,
|
|
send_packet_fn=self._send_packet,
|
|
radio_config=radio_config,
|
|
)
|
|
|
|
# Register by dest hash
|
|
hash_byte = identity.get_public_key()[0]
|
|
self.handlers[hash_byte] = {
|
|
"handler": handler,
|
|
"identity": identity,
|
|
"name": name,
|
|
"type": identity_type,
|
|
}
|
|
|
|
# Track repeater identity for CLI commands
|
|
if identity_type == "repeater":
|
|
self.repeater_hash = hash_byte
|
|
logger.info(f"Set repeater hash for CLI: 0x{hash_byte:02X}")
|
|
|
|
# Initialize CLI handler now that we have the repeater identity
|
|
if self.config_path and self.config and self.config_manager:
|
|
self.cli = MeshCLI(
|
|
self.config_path,
|
|
self.config,
|
|
self.config_manager,
|
|
identity_type="repeater",
|
|
enable_regions=True,
|
|
send_advert_callback=self.send_advert_callback,
|
|
identity=identity,
|
|
storage_handler=self.sqlite_handler,
|
|
)
|
|
logger.info(
|
|
"Initialized CLI handler for repeater commands with identity and storage"
|
|
)
|
|
|
|
# Create RoomServer instance for room_server identities
|
|
if identity_type == "room_server" and self.sqlite_handler:
|
|
try:
|
|
from .room_server import MAX_UNSYNCED_POSTS
|
|
|
|
room_config = radio_config or {}
|
|
max_posts = room_config.get("max_posts", MAX_UNSYNCED_POSTS)
|
|
|
|
# Enforce hard limit
|
|
if max_posts > MAX_UNSYNCED_POSTS:
|
|
logger.warning(
|
|
f"Room '{name}': Configured max_posts={max_posts} exceeds hard limit "
|
|
f"of {MAX_UNSYNCED_POSTS}, capping to {MAX_UNSYNCED_POSTS}"
|
|
)
|
|
max_posts = MAX_UNSYNCED_POSTS
|
|
|
|
room_server = RoomServer(
|
|
room_hash=hash_byte,
|
|
room_name=name,
|
|
local_identity=identity,
|
|
sqlite_handler=self.sqlite_handler,
|
|
packet_injector=self.packet_injector,
|
|
acl=identity_acl,
|
|
max_posts=max_posts,
|
|
config_path=self.config_path or "",
|
|
config=self.config or {},
|
|
config_manager=self.config_manager,
|
|
)
|
|
|
|
self.room_servers[hash_byte] = room_server
|
|
|
|
# Start sync loop — may be called from a non-async HTTP handler thread
|
|
try:
|
|
loop = asyncio.get_running_loop()
|
|
start_task = loop.create_task(room_server.start())
|
|
self._track_task(start_task)
|
|
except RuntimeError:
|
|
# No running event loop in this thread
|
|
if self._loop and self._loop.is_running():
|
|
future = asyncio.run_coroutine_threadsafe(room_server.start(), self._loop)
|
|
future.add_done_callback(
|
|
lambda f: (
|
|
logger.error(
|
|
f"Room server '{name}' failed: {f.exception()}",
|
|
exc_info=f.exception(),
|
|
)
|
|
if not f.cancelled() and f.exception()
|
|
else None
|
|
)
|
|
)
|
|
else:
|
|
logger.error(f"Cannot start room server '{name}': no event loop available")
|
|
|
|
logger.info(
|
|
f"Registered room server '{name}': hash=0x{hash_byte:02X}, "
|
|
f"max_posts={max_posts}"
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Failed to create room server '{name}': {e}", exc_info=True)
|
|
|
|
logger.info(f"Registered {identity_type} '{name}' text handler: hash=0x{hash_byte:02X}")
|
|
|
|
def _create_acl_contacts_wrapper(self, acl):
|
|
|
|
class ACLContactsWrapper:
|
|
def __init__(self, identity_acl):
|
|
self._acl = identity_acl
|
|
|
|
@property
|
|
def contacts(self):
|
|
contact_list = []
|
|
for client_info in self._acl.get_all_clients():
|
|
# Create a minimal contact object that TextMessageHandler needs
|
|
class ContactProxy:
|
|
def __init__(self, client):
|
|
self.public_key = client.id.get_public_key().hex()
|
|
self.name = f"client_{self.public_key[:8]}"
|
|
|
|
contact_list.append(ContactProxy(client_info))
|
|
return contact_list
|
|
|
|
return ACLContactsWrapper(acl)
|
|
|
|
async def process_text_packet(self, packet):
|
|
|
|
try:
|
|
if len(packet.payload) < 2:
|
|
return False
|
|
|
|
dest_hash = packet.payload[0]
|
|
src_hash = packet.payload[1]
|
|
|
|
handler_info = self.handlers.get(dest_hash)
|
|
if handler_info:
|
|
logger.debug(
|
|
f"Routing text message to '{handler_info['name']}': "
|
|
f"dest=0x{dest_hash:02X}, src=0x{src_hash:02X}"
|
|
)
|
|
|
|
# Let the handler attempt decryption. It authenticates only when
|
|
# the message actually decrypted for this identity. Otherwise the
|
|
# dest hash collided with ours (common with a 1-byte hash) but the
|
|
# packet is not really for us — do not consume it, so the engine can
|
|
# still forward/re-flood it (#353).
|
|
result = await handler_info["handler"](packet)
|
|
if not result.authenticated:
|
|
logger.debug(
|
|
f"TXT_MSG dest 0x{dest_hash:02X} did not decrypt for "
|
|
f"'{handler_info['name']}' (hash collision), allowing forward"
|
|
)
|
|
return False
|
|
|
|
# Call placeholder for custom processing
|
|
await self._on_message_received(
|
|
identity_name=handler_info["name"],
|
|
identity_type=handler_info["type"],
|
|
packet=packet,
|
|
dest_hash=dest_hash,
|
|
src_hash=src_hash,
|
|
)
|
|
|
|
# Mark packet as handled
|
|
packet.mark_do_not_retransmit()
|
|
return True
|
|
else:
|
|
logger.debug(f"No text handler for hash 0x{dest_hash:02X}, allowing forward")
|
|
return False
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error processing text packet: {e}")
|
|
return False
|
|
|
|
async def _on_message_received(
|
|
self,
|
|
identity_name: str,
|
|
identity_type: str,
|
|
packet,
|
|
dest_hash: int,
|
|
src_hash: int,
|
|
):
|
|
|
|
# Placeholder - can be overridden or callback can be added
|
|
logger.debug(
|
|
f"Message received for {identity_type} '{identity_name}' from 0x{src_hash:02X}"
|
|
)
|
|
|
|
# Extract decrypted message if available
|
|
if hasattr(packet, "decrypted") and packet.decrypted:
|
|
message_text = packet.decrypted.get("text", "<unknown>")
|
|
|
|
# Firmware gates the whole TXT_MSG branch on the text type before it
|
|
# looks at the text at all. Without this, a SIGNED_PLAIN — a room
|
|
# post, whose 4-byte author prefix the core handler has already
|
|
# split off, leaving bare text — reaches the repeater's CLI and runs
|
|
# as a command whenever it happens to start with a command prefix.
|
|
# A core older than the one that publishes ``txt_type`` reports
|
|
# None; behave as before rather than dropping every message.
|
|
txt_type = packet.decrypted.get("txt_type")
|
|
sender_timestamp = packet.decrypted.get("sender_timestamp")
|
|
if txt_type is None or sender_timestamp is None:
|
|
logger.warning(
|
|
"TXT_MSG carried no txt_type/sender_timestamp: openhop_core is "
|
|
"older than the build that publishes them, so the text-type "
|
|
"gate and the replay guard are open. Upgrade openhop_core."
|
|
)
|
|
if txt_type is not None and txt_type not in SERVER_TXT_TYPES:
|
|
logger.debug(
|
|
f"[{identity_type}:{identity_name}] unsupported text type "
|
|
f"{txt_type} from 0x{src_hash:02X}; dropping"
|
|
)
|
|
return
|
|
|
|
sender_client = self._resolve_sender_client(dest_hash, src_hash, packet)
|
|
|
|
# Firmware checks the type, then the replay watermark, and only then
|
|
# looks at the message. A retry is accepted but re-runs nothing.
|
|
accepted, is_retry = _accept_once(sender_client, sender_timestamp)
|
|
if not accepted:
|
|
logger.warning(
|
|
f"[{identity_type}:{identity_name}] replay from 0x{src_hash:02X}; dropping"
|
|
)
|
|
return
|
|
|
|
# Clean message text - remove null bytes and trailing whitespace
|
|
message_text = message_text.rstrip("\x00").rstrip()
|
|
|
|
logger.info(f"[{identity_type}:{identity_name}] Message: {message_text}")
|
|
|
|
# Handle room server messages
|
|
if identity_type == "room_server" and dest_hash in self.room_servers:
|
|
room_server = self.room_servers[dest_hash]
|
|
|
|
# simple_room_server::onPeerDataRecv splits by *type*, not by
|
|
# text: CLI_DATA/CLI_COMMAND go to the admin CLI, PLAIN becomes
|
|
# a post. Deciding from the text instead got both wrong -- a
|
|
# PLAIN "set ..." ran as a command instead of being posted, and
|
|
# a command whose text was not in the prefix list was published
|
|
# to the room.
|
|
if txt_type is None:
|
|
# Older core: no type to dispatch on, keep the text test.
|
|
room_is_command = self._is_cli_command(message_text)
|
|
else:
|
|
room_is_command = txt_type in (TXT_TYPE_CLI_DATA, TXT_TYPE_CLI_COMMAND)
|
|
|
|
if room_is_command:
|
|
# Handle CLI command - do NOT store as post
|
|
if room_server and room_server.cli:
|
|
try:
|
|
# Check admin permission
|
|
is_admin = self._check_admin_permission_for_identity(
|
|
sender_client, dest_hash
|
|
)
|
|
|
|
if not is_admin:
|
|
logger.warning(
|
|
f"Room '{identity_name}': CLI command denied from 0x{src_hash:02X} (not admin)"
|
|
)
|
|
return
|
|
|
|
# Get sender's full pubkey
|
|
sender_pubkey = bytes([src_hash]) + b"\x00" * 31 # Default
|
|
if sender_client is not None:
|
|
sender_pubkey = sender_client.id.get_public_key()
|
|
|
|
# A retry re-sends the same command; firmware
|
|
# answers it with an empty reply rather than running
|
|
# it a second time.
|
|
if is_retry:
|
|
logger.info(
|
|
f"Room '{identity_name}': retry of command from "
|
|
f"0x{src_hash:02X}; not re-running"
|
|
)
|
|
return
|
|
|
|
# Handle CLI command
|
|
reply = room_server.cli.handle_command(
|
|
sender_pubkey=sender_pubkey, command=message_text, is_admin=is_admin
|
|
)
|
|
|
|
logger.info(
|
|
f"Room '{identity_name}': CLI command from 0x{src_hash:02X}: {message_text[:50]} -> {reply[:100]}"
|
|
)
|
|
|
|
# Send reply back to sender
|
|
handler_info = self.handlers.get(dest_hash)
|
|
if handler_info:
|
|
await self._send_cli_reply(
|
|
packet,
|
|
reply,
|
|
handler_info,
|
|
sender_client=sender_client,
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Error processing room server CLI command: {e}", exc_info=True
|
|
)
|
|
|
|
# CLI command handled, don't store as post
|
|
return
|
|
|
|
# NOT a CLI command - store as regular room post
|
|
#
|
|
# A guest may read the room but not write to it:
|
|
# simple_room_server::onPeerDataRecv takes the
|
|
# `(client->permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_GUEST`
|
|
# branch for a PLAIN message and stores nothing (and sends no
|
|
# ACK). Without this, anyone who can log in with the guest
|
|
# password can post to the room.
|
|
if sender_client is not None and _is_guest_client(sender_client):
|
|
logger.warning(
|
|
f"Room '{identity_name}': post denied from 0x{src_hash:02X} (guest)"
|
|
)
|
|
return
|
|
|
|
# Firmware calls addPost only when `!is_retry`, so a resent post
|
|
# does not appear in the room twice.
|
|
if is_retry:
|
|
logger.info(
|
|
f"Room '{identity_name}': retry of post from 0x{src_hash:02X}; "
|
|
"not storing again"
|
|
)
|
|
return
|
|
|
|
try:
|
|
# Get sender's full pubkey
|
|
sender_pubkey = bytes([src_hash]) + b"\x00" * 31 # Default
|
|
if sender_client is not None:
|
|
sender_pubkey = sender_client.id.get_public_key()
|
|
|
|
# Store message as post
|
|
sender_timestamp = int(time.time())
|
|
success = await room_server.add_post(
|
|
client_pubkey=sender_pubkey,
|
|
message_text=message_text,
|
|
sender_timestamp=sender_timestamp,
|
|
txt_type=TXT_TYPE_PLAIN,
|
|
)
|
|
|
|
if success:
|
|
logger.info(
|
|
f"Room '{identity_name}': New post from {sender_pubkey[:4].hex()}: {message_text[:50]}"
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error storing room post: {e}", exc_info=True)
|
|
|
|
return
|
|
|
|
# A repeater has no chat function, so simple_repeater::onPeerDataRecv
|
|
# runs *every* accepted type from an admin through handleCommand with
|
|
# no text test at all. Matching that means a plain DM to the repeater
|
|
# is a command too -- which it always was; openHop simply dropped the
|
|
# ones whose text did not match a prefix.
|
|
if txt_type is None and not self._is_cli_command(message_text):
|
|
# Older core: no type to dispatch on, keep the text test.
|
|
return
|
|
|
|
if dest_hash == self.repeater_hash and self.cli:
|
|
try:
|
|
repeater_hash = self.repeater_hash
|
|
if repeater_hash is None:
|
|
return
|
|
|
|
# Check admin permission
|
|
is_admin = self._check_admin_permission_for_identity(
|
|
sender_client, repeater_hash
|
|
)
|
|
|
|
# If not admin, log and return without sending reply
|
|
if not is_admin:
|
|
logger.warning(
|
|
f"CLI command denied from 0x{src_hash:02X} (not admin): {message_text[:50]}"
|
|
)
|
|
return
|
|
|
|
# Get client for full public key
|
|
sender_pubkey = bytes([src_hash]) + b"\x00" * 31 # Default
|
|
if sender_client is not None:
|
|
sender_pubkey = sender_client.id.get_public_key()
|
|
|
|
# A retry re-sends the same command; firmware answers it
|
|
# with an empty reply rather than running it again.
|
|
if is_retry:
|
|
logger.info(f"Retry of command from 0x{src_hash:02X}; not re-running")
|
|
return
|
|
|
|
# Handle CLI command
|
|
reply = self.cli.handle_command(
|
|
sender_pubkey=sender_pubkey, command=message_text, is_admin=is_admin
|
|
)
|
|
|
|
logger.info(
|
|
f"CLI command from 0x{src_hash:02X}: {message_text[:50]} -> {reply[:100]}"
|
|
)
|
|
|
|
# Send reply back to sender
|
|
handler_info = self.handlers.get(dest_hash)
|
|
if handler_info:
|
|
await self._send_cli_reply(
|
|
packet,
|
|
reply,
|
|
handler_info,
|
|
sender_client=sender_client,
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error processing CLI command: {e}", exc_info=True)
|
|
|
|
async def _send_packet(self, packet, wait_for_ack: bool = False):
|
|
|
|
if self.packet_injector:
|
|
try:
|
|
return await self.packet_injector(packet, wait_for_ack=wait_for_ack)
|
|
except Exception as e:
|
|
logger.error(f"Error sending packet: {e}")
|
|
return False
|
|
else:
|
|
logger.error("No packet injector configured, cannot send packet")
|
|
return False
|
|
|
|
def set_message_callback(self, callback):
|
|
|
|
self._message_callback = callback
|
|
|
|
def list_registered_identities(self):
|
|
|
|
return [
|
|
{
|
|
"hash": hash_byte,
|
|
"name": info["name"],
|
|
"type": info["type"],
|
|
}
|
|
for hash_byte, info in self.handlers.items()
|
|
]
|
|
|
|
async def cleanup(self):
|
|
"""Cleanup room servers and handlers."""
|
|
# Stop all room server sync loops
|
|
for room_server in self.room_servers.values():
|
|
try:
|
|
await room_server.stop()
|
|
except Exception as e:
|
|
logger.error(f"Error stopping room server: {e}")
|
|
|
|
logger.info("TextHelper cleanup complete")
|
|
|
|
def _is_cli_command(self, message: str) -> bool:
|
|
"""Check if message looks like a CLI command."""
|
|
# Strip optional sequence prefix (XX|)
|
|
if len(message) > 4 and message[2] == "|":
|
|
message = message[3:].strip()
|
|
|
|
# Check for known command prefixes
|
|
command_prefixes = [
|
|
"get ",
|
|
"set ",
|
|
"reboot",
|
|
"advert",
|
|
"clock",
|
|
"time ",
|
|
"http ",
|
|
"password ",
|
|
"clear ",
|
|
"ver",
|
|
"board",
|
|
"neighbors",
|
|
"neighbor.",
|
|
"discover.",
|
|
"tempradio ",
|
|
"setperm ",
|
|
"region",
|
|
"sensor ",
|
|
"gps",
|
|
"log ",
|
|
"stats-",
|
|
"start ota",
|
|
]
|
|
|
|
return any(message.startswith(prefix) for prefix in command_prefixes)
|
|
|
|
def _check_admin_permission(self, src_hash: int) -> bool:
|
|
"""Check if sender has admin permissions for repeater (legacy method)."""
|
|
repeater_hash = self.repeater_hash
|
|
if repeater_hash is None:
|
|
return False
|
|
|
|
identity_acl = self.acl_dict.get(repeater_hash)
|
|
if not identity_acl:
|
|
return False
|
|
for client_info in identity_acl.get_all_clients():
|
|
pubkey = client_info.id.get_public_key()
|
|
if pubkey[0] == src_hash:
|
|
return self._check_admin_permission_for_identity(client_info, repeater_hash)
|
|
return False
|
|
|
|
def _check_admin_permission_for_identity(self, sender_client, identity_hash: int) -> bool:
|
|
"""Check admin permissions for a specific identity.
|
|
|
|
Accepts either a resolved ACL client object (preferred) or a legacy
|
|
``src_hash`` int for backwards-compatible helper/unit-test calls.
|
|
"""
|
|
# Get the identity's ACL
|
|
identity_acl = self.acl_dict.get(identity_hash)
|
|
if not identity_acl or sender_client is None:
|
|
return False
|
|
|
|
# Legacy compatibility: caller provided src_hash int instead of client.
|
|
if isinstance(sender_client, int):
|
|
src_hash = sender_client & 0xFF
|
|
for client_info in identity_acl.get_all_clients():
|
|
pubkey = client_info.id.get_public_key()
|
|
if pubkey[0] == src_hash:
|
|
permissions = getattr(client_info, "permissions", 0)
|
|
return is_admin_permissions(permissions)
|
|
return False
|
|
|
|
sender_pubkey = sender_client.id.get_public_key()
|
|
for client_info in identity_acl.get_all_clients():
|
|
if client_info.id.get_public_key() == sender_pubkey:
|
|
# Role is the low two bits with ADMIN == 3; a 0x02 bit test
|
|
# would also let READ_WRITE clients run admin CLI commands.
|
|
permissions = getattr(client_info, "permissions", 0)
|
|
return is_admin_permissions(permissions)
|
|
|
|
return False
|
|
|
|
def _get_shared_secret_for_client(self, client_info, identity) -> bytes:
|
|
"""Return shared secret for a client, deriving it when ACL cache is absent."""
|
|
shared_secret = getattr(client_info, "shared_secret", b"") or b""
|
|
if shared_secret:
|
|
return bytes(shared_secret)
|
|
|
|
if not identity:
|
|
return b""
|
|
|
|
try:
|
|
peer_pubkey = client_info.id.get_public_key()
|
|
peer_identity = Identity(peer_pubkey)
|
|
return peer_identity.calc_shared_secret(identity.get_private_key())
|
|
except Exception:
|
|
return b""
|
|
|
|
def _resolve_sender_client(
|
|
self,
|
|
identity_hash: int,
|
|
src_hash: int,
|
|
packet,
|
|
local_identity=None,
|
|
allow_hash_fallback: bool = False,
|
|
):
|
|
"""Resolve sender client by trying hash-collision candidates until decrypt succeeds."""
|
|
identity_acl = self.acl_dict.get(identity_hash)
|
|
if local_identity is None:
|
|
handler_info = self.handlers.get(identity_hash)
|
|
local_identity = handler_info.get("identity") if handler_info else None
|
|
|
|
if not identity_acl:
|
|
return None
|
|
|
|
# Optional compatibility path for direct helper calls/tests where we don't
|
|
# have decryptable payload bytes available; only accept a UNIQUE hash match.
|
|
if allow_hash_fallback and (not local_identity or len(packet.payload) < 4):
|
|
matches = [
|
|
c for c in identity_acl.get_all_clients() if c.id.get_public_key()[0] == src_hash
|
|
]
|
|
return matches[0] if len(matches) == 1 else None
|
|
|
|
if not local_identity or len(packet.payload) < 4:
|
|
return None
|
|
|
|
encrypted_data = bytes(packet.payload[2:])
|
|
for client_info in identity_acl.get_all_clients():
|
|
pubkey = client_info.id.get_public_key()
|
|
if pubkey[0] != src_hash:
|
|
continue
|
|
|
|
shared_secret = self._get_shared_secret_for_client(client_info, local_identity)
|
|
if len(shared_secret) < 16:
|
|
continue
|
|
|
|
try:
|
|
CryptoUtils.mac_then_decrypt(shared_secret[:16], shared_secret, encrypted_data)
|
|
return client_info
|
|
except Exception: # nosec B112
|
|
continue
|
|
|
|
return None
|
|
|
|
async def _send_cli_reply(
|
|
self,
|
|
original_packet,
|
|
reply_text: str,
|
|
handler_info: dict,
|
|
sender_client=None,
|
|
):
|
|
"""
|
|
Send CLI reply back to sender using TXT_MSG datagram.
|
|
|
|
Follows the C++ pattern (lines 603-609 in MyMesh.cpp):
|
|
- Creates TXT_MSG datagram with TXT_TYPE_CLI_DATA flag
|
|
- Encrypts with shared secret from ACL client
|
|
- Uses client->out_path_len to decide routing:
|
|
* if out_path_len < 0: sendFlood()
|
|
* else: sendDirect() with stored out_path
|
|
"""
|
|
import time
|
|
|
|
from openhop_core.protocol import PacketBuilder
|
|
from openhop_core.protocol.constants import PAYLOAD_TYPE_TXT_MSG
|
|
|
|
try:
|
|
src_hash = original_packet.payload[1]
|
|
dest_hash = original_packet.payload[0]
|
|
|
|
incoming_route = original_packet.get_route_type()
|
|
logger.debug(
|
|
f"CLI reply: original packet dest=0x{dest_hash:02X}, src=0x{src_hash:02X}, incoming_route={incoming_route}"
|
|
)
|
|
|
|
# Find the client in the DESTINATION identity's ACL (not always repeater!)
|
|
# dest_hash is the identity that received the command (repeater OR room server)
|
|
identity_acl = self.acl_dict.get(dest_hash)
|
|
if not identity_acl:
|
|
logger.error(f"No ACL found for identity 0x{dest_hash:02X} for CLI reply")
|
|
return
|
|
|
|
client = sender_client or self._resolve_sender_client(
|
|
dest_hash,
|
|
src_hash,
|
|
original_packet,
|
|
local_identity=handler_info.get("identity"),
|
|
allow_hash_fallback=True,
|
|
)
|
|
|
|
if not client:
|
|
logger.error(
|
|
f"Client 0x{src_hash:02X} not found in identity 0x{dest_hash:02X} ACL for CLI reply"
|
|
)
|
|
return
|
|
|
|
# Get shared secret from client
|
|
shared_secret = client.shared_secret
|
|
if not shared_secret or len(shared_secret) == 0:
|
|
logger.error(f"No shared secret for client 0x{src_hash:02X}")
|
|
return
|
|
|
|
# Build reply packet payload
|
|
# Format: timestamp(4) + flags(1) + reply_text
|
|
timestamp = int(time.time())
|
|
TXT_TYPE_CLI_DATA = 0x01
|
|
flags = TXT_TYPE_CLI_DATA << 2 # Upper 6 bits are txt_type
|
|
|
|
reply_bytes = reply_text.encode("utf-8")
|
|
plaintext = timestamp.to_bytes(4, "little") + bytes([flags]) + reply_bytes
|
|
|
|
# Decide routing based on client->out_path_len (C++ pattern)
|
|
# out_path is populated by PATH packets, NOT from incoming text message route
|
|
route_type = "flood" if client.out_path_len < 0 else "direct"
|
|
logger.debug(
|
|
f"CLI reply: client.out_path_len={client.out_path_len}, using route_type={route_type}"
|
|
)
|
|
|
|
reply_packet = PacketBuilder.create_datagram(
|
|
ptype=PAYLOAD_TYPE_TXT_MSG,
|
|
dest=client.id,
|
|
local_identity=handler_info["identity"],
|
|
secret=shared_secret,
|
|
plaintext=plaintext,
|
|
route_type=route_type,
|
|
)
|
|
|
|
# Add path for direct routing if available from PATH packets
|
|
if client.out_path_len >= 0 and len(client.out_path) > 0:
|
|
reply_packet.path = bytearray(client.out_path[: client.out_path_len])
|
|
reply_packet.path_len = client.out_path_len
|
|
logger.debug(
|
|
f"CLI reply: Added stored out_path - path_len={reply_packet.path_len}, path={[hex(b) for b in reply_packet.path]}"
|
|
)
|
|
|
|
# Send with delay (CLI_REPLY_DELAY_MILLIS = 600ms in C++)
|
|
CLI_REPLY_DELAY_MS = 600
|
|
await asyncio.sleep(CLI_REPLY_DELAY_MS / 1000.0)
|
|
|
|
await self._send_packet(reply_packet, wait_for_ack=False)
|
|
logger.info(
|
|
f"CLI reply sent to 0x{src_hash:02X} via {route_type.upper()}: {reply_text[:50]}"
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error sending CLI reply: {e}", exc_info=True)
|