Files
Remote-Terminal-for-MeshCore/app/routers/server_control.py
T
2026-07-25 15:12:12 -07:00

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,
)