Initial tcp proxy testing

This commit is contained in:
Jack Kingsman
2026-04-24 17:37:20 -07:00
parent 4eb29f376e
commit c31779f1a9
16 changed files with 2585 additions and 10 deletions
+6 -1
View File
@@ -55,6 +55,7 @@ app/
│ ├── send.py # pywebpush wrapper (async via thread executor)
│ └── manager.py # Push dispatch: filter, build payload, concurrent send
├── fanout/ # Fanout bus: MQTT, bots, webhooks, Apprise, SQS (see fanout/AGENTS_fanout.md)
├── tcp_proxy/ # MeshCore TCP companion protocol proxy (see tcp_proxy/AGENTS_tcp_proxy.md)
├── telemetry_interval.py # Shared telemetry interval math for tracked-repeater scheduler
├── path_utils.py # Path hex rendering and hop-width helpers
├── region_scope.py # Normalize/validate regional flood-scope values
@@ -426,7 +427,11 @@ tests/
├── test_telemetry_interval.py # Telemetry interval scheduling math
├── test_version_info.py # Version/build metadata resolution
├── test_websocket.py # WS manager broadcast/cleanup
── test_websocket_route.py # WS endpoint lifecycle
── test_websocket_route.py # WS endpoint lifecycle
├── test_tcp_proxy_protocol.py # TCP proxy frame parsing and helpers
├── test_tcp_proxy_encoder.py # TCP proxy binary encoding
├── test_tcp_proxy_session.py # TCP proxy session command handlers
└── test_tcp_proxy_integration.py # TCP proxy end-to-end frame exchange
```
## Errata & Known Non-Issues
+3
View File
@@ -31,6 +31,9 @@ class Settings(BaseSettings):
skip_post_connect_sync: bool = False
basic_auth_username: str = ""
basic_auth_password: str = ""
tcp_proxy_enabled: bool = False
tcp_proxy_bind: str = "0.0.0.0"
tcp_proxy_port: int = 5001
@model_validator(mode="after")
def validate_transport_exclusivity(self) -> "Settings":
+22 -9
View File
@@ -2,13 +2,14 @@ import logging
import sys
# ---------------------------------------------------------------------------
# Windows event-loop advisory for MQTT fanout
# Windows event-loop advisory for MQTT fanout and TCP proxy
# ---------------------------------------------------------------------------
# On Windows, uvicorn's default event loop (ProactorEventLoop) does not
# implement add_reader()/add_writer(), which paho-mqtt (via aiomqtt) requires.
# We cannot fix this from inside the app — the loop is already created by the
# time this module is imported. Log a prominent warning so Windows operators
# who want MQTT know to add ``--loop none`` to their uvicorn command.
# implement add_reader()/add_writer(), which paho-mqtt (via aiomqtt) and
# asyncio.start_server (TCP proxy) require. The loop is already created by
# the time this module is imported, so we cannot switch it here. Log a
# prominent warning so Windows operators know to start uvicorn with the
# selector loop policy set before import.
# ---------------------------------------------------------------------------
if sys.platform == "win32":
import asyncio as _asyncio
@@ -21,12 +22,15 @@ if sys.platform == "win32":
" NOTE FOR WINDOWS USERS\n" + "!" * 78 + "\n"
"\n"
" The running event loop is ProactorEventLoop, which is not\n"
" compatible with MQTT fanout (aiomqtt / paho-mqtt).\n"
" compatible with MQTT fanout or the TCP proxy.\n"
"\n"
" If you use MQTT integrations, restart with --loop none:\n"
" If you use either feature, restart with:\n"
"\n"
" uv run uvicorn app.main:app \033[1m--loop none\033[0m"
" [... other options ...]\n"
' python -c "import asyncio; asyncio.set_event_loop_policy('
'asyncio.WindowsSelectorEventLoopPolicy())" & '
"uv run uvicorn app.main:app [... options ...]\n"
"\n"
" Or add --loop asyncio to the uvicorn command.\n"
"\n"
" Everything else works fine as-is.\n"
"\n" + "!" * 78 + "\n",
@@ -130,12 +134,21 @@ async def lifespan(app: FastAPI):
except Exception:
logger.exception("Failed to start fanout modules")
if server_settings.tcp_proxy_enabled:
from app.tcp_proxy import start_tcp_proxy
await start_tcp_proxy()
startup_radio_task = asyncio.create_task(_startup_radio_connect_and_setup())
app.state.startup_radio_task = startup_radio_task
yield
logger.info("Shutting down")
if server_settings.tcp_proxy_enabled:
from app.tcp_proxy import stop_tcp_proxy
await stop_tcp_proxy()
if startup_radio_task and not startup_radio_task.done():
startup_radio_task.cancel()
try:
+118
View File
@@ -0,0 +1,118 @@
# TCP Proxy Architecture
MeshCore companion protocol proxy: emulates a MeshCore radio over TCP,
translating the binary companion protocol into in-process RemoteTerm
operations. MeshCore clients (mobile apps, meshcore-cli, meshcore-ha)
connect to it and interact with RemoteTerm as if it were a physical radio.
Enable with `MESHCORE_TCP_PROXY_ENABLED=true`.
## Module Map
```text
app/tcp_proxy/
├── __init__.py # start_tcp_proxy() / stop_tcp_proxy() lifecycle
├── protocol.py # Constants, FrameParser, frame helpers
├── encoder.py # Binary builders: contact, self_info, device_info
├── session.py # ProxySession: per-client command dispatch + event handlers
├── server.py # TCP server lifecycle, session registry, dispatch_event()
└── AGENTS_tcp_proxy.md # This file
```
## Protocol (protocol.py)
- Frame format: `0x3C`/`0x3E` marker + 2-byte LE length + payload
- Command constants (`CMD_*`): client → proxy (first payload byte)
- Response constants (`RESP_*`): proxy → client
- Push constants (`PUSH_*`): unsolicited proxy → client notifications
- `FrameParser`: stateful streaming frame decoder (mirrors meshcore_py `tcp_cx.py`)
- Helpers: `frame_response`, `build_ok`, `build_error`, `pad`, `encode_path_byte`
## Encoder (encoder.py)
Stateless binary serializers that build companion-protocol payloads from
domain data. All functions return raw `bytes` (no frame wrapping).
- `build_contact` / `build_contact_from_dict`: Contact → RESP_CONTACT / PUSH_NEW_ADVERT
- `build_self_info` / `build_self_info_from_runtime`: radio config → RESP_SELF_INFO
- `build_device_info`: → RESP_DEVICE_INFO (fixed proxy identity)
## Session (session.py)
One `ProxySession` per connected TCP client. Maintains per-client state:
- **contacts**: cached favorite contacts from DB
- **channels**: cached channel list
- **channel_slots** / **key_to_idx**: bidirectional channel index ↔ key mapping
- **_msg_queue**: queued incoming messages for the pull-based delivery model
### Command Dispatch
Command byte → handler method via class-level dispatch table. Unsupported
commands return `ERR_UNSUPPORTED`.
### Message Delivery (Pull Model)
MeshCore mobile apps use a pull model for incoming messages:
1. Broadcast event arrives → session builds a V3 message frame → queues it
2. Session sends `PUSH_MSG_WAITING` (0x83) to notify the client
3. Client calls `CMD_SYNC_NEXT_MESSAGE` (0x0A) to pull the message
4. Session dequeues and sends the frame
5. Client calls again → `RESP_NO_MORE_MSGS` when queue is empty
### DM Send Flow
1. Parse destination prefix/key from binary payload
2. Resolve to full public key via contacts cache
3. Send immediate `RESP_MSG_SENT` + `PUSH_ACK` (fake ACK) so client doesn't retry
4. Fire-and-forget `_do_send_dm()` task calls `send_direct_message_to_contact()`
5. RemoteTerm handles actual radio lock, retries, and ACK tracking
## Server (server.py)
- TCP server lifecycle (`start` / `stop`) following the `radio_stats.py` pattern
- Session registry (`register` / `unregister`)
- `dispatch_event()`: called from `broadcast_event()` in `websocket.py` for
`message`, `message_acked`, and `contact` events
## Data Flow
```
Client → TCP frame → FrameParser → ProxySession._dispatch
→ command handler → repository/service call → binary response → TCP frame
RemoteTerm event → broadcast_event → dispatch_event
→ ProxySession.on_event_* → push frame → TCP frame
```
## Integration Points
- `app/config.py`: `tcp_proxy_enabled`, `tcp_proxy_bind`, `tcp_proxy_port`
- `app/main.py`: conditional `start_tcp_proxy()` / `stop_tcp_proxy()` in lifespan
- `app/websocket.py`: `dispatch_event()` hook in `broadcast_event()` for message/ack/contact
## Design Constraints
- Never mutate RemoteTerm state from SET_CHANNEL (local slot mapping only)
- Only sync favorite contacts to clients
- Channel slots: pre-load favorites only, ERR_NOT_FOUND for empty slots
- DM sends return immediate fake ACK (RemoteTerm handles retries)
- Message delivery uses the pull model (PUSH_MSG_WAITING → SYNC_NEXT_MESSAGE)
## Config
| Variable | Default | Description |
|----------|---------|-------------|
| `MESHCORE_TCP_PROXY_ENABLED` | `false` | Enable the TCP companion protocol proxy |
| `MESHCORE_TCP_PROXY_BIND` | `0.0.0.0` | Bind address for the proxy TCP server |
| `MESHCORE_TCP_PROXY_PORT` | `5001` | Port for the proxy TCP server |
## Tests
```text
tests/
├── test_tcp_proxy_protocol.py # FrameParser, frame helpers (pure, no async)
├── test_tcp_proxy_encoder.py # Binary encoding against expected wire bytes
├── test_tcp_proxy_session.py # Command handlers with mocked radio + repos
└── test_tcp_proxy_integration.py # Real TCP server, end-to-end frame exchange
```
+28
View File
@@ -0,0 +1,28 @@
"""MeshCore TCP companion protocol proxy.
Emulates a MeshCore companion radio over TCP, translating the binary
protocol into in-process RemoteTerm operations. Enable with
``MESHCORE_TCP_PROXY_ENABLED=true``.
"""
from __future__ import annotations
import logging
logger = logging.getLogger(__name__)
async def start_tcp_proxy() -> None:
"""Start the TCP proxy server using settings from config."""
from app.config import settings
from .server import start
await start(settings.tcp_proxy_bind, settings.tcp_proxy_port)
async def stop_tcp_proxy() -> None:
"""Stop the TCP proxy server."""
from .server import stop
await stop()
+165
View File
@@ -0,0 +1,165 @@
"""Binary encoders that build companion-protocol response payloads.
All functions return raw ``bytes`` payloads (without frame wrapping).
The caller is responsible for framing via :func:`protocol.frame_response`.
"""
from __future__ import annotations
import struct
import time
from typing import Any
from .protocol import (
PROXY_FW_BUILD,
PROXY_FW_VER,
PROXY_FW_VERSION,
PROXY_MAX_CHANNELS,
PROXY_MAX_CONTACTS_RAW,
PROXY_MODEL,
PUSH_NEW_ADVERT,
RESP_CONTACT,
RESP_DEVICE_INFO,
RESP_SELF_INFO,
encode_path_byte,
pad,
)
def build_contact(
public_key: str,
*,
contact_type: int = 0,
favorite: bool = False,
direct_path: str | None = None,
direct_path_len: int = -1,
direct_path_hash_mode: int = -1,
name: str | None = None,
last_advert: int = 0,
lat: float = 0.0,
lon: float = 0.0,
lastmod: int | None = None,
push: bool = False,
) -> bytes:
"""Build a ``RESP_CONTACT`` (or ``PUSH_NEW_ADVERT``) payload.
Args:
push: If True, use ``PUSH_NEW_ADVERT`` (0x8A) instead of
``RESP_CONTACT`` (0x03) as the leading byte.
"""
out = bytearray()
out.append(PUSH_NEW_ADVERT if push else RESP_CONTACT)
out.extend(pad(bytes.fromhex(public_key), 32))
out.append(contact_type)
flags = 0x01 if favorite else 0x00
out.append(flags)
if direct_path_len >= 0 and direct_path_hash_mode >= 0:
out.append(encode_path_byte(direct_path_len, direct_path_hash_mode))
else:
out.append(0xFF) # flood
path_bytes = bytes.fromhex(direct_path) if direct_path else b""
out.extend(pad(path_bytes, 64))
out.extend(pad((name or "").encode("utf-8", "replace"), 32))
out.extend(struct.pack("<I", last_advert))
out.extend(struct.pack("<i", int(lat * 1e6)))
out.extend(struct.pack("<i", int(lon * 1e6)))
out.extend(struct.pack("<I", lastmod or int(time.time())))
return bytes(out)
def build_contact_from_dict(data: dict[str, Any], *, push: bool = False) -> bytes:
"""Build a contact payload from either a ``Contact`` model dict or a
WS event ``data`` dict. Accepts both snake_case model fields and
the shapes produced by Pydantic JSON serialisation."""
return build_contact(
public_key=data["public_key"],
contact_type=data.get("type") or 0,
favorite=bool(data.get("favorite")),
direct_path=data.get("direct_path") or None,
direct_path_len=data.get("direct_path_len", -1),
direct_path_hash_mode=data.get("direct_path_hash_mode", -1),
name=data.get("name"),
last_advert=int(data.get("last_advert") or 0),
lat=float(data.get("lat") or 0),
lon=float(data.get("lon") or 0),
lastmod=int(data.get("lastmod") or data.get("first_seen") or 0) or None,
push=push,
)
def build_self_info(
*,
public_key: str = "00" * 32,
name: str = "RemoteTerm",
tx_power: int = 20,
max_tx_power: int = 22,
lat: float = 0.0,
lon: float = 0.0,
multi_acks: bool = False,
advert_loc: bool = False,
radio_freq: float = 915.0,
radio_bw: float = 250.0,
radio_sf: int = 10,
radio_cr: int = 7,
) -> bytes:
"""Build a ``RESP_SELF_INFO`` payload (response to ``CMD_APP_START``)."""
out = bytearray()
out.append(RESP_SELF_INFO)
out.append(1) # adv_type = CHAT
out.append(tx_power)
out.append(max_tx_power)
out.extend(pad(bytes.fromhex(public_key), 32))
out.extend(struct.pack("<i", int(lat * 1e6)))
out.extend(struct.pack("<i", int(lon * 1e6)))
out.append(1 if multi_acks else 0)
out.append(1 if advert_loc else 0)
out.append(0) # telemetry_mode
out.append(0) # manual_add_contacts
out.extend(struct.pack("<I", int(radio_freq * 1000)))
out.extend(struct.pack("<I", int(radio_bw * 1000)))
out.append(radio_sf)
out.append(radio_cr)
out.extend(name.encode("utf-8"))
return bytes(out)
def build_self_info_from_runtime(self_info: dict[str, Any]) -> bytes:
"""Build ``RESP_SELF_INFO`` from ``radio_runtime.self_info``."""
return build_self_info(
public_key=self_info.get("public_key") or "00" * 32,
name=self_info.get("name") or "RemoteTerm",
tx_power=self_info.get("tx_power") or 20,
max_tx_power=self_info.get("max_tx_power") or 22,
lat=float(self_info.get("adv_lat") or 0),
lon=float(self_info.get("adv_lon") or 0),
multi_acks=bool(self_info.get("multi_acks")),
advert_loc=bool(self_info.get("adv_loc_policy")),
radio_freq=float(self_info.get("radio_freq") or 915.0),
radio_bw=float(self_info.get("radio_bw") or 250.0),
radio_sf=int(self_info.get("radio_sf") or 10),
radio_cr=int(self_info.get("radio_cr") or 7),
)
def build_device_info(path_hash_mode: int = 0) -> bytes:
"""Build a ``RESP_DEVICE_INFO`` payload (response to ``CMD_DEVICE_QUERY``)."""
out = bytearray()
out.append(RESP_DEVICE_INFO)
out.append(PROXY_FW_VER)
out.append(PROXY_MAX_CONTACTS_RAW) # ×2 by reader
out.append(PROXY_MAX_CHANNELS)
out.extend(struct.pack("<I", 0)) # ble_pin
out.extend(pad(PROXY_FW_BUILD.encode(), 12))
out.extend(pad(PROXY_MODEL.encode(), 40))
out.extend(pad(PROXY_FW_VERSION.encode(), 20))
out.append(0) # repeat mode (fw v9+)
out.append(path_hash_mode) # (fw v10+)
return bytes(out)
+195
View File
@@ -0,0 +1,195 @@
"""MeshCore companion protocol constants, frame helpers, and streaming parser."""
from __future__ import annotations
# ── Frame markers ────────────────────────────────────────────────────
FRAME_TX = 0x3C # client → radio
FRAME_RX = 0x3E # radio → client
MAX_FRAME_SIZE = 300 # firmware MAX_FRAME_SIZE is 172; we allow a bit more
# ── Command types (client → proxy) ──────────────────────────────────
CMD_APP_START = 0x01
CMD_SEND_TXT_MSG = 0x02
CMD_SEND_CHANNEL_TXT_MSG = 0x03
CMD_GET_CONTACTS = 0x04
CMD_GET_DEVICE_TIME = 0x05
CMD_SET_DEVICE_TIME = 0x06
CMD_SEND_SELF_ADVERT = 0x07
CMD_SET_ADVERT_NAME = 0x08
CMD_ADD_UPDATE_CONTACT = 0x09
CMD_SYNC_NEXT_MESSAGE = 0x0A
CMD_SET_RADIO_PARAMS = 0x0B
CMD_SET_RADIO_TX_POWER = 0x0C
CMD_RESET_PATH = 0x0D
CMD_SET_ADVERT_LATLON = 0x0E
CMD_REMOVE_CONTACT = 0x0F
CMD_REBOOT = 0x13
CMD_GET_BATT_AND_STORAGE = 0x14
CMD_DEVICE_QUERY = 0x16
CMD_EXPORT_PRIVATE_KEY = 0x17
CMD_HAS_CONNECTION = 0x1C
CMD_GET_CONTACT_BY_KEY = 0x1E
CMD_GET_CHANNEL = 0x1F
CMD_SET_CHANNEL = 0x20
CMD_SET_FLOOD_SCOPE = 0x36
CMD_GET_STATS = 0x38
CMD_NAMES: dict[int, str] = {
0x01: "APP_START",
0x02: "SEND_TXT_MSG",
0x03: "SEND_CHAN_MSG",
0x04: "GET_CONTACTS",
0x05: "GET_TIME",
0x06: "SET_TIME",
0x07: "SEND_ADVERT",
0x08: "SET_NAME",
0x09: "ADD_CONTACT",
0x0A: "SYNC_MSG",
0x0B: "SET_RADIO",
0x0C: "SET_TX_POWER",
0x0D: "RESET_PATH",
0x0E: "SET_LATLON",
0x0F: "REMOVE_CONTACT",
0x13: "REBOOT",
0x14: "GET_BATTERY",
0x16: "DEVICE_QUERY",
0x17: "EXPORT_PRIV_KEY",
0x1C: "HAS_CONNECTION",
0x1E: "GET_CONTACT_BY_KEY",
0x1F: "GET_CHANNEL",
0x20: "SET_CHANNEL",
0x36: "SET_FLOOD_SCOPE",
0x38: "GET_STATS",
}
# ── Response / push types (proxy → client) ──────────────────────────
RESP_OK = 0x00
RESP_ERR = 0x01
RESP_CONTACT_START = 0x02
RESP_CONTACT = 0x03
RESP_CONTACT_END = 0x04
RESP_SELF_INFO = 0x05
RESP_MSG_SENT = 0x06
RESP_CONTACT_MSG_RECV = 0x07
RESP_CHANNEL_MSG_RECV = 0x08
RESP_CURRENT_TIME = 0x09
RESP_NO_MORE_MSGS = 0x0A
RESP_BATTERY = 0x0C
RESP_DEVICE_INFO = 0x0D
RESP_DISABLED = 0x0F
RESP_CONTACT_MSG_RECV_V3 = 0x10
RESP_CHANNEL_MSG_RECV_V3 = 0x11
RESP_CHANNEL_INFO = 0x12
PUSH_ACK = 0x82
PUSH_MSG_WAITING = 0x83
PUSH_NEW_ADVERT = 0x8A
# ── Error codes ──────────────────────────────────────────────────────
ERR_UNSUPPORTED = 1
ERR_NOT_FOUND = 2
# ── Virtual device identity ─────────────────────────────────────────
PROXY_FW_VER = 11
PROXY_MAX_CONTACTS_RAW = 255 # reader multiplies by 2 → 510
PROXY_MAX_CHANNELS = 40
PROXY_MODEL = "RemoteTerm Proxy"
PROXY_FW_VERSION = "v0.1.0-proxy"
PROXY_FW_BUILD = "proxy"
# ── Frame helpers ────────────────────────────────────────────────────
def frame_response(payload: bytes) -> bytes:
"""Wrap *payload* in a ``0x3E`` frame for sending to the client."""
return bytes([FRAME_RX]) + len(payload).to_bytes(2, "little") + payload
def build_ok(value: int | None = None) -> bytes:
"""Build a ``RESP_OK`` payload, optionally with a 4-byte LE value."""
if value is not None:
return bytes([RESP_OK]) + value.to_bytes(4, "little")
return bytes([RESP_OK])
def build_error(code: int = ERR_UNSUPPORTED) -> bytes:
"""Build a ``RESP_ERR`` payload with the given error code."""
return bytes([RESP_ERR, code])
def pad(data: bytes, length: int) -> bytes:
"""Pad or truncate *data* to exactly *length* bytes."""
return data[:length].ljust(length, b"\x00")
def encode_path_byte(hop_count: int, hash_mode: int) -> int:
"""Encode hop count + hash mode into a single packed byte.
Returns ``0xFF`` (flood) when either value is negative.
"""
if hop_count < 0 or hash_mode < 0:
return 0xFF
return ((hash_mode & 0x03) << 6) | (hop_count & 0x3F)
# ── Streaming frame parser ──────────────────────────────────────────
class FrameParser:
"""Stateful parser for ``0x3C``-framed TCP data.
Mirrors the framing logic in ``meshcore_py`` ``tcp_cx.py``.
"""
def __init__(self) -> None:
self.header = b""
self.inframe = b""
self.frame_size = 0
self.started = False
def feed(self, data: bytes) -> list[bytes]:
"""Feed raw TCP bytes, return a list of complete payloads."""
payloads: list[bytes] = []
offset = 0
while offset < len(data):
remaining = data[offset:]
if not self.started:
needed = 3 - len(self.header)
chunk = remaining[:needed]
self.header += chunk
offset += len(chunk)
if len(self.header) < 3:
break
if self.header[0] != FRAME_TX:
self.header = b""
continue
self.frame_size = int.from_bytes(self.header[1:3], "little")
if self.frame_size > MAX_FRAME_SIZE:
self.header = b""
continue
self.started = True
else:
needed = self.frame_size - len(self.inframe)
chunk = remaining[:needed]
self.inframe += chunk
offset += len(chunk)
if len(self.inframe) >= self.frame_size:
payloads.append(self.inframe)
self.header = b""
self.inframe = b""
self.started = False
return payloads
+92
View File
@@ -0,0 +1,92 @@
"""TCP server lifecycle, session registry, and broadcast event dispatch."""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from .session import ProxySession
logger = logging.getLogger(__name__)
# ── Session registry ─────────────────────────────────────────────────
_sessions: set[ProxySession] = set()
_server: asyncio.Server | None = None
def register(session: ProxySession) -> None:
_sessions.add(session)
def unregister(session: ProxySession) -> None:
_sessions.discard(session)
# ── Event dispatch (called from broadcast_event) ─────────────────────
async def dispatch_event(event_type: str, data: dict[str, Any]) -> None:
"""Dispatch a broadcast event to all connected proxy sessions.
Called from :func:`app.websocket.broadcast_event` for ``message``,
``message_acked``, and ``contact`` events.
"""
for session in list(_sessions):
try:
if event_type == "message":
await session.on_event_message(data)
elif event_type == "contact":
await session.on_event_contact(data)
except Exception:
logger.exception("Error dispatching %s to %s", event_type, session.addr)
# ── TCP client handler ───────────────────────────────────────────────
async def _handle_client(
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
) -> None:
session = ProxySession(reader, writer)
register(session)
try:
await session.run()
finally:
unregister(session)
# ── Server lifecycle ─────────────────────────────────────────────────
async def start(host: str, port: int) -> None:
"""Start the TCP proxy server."""
global _server
if _server is not None:
return
_server = await asyncio.start_server(_handle_client, host, port)
addrs = ", ".join(str(s.getsockname()) for s in _server.sockets)
logger.info("TCP proxy listening on %s", addrs)
async def stop() -> None:
"""Stop the TCP proxy server and disconnect all clients."""
global _server
if _server is None:
return
# Close all active sessions
for session in list(_sessions):
try:
session.writer.close()
except Exception:
pass
_sessions.clear()
_server.close()
await _server.wait_closed()
_server = None
logger.info("TCP proxy stopped")
+644
View File
@@ -0,0 +1,644 @@
"""Per-client MeshCore companion protocol session.
Each connected TCP client gets its own ``ProxySession`` which:
- parses incoming 0x3C frames via :class:`protocol.FrameParser`
- dispatches commands to handler methods
- translates between binary companion payloads and in-process
repository / service calls
- receives broadcast events and queues push frames for the client
"""
from __future__ import annotations
import asyncio
import io
import logging
import random
import struct
import time
from typing import Any
from .encoder import (
build_contact_from_dict,
build_device_info,
build_self_info_from_runtime,
)
from .protocol import (
CMD_ADD_UPDATE_CONTACT,
CMD_APP_START,
CMD_DEVICE_QUERY,
CMD_EXPORT_PRIVATE_KEY,
CMD_GET_BATT_AND_STORAGE,
CMD_GET_CHANNEL,
CMD_GET_CONTACT_BY_KEY,
CMD_GET_CONTACTS,
CMD_GET_DEVICE_TIME,
CMD_HAS_CONNECTION,
CMD_NAMES,
CMD_REMOVE_CONTACT,
CMD_RESET_PATH,
CMD_SEND_CHANNEL_TXT_MSG,
CMD_SEND_SELF_ADVERT,
CMD_SEND_TXT_MSG,
CMD_SET_ADVERT_LATLON,
CMD_SET_ADVERT_NAME,
CMD_SET_CHANNEL,
CMD_SET_DEVICE_TIME,
CMD_SET_FLOOD_SCOPE,
CMD_SYNC_NEXT_MESSAGE,
ERR_NOT_FOUND,
ERR_UNSUPPORTED,
PROXY_MAX_CHANNELS,
PUSH_ACK,
PUSH_MSG_WAITING,
RESP_BATTERY,
RESP_CHANNEL_INFO,
RESP_CHANNEL_MSG_RECV_V3,
RESP_CONTACT_END,
RESP_CONTACT_MSG_RECV_V3,
RESP_CONTACT_START,
RESP_CURRENT_TIME,
RESP_DISABLED,
RESP_MSG_SENT,
RESP_NO_MORE_MSGS,
FrameParser,
build_error,
build_ok,
frame_response,
pad,
)
logger = logging.getLogger(__name__)
class ProxySession:
"""Handles one MeshCore TCP client, translating commands to RemoteTerm
repository and service calls."""
def __init__(
self,
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
) -> None:
self.reader = reader
self.writer = writer
self.addr = writer.get_extra_info("peername")
self.parser = FrameParser()
# Cached state
self.contacts: list[dict[str, Any]] = []
self.channels: list[dict[str, Any]] = []
# Channel index ↔ key mapping
self.channel_slots: dict[int, str] = {} # idx → key (lowercase hex)
self.key_to_idx: dict[str, int] = {} # key (lowercase) → idx
# Queued incoming messages for SYNC_NEXT_MESSAGE pull flow.
self._msg_queue: list[bytes] = []
# ── send helper ──────────────────────────────────────────────────
async def send(self, payload: bytes) -> None:
"""Frame and send a response payload."""
self.writer.write(frame_response(payload))
await self.writer.drain()
# ── main loop ────────────────────────────────────────────────────
async def run(self) -> None:
logger.info("Client connected: %s", self.addr)
try:
while True:
data = await self.reader.read(4096)
if not data:
break
for payload in self.parser.feed(data):
await self._dispatch(payload)
except (asyncio.CancelledError, ConnectionResetError):
pass
except Exception:
logger.exception("Session error [%s]", self.addr)
finally:
self.writer.close()
logger.info("Client disconnected: %s", self.addr)
# ── command dispatch ─────────────────────────────────────────────
_DISPATCH_TABLE: dict[int, str] | None = None
@classmethod
def _build_dispatch_table(cls) -> dict[int, str]:
if cls._DISPATCH_TABLE is None:
cls._DISPATCH_TABLE = {
CMD_APP_START: "_cmd_app_start",
CMD_DEVICE_QUERY: "_cmd_device_query",
CMD_GET_CONTACTS: "_cmd_get_contacts",
CMD_GET_CONTACT_BY_KEY: "_cmd_get_contact_by_key",
CMD_GET_CHANNEL: "_cmd_get_channel",
CMD_SET_CHANNEL: "_cmd_set_channel",
CMD_SEND_TXT_MSG: "_cmd_send_dm",
CMD_SEND_CHANNEL_TXT_MSG: "_cmd_send_channel",
CMD_GET_DEVICE_TIME: "_cmd_get_time",
CMD_SET_DEVICE_TIME: "_cmd_ok_stub",
CMD_SEND_SELF_ADVERT: "_cmd_advertise",
CMD_GET_BATT_AND_STORAGE: "_cmd_battery",
CMD_HAS_CONNECTION: "_cmd_has_connection",
CMD_SYNC_NEXT_MESSAGE: "_cmd_sync_next",
CMD_ADD_UPDATE_CONTACT: "_cmd_ok_stub",
CMD_REMOVE_CONTACT: "_cmd_remove_contact",
CMD_RESET_PATH: "_cmd_ok_stub",
CMD_SET_ADVERT_NAME: "_cmd_set_name",
CMD_SET_ADVERT_LATLON: "_cmd_set_latlon",
CMD_SET_FLOOD_SCOPE: "_cmd_ok_stub",
CMD_EXPORT_PRIVATE_KEY: "_cmd_disabled",
}
return cls._DISPATCH_TABLE
async def _dispatch(self, data: bytes) -> None:
if not data:
return
cmd = data[0]
name = CMD_NAMES.get(cmd, f"0x{cmd:02x}")
logger.debug("[%s] ← %s (%dB)", self.addr, name, len(data))
table = self._build_dispatch_table()
method_name = table.get(cmd)
if method_name:
handler = getattr(self, method_name)
try:
await handler(data)
except Exception:
logger.exception("[%s] Error in %s", self.addr, name)
await self.send(build_error())
else:
logger.warning("[%s] Unsupported: %s", self.addr, name)
await self.send(build_error(ERR_UNSUPPORTED))
# ── stubs ────────────────────────────────────────────────────────
async def _cmd_ok_stub(self, data: bytes) -> None:
await self.send(build_ok())
async def _cmd_disabled(self, data: bytes) -> None:
await self.send(bytes([RESP_DISABLED]))
# ── APP_START → SELF_INFO ────────────────────────────────────────
async def _cmd_app_start(self, data: bytes) -> None:
from app.repository import AppSettingsRepository, ChannelRepository, ContactRepository
from app.services.radio_runtime import radio_runtime
self.contacts = [c.model_dump() for c in await ContactRepository.get_favorites()]
self.channels = [c.model_dump() for c in await ChannelRepository.get_all()]
settings = await AppSettingsRepository.get()
lmt = settings.last_message_times or {}
self._sort_channels(lmt)
self._rebuild_slots()
mc = radio_runtime.meshcore
self_info = mc.self_info if mc else {}
await self.send(build_self_info_from_runtime(self_info or {}))
name = (self_info or {}).get("name", "?")
pubkey = (self_info or {}).get("public_key", "?" * 12)
logger.info(
"[%s] Session started — %s (%s...) | %d contacts, %d channel slots",
self.addr,
name,
pubkey[:12],
len(self.contacts),
len(self.channel_slots),
)
# ── DEVICE_QUERY → DEVICE_INFO ──────────────────────────────────
async def _cmd_device_query(self, data: bytes) -> None:
from app.services.radio_runtime import radio_runtime
mc = radio_runtime.meshcore
self_info = mc.self_info if mc else {}
# Fall back to radio_runtime.path_hash_mode which radio_lifecycle
# recovers from the raw device-info frame when self_info is missing it.
phm = (self_info or {}).get("path_hash_mode")
if phm is None:
phm = getattr(radio_runtime, "path_hash_mode", 0) or 0
await self.send(build_device_info(path_hash_mode=phm))
# ── GET_CONTACTS ─────────────────────────────────────────────────
async def _cmd_get_contacts(self, data: bytes) -> None:
from app.repository import ContactRepository
self.contacts = [c.model_dump() for c in await ContactRepository.get_favorites()]
count = len(self.contacts)
await self.send(bytes([RESP_CONTACT_START]) + count.to_bytes(4, "little"))
for c in self.contacts:
await self.send(build_contact_from_dict(c))
await self.send(bytes([RESP_CONTACT_END]) + int(time.time()).to_bytes(4, "little"))
logger.info("[%s] Sent %d contacts", self.addr, count)
# ── GET_CONTACT_BY_KEY ───────────────────────────────────────────
async def _cmd_get_contact_by_key(self, data: bytes) -> None:
if len(data) < 33:
await self.send(build_error(ERR_NOT_FOUND))
return
pubkey = data[1:33].hex()
contact = next((c for c in self.contacts if c["public_key"] == pubkey), None)
if contact is None:
await self.send(build_error(ERR_NOT_FOUND))
return
await self.send(build_contact_from_dict(contact))
# ── GET_CHANNEL → CHANNEL_INFO ───────────────────────────────────
async def _cmd_get_channel(self, data: bytes) -> None:
if len(data) < 2:
await self.send(build_error(ERR_NOT_FOUND))
return
idx = data[1]
key_hex = self.channel_slots.get(idx)
if key_hex is None:
await self.send(build_error(ERR_NOT_FOUND))
return
ch = next((c for c in self.channels if c["key"].lower() == key_hex), None)
name = (ch.get("name") or "") if ch else ""
out = bytearray()
out.append(RESP_CHANNEL_INFO)
out.append(idx)
out.extend(pad(name.encode("utf-8"), 32))
out.extend(pad(bytes.fromhex(key_hex), 16))
await self.send(bytes(out))
# ── SET_CHANNEL ──────────────────────────────────────────────────
async def _cmd_set_channel(self, data: bytes) -> None:
if len(data) < 50:
await self.send(build_error())
return
idx = data[1]
key_hex = data[34:50].hex()
# Clean up stale bidirectional mappings
old_key = self.channel_slots.get(idx)
if old_key is not None and old_key != key_hex:
self.key_to_idx.pop(old_key, None)
old_idx = self.key_to_idx.get(key_hex)
if old_idx is not None and old_idx != idx:
self.channel_slots.pop(old_idx, None)
self.channel_slots[idx] = key_hex
self.key_to_idx[key_hex] = idx
await self.send(build_ok())
# ── SEND_TXT_MSG (DM) ───────────────────────────────────────────
async def _cmd_send_dm(self, data: bytes) -> None:
buf = io.BytesIO(data)
buf.read(1) # cmd
buf.read(1) # txt_type
buf.read(1) # attempt
buf.read(4) # timestamp
remaining = buf.read()
full_key, text = self._parse_destination_and_text(remaining)
if not full_key or text is None:
logger.warning(
"[%s] Cannot resolve DM destination (remaining %dB)",
self.addr,
len(remaining),
)
await self.send(build_error(ERR_NOT_FOUND))
return
# Send immediate MSG_SENT + fake ACK — RemoteTerm handles retries.
ack_code = random.randbytes(4)
out = bytearray([RESP_MSG_SENT, 1]) # type=flood
out.extend(ack_code)
out.extend(struct.pack("<I", 5_000))
await self.send(bytes(out))
ack_frame = bytearray([PUSH_ACK])
ack_frame.extend(ack_code)
ack_frame.extend(struct.pack("<I", 100)) # fake trip_time
await self.send(bytes(ack_frame))
# Fire-and-forget the actual send
asyncio.create_task(self._do_send_dm(full_key, text))
logger.info("[%s] DM → %s...: %s", self.addr, full_key[:12], text[:40])
async def _do_send_dm(self, public_key: str, text: str) -> None:
"""Background task: send a DM through the radio via the service layer."""
try:
from app.event_handlers import track_pending_ack
from app.repository import ContactRepository, MessageRepository
from app.services.message_send import send_direct_message_to_contact
from app.services.radio_runtime import radio_runtime
from app.websocket import broadcast_event
contact = await ContactRepository.get_by_key_or_prefix(public_key)
if not contact:
logger.warning("DM send: contact %s not found", public_key[:12])
return
await send_direct_message_to_contact(
contact=contact,
text=text,
radio_manager=radio_runtime,
broadcast_fn=broadcast_event,
track_pending_ack_fn=track_pending_ack,
now_fn=time.time,
message_repository=MessageRepository,
contact_repository=ContactRepository,
)
except Exception:
logger.exception("[%s] DM send failed for %s", self.addr, public_key[:12])
def _parse_destination_and_text(self, remaining: bytes) -> tuple[str | None, str | None]:
"""Resolve destination key + text from the combined buffer.
Tries 32-byte full key first (always accepted — _do_send_dm resolves
from the repository), then falls back to 6-byte prefix matched against
the cached contacts list.
"""
# Try 32-byte full key first (send_msg_with_retry sends full keys)
if len(remaining) > 32:
candidate = remaining[:32].hex()
# Accept any well-formed 64-char hex key — _do_send_dm will
# resolve it from the repository, not just our favorites cache.
if len(candidate) == 64:
return candidate, remaining[32:].decode("utf-8", "ignore")
# Fall back to 6-byte prefix (send_msg default) — can only resolve
# against our cached contacts since we need an unambiguous match.
if len(remaining) > 6:
prefix = remaining[:6].hex()
matches = [c["public_key"] for c in self.contacts if c["public_key"].startswith(prefix)]
if len(matches) == 1:
return matches[0], remaining[6:].decode("utf-8", "ignore")
return None, None
# ── SEND_CHANNEL_TXT_MSG ─────────────────────────────────────────
async def _cmd_send_channel(self, data: bytes) -> None:
buf = io.BytesIO(data)
buf.read(1) # cmd
buf.read(1) # txt_type
channel_idx = buf.read(1)[0]
buf.read(4) # timestamp
text = buf.read().rstrip(b"\x00").decode("utf-8", "ignore")
key_hex = self.channel_slots.get(channel_idx)
if not key_hex:
logger.warning("[%s] No channel at slot %d", self.addr, channel_idx)
await self.send(build_error(ERR_NOT_FOUND))
return
# Verify the channel exists in RemoteTerm's DB before confirming.
# SET_CHANNEL is local-only, so client-loaded channels that aren't in
# the DB can't be sent on — return ERR_NOT_FOUND instead of false OK.
from app.repository import ChannelRepository
channel = await ChannelRepository.get_by_key(key_hex)
if not channel:
logger.warning("[%s] Channel %s not in DB", self.addr, key_hex[:12])
await self.send(build_error(ERR_NOT_FOUND))
return
await self.send(build_ok())
asyncio.create_task(self._do_send_channel(key_hex, text))
label = channel.name or key_hex[:8]
logger.info("[%s] Chan [%s]: %s", self.addr, label, text[:40])
async def _do_send_channel(self, channel_key: str, text: str) -> None:
"""Background task: send a channel message through the radio."""
try:
from app.repository import ChannelRepository, MessageRepository
from app.services.message_send import send_channel_message_to_channel
from app.services.radio_runtime import radio_runtime
from app.websocket import broadcast_error, broadcast_event
channel = await ChannelRepository.get_by_key(channel_key)
if not channel:
logger.warning("Channel send: key %s not found", channel_key[:12])
return
key_bytes = bytes.fromhex(channel_key)
await send_channel_message_to_channel(
channel=channel,
channel_key_upper=channel_key.upper(),
key_bytes=key_bytes,
text=text,
radio_manager=radio_runtime,
broadcast_fn=broadcast_event,
error_broadcast_fn=broadcast_error,
now_fn=time.time,
temp_radio_slot=0,
message_repository=MessageRepository,
)
except Exception:
logger.exception("[%s] Channel send failed for %s", self.addr, channel_key[:12])
# ── Simple command handlers ──────────────────────────────────────
async def _cmd_get_time(self, data: bytes) -> None:
t = int(time.time())
await self.send(bytes([RESP_CURRENT_TIME]) + t.to_bytes(4, "little"))
async def _cmd_advertise(self, data: bytes) -> None:
try:
from app.services.radio_runtime import radio_runtime
async with radio_runtime.radio_operation("proxy_advertise") as mc:
await mc.commands.send_advert(flood=True)
await self.send(build_ok())
except Exception:
logger.exception("Advertise failed")
await self.send(build_error())
async def _cmd_battery(self, data: bytes) -> None:
out = bytearray([RESP_BATTERY])
out.extend(struct.pack("<H", 0)) # no battery
await self.send(bytes(out))
async def _cmd_has_connection(self, data: bytes) -> None:
from app.services.radio_runtime import radio_runtime
val = 1 if radio_runtime.is_connected else 0
await self.send(build_ok(val))
async def _cmd_sync_next(self, data: bytes) -> None:
if self._msg_queue:
frame = self._msg_queue.pop(0)
await self.send(frame)
logger.debug(
"[%s] Delivered queued msg (%d remaining)",
self.addr,
len(self._msg_queue),
)
else:
await self.send(bytes([RESP_NO_MORE_MSGS]))
async def _cmd_remove_contact(self, data: bytes) -> None:
if len(data) < 33:
await self.send(build_error())
return
pubkey = data[1:33].hex()
self.contacts = [c for c in self.contacts if c["public_key"] != pubkey]
await self.send(build_ok())
async def _cmd_set_name(self, data: bytes) -> None:
name = data[1:].decode("utf-8", "ignore").rstrip("\x00")
try:
from app.services.radio_runtime import radio_runtime
async with radio_runtime.radio_operation("proxy_set_name") as mc:
await mc.commands.set_name(name)
await self.send(build_ok())
except Exception:
logger.exception("Set name failed")
await self.send(build_error())
async def _cmd_set_latlon(self, data: bytes) -> None:
if len(data) < 9:
await self.send(build_error())
return
lat = struct.unpack_from("<i", data, 1)[0] / 1e6
lon = struct.unpack_from("<i", data, 5)[0] / 1e6
try:
from app.services.radio_runtime import radio_runtime
async with radio_runtime.radio_operation("proxy_set_latlon") as mc:
await mc.commands.set_coords(lat, lon)
await self.send(build_ok())
except Exception:
logger.exception("Set lat/lon failed")
await self.send(build_error())
# ── Channel slot management ──────────────────────────────────────
def _sort_channels(self, last_message_times: dict[str, Any]) -> None:
"""Sort channels: favorites first, then most recently active."""
lmt = last_message_times
def key(ch: dict) -> tuple:
is_fav = 1 if ch.get("favorite") else 0
state_key = f"channel-{ch['key']}"
last_activity = lmt.get(state_key) or 0
return (-is_fav, -last_activity)
self.channels.sort(key=key)
def _rebuild_slots(self) -> None:
"""Pre-load only favorite channels into slots."""
self.channel_slots.clear()
self.key_to_idx.clear()
favorites = [ch for ch in self.channels if ch.get("favorite")]
for i, ch in enumerate(favorites[:PROXY_MAX_CHANNELS]):
k = ch["key"].lower()
self.channel_slots[i] = k
self.key_to_idx[k] = i
logger.debug("Pre-loaded %d favorite channel(s)", len(self.channel_slots))
# ── Broadcast event handlers (called by server.dispatch_event) ──
async def _push_contact_from_db(self, public_key: str) -> None:
"""Fetch a contact from the DB and push it to the client so it can
display messages from senders not in the favorites cache."""
try:
from app.repository import ContactRepository
contact = await ContactRepository.get_by_key(public_key)
if not contact:
return
contact_dict = contact.model_dump()
await self.send(build_contact_from_dict(contact_dict, push=True))
self.contacts.append(contact_dict)
except Exception:
logger.debug("Failed to push contact %s from DB", public_key[:12])
async def on_event_message(self, data: dict[str, Any]) -> None:
"""Translate a broadcast ``message`` event into a queued push frame."""
if data.get("outgoing"):
return
msg_type = data.get("type")
if msg_type == "PRIV":
sender_key = data.get("conversation_key", "")
if len(sender_key) < 12:
return
# If sender isn't in our cache, fetch from DB and push to client
# so it knows who the message is from.
if not any(c["public_key"] == sender_key for c in self.contacts):
await self._push_contact_from_db(sender_key)
text = data.get("text") or ""
ts = int(data.get("sender_timestamp") or time.time())
frame = bytearray([RESP_CONTACT_MSG_RECV_V3])
frame.append(0) # SNR
frame.extend(b"\x00\x00") # reserved
frame.extend(bytes.fromhex(sender_key[:12])) # 6-byte prefix
frame.append(0xFF) # flood
frame.append(0) # txt_type
frame.extend(struct.pack("<I", ts))
frame.extend(text.encode("utf-8"))
self._msg_queue.append(bytes(frame))
await self.send(bytes([PUSH_MSG_WAITING]))
elif msg_type == "CHAN":
conv_key = data.get("conversation_key", "").lower()
idx = self.key_to_idx.get(conv_key)
if idx is None:
return
text = data.get("text") or ""
ts = int(data.get("sender_timestamp") or time.time())
frame = bytearray([RESP_CHANNEL_MSG_RECV_V3])
frame.append(0) # SNR
frame.extend(b"\x00\x00") # reserved
frame.append(idx)
frame.append(0xFF) # flood
frame.append(0) # txt_type
frame.extend(struct.pack("<I", ts))
frame.extend(text.encode("utf-8"))
self._msg_queue.append(bytes(frame))
await self.send(bytes([PUSH_MSG_WAITING]))
async def on_event_contact(self, data: dict[str, Any]) -> None:
"""Translate a broadcast ``contact`` event into a PUSH_NEW_ADVERT."""
pubkey = data.get("public_key", "")
if len(pubkey) < 64:
return
# Only push contacts that are already in our favorites cache.
# Without this filter, a long-lived session would gradually sync
# every contact on the mesh, defeating the favorites-only policy.
existing = next((c for c in self.contacts if c["public_key"] == pubkey), None)
if existing is None:
return
try:
await self.send(build_contact_from_dict(data, push=True))
except Exception:
logger.debug("Failed to build contact push for %s", pubkey[:12])
existing.update(data)
+9
View File
@@ -117,6 +117,15 @@ def broadcast_event(event_type: str, data: dict, *, realtime: bool = True) -> No
elif event_type == "contact":
asyncio.create_task(fanout_manager.broadcast_contact(data))
# TCP proxy dispatch
if event_type in ("message", "message_acked", "contact"):
from app.config import settings
if settings.tcp_proxy_enabled:
from app.tcp_proxy.server import dispatch_event
asyncio.create_task(dispatch_event(event_type, data))
def broadcast_error(message: str, details: str | None = None) -> None:
"""Broadcast an error notification to all connected clients.