mirror of
https://github.com/alexhopeoconnor/firmware.git
synced 2026-10-13 15:11:20 +10:00
Very WIP dmshell
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user