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:
Ben Meadors
2026-07-14 20:48:54 -05:00
committed by GitHub
co-authored by GitHub
parent fb922c2413
commit 0902c13473
5 changed files with 81 additions and 2 deletions
+8
View File
@@ -88,6 +88,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
#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
+55
View File
@@ -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
+6
View File
@@ -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
*/