mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-31 14:02:26 +02:00
613 lines
23 KiB
Python
613 lines
23 KiB
Python
import asyncio
|
|
import logging
|
|
import time
|
|
from enum import Enum
|
|
from typing import TYPE_CHECKING
|
|
|
|
from fastapi import HTTPException
|
|
from meshcore import EventType
|
|
|
|
from app.models import (
|
|
CONTACT_TYPE_REPEATER,
|
|
CONTACT_TYPE_ROOM,
|
|
CommandResponse,
|
|
Contact,
|
|
RepeaterLoginResponse,
|
|
)
|
|
from app.radio_sync import (
|
|
_store_pending_channel_message,
|
|
_store_pending_direct_message,
|
|
drain_pending_messages,
|
|
)
|
|
from app.routers.contacts import _ensure_on_radio
|
|
from app.services.radio_runtime import radio_runtime as radio_manager
|
|
|
|
if TYPE_CHECKING:
|
|
from meshcore.events import Event
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
SERVER_LOGIN_RESPONSE_TIMEOUT_SECONDS = 5.0
|
|
|
|
|
|
def _monotonic() -> float:
|
|
"""Wrapper around time.monotonic() for testability."""
|
|
return time.monotonic()
|
|
|
|
|
|
def get_server_contact_label(contact: Contact) -> str:
|
|
"""Return a user-facing label for server-capable contacts."""
|
|
if contact.type == CONTACT_TYPE_REPEATER:
|
|
return "repeater"
|
|
if contact.type == CONTACT_TYPE_ROOM:
|
|
return "room server"
|
|
return "server"
|
|
|
|
|
|
def require_server_capable_contact(
|
|
contact: Contact,
|
|
*,
|
|
allowed_types: tuple[int, ...] = (CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM),
|
|
) -> None:
|
|
"""Raise 400 if the contact does not support server control/login features."""
|
|
if contact.type not in allowed_types:
|
|
expected = ", ".join(str(value) for value in allowed_types)
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Contact is not a supported server contact (type={contact.type}, expected one of {expected})",
|
|
)
|
|
|
|
|
|
def _login_rejected_message(label: str) -> str:
|
|
return (
|
|
f"The {label} replied but did not confirm this login. "
|
|
f"Existing access may still allow some {label} operations, but privileged actions may fail."
|
|
)
|
|
|
|
|
|
def _login_send_failed_message(label: str) -> str:
|
|
return (
|
|
f"The login request could not be sent to the {label}. "
|
|
f"You're free to attempt interaction; try logging in again if authenticated actions fail."
|
|
)
|
|
|
|
|
|
def _login_timeout_message(label: str) -> str:
|
|
return (
|
|
f"No login confirmation was heard from the {label}. "
|
|
"That can mean the password was wrong or the reply was missed in transit. "
|
|
"You're free to attempt interaction; try logging in again if authenticated actions fail."
|
|
)
|
|
|
|
|
|
def _login_flood_retry_timeout_message(label: str) -> str:
|
|
return (
|
|
f"No login confirmation was heard from the {label}, including a retry sent as flood "
|
|
"in case the stored route was stale. That can mean the password was wrong, the "
|
|
f"{label} is out of range, or the reply was missed in transit. "
|
|
"You're free to attempt interaction; try logging in again if authenticated actions fail."
|
|
)
|
|
|
|
|
|
def extract_response_text(event) -> str:
|
|
"""Extract text from a CLI response event, stripping the firmware '> ' prefix."""
|
|
text = event.payload.get("text", str(event.payload))
|
|
if text.startswith("> "):
|
|
text = text[2:]
|
|
return text
|
|
|
|
|
|
async def _flush_pending_messages(mc) -> None:
|
|
"""Drain the radio's pending-message buffer before issuing a CLI command.
|
|
|
|
A CLI response that arrived after a previous command already returned can
|
|
sit buffered in the radio. Without this flush, the next command's fetch
|
|
could pull that stale response and mis-attribute it as the new command's
|
|
answer (the firmware does not correlate responses to requests). Draining
|
|
first routes any real DMs/channel messages to storage and lets stale CLI
|
|
responses (txt_type=1) be dropped by ``event_handlers.on_contact_message``,
|
|
so they cannot be returned as this command's answer.
|
|
|
|
This shrinks — but cannot fully eliminate — same-contact straddle
|
|
mis-attribution: a reply that is still in flight when we send can only be
|
|
bounded by a protocol-level request id, which the wire format lacks.
|
|
"""
|
|
try:
|
|
drained = await drain_pending_messages(mc)
|
|
if drained:
|
|
logger.debug("Flushed %d buffered message(s) before CLI send", drained)
|
|
except Exception:
|
|
logger.debug("Pre-send message flush failed", exc_info=True)
|
|
|
|
|
|
async def fetch_contact_cli_response(
|
|
mc,
|
|
target_pubkey_prefix: str,
|
|
timeout: float = 20.0,
|
|
) -> "Event | None":
|
|
"""Fetch a CLI response (txt_type=1) from a specific contact.
|
|
|
|
CLI responses arrive as ``CONTACT_MSG_RECV`` events, and the dispatcher
|
|
clones every such event to *all* subscribers. The permanent handler in
|
|
``event_handlers.on_contact_message`` can therefore consume (and drop) a
|
|
response in the gap between this loop's ``get_msg`` polls, producing a
|
|
spurious timeout even though the response was delivered.
|
|
|
|
To close that race we hold a request-scoped subscription for the target's
|
|
CLI responses for the whole window. Whichever path observes the response
|
|
first wins — ``get_msg``'s return value on the happy path, or the
|
|
subscription when ``get_msg`` misses it — and the subscription is torn down
|
|
in ``finally`` so nothing outlives this call (no global state, so a late or
|
|
duplicate response cannot leak into an unrelated later fetch).
|
|
|
|
``get_msg`` is still polled to pump the radio into delivering buffered
|
|
frames and to route any unrelated DMs/channel messages to storage.
|
|
"""
|
|
loop = asyncio.get_running_loop()
|
|
response_future: asyncio.Future = loop.create_future()
|
|
|
|
def _capture(event: "Event") -> None:
|
|
# Dispatcher invokes sync callbacks inline with a cloned event; the
|
|
# attribute filter guarantees this only fires for the target's CLI
|
|
# responses, so we resolve with the first one seen.
|
|
if not response_future.done():
|
|
response_future.set_result(event)
|
|
|
|
subscription = mc.subscribe(
|
|
EventType.CONTACT_MSG_RECV,
|
|
_capture,
|
|
attribute_filters={"pubkey_prefix": target_pubkey_prefix, "txt_type": 1},
|
|
)
|
|
|
|
try:
|
|
deadline = _monotonic() + timeout
|
|
|
|
while _monotonic() < deadline:
|
|
if response_future.done():
|
|
return response_future.result()
|
|
|
|
try:
|
|
result = await mc.commands.get_msg(timeout=2.0)
|
|
except TimeoutError:
|
|
continue
|
|
except Exception as exc:
|
|
logger.debug("get_msg() exception: %s", exc)
|
|
await asyncio.sleep(1.0)
|
|
continue
|
|
|
|
if result.type == EventType.NO_MORE_MSGS:
|
|
# The subscription may have captured a late delivery the radio
|
|
# didn't hand back through this poll; prefer it over sleeping.
|
|
if response_future.done():
|
|
return response_future.result()
|
|
await asyncio.sleep(1.0)
|
|
continue
|
|
|
|
if result.type == EventType.ERROR:
|
|
logger.debug("get_msg() error: %s", result.payload)
|
|
await asyncio.sleep(1.0)
|
|
continue
|
|
|
|
if result.type == EventType.CONTACT_MSG_RECV:
|
|
msg_prefix = result.payload.get("pubkey_prefix", "")
|
|
txt_type = result.payload.get("txt_type", 0)
|
|
if msg_prefix == target_pubkey_prefix and txt_type == 1:
|
|
return result
|
|
logger.debug(
|
|
"Storing non-target DM (from=%s, txt_type=%d) consumed while waiting for %s",
|
|
msg_prefix,
|
|
txt_type,
|
|
target_pubkey_prefix,
|
|
)
|
|
await _store_pending_direct_message(result)
|
|
continue
|
|
|
|
if result.type == EventType.CHANNEL_MSG_RECV:
|
|
logger.debug(
|
|
"Storing channel message (channel_idx=%s) consumed during CLI fetch",
|
|
result.payload.get("channel_idx"),
|
|
)
|
|
await _store_pending_channel_message(mc, result.payload)
|
|
continue
|
|
|
|
logger.debug("Unexpected event type %s during CLI fetch, skipping", result.type)
|
|
|
|
# Final grace check in case a delivery raced the deadline.
|
|
if response_future.done():
|
|
return response_future.result()
|
|
logger.warning(
|
|
"No CLI response from contact %s within %.1fs", target_pubkey_prefix, timeout
|
|
)
|
|
return None
|
|
finally:
|
|
subscription.unsubscribe()
|
|
|
|
|
|
async def _attempt_server_login(
|
|
mc,
|
|
contact: Contact,
|
|
password: str,
|
|
*,
|
|
contact_label: str,
|
|
response_timeout: float,
|
|
) -> RepeaterLoginResponse:
|
|
"""Send one login and wait for the reply.
|
|
|
|
Subscriptions are per-attempt because the resolving future can only be
|
|
settled once — a retry needs a fresh pair.
|
|
"""
|
|
pubkey_prefix = contact.public_key[:12].lower()
|
|
loop = asyncio.get_running_loop()
|
|
login_future = loop.create_future()
|
|
|
|
def _resolve_login(event_type: EventType, message: str | None = None) -> None:
|
|
if login_future.done():
|
|
return
|
|
login_future.set_result(
|
|
RepeaterLoginResponse(
|
|
status="ok" if event_type == EventType.LOGIN_SUCCESS else "error",
|
|
authenticated=event_type == EventType.LOGIN_SUCCESS,
|
|
message=message,
|
|
)
|
|
)
|
|
|
|
success_subscription = mc.subscribe(
|
|
EventType.LOGIN_SUCCESS,
|
|
lambda _event: _resolve_login(EventType.LOGIN_SUCCESS),
|
|
attribute_filters={"pubkey_prefix": pubkey_prefix},
|
|
)
|
|
failed_subscription = mc.subscribe(
|
|
EventType.LOGIN_FAILED,
|
|
lambda _event: _resolve_login(
|
|
EventType.LOGIN_FAILED,
|
|
_login_rejected_message(contact_label),
|
|
),
|
|
attribute_filters={"pubkey_prefix": pubkey_prefix},
|
|
)
|
|
|
|
try:
|
|
logger.info("Sending login to %s %s", contact_label, contact.public_key[:12])
|
|
login_result = await mc.commands.send_login(contact.public_key, password)
|
|
|
|
if login_result.type == EventType.ERROR:
|
|
return RepeaterLoginResponse(
|
|
status="error",
|
|
authenticated=False,
|
|
message=f"{_login_send_failed_message(contact_label)} ({login_result.payload})",
|
|
)
|
|
|
|
try:
|
|
return await asyncio.wait_for(login_future, timeout=response_timeout)
|
|
except TimeoutError:
|
|
logger.warning(
|
|
"No login response from %s %s within %.1fs",
|
|
contact_label,
|
|
contact.public_key[:12],
|
|
response_timeout,
|
|
)
|
|
return RepeaterLoginResponse(
|
|
status="timeout",
|
|
authenticated=False,
|
|
message=_login_timeout_message(contact_label),
|
|
)
|
|
finally:
|
|
success_subscription.unsubscribe()
|
|
failed_subscription.unsubscribe()
|
|
|
|
|
|
async def prepare_authenticated_contact_connection(
|
|
mc,
|
|
contact: Contact,
|
|
password: str,
|
|
*,
|
|
label: str | None = None,
|
|
response_timeout: float = SERVER_LOGIN_RESPONSE_TIMEOUT_SECONDS,
|
|
) -> RepeaterLoginResponse:
|
|
"""Prepare connection to a server-capable contact by adding it to the radio and logging in.
|
|
|
|
A login that draws no reply at all may mean the stored direct route has gone
|
|
stale, so it escalates to one flood retry — mirroring the DM send path, which
|
|
already resets the path before its final attempt. This is deliberately more
|
|
than the reference clients do: firmware ``sendLogin`` and ``send_login_sync``
|
|
are both single-shot, and firmware only clears a stale path when the *host*
|
|
asks it to (``CMD_RESET_PATH``). Escalating is still the right call because
|
|
the server side treats an inbound flood as its cue to relearn the return path
|
|
(``simple_repeater``/``simple_room_server``: ``if (is_flood)
|
|
client->out_path_len = OUT_PATH_UNKNOWN``), so a flood login is exactly what
|
|
repairs a broken route in both directions.
|
|
|
|
Escalation is bounded to a single extra attempt and only fires when:
|
|
- the first attempt timed out. An explicit ``LOGIN_FAILED`` means the
|
|
server heard us and refused, so the route is fine and retrying would
|
|
just hammer it with bad credentials. A send error is a local radio
|
|
problem that a different route will not fix.
|
|
- the first attempt actually used a route. If the contact was already on
|
|
flood, the retry would be byte-identical for no gain.
|
|
|
|
``reset_path`` clears the route on the radio only; the stored contact route
|
|
is left alone, so the next ``add_contact`` re-stages it. That matches the DM
|
|
retry and keeps a single bad login from discarding a route that may be fine.
|
|
"""
|
|
contact_label = label or get_server_contact_label(contact)
|
|
|
|
try:
|
|
logger.info("Adding %s %s to radio", contact_label, contact.public_key[:12])
|
|
await _ensure_on_radio(mc, contact)
|
|
|
|
response = await _attempt_server_login(
|
|
mc,
|
|
contact,
|
|
password,
|
|
contact_label=contact_label,
|
|
response_timeout=response_timeout,
|
|
)
|
|
if response.status != "timeout":
|
|
return response
|
|
|
|
if contact.effective_route_source == "flood":
|
|
logger.debug(
|
|
"Login to %s %s timed out on flood; no route to escalate from",
|
|
contact_label,
|
|
contact.public_key[:12],
|
|
)
|
|
return response
|
|
|
|
logger.info(
|
|
"Login to %s %s timed out on the %s route; resetting path and retrying as flood",
|
|
contact_label,
|
|
contact.public_key[:12],
|
|
contact.effective_route_source,
|
|
)
|
|
reset_result = await mc.commands.reset_path(contact.public_key)
|
|
if reset_result is None:
|
|
logger.warning(
|
|
"No response from radio for reset_path to %s before flood login retry",
|
|
contact.public_key[:12],
|
|
)
|
|
return response
|
|
if reset_result.type == EventType.ERROR:
|
|
logger.warning(
|
|
"Failed to reset path before flood login retry to %s: %s",
|
|
contact.public_key[:12],
|
|
reset_result.payload,
|
|
)
|
|
return response
|
|
|
|
# Deliberately no _ensure_on_radio here — re-adding the contact would
|
|
# restore the very route we just cleared, and the retry would go direct.
|
|
flood_response = await _attempt_server_login(
|
|
mc,
|
|
contact,
|
|
password,
|
|
contact_label=contact_label,
|
|
response_timeout=response_timeout,
|
|
)
|
|
if flood_response.status == "timeout":
|
|
return RepeaterLoginResponse(
|
|
status="timeout",
|
|
authenticated=False,
|
|
message=_login_flood_retry_timeout_message(contact_label),
|
|
)
|
|
return flood_response
|
|
except HTTPException as exc:
|
|
logger.warning(
|
|
"%s login setup failed for %s: %s",
|
|
contact_label.capitalize(),
|
|
contact.public_key[:12],
|
|
exc.detail,
|
|
)
|
|
return RepeaterLoginResponse(
|
|
status="error",
|
|
authenticated=False,
|
|
message=f"{_login_send_failed_message(contact_label)} ({exc.detail})",
|
|
)
|
|
|
|
|
|
async def batch_cli_fetch(
|
|
contact: Contact,
|
|
operation_name: str,
|
|
commands: list[tuple[str, str]],
|
|
) -> dict[str, str | None]:
|
|
"""Send a batch of CLI commands to a server-capable contact and collect responses.
|
|
|
|
Each command acquires and releases the radio lock independently so that
|
|
other operations (sends, syncs) can slip in between commands.
|
|
"""
|
|
results: dict[str, str | None] = {field: None for _, field in commands}
|
|
|
|
for index, (cmd, field) in enumerate(commands):
|
|
if index > 0:
|
|
# Yield briefly so queued operations can acquire the lock.
|
|
await asyncio.sleep(0.25)
|
|
|
|
async with radio_manager.radio_operation(
|
|
operation_name,
|
|
pause_polling=True,
|
|
suspend_auto_fetch=True,
|
|
) as mc:
|
|
# Re-ensure contact is loaded each iteration; another operation
|
|
# may have evicted it while we didn't hold the lock.
|
|
await _ensure_on_radio(mc, contact)
|
|
await asyncio.sleep(1.0) # settle after add_contact
|
|
|
|
# Clear any stale buffered CLI response from a prior command so it
|
|
# cannot be pulled and mis-attributed to this one.
|
|
await _flush_pending_messages(mc)
|
|
|
|
send_result = await mc.commands.send_cmd(contact.public_key, cmd)
|
|
if send_result.type == EventType.ERROR:
|
|
logger.debug("Command '%s' send error: %s", cmd, send_result.payload)
|
|
continue
|
|
|
|
response_event = await fetch_contact_cli_response(
|
|
mc, contact.public_key[:12], timeout=10.0
|
|
)
|
|
if response_event is not None:
|
|
results[field] = extract_response_text(response_event)
|
|
else:
|
|
logger.warning("No response for command '%s' (%s)", cmd, field)
|
|
|
|
return results
|
|
|
|
|
|
class _RepeaterBinaryReqType(Enum):
|
|
"""Binary request types not (yet) wrapped by the installed meshcore library.
|
|
|
|
``REQ_TYPE_GET_OWNER_INFO`` (0x07) was added at repeater ``FIRMWARE_VER_LEVEL >= 2``.
|
|
The firmware serves it from ``handleRequest`` with no admin gate, so any
|
|
logged-in client — including a guest — can fetch it, unlike the CLI
|
|
``get owner.info`` / ``ver`` path which the firmware only routes for admins.
|
|
"""
|
|
|
|
OWNER_INFO = 0x07
|
|
|
|
|
|
def _parse_owner_info_payload(data_hex: str) -> dict[str, str | None] | None:
|
|
"""Parse a REQ_TYPE_GET_OWNER_INFO (0x07) binary response payload.
|
|
|
|
The repeater replies with ``"{firmware}\\n{name}\\n{owner_info}"`` (the 4-byte
|
|
request tag is already stripped by the library's frame parser, so
|
|
``payload["data"]`` starts at the firmware string). ``owner_info`` may itself
|
|
contain newlines, so only the first two separators are split.
|
|
"""
|
|
if not data_hex:
|
|
return None
|
|
try:
|
|
raw = bytes.fromhex(data_hex)
|
|
except ValueError:
|
|
return None
|
|
text = raw.decode("utf-8", "ignore").strip("\x00")
|
|
if not text.strip():
|
|
return None
|
|
parts = text.split("\n", 2)
|
|
firmware = parts[0].strip() if len(parts) > 0 else ""
|
|
name = parts[1].strip() if len(parts) > 1 else ""
|
|
owner = parts[2].strip() if len(parts) > 2 else ""
|
|
return {
|
|
"firmware_version": firmware or None,
|
|
"name": name or None,
|
|
"owner_info": owner or None,
|
|
}
|
|
|
|
|
|
async def fetch_repeater_owner_info_binary(
|
|
contact: Contact,
|
|
*,
|
|
operation_name: str = "repeater_owner_info_binary",
|
|
timeout: float = 10.0,
|
|
min_timeout: float = 5.0,
|
|
) -> dict[str, str | None] | None:
|
|
"""Fetch firmware/name/owner via the guest-accessible binary request (0x07).
|
|
|
|
This is the path Liam and other apps use to show owner info + firmware for a
|
|
guest: a binary ``REQ_TYPE_GET_OWNER_INFO`` request rather than an admin-only
|
|
CLI command. Returns ``None`` when the repeater does not answer — older
|
|
firmware (level < 2), not logged in, or out of range — so callers can fall
|
|
back or leave the fields blank. See issue #306.
|
|
"""
|
|
async with radio_manager.radio_operation(
|
|
operation_name, pause_polling=True, suspend_auto_fetch=True
|
|
) as mc:
|
|
# Ensure contact is on radio for reply routing.
|
|
await _ensure_on_radio(mc, contact)
|
|
await asyncio.sleep(1.0) # settle after add_contact
|
|
|
|
send_result = await mc.commands.send_binary_req(
|
|
contact.public_key,
|
|
_RepeaterBinaryReqType.OWNER_INFO,
|
|
timeout=timeout,
|
|
min_timeout=min_timeout,
|
|
)
|
|
if send_result.type == EventType.ERROR:
|
|
logger.debug("owner-info binary req send error: %s", send_result.payload)
|
|
return None
|
|
|
|
expected_ack = send_result.payload.get("expected_ack")
|
|
if expected_ack is None:
|
|
logger.debug("owner-info binary req missing expected_ack: %s", send_result.payload)
|
|
return None
|
|
exp_tag = expected_ack.hex()
|
|
|
|
wait_timeout = (
|
|
timeout if timeout > 0 else send_result.payload.get("suggested_timeout", 4000) / 800
|
|
)
|
|
wait_timeout = max(wait_timeout, min_timeout)
|
|
|
|
response = await mc.wait_for_event(
|
|
EventType.BINARY_RESPONSE,
|
|
attribute_filters={"tag": exp_tag},
|
|
timeout=wait_timeout,
|
|
)
|
|
if response is None:
|
|
logger.info(
|
|
"No owner-info binary response from %s within %.1fs",
|
|
contact.public_key[:12],
|
|
wait_timeout,
|
|
)
|
|
return None
|
|
|
|
return _parse_owner_info_payload(response.payload.get("data", ""))
|
|
|
|
|
|
async def send_contact_cli_command(
|
|
contact: Contact,
|
|
command: str,
|
|
*,
|
|
operation_name: str,
|
|
) -> CommandResponse:
|
|
"""Send a CLI command to a server-capable contact and return the text response."""
|
|
label = get_server_contact_label(contact)
|
|
|
|
async with radio_manager.radio_operation(
|
|
operation_name,
|
|
pause_polling=True,
|
|
suspend_auto_fetch=True,
|
|
) as mc:
|
|
logger.info("Adding %s %s to radio", label, contact.public_key[:12])
|
|
await _ensure_on_radio(mc, contact)
|
|
await asyncio.sleep(1.0)
|
|
|
|
# Clear any stale buffered CLI response from a prior command so it
|
|
# cannot be pulled and mis-attributed to this one.
|
|
await _flush_pending_messages(mc)
|
|
|
|
logger.info("Sending command to %s %s: %s", label, contact.public_key[:12], command)
|
|
send_result = await mc.commands.send_cmd(contact.public_key, command)
|
|
|
|
if send_result.type == EventType.ERROR:
|
|
raise HTTPException(
|
|
status_code=422, detail=f"Failed to send command: {send_result.payload}"
|
|
)
|
|
|
|
response_event = await fetch_contact_cli_response(mc, contact.public_key[:12])
|
|
|
|
if response_event is None:
|
|
logger.warning(
|
|
"No response from %s %s for command: %s",
|
|
label,
|
|
contact.public_key[:12],
|
|
command,
|
|
)
|
|
return CommandResponse(
|
|
command=command,
|
|
response="(no response - command may have been processed)",
|
|
)
|
|
|
|
response_text = extract_response_text(response_event)
|
|
sender_timestamp = response_event.payload.get(
|
|
"sender_timestamp",
|
|
response_event.payload.get("timestamp"),
|
|
)
|
|
logger.info(
|
|
"Received response from %s %s: %s",
|
|
label,
|
|
contact.public_key[:12],
|
|
response_text,
|
|
)
|
|
|
|
return CommandResponse(
|
|
command=command,
|
|
response=response_text,
|
|
sender_timestamp=sender_timestamp,
|
|
)
|