diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2782ff918..f0c456f4b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -763,7 +763,7 @@ The repo registers the server via `.mcp.json` at the repo root - Claude Code / C **One MCP call per port at a time.** `SerialInterface` holds an exclusive OS-level lock on the serial port for its lifetime. If a `serial_*` session is open on `/dev/cu.usbmodem101`, calling `device_info` on the same port will fail fast pointing at the active session. Sequence calls: open → read/mutate → close, then next device. Never parallelize tool calls on the same port. -### MCP tool surface (43 tools) +### MCP tool surface (44 tools) Grouped by purpose. Full argument shapes in the meshtastic-mcp repo's README; a few high-value signatures are called out here. @@ -771,7 +771,7 @@ Grouped by purpose. Full argument shapes in the meshtastic-mcp repo's README; a - **Build & flash**: `build`, `clean`, `pio_flash`, `erase_and_flash` (ESP32 only), `update_flash` (ESP32 OTA), `touch_1200bps` - **Serial sessions** (long-running, 10k-line ring buffer): `serial_open`, `serial_read`, `serial_list`, `serial_close` - **Device reads**: `device_info`, `list_nodes` -- **Device writes**: `set_owner`, `get_config`, `set_config`, `get_channel_url`, `set_channel_url`, `send_text`, `send_input_event` (inject a button/key press via the firmware's InputBroker), `set_debug_log_api`; destructive/power-state writes require `confirm=True`: `reboot`, `shutdown`, `factory_reset` +- **Device writes**: `set_owner`, `get_config`, `set_config`, `get_channel_url`, `set_channel_url`, `send_text`, `send_input_event` (inject a button/key press via the firmware's InputBroker), `inject_frame` (inject an over-the-air-style frame into the RX pipeline - see below), `set_debug_log_api`; destructive/power-state writes require `confirm=True`: `reboot`, `shutdown`, `factory_reset` - **userPrefs admin** (build-time constants, not runtime config): `userprefs_get`, `userprefs_set`, `userprefs_reset`, `userprefs_manifest`, `userprefs_testing_profile` - **Vendor escape hatches**: `esptool_chip_info`, `esptool_erase_flash`, `esptool_raw`, `nrfutil_dfu`, `nrfutil_raw`, `picotool_info`, `picotool_load`, `picotool_raw` - **USB power control** (via `uhubctl`, per-port PPPS toggle): `uhubctl_list` (read-only), `uhubctl_power(action='on'|'off', confirm=True)`, `uhubctl_cycle(delay_s, confirm=True)`. Target by raw `(location, port)` or by `role` (`"nrf52"`, `"esp32s3"`); role lookup checks `MESHTASTIC_UHUBCTL_LOCATION_` + `_PORT_` env vars first, falls back to VID auto-detection. @@ -781,6 +781,15 @@ Grouped by purpose. Full argument shapes in the meshtastic-mcp repo's README; a **TCP / native-host nodes.** Setting `MESHTASTIC_MCP_TCP_HOST=` makes `list_devices` surface a `meshtasticd` daemon (e.g. the `native-macos` build) as a synthetic `tcp://host:port` entry, and `connect()` routes through `meshtastic.tcp_interface.TCPInterface` instead of `SerialInterface`. Every read/write/admin tool that flows through `connect()` works against the daemon transparently. USB-only tools (`pio_flash`, `erase_and_flash`, `update_flash`, `touch_1200bps`, `serial_open`, `esptool_*`, `nrfutil_*`, `picotool_*`) raise a clear `ConnectionError` when handed a `tcp://` port; `pio_flash` against a `native*` env raises a `FlashError` (no upload step - use `build` and run the binary directly). The pytest harness still assumes USB-attached devices per role; TCP-aware fixtures are deferred. See the meshtastic-mcp repo's README § "TCP / native-host nodes". +### Frame injection: testing the off-air receive path + +The `toRadio` API can only inject **locally-originated** traffic - the firmware forces `p.from = 0` in `MeshService::handleToRadio`, which bypasses the `from != 0` receive path and everything gated on it (remote admin authorization, the admin session-passkey check, hop handling, promiscuous sniffing). To exercise those paths you either need a second transmitting radio, or **frame injection**: a build-flagged seam that delivers a client-supplied frame into the real RX pipeline as if it arrived off the LoRa chip. + +- **Firmware:** build with `-D MESHTASTIC_ENABLE_FRAME_INJECTION=1` (`src/configuration.h`, off by default - it forges over-the-air traffic and must never ship enabled). `MeshService::injectAsReceived` extends the existing portduino `SimRadio` `SIMULATOR_APP` path to real hardware: it unwraps a `Compressed` envelope (`portnum == UNKNOWN_APP` → verbatim ciphertext the router decrypts; else → decoded payload for that portnum), then calls `router->enqueueReceivedMessage()` - the exact entry point `RadioLibInterface::handleReceiveInterrupt` uses. Injection is reached before the `p.from = 0` line, so a forged sender survives; `from == 0` is dropped to match real RX. +- **Host:** drive it with the meshtastic-mcp `inject_frame` tool (or `cli/meshinject.py`). The crafter replicates channel crypto (default-PSK expansion, `xorHash` channel hash, AES-CTR with the `packetId|from|0` nonce), so an encrypted frame decrypts on-device as if received. Modes: `text`, `raw`, `admin` (+ `pki`/`public_key` for the PKC-admin path), `ciphertext`, `fuzz` (malformed-frame decode-path/crash testing). +- **Example - remote-admin session-key repro:** set the target's `admin_key[0]` to a key you hold, then inject an `admin` set_owner with `pki=true`, that key, and a stale `session_hex`. The node logs `PKC admin payload with authorized sender key` → `Expected session key: 00…` → `Admin message without session_key!` - the exact ndoo scenario, on real silicon. Capture logs via `set_debug_log_api` on the same connection. +- **nRF52 gotcha:** the USB CDC wedges under rapid `SerialInterface` open/close churn (unrelated to injection) - keep setup + inject + log-capture on one connection; recover a hung board via a 1200 bps-touch DFU reflash. + ### Hardware test suite (`run-tests.sh`, from a meshtastic-mcp checkout) The wrapper auto-detects connected devices (VID → role map: `0x239A` → `nrf52`, `0x303A`/`0x10C4` → `esp32s3`), maps each role to a PlatformIO env (`nrf52` → `rak4631`, `esp32s3` → `heltec-v3`, overridable via `MESHTASTIC_MCP_ENV_`), then invokes pytest. Zero pre-flight config needed from the operator. diff --git a/AGENTS.md b/AGENTS.md index 7c25bdc2f..9423c5169 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,6 +50,7 @@ The [meshtastic-mcp](https://github.com/meshtastic/meshtastic-mcp) server expose - **Serial sessions**: `serial_open`, `serial_read`, `serial_list`, `serial_close` - **Device reads**: `device_info`, `list_nodes` - **Device writes** (require `confirm=True`): `set_owner`, `get_config`, `set_config`, `get_channel_url`, `set_channel_url`, `send_text`, `reboot`, `shutdown`, `factory_reset`, `set_debug_log_api` +- **Frame injection**: `inject_frame` - deliver a crafted frame into a board's real RX pipeline as if received off LoRa (reaches `from != 0` / decrypt / remote-admin paths the `toRadio` API can't). Needs firmware built with `-D MESHTASTIC_ENABLE_FRAME_INJECTION=1` (`MeshService::injectAsReceived`); sim nodes always. See the copilot-instructions **Frame injection** section. - **userPrefs admin**: `userprefs_get`, `userprefs_set`, `userprefs_reset`, `userprefs_manifest`, `userprefs_testing_profile` - **Vendor escape hatches**: `esptool_*`, `nrfutil_*`, `picotool_*` diff --git a/src/configuration.h b/src/configuration.h index aaba2bbfd..eb3db7e46 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -88,6 +88,14 @@ along with this program. If not, see . #define MESHTASTIC_PREHOP_DROP 1 #endif +// Debug/test only: let a wired client (serial/TCP) inject frames into the RX pipeline as if they had +// arrived over LoRa - a SIMULATOR_APP ToRadio packet is delivered through the real receive path on real +// hardware (see MeshService::injectAsReceived). This forges over-the-air traffic, so it MUST stay 0 in +// any shipping build; enable per-build with -D MESHTASTIC_ENABLE_FRAME_INJECTION=1. +#ifndef MESHTASTIC_ENABLE_FRAME_INJECTION +#define MESHTASTIC_ENABLE_FRAME_INJECTION 0 +#endif + /// Convert a preprocessor name into a quoted string #define xstr(s) ystr(s) #define ystr(s) #s diff --git a/src/mesh/MeshService.cpp b/src/mesh/MeshService.cpp index fb56b127f..48ef93672 100644 --- a/src/mesh/MeshService.cpp +++ b/src/mesh/MeshService.cpp @@ -173,6 +173,51 @@ NodeNum MeshService::getNodenumFromRequestId(uint32_t request_id) return nodenum; } +#if MESHTASTIC_ENABLE_FRAME_INJECTION +// Deliver a client-supplied frame into the receive pipeline as if it arrived off the LoRa chip. Mirrors +// the portduino SimRadio SIMULATOR_APP unwrap so the same host wire format works on real hardware: the +// frame rides inside a Compressed envelope wrapped in a MeshPacket that carries from/to/id/channel. +// Compressed.portnum == UNKNOWN_APP -> Compressed.data is verbatim ciphertext, decrypted as if off-air +// otherwise -> Compressed.data is the plaintext payload for Compressed.portnum +void MeshService::injectAsReceived(meshtastic_MeshPacket &p) +{ + meshtastic_Compressed scratch; + if (p.which_payload_variant == meshtastic_MeshPacket_decoded_tag) { + memset(&scratch, 0, sizeof(scratch)); + if (pb_decode_from_bytes(p.decoded.payload.bytes, p.decoded.payload.size, &meshtastic_Compressed_msg, &scratch)) { + if (scratch.portnum == meshtastic_PortNum_UNKNOWN_APP) { + p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + memcpy(p.encrypted.bytes, scratch.data.bytes, scratch.data.size); + p.encrypted.size = scratch.data.size; + } else { + memcpy(&p.decoded.payload, &scratch.data, sizeof(scratch.data)); + p.decoded.portnum = scratch.portnum; + } + } else { + LOG_ERROR("inject: could not decode Compressed envelope, dropping"); + return; + } + } + // The real RX path (RadioLibInterface::handleReceiveInterrupt) drops sender==0; mirror it so injection + // behaves identically to an over-the-air frame. + if (p.from == 0) { + LOG_WARN("inject: dropping frame with from==0 (matches real LoRa RX)"); + return; + } + meshtastic_MeshPacket *mp = packetPool.allocCopy(p); + if (!mp) + return; + if (mp->rx_snr == 0) // plausible synthetic link metadata unless the caller set it + mp->rx_snr = 8; + if (mp->rx_rssi == 0) + mp->rx_rssi = -40; + mp->rx_time = getValidTime(RTCQualityFromNet); + LOG_INFO("inject: RX from=0x%08x to=0x%08x id=0x%08x ch=%d %s", mp->from, mp->to, mp->id, mp->channel, + mp->which_payload_variant == meshtastic_MeshPacket_encrypted_tag ? "encrypted" : "decoded"); + router->enqueueReceivedMessage(mp); +} +#endif + /** * Given a ToRadio buffer parse it and properly handle it (setup radio, owner or send packet into the mesh) * Called by PhoneAPI.handleToRadio. Note: p is a scratch buffer, this function is allowed to write to it but it can not keep a @@ -186,6 +231,16 @@ void MeshService::handleToRadio(meshtastic_MeshPacket &p) SimRadio::instance->unpackAndReceive(p); return; } +#endif +#if MESHTASTIC_ENABLE_FRAME_INJECTION + // Real-hardware analog of the SimRadio path above: deliver a client-supplied frame into the RX + // pipeline exactly as if it had arrived off the LoRa chip. Reached before the p.from=0 line below, + // so an injected sender is preserved. Build-flag gated (off by default) - it lets anything with a + // wired connection forge over-the-air traffic, so it must never ship enabled. + if (p.which_payload_variant == meshtastic_MeshPacket_decoded_tag && p.decoded.portnum == meshtastic_PortNum_SIMULATOR_APP) { + injectAsReceived(p); + return; + } #endif p.from = 0; // We don't let clients assign nodenums to their sent messages p.next_hop = NO_NEXT_HOP_PREFERENCE; // We don't let clients assign next_hop to their sent messages diff --git a/src/mesh/MeshService.h b/src/mesh/MeshService.h index 41dcf1c80..b529d2836 100644 --- a/src/mesh/MeshService.h +++ b/src/mesh/MeshService.h @@ -159,6 +159,12 @@ class MeshService */ void handleToRadio(meshtastic_MeshPacket &p); +#if MESHTASTIC_ENABLE_FRAME_INJECTION + /// Test/debug seam (build-flag gated, off by default): deliver a client-supplied frame into the + /// receive pipeline as if it had arrived off the LoRa chip. See the definition for the wire format. + void injectAsReceived(meshtastic_MeshPacket &p); +#endif + /** The radioConfig object just changed, call this to force the hw to change to the new settings * @return true if client devices should be sent a new set of radio configs */