From 27cc76d5ed689d3fe61ff7dfb8f010454704735a Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Wed, 8 Apr 2026 13:18:21 -0500 Subject: [PATCH] Very WIP dmshell --- bin/dmshell_client.py | 375 +++++++++++++++++ src/mesh/generated/meshtastic/mesh.pb.cpp | 5 + src/mesh/generated/meshtastic/mesh.pb.h | 75 ++++ src/mesh/generated/meshtastic/portnums.pb.h | 2 + src/modules/DMShell.cpp | 397 ++++++++++++++++++ src/modules/DMShell.h | 71 ++++ src/modules/Modules.cpp | 2 + .../ports/test_dmshell.cpp | 112 +++++ .../test_serializer.cpp | 10 + 9 files changed, 1049 insertions(+) create mode 100644 bin/dmshell_client.py create mode 100644 src/modules/DMShell.cpp create mode 100644 src/modules/DMShell.h create mode 100644 test/test_meshpacket_serializer/ports/test_dmshell.cpp diff --git a/bin/dmshell_client.py b/bin/dmshell_client.py new file mode 100644 index 000000000..12da26104 --- /dev/null +++ b/bin/dmshell_client.py @@ -0,0 +1,375 @@ +#!/usr/bin/env python3 + +import argparse +import os +import queue +import random +import shutil +import socket +import subprocess +import sys +import tempfile +import threading +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + + +START1 = 0x94 +START2 = 0xC3 +HEADER_LEN = 4 +DEFAULT_API_PORT = 4403 +DEFAULT_HOP_LIMIT = 3 + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Tiny DMShell client for Meshtastic native TCP API", + epilog=( + "Examples:\n" + " bin/dmshell_client.py --to !170896f7\n" + " bin/dmshell_client.py --to 0x170896f7 --command 'uname -a' --command 'id'" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--host", default="127.0.0.1", help="meshtasticd API host") + parser.add_argument("--port", type=int, default=DEFAULT_API_PORT, help="meshtasticd API port") + parser.add_argument("--to", required=True, help="destination node number, e.g. !170896f7 or 0x170896f7") + parser.add_argument("--channel", type=int, default=0, help="channel index to use") + parser.add_argument("--cols", type=int, default=120, help="initial terminal columns") + parser.add_argument("--rows", type=int, default=40, help="initial terminal rows") + parser.add_argument("--command", action="append", default=[], help="send a command line after opening") + parser.add_argument("--close-after", type=float, default=2.0, help="seconds to wait before closing in command mode") + parser.add_argument("--timeout", type=float, default=10.0, help="seconds to wait for API/session events") + parser.add_argument("--verbose", action="store_true", help="print extra protocol events") + return parser.parse_args() + + +def repo_root() -> Path: + return Path(__file__).resolve().parent.parent + + +def load_proto_modules() -> object: + try: + import google.protobuf # noqa: F401 + except ImportError as exc: + raise SystemExit("python package 'protobuf' is required to run this client") from exc + + protoc = shutil.which("protoc") + if not protoc: + raise SystemExit("'protoc' is required to generate temporary Python protobuf bindings") + + out_dir = Path(tempfile.mkdtemp(prefix="meshtastic_dmshell_proto_")) + proto_dir = repo_root() / "protobufs" + + # Compile all required protos for DMShell client (mesh and dependencies) + # Excludes nanopb.proto and other complex build artifacts + required_protos = [ + "mesh.proto", + "channel.proto", + "config.proto", + "device_ui.proto", + "module_config.proto", + "atak.proto", + "portnums.proto", + "telemetry.proto", + "xmodem.proto", + ] + proto_files = [proto_dir / "meshtastic" / name for name in required_protos] + for pf in proto_files: + if not pf.exists(): + raise SystemExit(f"could not find required proto file: {pf}") + + # Create __init__.py to make meshtastic a package + (out_dir / "meshtastic").mkdir(exist_ok=True) + (out_dir / "meshtastic" / "__init__.py").touch() + + # Build protoc command with just the meshtastic proto directory as include path + # protoc will use its built-in includes for standard google protobuf types + cmd = [protoc, f"-I{proto_dir}", f"--python_out={out_dir}", *[str(path) for path in proto_files]] + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + print(f"protoc stderr: {result.stderr}", file=sys.stderr) + print(f"protoc stdout: {result.stdout}", file=sys.stderr) + print(f"protoc command: {' '.join(cmd)}", file=sys.stderr) + raise SystemExit(f"protoc failed with return code {result.returncode}") + + # Create _pb2_grpc module stub if not present (protoc 3.20+) + mesh_pb2_file = out_dir / "meshtastic" / "mesh_pb2.py" + if not mesh_pb2_file.exists(): + raise SystemExit(f"protoc did not generate mesh_pb2.py in {out_dir / 'meshtastic'}") + + sys.path.insert(0, str(out_dir)) + try: + from meshtastic import mesh_pb2, portnums_pb2 # type: ignore + except ImportError as exc: + print(f"Failed to import protobuf modules. Output dir contents:", file=sys.stderr) + for item in (out_dir / "meshtastic").iterdir(): + print(f" {item.name}", file=sys.stderr) + raise SystemExit(f"could not import meshtastic proto modules: {exc}") from exc + + # Return an object that has both modules accessible + class ProtoModules: + pass + + pb2 = ProtoModules() + pb2.mesh = mesh_pb2 + pb2.portnums = portnums_pb2 + return pb2 + + +def parse_node_num(raw: str) -> int: + value = raw.strip() + if value.startswith("!"): + value = value[1:] + if value.lower().startswith("0x"): + return int(value, 16) + if any(ch in "abcdefABCDEF" for ch in value): + return int(value, 16) + return int(value, 10) + + +def recv_exact(sock: socket.socket, length: int) -> bytes: + chunks = bytearray() + while len(chunks) < length: + piece = sock.recv(length - len(chunks)) + if not piece: + raise ConnectionError("connection closed by server") + chunks.extend(piece) + return bytes(chunks) + + +def recv_stream_frame(sock: socket.socket) -> bytes: + while True: + start = recv_exact(sock, 1)[0] + if start != START1: + continue + if recv_exact(sock, 1)[0] != START2: + continue + header = recv_exact(sock, 2) + length = (header[0] << 8) | header[1] + return recv_exact(sock, length) + + +def send_stream_frame(sock: socket.socket, payload: bytes) -> None: + if len(payload) > 0xFFFF: + raise ValueError("payload too large for stream API") + header = bytes((START1, START2, (len(payload) >> 8) & 0xFF, len(payload) & 0xFF)) + sock.sendall(header + payload) + + +@dataclass +class SessionState: + pb2: object # ProtoModules with mesh and portnums attributes + target: int + channel: int + verbose: bool + session_id: int = field(default_factory=lambda: random.randint(1, 0x7FFFFFFF)) + next_seq: int = 1 + active: bool = False + stopped: bool = False + opened_event: threading.Event = field(default_factory=threading.Event) + closed_event: threading.Event = field(default_factory=threading.Event) + event_queue: "queue.Queue[str]" = field(default_factory=queue.Queue) + + def alloc_seq(self) -> int: + value = self.next_seq + self.next_seq += 1 + return value + + +def send_toradio(sock: socket.socket, toradio) -> None: + send_stream_frame(sock, toradio.SerializeToString()) + + +def make_toradio_packet(pb2, state: SessionState, shell_msg) -> object: + packet = pb2.mesh.MeshPacket() + packet.id = random.randint(1, 0x7FFFFFFF) + packet.to = state.target + # The 'from' field is a reserved keyword in Python, so use setattr + setattr(packet, "from", 0) + packet.channel = state.channel + packet.hop_limit = DEFAULT_HOP_LIMIT + packet.want_ack = False + packet.decoded.portnum = pb2.portnums.DM_SHELL_APP + packet.decoded.payload = shell_msg.SerializeToString() + packet.decoded.want_response = False + packet.decoded.dest = state.target + packet.decoded.source = 0 + + toradio = pb2.mesh.ToRadio() + toradio.packet.CopyFrom(packet) + return toradio + + +def send_shell_frame(sock: socket.socket, state: SessionState, op: int, payload: bytes = b"", cols: int = 0, rows: int = 0) -> None: + shell = state.pb2.mesh.DMShell() + shell.op = op + shell.session_id = state.session_id + shell.seq = state.alloc_seq() + shell.cols = cols + shell.rows = rows + if payload: + shell.payload = payload + send_toradio(sock, make_toradio_packet(state.pb2, state, shell)) + + +def wait_for_config_complete(sock: socket.socket, pb2, timeout: float, verbose: bool) -> None: + nonce = random.randint(1, 0x7FFFFFFF) + toradio = pb2.mesh.ToRadio() + toradio.want_config_id = nonce + send_toradio(sock, toradio) + + deadline = time.time() + timeout + while time.time() < deadline: + fromradio = pb2.mesh.FromRadio() + fromradio.ParseFromString(recv_stream_frame(sock)) + variant = fromradio.WhichOneof("payload_variant") + if verbose and variant: + print(f"[api] fromradio {variant}", file=sys.stderr) + if variant == "config_complete_id" and fromradio.config_complete_id == nonce: + return + raise TimeoutError("timed out waiting for config handshake to complete") + + +def decode_shell_packet(state: SessionState, packet) -> Optional[object]: + if packet.WhichOneof("payload_variant") != "decoded": + return None + if packet.decoded.portnum != state.pb2.portnums.DM_SHELL_APP: + return None + shell = state.pb2.mesh.DMShell() + shell.ParseFromString(packet.decoded.payload) + return shell + + +def reader_loop(sock: socket.socket, state: SessionState) -> None: + while not state.stopped: + try: + fromradio = state.pb2.mesh.FromRadio() + fromradio.ParseFromString(recv_stream_frame(sock)) + except Exception as exc: + if not state.stopped: + state.event_queue.put(f"connection error: {exc}") + state.closed_event.set() + return + + variant = fromradio.WhichOneof("payload_variant") + if variant == "packet": + shell = decode_shell_packet(state, fromradio.packet) + if not shell: + continue + if shell.op == state.pb2.mesh.DMShell.OPEN_OK: + state.session_id = shell.session_id + state.active = True + state.opened_event.set() + pid = int.from_bytes(shell.payload, "big") if shell.payload else 0 + state.event_queue.put( + f"opened session=0x{shell.session_id:08x} cols={shell.cols} rows={shell.rows} pid={pid}" + ) + elif shell.op == state.pb2.mesh.DMShell.OUTPUT: + if shell.payload: + sys.stdout.buffer.write(shell.payload) + sys.stdout.buffer.flush() + elif shell.op == state.pb2.mesh.DMShell.ERROR: + message = shell.payload.decode("utf-8", errors="replace") + state.event_queue.put(f"remote error: {message}") + elif shell.op == state.pb2.mesh.DMShell.CLOSED: + message = shell.payload.decode("utf-8", errors="replace") + state.event_queue.put(f"session closed: {message}") + state.closed_event.set() + state.active = False + return + elif shell.op == state.pb2.mesh.DMShell.PONG: + state.event_queue.put("pong") + elif state.verbose and variant: + state.event_queue.put(f"fromradio {variant}") + + +def drain_events(state: SessionState) -> None: + while True: + try: + event = state.event_queue.get_nowait() + except queue.Empty: + return + print(f"[dmshell] {event}", file=sys.stderr) + + +def run_command_mode(sock: socket.socket, state: SessionState, commands: list[str], close_after: float) -> None: + for command in commands: + send_shell_frame(sock, state, state.mesh_pb2.DMShell.INPUT, (command + "\n").encode("utf-8")) + time.sleep(close_after) + send_shell_frame(sock, state, state.mesh_pb2.DMShell.CLOSE) + state.closed_event.wait(timeout=close_after + 5.0) + + +def run_interactive_mode(sock: socket.socket, state: SessionState) -> None: + print("Enter shell lines to send. Commands: /close, /ping, /resize COLS ROWS", file=sys.stderr) + while not state.closed_event.is_set(): + drain_events(state) + try: + line = input("dmshell> ") + except EOFError: + send_shell_frame(sock, state, state.pb2.mesh.DMShell.CLOSE) + break + except KeyboardInterrupt: + print("", file=sys.stderr) + send_shell_frame(sock, state, state.pb2.mesh.DMShell.CLOSE) + break + + if not line: + continue + if line == "/close": + send_shell_frame(sock, state, state.pb2.mesh.DMShell.CLOSE) + break + if line == "/ping": + send_shell_frame(sock, state, state.pb2.mesh.DMShell.PING) + continue + if line.startswith("/resize "): + parts = line.split() + if len(parts) != 3: + print("usage: /resize COLS ROWS", file=sys.stderr) + continue + send_shell_frame(sock, state, state.pb2.mesh.DMShell.RESIZE, cols=int(parts[1]), rows=int(parts[2])) + continue + + send_shell_frame(sock, state, state.pb2.mesh.DMShell.INPUT, (line + "\n").encode("utf-8")) + + +def main() -> int: + args = parse_args() + pb2 = load_proto_modules() + + state = SessionState( + pb2=pb2, + target=parse_node_num(args.to), + channel=args.channel, + verbose=args.verbose, + ) + + with socket.create_connection((args.host, args.port), timeout=args.timeout) as sock: + sock.settimeout(None) + wait_for_config_complete(sock, pb2, args.timeout, args.verbose) + + reader = threading.Thread(target=reader_loop, args=(sock, state), daemon=True) + reader.start() + + send_shell_frame(sock, state, pb2.mesh.DMShell.OPEN, cols=args.cols, rows=args.rows) + if not state.opened_event.wait(timeout=args.timeout): + raise SystemExit("timed out waiting for OPEN_OK from remote DMShell") + + drain_events(state) + if args.command: + run_command_mode(sock, state, args.command, args.close_after) + else: + run_interactive_mode(sock, state) + + state.stopped = True + drain_events(state) + reader.join(timeout=1.0) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/src/mesh/generated/meshtastic/mesh.pb.cpp b/src/mesh/generated/meshtastic/mesh.pb.cpp index 7f1a738c6..fbcd1b0e3 100644 --- a/src/mesh/generated/meshtastic/mesh.pb.cpp +++ b/src/mesh/generated/meshtastic/mesh.pb.cpp @@ -27,6 +27,9 @@ PB_BIND(meshtastic_KeyVerification, meshtastic_KeyVerification, AUTO) PB_BIND(meshtastic_StoreForwardPlusPlus, meshtastic_StoreForwardPlusPlus, 2) +PB_BIND(meshtastic_DMShell, meshtastic_DMShell, AUTO) + + PB_BIND(meshtastic_Waypoint, meshtastic_Waypoint, AUTO) @@ -129,6 +132,8 @@ PB_BIND(meshtastic_ChunkedPayloadResponse, meshtastic_ChunkedPayloadResponse, AU + + diff --git a/src/mesh/generated/meshtastic/mesh.pb.h b/src/mesh/generated/meshtastic/mesh.pb.h index 477c3b31b..02165f957 100644 --- a/src/mesh/generated/meshtastic/mesh.pb.h +++ b/src/mesh/generated/meshtastic/mesh.pb.h @@ -308,6 +308,8 @@ typedef enum _meshtastic_HardwareModel { meshtastic_HardwareModel_TDISPLAY_S3_PRO = 126, /* Heltec Mesh Node T096 board features an nRF52840 CPU and a TFT screen. */ meshtastic_HardwareModel_HELTEC_MESH_NODE_T096 = 127, + /* Seeed studio T1000-E Pro tracker card. NRF52840 w/ LR2021 radio, GPS, button, buzzer, and sensors. */ + meshtastic_HardwareModel_TRACKER_T1000_E_PRO = 128, /* ------------------------------------------------------------------------------------------------------------------------------------------ Reserved ID For developing private Ports. These will show up in live traffic sparsely, so we can use a high number. Keep it within 8 bits. ------------------------------------------------------------------------------------------------------------------------------------------ */ @@ -511,6 +513,27 @@ typedef enum _meshtastic_StoreForwardPlusPlus_SFPP_message_type { meshtastic_StoreForwardPlusPlus_SFPP_message_type_LINK_PROVIDE_SECONDHALF = 6 } meshtastic_StoreForwardPlusPlus_SFPP_message_type; +/* Frame op code for PTY session control and stream transport. + + Values 1-63 are client->server requests. + Values 64-127 are server->client responses/events. */ +typedef enum _meshtastic_DMShell_OpCode { + meshtastic_DMShell_OpCode_OP_UNSET = 0, + /* Client -> server */ + meshtastic_DMShell_OpCode_OPEN = 1, + meshtastic_DMShell_OpCode_INPUT = 2, + meshtastic_DMShell_OpCode_RESIZE = 3, + meshtastic_DMShell_OpCode_CLOSE = 4, + meshtastic_DMShell_OpCode_PING = 5, + meshtastic_DMShell_OpCode_ACK = 6, + /* Server -> client */ + meshtastic_DMShell_OpCode_OPEN_OK = 64, + meshtastic_DMShell_OpCode_OUTPUT = 65, + meshtastic_DMShell_OpCode_CLOSED = 66, + meshtastic_DMShell_OpCode_ERROR = 67, + meshtastic_DMShell_OpCode_PONG = 68 +} meshtastic_DMShell_OpCode; + /* The priority of this message for sending. Higher priorities are sent first (when managing the transmit queue). This field is never sent over the air, it is only used internally inside of a local device node. @@ -843,6 +866,27 @@ typedef struct _meshtastic_StoreForwardPlusPlus { uint32_t chain_count; } meshtastic_StoreForwardPlusPlus; +typedef PB_BYTES_ARRAY_T(200) meshtastic_DMShell_payload_t; +/* The actual over-the-mesh message doing DMShell */ +typedef struct _meshtastic_DMShell { + /* Structured frame operation. */ + meshtastic_DMShell_OpCode op; + /* Logical PTY session identifier. */ + uint32_t session_id; + /* Monotonic sequence number for this frame. */ + uint32_t seq; + /* Cumulative ack sequence number. */ + uint32_t ack_seq; + /* Opaque bytes payload for INPUT/OUTPUT/ERROR and other frame bodies. */ + meshtastic_DMShell_payload_t payload; + /* Terminal size columns used for OPEN/RESIZE signaling. */ + uint32_t cols; + /* Terminal size rows used for OPEN/RESIZE signaling. */ + uint32_t rows; + /* Bit flags for protocol extensions. */ + uint32_t flags; +} meshtastic_DMShell; + /* Waypoint message, used to share arbitrary locations across the mesh */ typedef struct _meshtastic_Waypoint { /* Id of the waypoint */ @@ -1383,6 +1427,10 @@ extern "C" { #define _meshtastic_StoreForwardPlusPlus_SFPP_message_type_MAX meshtastic_StoreForwardPlusPlus_SFPP_message_type_LINK_PROVIDE_SECONDHALF #define _meshtastic_StoreForwardPlusPlus_SFPP_message_type_ARRAYSIZE ((meshtastic_StoreForwardPlusPlus_SFPP_message_type)(meshtastic_StoreForwardPlusPlus_SFPP_message_type_LINK_PROVIDE_SECONDHALF+1)) +#define _meshtastic_DMShell_OpCode_MIN meshtastic_DMShell_OpCode_OP_UNSET +#define _meshtastic_DMShell_OpCode_MAX meshtastic_DMShell_OpCode_PONG +#define _meshtastic_DMShell_OpCode_ARRAYSIZE ((meshtastic_DMShell_OpCode)(meshtastic_DMShell_OpCode_PONG+1)) + #define _meshtastic_MeshPacket_Priority_MIN meshtastic_MeshPacket_Priority_UNSET #define _meshtastic_MeshPacket_Priority_MAX meshtastic_MeshPacket_Priority_MAX #define _meshtastic_MeshPacket_Priority_ARRAYSIZE ((meshtastic_MeshPacket_Priority)(meshtastic_MeshPacket_Priority_MAX+1)) @@ -1413,6 +1461,8 @@ extern "C" { #define meshtastic_StoreForwardPlusPlus_sfpp_message_type_ENUMTYPE meshtastic_StoreForwardPlusPlus_SFPP_message_type +#define meshtastic_DMShell_op_ENUMTYPE meshtastic_DMShell_OpCode + @@ -1457,6 +1507,7 @@ extern "C" { #define meshtastic_Data_init_default {_meshtastic_PortNum_MIN, {0, {0}}, 0, 0, 0, 0, 0, 0, false, 0} #define meshtastic_KeyVerification_init_default {0, {0, {0}}, {0, {0}}} #define meshtastic_StoreForwardPlusPlus_init_default {_meshtastic_StoreForwardPlusPlus_SFPP_message_type_MIN, {0, {0}}, {0, {0}}, {0, {0}}, {0, {0}}, 0, 0, 0, 0, 0} +#define meshtastic_DMShell_init_default {_meshtastic_DMShell_OpCode_MIN, 0, 0, 0, {0, {0}}, 0, 0, 0} #define meshtastic_Waypoint_init_default {0, false, 0, false, 0, 0, 0, "", "", 0} #define meshtastic_StatusMessage_init_default {""} #define meshtastic_MqttClientProxyMessage_init_default {"", 0, {{0, {0}}}, 0} @@ -1490,6 +1541,7 @@ extern "C" { #define meshtastic_Data_init_zero {_meshtastic_PortNum_MIN, {0, {0}}, 0, 0, 0, 0, 0, 0, false, 0} #define meshtastic_KeyVerification_init_zero {0, {0, {0}}, {0, {0}}} #define meshtastic_StoreForwardPlusPlus_init_zero {_meshtastic_StoreForwardPlusPlus_SFPP_message_type_MIN, {0, {0}}, {0, {0}}, {0, {0}}, {0, {0}}, 0, 0, 0, 0, 0} +#define meshtastic_DMShell_init_zero {_meshtastic_DMShell_OpCode_MIN, 0, 0, 0, {0, {0}}, 0, 0, 0} #define meshtastic_Waypoint_init_zero {0, false, 0, false, 0, 0, 0, "", "", 0} #define meshtastic_StatusMessage_init_zero {""} #define meshtastic_MqttClientProxyMessage_init_zero {"", 0, {{0, {0}}}, 0} @@ -1579,6 +1631,14 @@ extern "C" { #define meshtastic_StoreForwardPlusPlus_encapsulated_from_tag 8 #define meshtastic_StoreForwardPlusPlus_encapsulated_rxtime_tag 9 #define meshtastic_StoreForwardPlusPlus_chain_count_tag 10 +#define meshtastic_DMShell_op_tag 1 +#define meshtastic_DMShell_session_id_tag 2 +#define meshtastic_DMShell_seq_tag 3 +#define meshtastic_DMShell_ack_seq_tag 4 +#define meshtastic_DMShell_payload_tag 5 +#define meshtastic_DMShell_cols_tag 6 +#define meshtastic_DMShell_rows_tag 7 +#define meshtastic_DMShell_flags_tag 8 #define meshtastic_Waypoint_id_tag 1 #define meshtastic_Waypoint_latitude_i_tag 2 #define meshtastic_Waypoint_longitude_i_tag 3 @@ -1811,6 +1871,18 @@ X(a, STATIC, SINGULAR, UINT32, chain_count, 10) #define meshtastic_StoreForwardPlusPlus_CALLBACK NULL #define meshtastic_StoreForwardPlusPlus_DEFAULT NULL +#define meshtastic_DMShell_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UENUM, op, 1) \ +X(a, STATIC, SINGULAR, UINT32, session_id, 2) \ +X(a, STATIC, SINGULAR, UINT32, seq, 3) \ +X(a, STATIC, SINGULAR, UINT32, ack_seq, 4) \ +X(a, STATIC, SINGULAR, BYTES, payload, 5) \ +X(a, STATIC, SINGULAR, UINT32, cols, 6) \ +X(a, STATIC, SINGULAR, UINT32, rows, 7) \ +X(a, STATIC, SINGULAR, UINT32, flags, 8) +#define meshtastic_DMShell_CALLBACK NULL +#define meshtastic_DMShell_DEFAULT NULL + #define meshtastic_Waypoint_FIELDLIST(X, a) \ X(a, STATIC, SINGULAR, UINT32, id, 1) \ X(a, STATIC, OPTIONAL, SFIXED32, latitude_i, 2) \ @@ -2093,6 +2165,7 @@ extern const pb_msgdesc_t meshtastic_Routing_msg; extern const pb_msgdesc_t meshtastic_Data_msg; extern const pb_msgdesc_t meshtastic_KeyVerification_msg; extern const pb_msgdesc_t meshtastic_StoreForwardPlusPlus_msg; +extern const pb_msgdesc_t meshtastic_DMShell_msg; extern const pb_msgdesc_t meshtastic_Waypoint_msg; extern const pb_msgdesc_t meshtastic_StatusMessage_msg; extern const pb_msgdesc_t meshtastic_MqttClientProxyMessage_msg; @@ -2128,6 +2201,7 @@ extern const pb_msgdesc_t meshtastic_ChunkedPayloadResponse_msg; #define meshtastic_Data_fields &meshtastic_Data_msg #define meshtastic_KeyVerification_fields &meshtastic_KeyVerification_msg #define meshtastic_StoreForwardPlusPlus_fields &meshtastic_StoreForwardPlusPlus_msg +#define meshtastic_DMShell_fields &meshtastic_DMShell_msg #define meshtastic_Waypoint_fields &meshtastic_Waypoint_msg #define meshtastic_StatusMessage_fields &meshtastic_StatusMessage_msg #define meshtastic_MqttClientProxyMessage_fields &meshtastic_MqttClientProxyMessage_msg @@ -2162,6 +2236,7 @@ extern const pb_msgdesc_t meshtastic_ChunkedPayloadResponse_msg; #define meshtastic_ChunkedPayload_size 245 #define meshtastic_ClientNotification_size 482 #define meshtastic_Compressed_size 239 +#define meshtastic_DMShell_size 241 #define meshtastic_Data_size 269 #define meshtastic_DeviceMetadata_size 54 #define meshtastic_DuplicatedPublicKey_size 0 diff --git a/src/mesh/generated/meshtastic/portnums.pb.h b/src/mesh/generated/meshtastic/portnums.pb.h index bd1fe48c4..5ca2fca5d 100644 --- a/src/mesh/generated/meshtastic/portnums.pb.h +++ b/src/mesh/generated/meshtastic/portnums.pb.h @@ -76,6 +76,8 @@ typedef enum _meshtastic_PortNum { meshtastic_PortNum_ALERT_APP = 11, /* Module/port for handling key verification requests. */ meshtastic_PortNum_KEY_VERIFICATION_APP = 12, + /* Module/port for handling key verification requests. */ + meshtastic_PortNum_DM_SHELL_APP = 13, /* Provides a 'ping' service that replies to any packet it receives. Also serves as a small example module. ENCODING: ASCII Plaintext */ diff --git a/src/modules/DMShell.cpp b/src/modules/DMShell.cpp new file mode 100644 index 000000000..ba7ad7acb --- /dev/null +++ b/src/modules/DMShell.cpp @@ -0,0 +1,397 @@ +#include "DMShell.h" + +#if defined(ARCH_PORTDUINO) + +#include "Channels.h" +#include "MeshService.h" +#include "NodeDB.h" +#include "Throttle.h" +#include "configuration.h" +#include "mesh/generated/meshtastic/mesh.pb.h" +#include "mesh/mesh-pb-constants.h" +#include "pb_decode.h" +#include "pb_encode.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +DMShellModule *dmShellModule; + +namespace +{ +constexpr uint16_t PTY_COLS_DEFAULT = 120; +constexpr uint16_t PTY_ROWS_DEFAULT = 40; +constexpr size_t MAX_PTY_READ_SIZE = 200; + +struct BytesDecodeState { + uint8_t *buf; + size_t maxLen; + size_t outLen; +}; + +struct BytesEncodeState { + const uint8_t *buf; + size_t len; +}; + +bool decodeBytesField(pb_istream_t *stream, const pb_field_iter_t *field, void **arg) +{ + (void)field; + auto *state = static_cast(*arg); + const size_t fieldLen = stream->bytes_left; + if (fieldLen > state->maxLen) { + return false; + } + if (!pb_read(stream, state->buf, fieldLen)) { + return false; + } + state->outLen = fieldLen; + return true; +} + +bool encodeBytesField(pb_ostream_t *stream, const pb_field_iter_t *field, void *const *arg) +{ + auto *state = static_cast(*arg); + if (!state || !state->buf || state->len == 0) { + return true; + } + if (!pb_encode_tag_for_field(stream, field)) { + return false; + } + return pb_encode_string(stream, state->buf, state->len); +} +} // namespace + +DMShellModule::DMShellModule() + : SinglePortModule("DMShellModule", meshtastic_PortNum_DM_SHELL_APP), concurrency::OSThread("DMShell", 100) +{ + LOG_WARN("DMShell enabled on Portduino: remote shell access is dangerous and intended for trusted debugging only"); +} + +ProcessMessage DMShellModule::handleReceived(const meshtastic_MeshPacket &mp) +{ + DMShellFrame frame; + if (!parseFrame(mp, frame)) { + LOG_WARN("DMShell: ignoring malformed frame"); + return ProcessMessage::STOP; + } + + if (!isAuthorizedPacket(mp)) { + LOG_WARN("DMShell: unauthorized sender 0x%x", mp.from); + myReply = allocErrorResponse(meshtastic_Routing_Error_NOT_AUTHORIZED, &mp); + return ProcessMessage::STOP; + } + + if (frame.op == meshtastic_DMShell_OpCode_OPEN) { + LOG_WARN("DMShell: received OPEN from 0x%x sessionId=0x%x", mp.from, frame.sessionId); + if (!openSession(mp, frame)) { + const char *msg = "open_failed"; + sendFrameToPeer(getFrom(&mp), mp.channel, meshtastic_DMShell_OpCode_ERROR, frame.sessionId, frame.seq, + reinterpret_cast(msg), strlen(msg)); + } + return ProcessMessage::STOP; + } + + if (!session.active || frame.sessionId != session.sessionId || getFrom(&mp) != session.peer) { + const char *msg = "invalid_session"; + sendFrameToPeer(getFrom(&mp), mp.channel, meshtastic_DMShell_OpCode_ERROR, frame.sessionId, frame.seq, + reinterpret_cast(msg), strlen(msg)); + return ProcessMessage::STOP; + } + + session.lastActivityMs = millis(); + + switch (frame.op) { + case meshtastic_DMShell_OpCode_INPUT: + if (!writeSessionInput(frame)) { + sendError("input_write_failed"); + } + break; + case meshtastic_DMShell_OpCode_RESIZE: + if (frame.rows > 0 && frame.cols > 0) { + struct winsize ws = {}; + ws.ws_row = frame.rows; + ws.ws_col = frame.cols; + if (session.masterFd >= 0) { + ioctl(session.masterFd, TIOCSWINSZ, &ws); + } + } + break; + case meshtastic_DMShell_OpCode_PING: + sendControl(meshtastic_DMShell_OpCode_PONG, nullptr, 0, frame.seq); + break; + case meshtastic_DMShell_OpCode_CLOSE: + closeSession("peer_close", true); + break; + default: + sendError("unsupported_op"); + break; + } + + return ProcessMessage::STOP; +} + +int32_t DMShellModule::runOnce() +{ + if (!session.active) { + return 100; + } + + reapChildIfExited(); + if (!session.active) { + return 100; + } + + if (Throttle::isWithinTimespanMs(session.lastActivityMs, SESSION_IDLE_TIMEOUT_MS) == false) { + closeSession("idle_timeout", true); + return 100; + } + + uint8_t outBuf[MAX_PTY_READ_SIZE]; + while (session.masterFd >= 0) { + const ssize_t bytesRead = read(session.masterFd, outBuf, sizeof(outBuf)); + if (bytesRead > 0) { + LOG_WARN("DMShell: read %d bytes from PTY", bytesRead); + sendControl(meshtastic_DMShell_OpCode_OUTPUT, outBuf, static_cast(bytesRead), session.txSeq++); + session.lastActivityMs = millis(); + continue; + } + + if (bytesRead == 0) { + closeSession("pty_eof", true); + break; + } + + if (errno == EAGAIN || errno == EWOULDBLOCK) { + break; + } + + LOG_WARN("DMShell: PTY read error errno=%d", errno); + closeSession("pty_read_error", true); + break; + } + + return 100; +} + +bool DMShellModule::parseFrame(const meshtastic_MeshPacket &mp, DMShellFrame &outFrame) +{ + if (mp.which_payload_variant != meshtastic_MeshPacket_decoded_tag) { + return false; + } + + meshtastic_DMShell decodedMsg = meshtastic_DMShell_init_zero; + if (pb_decode_from_bytes(mp.decoded.payload.bytes, mp.decoded.payload.size, meshtastic_DMShell_fields, &decodedMsg)) { + LOG_INFO("Received a DMShell message"); + } else { + LOG_ERROR("Error decoding DMShell message!"); + return false; + } + + outFrame.op = decodedMsg.op; + outFrame.sessionId = decodedMsg.session_id; + outFrame.seq = decodedMsg.seq; + outFrame.ackSeq = decodedMsg.ack_seq; + outFrame.cols = decodedMsg.cols; + outFrame.rows = decodedMsg.rows; + outFrame.flags = decodedMsg.flags; + outFrame.payloadLen = decodedMsg.payload.size; + memcpy(outFrame.payload, decodedMsg.payload.bytes, outFrame.payloadLen); + + return true; +} + +bool DMShellModule::isAuthorizedPacket(const meshtastic_MeshPacket &mp) const +{ + if (mp.from == 0) { + return !config.security.is_managed; + } + + const meshtastic_Channel *ch = &channels.getByIndex(mp.channel); + if (strcasecmp(ch->settings.name, Channels::adminChannel) == 0) { + return config.security.admin_channel_enabled; + } + + if (mp.pki_encrypted) { + for (uint8_t i = 0; i < 3; ++i) { + if (config.security.admin_key[i].size == 32 && + memcmp(mp.public_key.bytes, config.security.admin_key[i].bytes, 32) == 0) { + return true; + } + } + } + + return false; +} + +bool DMShellModule::openSession(const meshtastic_MeshPacket &mp, const DMShellFrame &frame) +{ + if (session.active) { + closeSession("preempted", true); + } + + int masterFd = -1; + struct winsize ws = {}; + ws.ws_col = PTY_COLS_DEFAULT; + ws.ws_row = PTY_ROWS_DEFAULT; + const pid_t childPid = forkpty(&masterFd, nullptr, nullptr, &ws); + if (childPid < 0) { + LOG_ERROR("DMShell: forkpty failed errno=%d", errno); + return false; + } + + if (childPid == 0) { + const char *shell = getenv("SHELL"); + if (!shell || !*shell) { + shell = "/bin/sh"; + } + execl(shell, shell, "-i", static_cast(nullptr)); + _exit(127); + } + + const int flags = fcntl(masterFd, F_GETFL, 0); + if (flags >= 0) { + fcntl(masterFd, F_SETFL, flags | O_NONBLOCK); + } + + session.active = true; + session.sessionId = (frame.sessionId != 0) ? frame.sessionId : static_cast(random(1, 0x7fffffff)); + session.peer = getFrom(&mp); + session.channel = mp.channel; + session.masterFd = masterFd; + session.childPid = childPid; + session.txSeq = 0; + session.lastActivityMs = millis(); + + uint8_t payload[sizeof(uint32_t)] = {0}; + uint32_t pidBE = (static_cast(session.childPid) << 24) | ((static_cast(session.childPid) >> 8) & 0xff00) | + ((static_cast(session.childPid) << 8) & 0xff0000) | + (static_cast(session.childPid) >> 24); + memcpy(payload, &pidBE, sizeof(payload)); + sendFrameToPeer(session.peer, session.channel, meshtastic_DMShell_OpCode_OPEN_OK, session.sessionId, frame.seq, payload, + sizeof(payload), PTY_COLS_DEFAULT, PTY_ROWS_DEFAULT); + + LOG_INFO("DMShell: opened session=0x%x peer=0x%x pid=%d", session.sessionId, session.peer, session.childPid); + return true; +} + +bool DMShellModule::writeSessionInput(const DMShellFrame &frame) +{ + if (session.masterFd < 0) { + return false; + } + if (frame.payloadLen == 0) { + return true; + } + + const ssize_t bytesWritten = write(session.masterFd, frame.payload, frame.payloadLen); + return bytesWritten >= 0; +} + +void DMShellModule::closeSession(const char *reason, bool notifyPeer) +{ + if (!session.active) { + return; + } + + if (notifyPeer) { + const size_t reasonLen = strnlen(reason, 256); + sendControl(meshtastic_DMShell_OpCode_CLOSED, reinterpret_cast(reason), reasonLen, session.txSeq++); + } + + if (session.masterFd >= 0) { + close(session.masterFd); + session.masterFd = -1; + } + + if (session.childPid > 0) { + kill(session.childPid, SIGTERM); + int status = 0; + waitpid(session.childPid, &status, WNOHANG); + session.childPid = -1; + } + + LOG_INFO("DMShell: closed session=0x%x reason=%s", session.sessionId, reason); + session = DMShellSession{}; +} + +void DMShellModule::reapChildIfExited() +{ + if (!session.active || session.childPid <= 0) { + return; + } + + int status = 0; + const pid_t result = waitpid(session.childPid, &status, WNOHANG); + if (result == session.childPid) { + closeSession("shell_exited", true); + } +} + +void DMShellModule::sendControl(meshtastic_DMShell_OpCode op, const uint8_t *payload, size_t payloadLen, uint32_t seq) +{ + sendFrameToPeer(session.peer, session.channel, op, session.sessionId, seq, payload, payloadLen); +} + +void DMShellModule::sendFrameToPeer(NodeNum peer, uint8_t channel, meshtastic_DMShell_OpCode op, uint32_t sessionId, uint32_t seq, + const uint8_t *payload, size_t payloadLen, uint32_t cols, uint32_t rows, uint32_t ackSeq, + uint32_t flags) +{ + meshtastic_MeshPacket *packet = buildFramePacket(op, sessionId, seq, payload, payloadLen, cols, rows, ackSeq, flags); + if (!packet) { + return; + } + + packet->to = peer; + packet->channel = channel; + packet->want_ack = false; + packet->priority = meshtastic_MeshPacket_Priority_RELIABLE; + service->sendToMesh(packet); +} + +void DMShellModule::sendError(const char *message) +{ + const size_t len = strnlen(message, meshtastic_Constants_DATA_PAYLOAD_LEN); + sendControl(meshtastic_DMShell_OpCode_ERROR, reinterpret_cast(message), len, session.txSeq++); +} + +meshtastic_MeshPacket *DMShellModule::buildFramePacket(meshtastic_DMShell_OpCode op, uint32_t sessionId, uint32_t seq, + const uint8_t *payload, size_t payloadLen, uint32_t cols, uint32_t rows, + uint32_t ackSeq, uint32_t flags) +{ + meshtastic_DMShell frame = meshtastic_DMShell_init_zero; + frame.op = op; + frame.session_id = sessionId; + frame.seq = seq; + frame.ack_seq = ackSeq; + frame.cols = cols; + frame.rows = rows; + frame.flags = flags; + + if (payload && payloadLen > 0) { + memcpy(frame.payload.bytes, payload, payloadLen); + frame.payload.size = payloadLen; + } + + meshtastic_MeshPacket *packet = allocDataPacket(); + if (!packet) { + return nullptr; + } + LOG_WARN("DMShell: building packet op=%d session=0x%x seq=%d payloadLen=%d", op, sessionId, seq, payloadLen); + const size_t encoded = pb_encode_to_bytes(packet->decoded.payload.bytes, sizeof(packet->decoded.payload.bytes), + meshtastic_DMShell_fields, &frame); + if (encoded == 0) { + return nullptr; + } + packet->decoded.payload.size = encoded; + return packet; +} + +#endif \ No newline at end of file diff --git a/src/modules/DMShell.h b/src/modules/DMShell.h new file mode 100644 index 000000000..dde56a3c7 --- /dev/null +++ b/src/modules/DMShell.h @@ -0,0 +1,71 @@ +#pragma once + +#include "MeshModule.h" +#include "Router.h" +#include "SinglePortModule.h" +#include "concurrency/OSThread.h" +#include "configuration.h" +#include "mesh/generated/meshtastic/mesh.pb.h" +#include +#include + +#if defined(ARCH_PORTDUINO) + +struct DMShellFrame { + meshtastic_DMShell_OpCode op = meshtastic_DMShell_OpCode_ERROR; + uint32_t sessionId = 0; + uint32_t seq = 0; + uint32_t ackSeq = 0; + uint32_t cols = 0; + uint32_t rows = 0; + uint32_t flags = 0; + uint8_t payload[meshtastic_Constants_DATA_PAYLOAD_LEN] = {0}; + size_t payloadLen = 0; +}; + +struct DMShellSession { + bool active = false; + uint32_t sessionId = 0; + NodeNum peer = 0; + uint8_t channel = 0; + int masterFd = -1; + int childPid = -1; + uint32_t txSeq = 0; + uint32_t lastActivityMs = 0; +}; + +class DMShellModule : private concurrency::OSThread, public SinglePortModule +{ + + public: + DMShellModule(); + + protected: + virtual ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override; + virtual int32_t runOnce() override; + + private: + static constexpr uint32_t SESSION_IDLE_TIMEOUT_MS = 5 * 60 * 1000; + + DMShellSession session; + + bool parseFrame(const meshtastic_MeshPacket &mp, DMShellFrame &outFrame); + bool isAuthorizedPacket(const meshtastic_MeshPacket &mp) const; + bool openSession(const meshtastic_MeshPacket &mp, const DMShellFrame &frame); + bool writeSessionInput(const DMShellFrame &frame); + void closeSession(const char *reason, bool notifyPeer); + void reapChildIfExited(); + + void sendControl(meshtastic_DMShell_OpCode op, const uint8_t *payload, size_t payloadLen, uint32_t seq = 0); + void sendFrameToPeer(NodeNum peer, uint8_t channel, meshtastic_DMShell_OpCode op, uint32_t sessionId, uint32_t seq, + const uint8_t *payload, size_t payloadLen, uint32_t cols = 0, uint32_t rows = 0, uint32_t ackSeq = 0, + uint32_t flags = 0); + void sendError(const char *message); + meshtastic_MeshPacket *buildFramePacket(meshtastic_DMShell_OpCode op, uint32_t sessionId, uint32_t seq, + const uint8_t *payload, size_t payloadLen, uint32_t cols, uint32_t rows, + uint32_t ackSeq, uint32_t flags); +}; + +extern DMShellModule *dmShellModule; + +#endif \ No newline at end of file diff --git a/src/modules/Modules.cpp b/src/modules/Modules.cpp index 64e90c9c2..bebd998fa 100644 --- a/src/modules/Modules.cpp +++ b/src/modules/Modules.cpp @@ -46,6 +46,7 @@ #include "modules/WaypointModule.h" #endif #if ARCH_PORTDUINO +#include "modules/DMShell.h" #include "modules/Telemetry/HostMetrics.h" #if !MESHTASTIC_EXCLUDE_STOREFORWARD #include "modules/StoreForwardModule.h" @@ -184,6 +185,7 @@ void setupModules() #endif #if ARCH_PORTDUINO new HostMetricsModule(); + dmShellModule = new DMShellModule(); #endif #if HAS_TELEMETRY new DeviceTelemetryModule(); diff --git a/test/test_meshpacket_serializer/ports/test_dmshell.cpp b/test/test_meshpacket_serializer/ports/test_dmshell.cpp new file mode 100644 index 000000000..05e901dc6 --- /dev/null +++ b/test/test_meshpacket_serializer/ports/test_dmshell.cpp @@ -0,0 +1,112 @@ +#include "../test_helpers.h" +#include "mesh/mesh-pb-constants.h" + +namespace +{ +struct BytesDecodeState { + uint8_t *buffer; + size_t capacity; + size_t length; +}; + +struct BytesEncodeState { + const uint8_t *buffer; + size_t length; +}; + +bool decodeBytesField(pb_istream_t *stream, const pb_field_iter_t *field, void **arg) +{ + (void)field; + auto *state = static_cast(*arg); + if (!state) { + return false; + } + + const size_t fieldLen = stream->bytes_left; + if (fieldLen > state->capacity) { + return false; + } + + if (!pb_read(stream, state->buffer, fieldLen)) { + return false; + } + + state->length = fieldLen; + return true; +} + +bool encodeBytesField(pb_ostream_t *stream, const pb_field_iter_t *field, void *const *arg) +{ + auto *state = static_cast(*arg); + if (!state || !state->buffer || state->length == 0) { + return true; + } + + if (!pb_encode_tag_for_field(stream, field)) { + return false; + } + + return pb_encode_string(stream, state->buffer, state->length); +} + +void assert_dmshell_roundtrip(meshtastic_DMShell_OpCode op, uint32_t sessionId, uint32_t seq, const uint8_t *payload, + size_t payloadLen, uint32_t cols = 0, uint32_t rows = 0) +{ + meshtastic_DMShell tx = meshtastic_DMShell_init_zero; + tx.op = op; + tx.session_id = sessionId; + tx.seq = seq; + tx.cols = cols; + tx.rows = rows; + + BytesEncodeState txPayload = {payload, payloadLen}; + if (payload && payloadLen > 0) { + tx.payload.funcs.encode = encodeBytesField; + tx.payload.arg = &txPayload; + } + + uint8_t encoded[meshtastic_Constants_DATA_PAYLOAD_LEN] = {0}; + size_t encodedLen = pb_encode_to_bytes(encoded, sizeof(encoded), meshtastic_DMShell_fields, &tx); + TEST_ASSERT_GREATER_THAN_UINT32(0, encodedLen); + + meshtastic_DMShell rx = meshtastic_DMShell_init_zero; + uint8_t decodedPayload[meshtastic_Constants_DATA_PAYLOAD_LEN] = {0}; + BytesDecodeState rxPayload = {decodedPayload, sizeof(decodedPayload), 0}; + rx.payload.funcs.decode = decodeBytesField; + rx.payload.arg = &rxPayload; + + TEST_ASSERT_TRUE(pb_decode_from_bytes(encoded, encodedLen, meshtastic_DMShell_fields, &rx)); + TEST_ASSERT_EQUAL(op, rx.op); + TEST_ASSERT_EQUAL_UINT32(sessionId, rx.session_id); + TEST_ASSERT_EQUAL_UINT32(seq, rx.seq); + TEST_ASSERT_EQUAL_UINT32(cols, rx.cols); + TEST_ASSERT_EQUAL_UINT32(rows, rx.rows); + TEST_ASSERT_EQUAL_UINT32(payloadLen, rxPayload.length); + + if (payloadLen > 0) { + TEST_ASSERT_EQUAL_UINT8_ARRAY(payload, decodedPayload, payloadLen); + } +} +} // namespace + +void test_dmshell_open_roundtrip() +{ + assert_dmshell_roundtrip(meshtastic_DMShell_OpCode_OPEN, 0x101, 1, nullptr, 0, 120, 40); +} + +void test_dmshell_input_roundtrip() +{ + const uint8_t payload[] = {'l', 's', '\n'}; + assert_dmshell_roundtrip(meshtastic_DMShell_OpCode_INPUT, 0x202, 2, payload, sizeof(payload)); +} + +void test_dmshell_resize_roundtrip() +{ + assert_dmshell_roundtrip(meshtastic_DMShell_OpCode_RESIZE, 0x303, 3, nullptr, 0, 180, 55); +} + +void test_dmshell_close_roundtrip() +{ + const uint8_t reason[] = {'b', 'y', 'e'}; + assert_dmshell_roundtrip(meshtastic_DMShell_OpCode_CLOSE, 0x404, 4, reason, sizeof(reason)); +} \ No newline at end of file diff --git a/test/test_meshpacket_serializer/test_serializer.cpp b/test/test_meshpacket_serializer/test_serializer.cpp index 484db8d74..3a19745d8 100644 --- a/test/test_meshpacket_serializer/test_serializer.cpp +++ b/test/test_meshpacket_serializer/test_serializer.cpp @@ -19,6 +19,10 @@ void test_telemetry_environment_metrics_complete_coverage(); void test_telemetry_environment_metrics_unset_fields(); void test_encrypted_packet_serialization(); void test_empty_encrypted_packet(); +void test_dmshell_open_roundtrip(); +void test_dmshell_input_roundtrip(); +void test_dmshell_resize_roundtrip(); +void test_dmshell_close_roundtrip(); void setup() { @@ -52,6 +56,12 @@ void setup() RUN_TEST(test_encrypted_packet_serialization); RUN_TEST(test_empty_encrypted_packet); + // DMShell protobuf transport tests + RUN_TEST(test_dmshell_open_roundtrip); + RUN_TEST(test_dmshell_input_roundtrip); + RUN_TEST(test_dmshell_resize_roundtrip); + RUN_TEST(test_dmshell_close_roundtrip); + UNITY_END(); }