feat: build-flagged frame injection into the RX pipeline for testing (#11011)
MeshService::injectAsReceived (gated by MESHTASTIC_ENABLE_FRAME_INJECTION, off by default) extends the portduino SimRadio SIMULATOR_APP path to real hardware: a client-supplied frame, wrapped in a Compressed envelope on the SIMULATOR_APP portnum, is delivered through the real receive pipeline (router->enqueueReceivedMessage) as if it arrived off the LoRa chip. It therefore exercises from!=0 enforcement, channel/PKC decryption, remote-admin authorization, and hop/dedup/module dispatch - paths the toRadio API cannot reach (it forces from=0). from==0 is dropped to match real RX. This forges over-the-air traffic, so the flag must never ship enabled. Drive it with the meshtastic-mcp inject_frame tool / cli/meshinject.py. Documents the technique in the agent-facing copilot-instructions.md + AGENTS.md.
This commit is contained in:
@@ -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.
|
**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.
|
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`
|
- **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`
|
- **Serial sessions** (long-running, 10k-line ring buffer): `serial_open`, `serial_read`, `serial_list`, `serial_close`
|
||||||
- **Device reads**: `device_info`, `list_nodes`
|
- **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`
|
- **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`
|
- **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_<ROLE>` + `_PORT_<ROLE>` env vars first, falls back to VID auto-detection.
|
- **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_<ROLE>` + `_PORT_<ROLE>` 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=<host[:port]>` 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".
|
**TCP / native-host nodes.** Setting `MESHTASTIC_MCP_TCP_HOST=<host[:port]>` 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)
|
### 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_<ROLE>`), then invokes pytest. Zero pre-flight config needed from the operator.
|
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_<ROLE>`), then invokes pytest. Zero pre-flight config needed from the operator.
|
||||||
|
|||||||
@@ -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`
|
- **Serial sessions**: `serial_open`, `serial_read`, `serial_list`, `serial_close`
|
||||||
- **Device reads**: `device_info`, `list_nodes`
|
- **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`
|
- **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`
|
- **userPrefs admin**: `userprefs_get`, `userprefs_set`, `userprefs_reset`, `userprefs_manifest`, `userprefs_testing_profile`
|
||||||
- **Vendor escape hatches**: `esptool_*`, `nrfutil_*`, `picotool_*`
|
- **Vendor escape hatches**: `esptool_*`, `nrfutil_*`, `picotool_*`
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||||||
#define MESHTASTIC_PREHOP_DROP 1
|
#define MESHTASTIC_PREHOP_DROP 1
|
||||||
#endif
|
#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
|
/// Convert a preprocessor name into a quoted string
|
||||||
#define xstr(s) ystr(s)
|
#define xstr(s) ystr(s)
|
||||||
#define ystr(s) #s
|
#define ystr(s) #s
|
||||||
|
|||||||
@@ -173,6 +173,51 @@ NodeNum MeshService::getNodenumFromRequestId(uint32_t request_id)
|
|||||||
return nodenum;
|
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)
|
* 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
|
* 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);
|
SimRadio::instance->unpackAndReceive(p);
|
||||||
return;
|
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
|
#endif
|
||||||
p.from = 0; // We don't let clients assign nodenums to their sent messages
|
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
|
p.next_hop = NO_NEXT_HOP_PREFERENCE; // We don't let clients assign next_hop to their sent messages
|
||||||
|
|||||||
@@ -159,6 +159,12 @@ class MeshService
|
|||||||
*/
|
*/
|
||||||
void handleToRadio(meshtastic_MeshPacket &p);
|
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
|
/** 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
|
* @return true if client devices should be sent a new set of radio configs
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user