Very WIP dmshell

This commit is contained in:
Jonathan Bennett
2026-04-08 13:18:21 -05:00
parent 934c3fa8be
commit 27cc76d5ed
9 changed files with 1049 additions and 0 deletions
+375
View File
@@ -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())
@@ -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
+75
View File
@@ -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
@@ -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 */
+397
View File
@@ -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 <errno.h>
#include <fcntl.h>
#include <pty.h>
#include <signal.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
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<BytesDecodeState *>(*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<const BytesEncodeState *>(*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<const uint8_t *>(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<const uint8_t *>(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<size_t>(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<char *>(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<uint32_t>(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<uint32_t>(session.childPid) << 24) | ((static_cast<uint32_t>(session.childPid) >> 8) & 0xff00) |
((static_cast<uint32_t>(session.childPid) << 8) & 0xff0000) |
(static_cast<uint32_t>(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<const uint8_t *>(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<const uint8_t *>(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
+71
View File
@@ -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 <Arduino.h>
#include <functional>
#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
+2
View File
@@ -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();
@@ -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<BytesDecodeState *>(*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<const BytesEncodeState *>(*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));
}
@@ -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();
}