From d30b06d1a574688a398be42c962a7da688ac7bed Mon Sep 17 00:00:00 2001 From: ayysasha <19575937+ayysasha@users.noreply.github.com> Date: Fri, 17 Jul 2026 02:40:23 -0400 Subject: [PATCH 01/12] Block coordinate traffic on configured event channels Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/mesh/Channels.cpp | 17 +- src/mesh/Channels.h | 13 +- src/mesh/PositionPrecision.cpp | 4 + src/mesh/Router.cpp | 115 ++++++++++--- src/mesh/Router.h | 9 + test/test_position_precision/test_main.cpp | 182 +++++++++++++++++++++ 6 files changed, 314 insertions(+), 26 deletions(-) diff --git a/src/mesh/Channels.cpp b/src/mesh/Channels.cpp index b086f5e6b3e..dab9d33032c 100644 --- a/src/mesh/Channels.cpp +++ b/src/mesh/Channels.cpp @@ -495,6 +495,21 @@ bool Channels::isWellKnownChannel(ChannelIndex chIndex) return false; } +bool Channels::isEventChannel(ChannelIndex chIndex) +{ +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && defined(USERPREFS_CHANNEL_0_PSK) + static const uint8_t configuredEventPsk[] = USERPREFS_CHANNEL_0_PSK; + static_assert(sizeof(configuredEventPsk) == 16 || sizeof(configuredEventPsk) == 32, + "USERPREFS_CHANNEL_0_PSK must be an AES-128 or AES-256 key"); + CryptoKey effectiveKey = getKey(chIndex); + return effectiveKey.length == sizeof(configuredEventPsk) && + memcmp(effectiveKey.bytes, configuredEventPsk, sizeof(configuredEventPsk)) == 0; +#else + (void)chIndex; + return false; +#endif +} + bool Channels::hasDefaultChannel() { // If we don't use a preset or the default frequency slot, or we override the frequency, we don't have a default channel @@ -563,4 +578,4 @@ bool Channels::setDefaultPresetCryptoForHash(ChannelHash channelHash) int16_t Channels::setActiveByIndex(ChannelIndex channelIndex) { return setCrypto(channelIndex); -} \ No newline at end of file +} diff --git a/src/mesh/Channels.h b/src/mesh/Channels.h index 6e17a7ab618..6fc40b0e413 100644 --- a/src/mesh/Channels.h +++ b/src/mesh/Channels.h @@ -5,6 +5,14 @@ #include "mesh-pb-constants.h" #include +#ifndef USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL +#define USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL USERPREFS_EVENT_MODE +#endif + +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && !defined(USERPREFS_CHANNEL_0_PSK) +#error "USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL requires USERPREFS_CHANNEL_0_PSK" +#endif + /** A channel number (index into the channel table) */ typedef uint8_t ChannelIndex; @@ -95,6 +103,9 @@ class Channels // matches the current preset's name and PSK byte 1. bool isWellKnownChannel(ChannelIndex chIndex); + // Returns true if this channel's effective key matches USERPREFS_CHANNEL_0_PSK. + bool isEventChannel(ChannelIndex chIndex); + // Returns true if we can be reached via a channel with the default settings given a region and modem preset bool hasDefaultChannel(); @@ -164,4 +175,4 @@ bool channelFileUsesPublicKey(const meshtastic_ChannelFile &cf, ChannelIndex chI static const uint8_t eventpsk[] = {0x38, 0x4b, 0xbc, 0xc0, 0x1d, 0xc0, 0x22, 0xd1, 0x81, 0xbf, 0x36, 0xb8, 0x61, 0x21, 0xe1, 0xfb, 0x96, 0xb7, 0x2e, 0x55, 0xbf, 0x74, - 0x22, 0x7e, 0x9d, 0x6a, 0xfb, 0x48, 0xd6, 0x4c, 0xb1, 0xa1}; \ No newline at end of file + 0x22, 0x7e, 0x9d, 0x6a, 0xfb, 0x48, 0xd6, 0x4c, 0xb1, 0xa1}; diff --git a/src/mesh/PositionPrecision.cpp b/src/mesh/PositionPrecision.cpp index 4302531a5b9..d34c660861f 100644 --- a/src/mesh/PositionPrecision.cpp +++ b/src/mesh/PositionPrecision.cpp @@ -16,6 +16,10 @@ uint32_t getPositionPrecisionForChannel(const meshtastic_Channel &channel) uint32_t getPositionPrecisionForChannel(uint8_t channelIndex) { + // Event-channel privacy takes precedence over every stored precision and key policy. + if (channels.isEventChannel(channelIndex)) + return 0; + const meshtastic_Channel &ch = channels.getByIndex(channelIndex); if (ch.role == meshtastic_Channel_Role_DISABLED) return 0; diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 9b1cc3ff28d..06892d08dc2 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -67,6 +67,72 @@ Allocator &packetPool = staticPool; static uint8_t bytes[MAX_LORA_PAYLOAD_LEN + 1] __attribute__((__aligned__)); +static ChannelIndex getEffectiveChannelIndex(const meshtastic_MeshPacket *p) +{ + ChannelIndex chIndex = p->channel; + if (nodeDB && isFromUs(p) && !chIndex && !p->pki_encrypted && !isBroadcast(p->to)) { + const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->to); + if (node) + chIndex = node->channel; + } + return chIndex; +} + +#if !(MESHTASTIC_EXCLUDE_PKI) +static bool willUsePki(const meshtastic_MeshPacket *p, ChannelIndex chIndex, bool haveDestKey) +{ + if (p->which_payload_variant != meshtastic_MeshPacket_decoded_tag || !isFromUs(p)) + return false; +#if ARCH_PORTDUINO + if (portduino_config.force_simradio) + return false; +#endif + bool eligible = !owner.is_licensed && + !(p->pki_encrypted != true && (strcasecmp(channels.getName(chIndex), Channels::serialChannel) == 0 || + strcasecmp(channels.getName(chIndex), Channels::gpioChannel) == 0)) && + config.security.private_key.size == 32 && !isBroadcast(p->to) && + p->decoded.portnum != meshtastic_PortNum_TRACEROUTE_APP && + p->decoded.portnum != meshtastic_PortNum_NODEINFO_APP && + p->decoded.portnum != meshtastic_PortNum_ROUTING_APP && p->decoded.portnum != meshtastic_PortNum_POSITION_APP; + return eligible && (p->decoded.portnum != meshtastic_PortNum_KEY_VERIFICATION_APP || haveDestKey); +} +#endif + +bool isBlockedEventCoordinatePacket(const meshtastic_MeshPacket *p) +{ +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL + if (p->pki_encrypted || willUsePki(p)) { + return false; + } + if (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag) { + return isCoordinatePortnum(p->decoded.portnum) && channels.isEventChannel(getEffectiveChannelIndex(p)); + } + return false; +#else + (void)p; + return false; +#endif +} + +bool willUsePki(const meshtastic_MeshPacket *p) +{ +#if !(MESHTASTIC_EXCLUDE_PKI) + if (p->which_payload_variant != meshtastic_MeshPacket_decoded_tag || !isFromUs(p)) + return false; + bool haveDestKey = false; + if (p->decoded.portnum == meshtastic_PortNum_KEY_VERIFICATION_APP) { + meshtastic_NodeInfoLite_public_key_t destKey = {0, {0}}; + haveDestKey = nodeDB->copyPublicKey(p->to, destKey); + if (!haveDestKey && p->pki_encrypted) + haveDestKey = crypto->getPendingPublicKey(p->to, destKey); + } + return willUsePki(p, getEffectiveChannelIndex(p), haveDestKey); +#else + (void)p; + return false; +#endif +} + /** * Constructor * @@ -263,9 +329,9 @@ ErrorCode Router::sendLocal(meshtastic_MeshPacket *p, RxSource src) // don't override if a channel was requested and no need to set it when PKI is enforced if (!p->channel && !p->pki_encrypted && !isBroadcast(p->to)) { - meshtastic_NodeInfoLite const *node = nodeDB->getMeshNode(p->to); - if (node) { - p->channel = node->channel; + ChannelIndex chIndex = getEffectiveChannelIndex(p); + if (chIndex) { + p->channel = chIndex; LOG_DEBUG("localSend to channel %d", p->channel); } } @@ -383,6 +449,12 @@ ErrorCode Router::send(meshtastic_MeshPacket *p) fixPriority(p); // Before encryption, fix the priority if it's unset // Position precision is an originator-only privacy policy. Relays keep // p->from as the original sender, so do not rewrite their POSITION_APP payload. + if (isBlockedEventCoordinatePacket(p)) { + LOG_DEBUG("Suppress coordinate send on event (everyone) channel"); + packetPool.release(p); + return meshtastic_Routing_Error_NOT_AUTHORIZED; + } + if (isFromUs(p)) { if (!applyPositionPrecisionForChannel(*p, p->channel)) { LOG_ERROR("Dropping malformed position packet before send"); @@ -622,6 +694,11 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) // parsing was successful p->channel = chIndex; // change to store the index instead of the hash + if (isBlockedEventCoordinatePacket(p)) { + LOG_DEBUG("Decoded coordinate packet on event channel; suppress payload logging"); + return DecodeState::DECODE_SUCCESS; + } + #if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA) // Runs before the bitfield merge below: that merge can set want_response, adding wire bytes // the sender never encoded and skewing the policy's sizing of p->decoded. @@ -796,27 +873,7 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p) // We may want to retool things so we can send a PKC packet when the client specifies a key and nodenum, even if the node // is not in the local nodedb // First, only PKC encrypt packets we are originating - if (isFromUs(p) && -#if ARCH_PORTDUINO - // Sim radio via the cli flag skips PKC - !portduino_config.force_simradio && -#endif - // Don't use PKC with Ham mode - !owner.is_licensed && - // Don't use PKC on 'serial' or 'gpio' channels unless explicitly requested - !(p->pki_encrypted != true && (strcasecmp(channels.getName(chIndex), Channels::serialChannel) == 0 || - strcasecmp(channels.getName(chIndex), Channels::gpioChannel) == 0)) && - // Check for valid keys and single node destination - config.security.private_key.size == 32 && !isBroadcast(p->to) && - // Some portnums either make no sense to send with PKC - p->decoded.portnum != meshtastic_PortNum_TRACEROUTE_APP && p->decoded.portnum != meshtastic_PortNum_NODEINFO_APP && - p->decoded.portnum != meshtastic_PortNum_ROUTING_APP && p->decoded.portnum != meshtastic_PortNum_POSITION_APP && - // We allow Key Verification messages to be sent without a known destination key, since the point of those messages is - // to exchange keys. The first exchange (no usable key yet) falls through to channel encryption; the follow-on packet - // uses the pending key resolved into haveDestKey/destKey above. - // Though possible the first packet each direction should go non-pkc - // to handle the case where the remote node has our key, but we don't have theirs. - !(p->decoded.portnum == meshtastic_PortNum_KEY_VERIFICATION_APP && !haveDestKey)) { + if (willUsePki(p, chIndex, haveDestKey)) { LOG_DEBUG("Use PKI!"); if (numbytes + MESHTASTIC_HEADER_LENGTH + MESHTASTIC_PKC_OVERHEAD > MAX_LORA_PAYLOAD_LEN) return meshtastic_Routing_Error_TOO_LARGE; @@ -966,6 +1023,16 @@ void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src) cancelSending(p->from, p->id); skipHandle = true; } + +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL + // Discard coordinate-bearing packets that arrive on the event ("everyone") + // channel: don't process, store in NodeDB, or rebroadcast them. + if (!skipHandle && isBlockedEventCoordinatePacket(p)) { + LOG_DEBUG("Drop coordinate packet on event (everyone) channel"); + cancelSending(p->from, p->id); + skipHandle = true; + } +#endif } else { printPacket("packet decoding failed or skipped (no PSK?)", p); } diff --git a/src/mesh/Router.h b/src/mesh/Router.h index d5d4b76adb9..cadec8ebf22 100644 --- a/src/mesh/Router.h +++ b/src/mesh/Router.h @@ -10,6 +10,15 @@ #include "concurrency/OSThread.h" #include +inline bool isCoordinatePortnum(meshtastic_PortNum portnum) +{ + return portnum == meshtastic_PortNum_POSITION_APP || portnum == meshtastic_PortNum_WAYPOINT_APP || + portnum == meshtastic_PortNum_MAP_REPORT_APP; +} + +bool isBlockedEventCoordinatePacket(const meshtastic_MeshPacket *p); +bool willUsePki(const meshtastic_MeshPacket *p); + /** * A mesh aware router that supports multiple interfaces. */ diff --git a/test/test_position_precision/test_main.cpp b/test/test_position_precision/test_main.cpp index 5f55697522c..0989dc3a999 100644 --- a/test/test_position_precision/test_main.cpp +++ b/test/test_position_precision/test_main.cpp @@ -1,11 +1,16 @@ #include "Channels.h" #include "GeoCoord.h" +#include "NodeDB.h" #include "PositionPrecision.h" +#include "Router.h" #include "TestUtil.h" #include "mesh-pb-constants.h" #include #include #include +#if ARCH_PORTDUINO +#include "platform/portduino/PortduinoGlue.h" +#endif static meshtastic_Position makePosition() { @@ -129,6 +134,8 @@ static void test_getPositionPrecisionForChannel_clampsPreciseOnDefaultKeyChannel channels.initDefaults(); // channel 0: primary, default key (psk {0x01}) -> publicly decryptable uint8_t idx = 0; meshtastic_Channel &ch = channels.getByIndex(idx); + ch.settings.psk.size = 1; + ch.settings.psk.bytes[0] = 0x01; ch.settings.has_module_settings = true; ch.settings.module_settings.position_precision = 32; // user requests "Precise" on a public channel @@ -236,6 +243,175 @@ static void test_geocoord_extreme_coords_no_oob() } } +static void configureEventChannels(bool eventAtIndexOne, bool inheritEventKeyOnSecondary) +{ + memset(&channelFile, 0, sizeof(channelFile)); + channelFile.channels_count = 2; + + meshtastic_Channel eventChannel = makeChannel(meshtastic_Channel_Role_PRIMARY, true, 16); + meshtastic_Channel otherChannel = makeChannel(meshtastic_Channel_Role_SECONDARY, true, 16); + strcpy(eventChannel.settings.name, "everyone"); +#ifdef USERPREFS_CHANNEL_0_PSK + static const uint8_t configuredEventPsk[] = USERPREFS_CHANNEL_0_PSK; + eventChannel.settings.psk.size = sizeof(configuredEventPsk); + memcpy(eventChannel.settings.psk.bytes, configuredEventPsk, sizeof(configuredEventPsk)); +#endif + if (!inheritEventKeyOnSecondary) { + otherChannel.settings.psk.size = 32; + memset(otherChannel.settings.psk.bytes, 0xAB, 32); + strcpy(otherChannel.settings.name, "private"); + } + + eventChannel.index = eventAtIndexOne ? 1 : 0; + otherChannel.index = eventAtIndexOne ? 0 : 1; + channelFile.channels[eventChannel.index] = eventChannel; + channelFile.channels[otherChannel.index] = otherChannel; + channels.onConfigChanged(); +} + +static void test_getPositionPrecisionForChannel_eventChannelClampedToZero() +{ + // The event ("everyone") channel must never share location, even when the + // stored precision is non-zero. Under the block gate the clamp forces 0; + // otherwise the stored value is honored like any other channel. +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && defined(USERPREFS_CHANNEL_0_PSK) + configureEventChannels(false, false); + TEST_ASSERT_TRUE(channels.isEventChannel(0)); + TEST_ASSERT_EQUAL_UINT32(0, getPositionPrecisionForChannel(0)); +#else + meshtastic_Channel channel = makeChannel(meshtastic_Channel_Role_PRIMARY, true, 16); + TEST_ASSERT_EQUAL_UINT32(16, getPositionPrecisionForChannel(channel)); +#endif +} + +static void test_eventChannelIdentity_usesEffectiveKeyAndSurvivesReorder() +{ +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && defined(USERPREFS_CHANNEL_0_PSK) + configureEventChannels(false, true); + TEST_ASSERT_TRUE(channels.isEventChannel(0)); + TEST_ASSERT_TRUE(channels.isEventChannel(1)); + TEST_ASSERT_EQUAL_UINT32(0, getPositionPrecisionForChannel(1)); + + configureEventChannels(true, false); + TEST_ASSERT_FALSE(channels.isEventChannel(0)); + TEST_ASSERT_TRUE(channels.isEventChannel(1)); + TEST_ASSERT_EQUAL_UINT32(16, getPositionPrecisionForChannel(0)); + TEST_ASSERT_EQUAL_UINT32(0, getPositionPrecisionForChannel(1)); +#else + TEST_ASSERT_FALSE(channels.isEventChannel(0)); +#endif +} + +static meshtastic_MeshPacket makeDecodedPacket(meshtastic_PortNum portnum, uint8_t channelIndex) +{ + meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_default; + packet.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + packet.decoded.portnum = portnum; + packet.channel = channelIndex; + return packet; +} + +static void test_eventCoordinatePolicy_coversPortsAndExcludesPki() +{ +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && defined(USERPREFS_CHANNEL_0_PSK) + configureEventChannels(false, false); + auto position = makeDecodedPacket(meshtastic_PortNum_POSITION_APP, 0); + auto waypoint = makeDecodedPacket(meshtastic_PortNum_WAYPOINT_APP, 0); + auto mapReport = makeDecodedPacket(meshtastic_PortNum_MAP_REPORT_APP, 0); + auto text = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, 0); + + TEST_ASSERT_TRUE(isBlockedEventCoordinatePacket(&position)); + TEST_ASSERT_TRUE(isBlockedEventCoordinatePacket(&waypoint)); + TEST_ASSERT_TRUE(isBlockedEventCoordinatePacket(&mapReport)); + TEST_ASSERT_FALSE(isBlockedEventCoordinatePacket(&text)); + + waypoint.pki_encrypted = true; + TEST_ASSERT_FALSE(isBlockedEventCoordinatePacket(&waypoint)); + + waypoint.pki_encrypted = false; + waypoint.to = 0x12345678; + config.security.private_key.size = 32; + owner.is_licensed = false; +#if ARCH_PORTDUINO + portduino_config.force_simradio = false; +#endif + TEST_ASSERT_TRUE(willUsePki(&waypoint)); + TEST_ASSERT_FALSE(isBlockedEventCoordinatePacket(&waypoint)); +#else + auto position = makeDecodedPacket(meshtastic_PortNum_POSITION_APP, 0); + TEST_ASSERT_FALSE(isBlockedEventCoordinatePacket(&position)); +#endif +} + +static void test_eventCoordinatePolicy_doesNotClassifyOpaquePacketsByHash() +{ +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && defined(USERPREFS_CHANNEL_0_PSK) + configureEventChannels(false, false); + meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_default; + packet.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + packet.channel = channels.getHash(0); + packet.from = 0; + TEST_ASSERT_FALSE(isBlockedEventCoordinatePacket(&packet)); + + packet.pki_encrypted = true; + TEST_ASSERT_FALSE(isBlockedEventCoordinatePacket(&packet)); + + packet.channel = 0; + TEST_ASSERT_FALSE(isBlockedEventCoordinatePacket(&packet)); + + packet.pki_encrypted = false; + packet.channel = channels.getHash(0); + packet.from = 0x12345678; + TEST_ASSERT_FALSE(isBlockedEventCoordinatePacket(&packet)); + + packet.from = 0; + packet.channel = channels.getHash(1); + TEST_ASSERT_FALSE(isBlockedEventCoordinatePacket(&packet)); +#else + TEST_ASSERT_TRUE(true); +#endif +} + +static void test_eventCoordinatePolicy_usesResolvedUnicastChannel() +{ +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && defined(USERPREFS_CHANNEL_0_PSK) + constexpr NodeNum destination = 0x12345678; + NodeDB *savedNodeDB = nodeDB; + nodeDB = new NodeDB(); + configureEventChannels(false, false); + meshtastic_NodeInfoLite *node = nodeDB->getOrCreateMeshNode(destination); + TEST_ASSERT_NOT_NULL(node); + + auto position = makeDecodedPacket(meshtastic_PortNum_POSITION_APP, 0); + position.to = destination; + node->channel = 1; + TEST_ASSERT_FALSE(isBlockedEventCoordinatePacket(&position)); + + position.from = 0x87654321; + TEST_ASSERT_TRUE(isBlockedEventCoordinatePacket(&position)); + + position.from = 0; + node->channel = 0; + TEST_ASSERT_TRUE(isBlockedEventCoordinatePacket(&position)); + delete nodeDB; + nodeDB = savedNodeDB; +#else + TEST_ASSERT_TRUE(true); +#endif +} + +static void test_getPositionPrecisionForChannel_nonEventFullKeyIsHonored() +{ + // A private channel with a full 32-byte key that is not the configured + // channel-0 PSK must be + // unaffected by the clamp on either side of the gate. + meshtastic_Channel channel = makeChannel(meshtastic_Channel_Role_PRIMARY, true, 16); + channel.settings.psk.size = 32; + memset(channel.settings.psk.bytes, 0xAB, 32); + + TEST_ASSERT_EQUAL_UINT32(16, getPositionPrecisionForChannel(channel)); +} + void setUp(void) {} void tearDown(void) {} @@ -262,6 +438,12 @@ void setup() RUN_TEST(test_cryptoKeyIsPublic_aes256KeyIsPrivate); RUN_TEST(test_cryptoKeyIsPublic_invalidKeyIsNotPublic); RUN_TEST(test_geocoord_extreme_coords_no_oob); + RUN_TEST(test_getPositionPrecisionForChannel_eventChannelClampedToZero); + RUN_TEST(test_eventChannelIdentity_usesEffectiveKeyAndSurvivesReorder); + RUN_TEST(test_eventCoordinatePolicy_coversPortsAndExcludesPki); + RUN_TEST(test_eventCoordinatePolicy_doesNotClassifyOpaquePacketsByHash); + RUN_TEST(test_eventCoordinatePolicy_usesResolvedUnicastChannel); + RUN_TEST(test_getPositionPrecisionForChannel_nonEventFullKeyIsHonored); exit(UNITY_END()); } From f6bd014ee7a5f4fea94d1813787d03ed00404e40 Mon Sep 17 00:00:00 2001 From: ayysasha <19575937+ayysasha@users.noreply.github.com> Date: Fri, 17 Jul 2026 02:40:54 -0400 Subject: [PATCH 02/12] Suppress event coordinates in reliable relay paths Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/mesh/FloodingRouter.cpp | 31 +++++++++++++++++++------------ src/mesh/FloodingRouter.h | 4 ++-- src/mesh/NextHopRouter.cpp | 16 ++++++++++++---- src/mesh/ReliableRouter.cpp | 8 +++++++- 4 files changed, 40 insertions(+), 19 deletions(-) diff --git a/src/mesh/FloodingRouter.cpp b/src/mesh/FloodingRouter.cpp index 13f98299f93..0fa4c07c974 100644 --- a/src/mesh/FloodingRouter.cpp +++ b/src/mesh/FloodingRouter.cpp @@ -51,8 +51,8 @@ bool FloodingRouter::shouldFilterReceived(const meshtastic_MeshPacket *p) LOG_DEBUG("Repeated reliable tx"); // Check if it's still in the Tx queue, if not, we have to relay it again if (!findInTxQueue(p->from, p->id)) { - reprocessPacket(p); - perhapsRebroadcast(p); + if (reprocessPacket(p)) + perhapsRebroadcast(p); } } else { perhapsCancelDupe(p); @@ -75,8 +75,8 @@ bool FloodingRouter::perhapsHandleUpgradedPacket(const meshtastic_MeshPacket *p) LOG_DEBUG("Processing upgraded packet 0x%08x for rebroadcast with hop limit %d (dropping queued < %d)", p->id, p->hop_limit, dropThreshold); - reprocessPacket(p); - perhapsRebroadcast(p); + if (reprocessPacket(p)) + perhapsRebroadcast(p); rxDupe++; // We already enqueued the improved copy, so make sure the incoming packet stops here. @@ -87,32 +87,39 @@ bool FloodingRouter::perhapsHandleUpgradedPacket(const meshtastic_MeshPacket *p) return false; } -void FloodingRouter::reprocessPacket(const meshtastic_MeshPacket *p) +bool FloodingRouter::reprocessPacket(const meshtastic_MeshPacket *p) { if (nodeDB) nodeDB->updateFrom(*p); -#if !MESHTASTIC_EXCLUDE_TRACEROUTE - if (traceRouteModule && p->which_payload_variant != meshtastic_MeshPacket_decoded_tag) { - // If we got a packet that is not decoded, try to decode it so we can check for traceroute. + bool shouldDecode = false; +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL + shouldDecode = true; +#elif !MESHTASTIC_EXCLUDE_TRACEROUTE + shouldDecode = traceRouteModule != nullptr; +#endif + if (shouldDecode && p->which_payload_variant != meshtastic_MeshPacket_decoded_tag) { + // Duplicate handling can rebroadcast before the normal receive path. The + // event policy needs the portnum even when TraceRoute is excluded. auto decodedState = perhapsDecode(const_cast(p)); if (decodedState == DecodeState::DECODE_SUCCESS) { // parsing was successful, print for debugging printPacket("reprocessPacket(DUP)", p); } else { // Fatal decoding error, we can't do anything with this packet - LOG_WARN( - "FloodingRouter::reprocessPacket: Fatal decode error (state=%d, id=0x%08x, from=%u), can't check for traceroute", - static_cast(decodedState), p->id, getFrom(p)); - return; + LOG_WARN("FloodingRouter::reprocessPacket: decode error (state=%d, id=0x%08x, from=%u)", + static_cast(decodedState), p->id, getFrom(p)); + return true; } } +#if !MESHTASTIC_EXCLUDE_TRACEROUTE if (traceRouteModule && p->which_payload_variant == meshtastic_MeshPacket_decoded_tag && p->decoded.portnum == meshtastic_PortNum_TRACEROUTE_APP) { traceRouteModule->processUpgradedPacket(*p); } #endif + return true; } bool FloodingRouter::roleAllowsCancelingDupe(const meshtastic_MeshPacket *p) diff --git a/src/mesh/FloodingRouter.h b/src/mesh/FloodingRouter.h index e8a2e9685fa..6bd5fca4166 100644 --- a/src/mesh/FloodingRouter.h +++ b/src/mesh/FloodingRouter.h @@ -64,7 +64,7 @@ class FloodingRouter : public Router bool perhapsHandleUpgradedPacket(const meshtastic_MeshPacket *p); /* Call when we receive a packet that needs some reprocessing, but afterwards should be filtered */ - void reprocessPacket(const meshtastic_MeshPacket *p); + bool reprocessPacket(const meshtastic_MeshPacket *p); // Return false for roles like ROUTER which should always rebroadcast even when we've heard another rebroadcast of // the same packet @@ -75,4 +75,4 @@ class FloodingRouter : public Router // Return true if we are a rebroadcaster bool isRebroadcaster(); -}; \ No newline at end of file +}; diff --git a/src/mesh/NextHopRouter.cpp b/src/mesh/NextHopRouter.cpp index f33de779cc0..4b26dc80e10 100644 --- a/src/mesh/NextHopRouter.cpp +++ b/src/mesh/NextHopRouter.cpp @@ -65,16 +65,16 @@ bool NextHopRouter::shouldFilterReceived(const meshtastic_MeshPacket *p) LOG_INFO("Fallback to flooding from relay_node=0x%x", p->relay_node); // Check if it's still in the Tx queue, if not, we have to relay it again if (!findInTxQueue(p->from, p->id)) { - reprocessPacket(p); - perhapsRebroadcast(p); + if (reprocessPacket(p)) + perhapsRebroadcast(p); } } else { bool isRepeated = getHopsAway(*p) == 0; // If repeated and not in Tx queue anymore, try relaying again, or if we are the destination, send the ACK again if (isRepeated) { if (!findInTxQueue(p->from, p->id)) { - reprocessPacket(p); - if (!perhapsRebroadcast(p) && isToUs(p) && p->want_ack) { + if (reprocessPacket(p) && !isBlockedEventCoordinatePacket(p) && !perhapsRebroadcast(p) && isToUs(p) && + p->want_ack) { sendAckNak(meshtastic_Routing_Error_NONE, getFrom(p), p->id, p->channel, 0); } } @@ -151,6 +151,14 @@ void NextHopRouter::sniffReceived(const meshtastic_MeshPacket *p, const meshtast /* Check if we should be rebroadcasting this packet if so, do so. */ bool NextHopRouter::perhapsRebroadcast(const meshtastic_MeshPacket *p) { +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL + // Never relay coordinate-bearing packets on the event ("everyone") channel. + // Closes the reliable-retransmit-dupe path that runs before handleReceived(). + if (isBlockedEventCoordinatePacket(p)) { + return false; + } +#endif + // Check if traffic management wants to exhaust this packet's hops bool exhaustHops = false; #if HAS_TRAFFIC_MANAGEMENT diff --git a/src/mesh/ReliableRouter.cpp b/src/mesh/ReliableRouter.cpp index 7ec6bb4b72d..667d2d6222d 100644 --- a/src/mesh/ReliableRouter.cpp +++ b/src/mesh/ReliableRouter.cpp @@ -16,6 +16,12 @@ */ ErrorCode ReliableRouter::send(meshtastic_MeshPacket *p) { + if (isBlockedEventCoordinatePacket(p)) { + LOG_DEBUG("Suppress reliable coordinate send on event (everyone) channel"); + packetPool.release(p); + return meshtastic_Routing_Error_NOT_AUTHORIZED; + } + if (p->want_ack) { DEBUG_HEAP_BEFORE; auto copy = packetPool.allocCopy(*p); @@ -196,4 +202,4 @@ bool ReliableRouter::shouldSuccessAckWithWantAck(const meshtastic_MeshPacket *p) } return false; -} \ No newline at end of file +} From 28374049199a5cb18d4146fe4a537df09bdae081 Mon Sep 17 00:00:00 2001 From: ayysasha <19575937+ayysasha@users.noreply.github.com> Date: Fri, 17 Jul 2026 02:41:19 -0400 Subject: [PATCH 03/12] Reject blocked phone coordinates before rate limiting Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/mesh/PhoneAPI.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp index 8b924a16750..0921f14048c 100644 --- a/src/mesh/PhoneAPI.cpp +++ b/src/mesh/PhoneAPI.cpp @@ -1744,6 +1744,16 @@ bool PhoneAPI::handleToRadioPacket(meshtastic_MeshPacket &p) } #endif + // Reject before recording duplicate or per-port cooldown state, so a blocked + // attempt cannot throttle a valid private-channel position retry. + if (isBlockedEventCoordinatePacket(&p)) { + LOG_DEBUG("Suppress phone coordinate send on event (everyone) channel"); + meshtastic_QueueStatus qs = router->getQueueStatus(); + service->sendQueueStatusToPhone(qs, 0, p.id); + sendNotification(meshtastic_LogRecord_Level_WARNING, p.id, "Location sharing is disabled on this channel"); + return false; + } + #if defined(ARCH_PORTDUINO) // For use with the simulator, we should not ignore duplicate packets from the phone if (SimRadio::instance == nullptr) From 7ad2d44c3a22077450bb3cd0fa4d40c8f2a8945c Mon Sep 17 00:00:00 2001 From: ayysasha <19575937+ayysasha@users.noreply.github.com> Date: Fri, 17 Jul 2026 02:41:48 -0400 Subject: [PATCH 04/12] Prevent event coordinates from reaching MQTT Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/mqtt/MQTT.cpp | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/mqtt/MQTT.cpp b/src/mqtt/MQTT.cpp index 907888bff88..4ce97b14f3c 100644 --- a/src/mqtt/MQTT.cpp +++ b/src/mqtt/MQTT.cpp @@ -652,6 +652,12 @@ void MQTT::onSend(const meshtastic_MeshPacket &mp_encrypted, const meshtastic_Me { if (mp_encrypted.via_mqtt) return; // Don't send messages that came from MQTT back into MQTT +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL + if (isBlockedEventCoordinatePacket(&mp_decoded)) { + LOG_DEBUG("MQTT onSend - Suppress coordinate packet on event channel"); + return; + } +#endif bool uplinkEnabled = false; for (int i = 0; i <= 7; i++) { if (channels.getByIndex(i).settings.uplink_enabled) @@ -729,6 +735,12 @@ void MQTT::onSend(const meshtastic_MeshPacket &mp_encrypted, const meshtastic_Me void MQTT::perhapsReportToMap() { +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL + if (channels.isEventChannel(channels.getPrimaryIndex())) { + LOG_DEBUG("Suppress MQTT map report on event (everyone) channel"); + return; + } +#endif if (!moduleConfig.mqtt.map_reporting_enabled || !moduleConfig.mqtt.map_report_settings.should_report_location || !(moduleConfig.mqtt.proxy_to_client_enabled || isConnectedDirectly())) return; From f9762a38f70c681be43a45781663e0672962d823 Mon Sep 17 00:00:00 2001 From: ayysasha <19575937+ayysasha@users.noreply.github.com> Date: Fri, 17 Jul 2026 02:42:18 -0400 Subject: [PATCH 05/12] Add event coordinate policy preference Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- userPrefs.jsonc | 1 + 1 file changed, 1 insertion(+) diff --git a/userPrefs.jsonc b/userPrefs.jsonc index 3e3fbd5c94d..fc13ff84ac9 100644 --- a/userPrefs.jsonc +++ b/userPrefs.jsonc @@ -24,6 +24,7 @@ // "USERPREFS_CONFIG_OWNER_SHORT_NAME": "MLN", // "USERPREFS_CONFIG_DEVICE_ROLE": "meshtastic_Config_DeviceConfig_Role_CLIENT", // Defaults to CLIENT. ROUTER*, and LOST AND FOUND roles are restricted. // "USERPREFS_EVENT_MODE": "1", + // "USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL": "1", // Block location TX + discard inbound location on channels keyed with USERPREFS_CHANNEL_0_PSK. Defaults on under EVENT_MODE. // "USERPREFS_TMM_APPLY_TO_PRIVATE_CHANNELS": "1", // Extend TMM position dedup and precision clamping to private/custom-key channels (default: well-known channels only) // "USERPREFS_FIRMWARE_EDITION": "meshtastic_FirmwareEdition_BURNING_MAN", // "USERPREFS_FIXED_BLUETOOTH": "121212", From ef26f0561ece3e85cad800d208e36df3afeece33 Mon Sep 17 00:00:00 2001 From: ayysasha <19575937+ayysasha@users.noreply.github.com> Date: Fri, 17 Jul 2026 02:42:44 -0400 Subject: [PATCH 06/12] Test event coordinate policy in native CI Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .github/workflows/test_native.yml | 5 ++++- variants/native/portduino/platformio.ini | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test_native.yml b/.github/workflows/test_native.yml index 3b1b95ee8a0..7b5bc98d033 100644 --- a/.github/workflows/test_native.yml +++ b/.github/workflows/test_native.yml @@ -114,13 +114,16 @@ jobs: # the test artifact is unaffected. platformio test -e coverage -v --junit-output-path testreport.xml 2>&1 | grep -v "[[:space:]]SKIPPED$" + - name: Event channel policy tests + run: platformio test -e coverage-event-policy -v --junit-output-path event-policy-testreport.xml + - name: Save test results if: always() # run this step even if previous step failed uses: actions/upload-artifact@v7 with: name: platformio-test-report-${{ steps.version.outputs.long }} overwrite: true - path: ./testreport.xml + path: ./*testreport.xml - name: Capture coverage information if: always() # run this step even if previous step failed diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini index 5694a0d3b53..71f06c5b96e 100644 --- a/variants/native/portduino/platformio.ini +++ b/variants/native/portduino/platformio.ini @@ -123,6 +123,13 @@ test_testing_command = ${platformio.build_dir}/${this.__env__}/meshtasticd -s +[env:coverage-event-policy] +extends = env:coverage +build_flags = ${env:coverage.build_flags} + -DUSERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL=1 + -DUSERPREFS_CHANNEL_0_PSK='{0x00,0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x08,0x09,0x0a,0x0b,0x0c,0x0d,0x0e,0x0f}' +test_filter = test_position_precision + ; --------------------------------------------------------------------------- ; Native build for macOS (Darwin / arm64 + x86_64). Headless meshtasticd that ; runs in SimRadio mode (`-s`) or against real LoRa hardware via a CH341 From 6c941f3a9824d84f2c0d95905c02ccc24ce75490 Mon Sep 17 00:00:00 2001 From: ayysasha <19575937+ayysasha@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:57:49 -0400 Subject: [PATCH 07/12] Make event policy test tolerate a full NodeDB Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- test/test_position_precision/test_main.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/test_position_precision/test_main.cpp b/test/test_position_precision/test_main.cpp index 0989dc3a999..ab3072e1726 100644 --- a/test/test_position_precision/test_main.cpp +++ b/test/test_position_precision/test_main.cpp @@ -375,12 +375,14 @@ static void test_eventCoordinatePolicy_doesNotClassifyOpaquePacketsByHash() static void test_eventCoordinatePolicy_usesResolvedUnicastChannel() { #if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && defined(USERPREFS_CHANNEL_0_PSK) - constexpr NodeNum destination = 0x12345678; NodeDB *savedNodeDB = nodeDB; nodeDB = new NodeDB(); configureEventChannels(false, false); - meshtastic_NodeInfoLite *node = nodeDB->getOrCreateMeshNode(destination); + meshtastic_NodeInfoLite *node = + nodeDB->getNumMeshNodes() > 1 ? nodeDB->getMeshNodeByIndex(1) : nodeDB->getOrCreateMeshNode(0x12345678); TEST_ASSERT_NOT_NULL(node); + const NodeNum destination = node->num; + const uint8_t savedChannel = node->channel; auto position = makeDecodedPacket(meshtastic_PortNum_POSITION_APP, 0); position.to = destination; @@ -393,6 +395,7 @@ static void test_eventCoordinatePolicy_usesResolvedUnicastChannel() position.from = 0; node->channel = 0; TEST_ASSERT_TRUE(isBlockedEventCoordinatePacket(&position)); + node->channel = savedChannel; delete nodeDB; nodeDB = savedNodeDB; #else From 9d35291ee9febc5ecedcf7d38a1866bd4d5008ef Mon Sep 17 00:00:00 2001 From: ayysasha <19575937+ayysasha@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:29:41 -0400 Subject: [PATCH 08/12] Test Router event coordinate enforcement Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- test/test_event_channel_router/test_main.cpp | 433 +++++++++++++++++++ 1 file changed, 433 insertions(+) create mode 100644 test/test_event_channel_router/test_main.cpp diff --git a/test/test_event_channel_router/test_main.cpp b/test/test_event_channel_router/test_main.cpp new file mode 100644 index 00000000000..7157b319d92 --- /dev/null +++ b/test/test_event_channel_router/test_main.cpp @@ -0,0 +1,433 @@ +#include "MeshTypes.h" +#include "TestUtil.h" +#include + +#include "airtime.h" +#include "mesh/Channels.h" +#include "mesh/CryptoEngine.h" +#include "mesh/MeshModule.h" +#include "mesh/MeshRadio.h" +#include "mesh/MeshService.h" +#include "mesh/NodeDB.h" +#include "mesh/Router.h" +#if ARCH_PORTDUINO +#include "platform/portduino/PortduinoGlue.h" +#endif +#include +#include +#include +#include +#include +#include + +#if ARCH_PORTDUINO +#define EVENT_ROUTER_TEST_ENTRY extern "C" +#else +#define EVENT_ROUTER_TEST_ENTRY +#endif + +namespace +{ + +constexpr NodeNum kLocalNode = 0x11111111; +constexpr NodeNum kRemoteNode = 0x22222222; +constexpr NodeNum kPkiPeer = 0x33333333; +constexpr ChannelIndex kEventChannel = 0; +constexpr ChannelIndex kPrivateChannel = 1; + +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL +constexpr bool kBlockEventCoordinates = true; +constexpr ErrorCode kExpectedEventTxResult = meshtastic_Routing_Error_NOT_AUTHORIZED; +constexpr size_t kExpectedEventDeliveryCount = 0; +#else +constexpr bool kBlockEventCoordinates = false; +constexpr ErrorCode kExpectedEventTxResult = ERRNO_OK; +constexpr size_t kExpectedEventDeliveryCount = 3; +#endif + +constexpr std::array kCoordinatePorts = { + meshtastic_PortNum_POSITION_APP, + meshtastic_PortNum_WAYPOINT_APP, + meshtastic_PortNum_MAP_REPORT_APP, +}; + +class TestNodeDB : public NodeDB +{ + public: + void clearTestNodes() + { + testNodes.clear(); + meshNodes = &testNodes; + numMeshNodes = 0; + } + + void addNode(NodeNum num, ChannelIndex channel, const uint8_t *publicKey = nullptr) + { + meshtastic_NodeInfoLite node = meshtastic_NodeInfoLite_init_zero; + node.num = num; + node.channel = channel; + if (publicKey) { + node.public_key.size = 32; + memcpy(node.public_key.bytes, publicKey, 32); + } + testNodes.push_back(node); + meshNodes = &testNodes; + numMeshNodes = testNodes.size(); + } + + private: + std::vector testNodes; +}; + +class CaptureRadio : public RadioInterface +{ + public: + ErrorCode send(meshtastic_MeshPacket *packet) override + { + packets.push_back(*packet); + packetPool.release(packet); + return ERRNO_OK; + } + + uint32_t getPacketTime(uint32_t, bool = false) override { return 0; } + + std::vector packets; +}; + +class CaptureModule : public MeshModule +{ + public: + CaptureModule() : MeshModule("event-router-capture") { encryptedOk = true; } + + bool wantPacket(const meshtastic_MeshPacket *) override { return true; } + + ProcessMessage handleReceived(const meshtastic_MeshPacket &packet) override + { + packets.push_back(packet); + return ProcessMessage::CONTINUE; + } + + std::vector packets; +}; + +struct SavedGlobals { + meshtastic_LocalConfig config; + meshtastic_LocalModuleConfig moduleConfig; + meshtastic_ChannelFile channelFile; + meshtastic_User owner; + meshtastic_MyNodeInfo myNodeInfo; + NodeDB *nodeDB; + Router *router; + MeshService *service; + AirTime *airTime; + concurrency::Lock *cryptLock; +#if ARCH_PORTDUINO + bool forceSimRadio; +#endif +}; + +SavedGlobals saved; +TestNodeDB *testNodeDB = nullptr; +Router *testRouter = nullptr; +CaptureRadio *captureRadio = nullptr; +CaptureModule *captureModule = nullptr; +AirTime *testAirTime = nullptr; + +static void installChannels() +{ + memset(&channelFile, 0, sizeof(channelFile)); + channelFile.channels_count = 2; + + meshtastic_Channel &event = channelFile.channels[kEventChannel]; + memset(&event, 0, sizeof(event)); + event.index = kEventChannel; + event.role = meshtastic_Channel_Role_PRIMARY; + event.has_settings = true; + strncpy(event.settings.name, "everyone", sizeof(event.settings.name) - 1); +#ifdef USERPREFS_CHANNEL_0_PSK + static const uint8_t eventKey[] = USERPREFS_CHANNEL_0_PSK; + static_assert(sizeof(eventKey) == 16 || sizeof(eventKey) == 32); + event.settings.psk.size = sizeof(eventKey); + memcpy(event.settings.psk.bytes, eventKey, sizeof(eventKey)); +#else + static const uint8_t eventKey[16] = {0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, + 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f}; + event.settings.psk.size = sizeof(eventKey); + memcpy(event.settings.psk.bytes, eventKey, sizeof(eventKey)); +#endif + + meshtastic_Channel &privateChannel = channelFile.channels[kPrivateChannel]; + memset(&privateChannel, 0, sizeof(privateChannel)); + privateChannel.index = kPrivateChannel; + privateChannel.role = meshtastic_Channel_Role_SECONDARY; + privateChannel.has_settings = true; + strncpy(privateChannel.settings.name, "private", sizeof(privateChannel.settings.name) - 1); + privateChannel.settings.psk.size = 32; + for (size_t i = 0; i < privateChannel.settings.psk.size; ++i) + privateChannel.settings.psk.bytes[i] = static_cast(0x80 + i); + + channels.onConfigChanged(); +} + +static meshtastic_MeshPacket makeDecodedPacket(meshtastic_PortNum port, NodeNum from, NodeNum to, ChannelIndex channel) +{ + meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_zero; + packet.from = from; + packet.to = to; + packet.id = 0x40000000u + static_cast(port); + packet.channel = channel; + packet.hop_start = 3; + packet.hop_limit = 3; + packet.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + packet.decoded.portnum = port; + + if (port == meshtastic_PortNum_POSITION_APP) { + meshtastic_Position position = meshtastic_Position_init_zero; + position.has_latitude_i = true; + position.latitude_i = 374221234; + position.has_longitude_i = true; + position.longitude_i = -1220845678; + packet.decoded.payload.size = pb_encode_to_bytes(packet.decoded.payload.bytes, sizeof(packet.decoded.payload.bytes), + &meshtastic_Position_msg, &position); + } else { + packet.decoded.payload.size = 1; + packet.decoded.payload.bytes[0] = 0x5a; + } + return packet; +} + +static ErrorCode sendCoordinate(meshtastic_PortNum port, ChannelIndex channel, NodeNum to = NODENUM_BROADCAST) +{ + meshtastic_MeshPacket *packet = testRouter->allocForSending(); + TEST_ASSERT_NOT_NULL(packet); + const meshtastic_MeshPacket contents = makeDecodedPacket(port, kLocalNode, to, channel); + packet->to = contents.to; + packet->channel = contents.channel; + packet->decoded = contents.decoded; + return testRouter->send(packet); +} + +static void receivePacket(const meshtastic_MeshPacket &contents) +{ + meshtastic_MeshPacket *packet = packetPool.allocCopy(contents); + TEST_ASSERT_NOT_NULL(packet); + testRouter->enqueueReceivedMessage(packet); + testRouter->runOnce(); +} + +static void test_tx_event_channel_enforces_compile_time_policy_for_all_coordinate_ports() +{ + TEST_ASSERT_EQUAL(kBlockEventCoordinates, channels.isEventChannel(kEventChannel)); + + for (meshtastic_PortNum port : kCoordinatePorts) { + const size_t before = captureRadio->packets.size(); + TEST_ASSERT_EQUAL_INT(kExpectedEventTxResult, sendCoordinate(port, kEventChannel)); + TEST_ASSERT_EQUAL_UINT32(before + (kBlockEventCoordinates ? 0 : 1), captureRadio->packets.size()); + } +} + +static void test_rx_event_channel_enforces_compile_time_policy_for_all_coordinate_ports() +{ + for (meshtastic_PortNum port : kCoordinatePorts) + receivePacket(makeDecodedPacket(port, kRemoteNode, NODENUM_BROADCAST, kEventChannel)); + + TEST_ASSERT_EQUAL_UINT32(kExpectedEventDeliveryCount, captureModule->packets.size()); +} + +static void test_private_channel_preserves_legacy_tx_and_rx_for_all_coordinate_ports() +{ + TEST_ASSERT_FALSE(channels.isEventChannel(kPrivateChannel)); + + for (meshtastic_PortNum port : kCoordinatePorts) { + TEST_ASSERT_EQUAL_INT(ERRNO_OK, sendCoordinate(port, kPrivateChannel)); + receivePacket(makeDecodedPacket(port, kRemoteNode, NODENUM_BROADCAST, kPrivateChannel)); + } + + TEST_ASSERT_EQUAL_UINT32(kCoordinatePorts.size(), captureRadio->packets.size()); + TEST_ASSERT_EQUAL_UINT32(kCoordinatePorts.size(), captureModule->packets.size()); +} + +static void test_pki_marked_rx_bypasses_event_gate_for_all_coordinate_ports() +{ + for (meshtastic_PortNum port : kCoordinatePorts) { + meshtastic_MeshPacket packet = makeDecodedPacket(port, kRemoteNode, kLocalNode, kEventChannel); + packet.pki_encrypted = true; + receivePacket(packet); + } + + TEST_ASSERT_EQUAL_UINT32(kCoordinatePorts.size(), captureModule->packets.size()); + for (const meshtastic_MeshPacket &packet : captureModule->packets) + TEST_ASSERT_TRUE(packet.pki_encrypted); +} + +#if !(MESHTASTIC_EXCLUDE_PKI) +static void test_tx_event_coordinate_that_uses_pki_reaches_radio() +{ + uint8_t peerPublic[32], peerPrivate[32]; + uint8_t localPublic[32], localPrivate[32]; + crypto->generateKeyPair(peerPublic, peerPrivate); + crypto->generateKeyPair(localPublic, localPrivate); + + config.has_security = true; + config.security.private_key.size = 32; + config.security.public_key.size = 32; + memcpy(config.security.private_key.bytes, localPrivate, 32); + memcpy(config.security.public_key.bytes, localPublic, 32); + crypto->setDHPrivateKey(localPrivate); + testNodeDB->addNode(kPkiPeer, kEventChannel, peerPublic); + + TEST_ASSERT_EQUAL_INT(ERRNO_OK, sendCoordinate(meshtastic_PortNum_WAYPOINT_APP, kEventChannel, kPkiPeer)); + TEST_ASSERT_EQUAL_UINT32(1, captureRadio->packets.size()); + TEST_ASSERT_TRUE(captureRadio->packets.front().pki_encrypted); + TEST_ASSERT_EQUAL(meshtastic_MeshPacket_encrypted_tag, captureRadio->packets.front().which_payload_variant); +} +#endif + +static void test_opaque_tx_and_rx_are_not_misclassified_as_coordinates() +{ + meshtastic_MeshPacket *outgoing = testRouter->allocForSending(); + TEST_ASSERT_NOT_NULL(outgoing); + outgoing->channel = channels.getHash(kEventChannel); + outgoing->which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + outgoing->encrypted.size = 1; + outgoing->encrypted.bytes[0] = 0xa5; + + TEST_ASSERT_EQUAL_INT(ERRNO_OK, testRouter->send(outgoing)); + TEST_ASSERT_EQUAL_UINT32(1, captureRadio->packets.size()); + + meshtastic_MeshPacket opaque = meshtastic_MeshPacket_init_zero; + opaque.from = kRemoteNode; + opaque.to = NODENUM_BROADCAST; + opaque.id = 0x55667788; + opaque.channel = channels.getHash(kEventChannel); + opaque.hop_start = 3; + opaque.hop_limit = 3; + opaque.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + opaque.encrypted.size = 0; + receivePacket(opaque); + + TEST_ASSERT_EQUAL_UINT32(1, captureModule->packets.size()); + TEST_ASSERT_EQUAL(meshtastic_MeshPacket_encrypted_tag, captureModule->packets.front().which_payload_variant); +} + +static void test_capture_endpoints_release_packet_pool_ownership() +{ + constexpr size_t iterations = 64; + for (size_t i = 0; i < iterations; ++i) { + meshtastic_MeshPacket *outgoing = testRouter->allocForSending(); + TEST_ASSERT_NOT_NULL(outgoing); + outgoing->channel = kPrivateChannel; + outgoing->decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; + outgoing->decoded.payload.size = 1; + outgoing->decoded.payload.bytes[0] = static_cast(i); + TEST_ASSERT_EQUAL_INT(ERRNO_OK, testRouter->send(outgoing)); + + meshtastic_MeshPacket incoming = + makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode, NODENUM_BROADCAST, kPrivateChannel); + incoming.id += i; + receivePacket(incoming); + } + + TEST_ASSERT_EQUAL_UINT32(iterations, captureRadio->packets.size()); + TEST_ASSERT_EQUAL_UINT32(iterations, captureModule->packets.size()); +} + +} // namespace + +void setUp(void) +{ + saved.config = config; + saved.moduleConfig = moduleConfig; + saved.channelFile = channelFile; + saved.owner = owner; + saved.myNodeInfo = myNodeInfo; + saved.nodeDB = nodeDB; + saved.router = router; + saved.service = service; + saved.airTime = airTime; + saved.cryptLock = cryptLock; +#if ARCH_PORTDUINO + saved.forceSimRadio = portduino_config.force_simradio; +#endif + + testNodeDB = new TestNodeDB(); + testNodeDB->clearTestNodes(); + nodeDB = testNodeDB; + + memset(&config, 0, sizeof(config)); + config.lora.override_duty_cycle = true; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + memset(&moduleConfig, 0, sizeof(moduleConfig)); + memset(&owner, 0, sizeof(owner)); + memset(&myNodeInfo, 0, sizeof(myNodeInfo)); + myNodeInfo.my_node_num = kLocalNode; + service = nullptr; +#if ARCH_PORTDUINO + portduino_config.force_simradio = false; +#endif + installChannels(); + + testAirTime = new AirTime(); + airTime = testAirTime; + + cryptLock = nullptr; + testRouter = new Router(); + router = testRouter; + std::unique_ptr radio(new CaptureRadio()); + captureRadio = radio.get(); + testRouter->addInterface(std::move(radio)); + captureModule = new CaptureModule(); +} + +void tearDown(void) +{ + delete captureModule; + captureModule = nullptr; + + router = nullptr; + delete testRouter; + testRouter = nullptr; + captureRadio = nullptr; + delete cryptLock; + cryptLock = saved.cryptLock; + + delete testNodeDB; + testNodeDB = nullptr; + delete testAirTime; + testAirTime = nullptr; + + config = saved.config; + moduleConfig = saved.moduleConfig; + channelFile = saved.channelFile; + owner = saved.owner; + myNodeInfo = saved.myNodeInfo; + channels.onConfigChanged(); + nodeDB = saved.nodeDB; + router = saved.router; + service = saved.service; + airTime = saved.airTime; +#if ARCH_PORTDUINO + portduino_config.force_simradio = saved.forceSimRadio; +#endif +} + +EVENT_ROUTER_TEST_ENTRY void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + + printf("\n=== Router event-channel coordinate enforcement ===\n"); + RUN_TEST(test_tx_event_channel_enforces_compile_time_policy_for_all_coordinate_ports); + RUN_TEST(test_rx_event_channel_enforces_compile_time_policy_for_all_coordinate_ports); + RUN_TEST(test_private_channel_preserves_legacy_tx_and_rx_for_all_coordinate_ports); + RUN_TEST(test_pki_marked_rx_bypasses_event_gate_for_all_coordinate_ports); +#if !(MESHTASTIC_EXCLUDE_PKI) + RUN_TEST(test_tx_event_coordinate_that_uses_pki_reaches_radio); +#endif + RUN_TEST(test_opaque_tx_and_rx_are_not_misclassified_as_coordinates); + RUN_TEST(test_capture_endpoints_release_packet_pool_ownership); + + exit(UNITY_END()); +} + +EVENT_ROUTER_TEST_ENTRY void loop() {} From 0f58722c607d816546552a553624f38e87442737 Mon Sep 17 00:00:00 2001 From: ayysasha <19575937+ayysasha@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:30:09 -0400 Subject: [PATCH 09/12] Test PhoneAPI event coordinate retry handling Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../test_main.cpp | 255 ++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 test/test_event_channel_phone_api/test_main.cpp diff --git a/test/test_event_channel_phone_api/test_main.cpp b/test/test_event_channel_phone_api/test_main.cpp new file mode 100644 index 00000000000..d0102cb1f1b --- /dev/null +++ b/test/test_event_channel_phone_api/test_main.cpp @@ -0,0 +1,255 @@ +#include "Channels.h" +#include "MeshService.h" +#include "NodeDB.h" +#include "RadioInterface.h" +#include "Router.h" +#include "StreamAPI.h" +#include "TestUtil.h" +#include "mesh-pb-constants.h" +#include +#include +#include +#include + +namespace +{ +constexpr PacketId BLOCKED_PACKET_ID = 0x10203040; +constexpr PacketId FOLLOWUP_PACKET_ID = 0x50607080; +constexpr ChannelIndex EVENT_CHANNEL = 0; +constexpr ChannelIndex PRIVATE_CHANNEL = 1; +constexpr NodeNum REMOTE_NODE = 0x12345678; + +class MockRadioInterface : public RadioInterface +{ + public: + ErrorCode send(meshtastic_MeshPacket *packet) override + { + packetPool.release(packet); + return ERRNO_OK; + } + + uint32_t getPacketTime(uint32_t, bool) override { return 0; } +}; + +class MockRouter : public Router +{ + public: + MockRouter() { addInterface(std::make_unique()); } + + ~MockRouter() + { + delete cryptLock; + cryptLock = nullptr; + } + + ErrorCode send(meshtastic_MeshPacket *packet) override + { + sentPackets.push_back(*packet); + packetPool.release(packet); + return ERRNO_OK; + } + + std::vector sentPackets; +}; + +class MockMeshService : public MeshService +{ + public: + ~MockMeshService() + { + while (auto *status = getQueueStatusForPhone()) { + releaseQueueStatusToPool(status); + } + } + + void sendClientNotification(meshtastic_ClientNotification *notification) override + { + notifications.push_back(*notification); + releaseClientNotificationToPool(notification); + } + + void assertQueueStatus(PacketId packetId) + { + auto *status = getQueueStatusForPhone(); + TEST_ASSERT_NOT_NULL(status); + TEST_ASSERT_EQUAL_UINT32(packetId, status->mesh_packet_id); + releaseQueueStatusToPool(status); + } + + std::vector notifications; +}; + +class TestStreamAPI : public StreamAPI +{ + public: + TestStreamAPI() : StreamAPI(nullptr) {} + bool checkIsConnected() override { return true; } +}; + +struct GlobalState { + MeshService *service; + Router *router; + NodeDB *nodeDB; + Channels channels; + meshtastic_ChannelFile channelFile; + meshtastic_LocalConfig config; + meshtastic_LocalModuleConfig moduleConfig; + meshtastic_DeviceState deviceState; +}; + +GlobalState *savedState; +MockMeshService *mockService; +MockRouter *mockRouter; +NodeDB *mockNodeDB; +TestStreamAPI *streamAPI; + +void configureChannels() +{ + const meshtastic_ChannelFile defaultChannelFile = meshtastic_ChannelFile_init_default; + channelFile = defaultChannelFile; + channelFile.channels_count = 2; + + auto &eventChannel = channelFile.channels[EVENT_CHANNEL]; + eventChannel.index = EVENT_CHANNEL; + eventChannel.has_settings = true; + eventChannel.role = meshtastic_Channel_Role_PRIMARY; + strcpy(eventChannel.settings.name, "everyone"); +#ifdef USERPREFS_CHANNEL_0_PSK + static const uint8_t eventPsk[] = USERPREFS_CHANNEL_0_PSK; + eventChannel.settings.psk.size = sizeof(eventPsk); + memcpy(eventChannel.settings.psk.bytes, eventPsk, sizeof(eventPsk)); +#endif + + auto &privateChannel = channelFile.channels[PRIVATE_CHANNEL]; + privateChannel.index = PRIVATE_CHANNEL; + privateChannel.has_settings = true; + privateChannel.role = meshtastic_Channel_Role_SECONDARY; + strcpy(privateChannel.settings.name, "private"); + privateChannel.settings.psk.size = 32; + memset(privateChannel.settings.psk.bytes, 0xab, privateChannel.settings.psk.size); + + channels.onConfigChanged(); +} + +meshtastic_ToRadio makePositionToRadio(PacketId id, ChannelIndex channel) +{ + meshtastic_ToRadio message = meshtastic_ToRadio_init_default; + const meshtastic_MeshPacket defaultPacket = meshtastic_MeshPacket_init_default; + message.which_payload_variant = meshtastic_ToRadio_packet_tag; + message.packet = defaultPacket; + message.packet.to = REMOTE_NODE; + message.packet.id = id; + message.packet.channel = channel; + message.packet.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + message.packet.decoded.portnum = meshtastic_PortNum_POSITION_APP; + return message; +} + +bool sendToRadio(const meshtastic_ToRadio &message) +{ + uint8_t encoded[meshtastic_ToRadio_size] = {}; + const size_t encodedSize = + pb_encode_to_bytes(encoded, sizeof(encoded), &meshtastic_ToRadio_msg, const_cast(&message)); + if (encodedSize == 0) { + return false; + } + return streamAPI->handleToRadio(encoded, encodedSize); +} + +void assertSentPacket(size_t index, PacketId id, ChannelIndex channel) +{ + TEST_ASSERT_GREATER_THAN(index, mockRouter->sentPackets.size()); + const auto &packet = mockRouter->sentPackets[index]; + TEST_ASSERT_EQUAL_UINT32(id, packet.id); + TEST_ASSERT_EQUAL_UINT8(channel, packet.channel); + TEST_ASSERT_EQUAL(meshtastic_PortNum_POSITION_APP, packet.decoded.portnum); +} +} // namespace + +void setUp(void) +{ + savedState = new GlobalState{service, router, nodeDB, channels, channelFile, config, moduleConfig, devicestate}; + + service = mockService = new MockMeshService(); + nodeDB = mockNodeDB = new NodeDB(); + myNodeInfo.my_node_num = 0x87654321; + configureChannels(); + router = mockRouter = new MockRouter(); + streamAPI = new TestStreamAPI(); + testDelay(1); +} + +void tearDown(void) +{ + delete streamAPI; + streamAPI = nullptr; + delete mockRouter; + mockRouter = nullptr; + delete mockNodeDB; + mockNodeDB = nullptr; + delete mockService; + mockService = nullptr; + + service = savedState->service; + router = savedState->router; + nodeDB = savedState->nodeDB; + channels = savedState->channels; + channelFile = savedState->channelFile; + config = savedState->config; + moduleConfig = savedState->moduleConfig; + devicestate = savedState->deviceState; + delete savedState; + savedState = nullptr; +} + +static void test_event_position_ingress_does_not_poison_retry_state() +{ + const auto eventAttempt = makePositionToRadio(BLOCKED_PACKET_ID, EVENT_CHANNEL); + const auto sameIdPrivateRetry = makePositionToRadio(BLOCKED_PACKET_ID, PRIVATE_CHANNEL); + const auto immediatePrivateFollowup = makePositionToRadio(FOLLOWUP_PACKET_ID, PRIVATE_CHANNEL); + +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && defined(USERPREFS_CHANNEL_0_PSK) + TEST_ASSERT_FALSE(sendToRadio(eventAttempt)); + TEST_ASSERT_EQUAL(0, mockRouter->sentPackets.size()); + mockService->assertQueueStatus(BLOCKED_PACKET_ID); + TEST_ASSERT_EQUAL(1, mockService->notifications.size()); + TEST_ASSERT_EQUAL_UINT32(BLOCKED_PACKET_ID, mockService->notifications[0].reply_id); + + TEST_ASSERT_TRUE(sendToRadio(sameIdPrivateRetry)); + TEST_ASSERT_EQUAL(1, mockRouter->sentPackets.size()); + assertSentPacket(0, BLOCKED_PACKET_ID, PRIVATE_CHANNEL); + mockService->assertQueueStatus(BLOCKED_PACKET_ID); + + TEST_ASSERT_FALSE(sendToRadio(immediatePrivateFollowup)); + TEST_ASSERT_EQUAL(1, mockRouter->sentPackets.size()); + mockService->assertQueueStatus(FOLLOWUP_PACKET_ID); + TEST_ASSERT_EQUAL(1, mockService->notifications.size()); +#else + TEST_ASSERT_TRUE(sendToRadio(eventAttempt)); + TEST_ASSERT_EQUAL(1, mockRouter->sentPackets.size()); + assertSentPacket(0, BLOCKED_PACKET_ID, EVENT_CHANNEL); + mockService->assertQueueStatus(BLOCKED_PACKET_ID); + TEST_ASSERT_EQUAL(0, mockService->notifications.size()); + + TEST_ASSERT_FALSE(sendToRadio(sameIdPrivateRetry)); + TEST_ASSERT_EQUAL(1, mockRouter->sentPackets.size()); + TEST_ASSERT_NULL(mockService->getQueueStatusForPhone()); + + TEST_ASSERT_FALSE(sendToRadio(immediatePrivateFollowup)); + TEST_ASSERT_EQUAL(1, mockRouter->sentPackets.size()); + mockService->assertQueueStatus(FOLLOWUP_PACKET_ID); + TEST_ASSERT_EQUAL(0, mockService->notifications.size()); +#endif +} + +extern "C" { +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_event_position_ingress_does_not_poison_retry_state); + exit(UNITY_END()); +} + +void loop() {} +} From 0e66177f7fbbbfc7213927292608eb4ee62b22e2 Mon Sep 17 00:00:00 2001 From: ayysasha <19575937+ayysasha@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:30:41 -0400 Subject: [PATCH 10/12] Test reliable event coordinate suppression Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- test/test_nexthop_routing/test_main.cpp | 331 +++++++++++++++++++++++- 1 file changed, 330 insertions(+), 1 deletion(-) diff --git a/test/test_nexthop_routing/test_main.cpp b/test/test_nexthop_routing/test_main.cpp index b3964eb2502..0cc4c26aa5d 100644 --- a/test/test_nexthop_routing/test_main.cpp +++ b/test/test_nexthop_routing/test_main.cpp @@ -11,13 +11,19 @@ #include "TestUtil.h" #include +#include "airtime.h" #include "configuration.h" #include "gps/RTC.h" #include "mesh/NextHopRouter.h" #include "mesh/NodeDB.h" +#include "mesh/ReliableRouter.h" +#include "modules/RoutingModule.h" #include #include +#include #include +#include +#include #define MSG_BUF_LEN 200 #define TEST_MSG_FMT(fmt, ...) \ @@ -28,6 +34,13 @@ } while (0) static constexpr NodeNum kLocalNode = 0x11111111; // last byte 0x11 +static constexpr NodeNum kRemoteNode = 0x22222222; + +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && defined(USERPREFS_CHANNEL_0_PSK) +static constexpr bool kEventPolicyEnabled = true; +#else +static constexpr bool kEventPolicyEnabled = false; +#endif // --------------------------------------------------------------------------- // MockNodeDB - inject nodes with controlled last byte, hop distance, age, role, favorite flag. @@ -89,6 +102,15 @@ class NextHopRouterTestShim : public NextHopRouter using NextHopRouter::noteRouteSuccess; using Router::shouldDecrementHopLimit; // protected in Router + bool filterViaFlooding(const meshtastic_MeshPacket *p) { return FloodingRouter::shouldFilterReceived(p); } + bool filterViaNextHop(const meshtastic_MeshPacket *p) { return NextHopRouter::shouldFilterReceived(p); } + + void clearPendingForTest() + { + while (!pending.empty()) + stopRetransmission(pending.begin()->first); + } + void resetRouteHealthForTest() { for (auto &h : routeHealth) @@ -96,8 +118,113 @@ class NextHopRouterTestShim : public NextHopRouter } }; +class CaptureRadioInterface : public RadioInterface +{ + public: + ErrorCode send(meshtastic_MeshPacket *p) override + { + sentPackets.push_back(*p); + packetPool.release(p); + return ERRNO_OK; + } + + bool cancelSending(NodeNum from, PacketId id) override + { + (void)from; + (void)id; + cancelCount++; + return false; + } + + bool findInTxQueue(NodeNum from, PacketId id) override + { + (void)from; + (void)id; + return false; + } + + uint32_t getPacketTime(uint32_t totalPacketLen, bool received = false) override + { + (void)totalPacketLen; + (void)received; + return 0; + } + + void reset() + { + sentPackets.clear(); + cancelCount = 0; + } + + std::vector sentPackets; + uint32_t cancelCount = 0; +}; + +class ReliableRouterTestShim : public ReliableRouter +{ + public: + ReliableRouterTestShim() : ReliableRouter() {} + + size_t pendingCount() const { return pending.size(); } + + void seedRetry(const meshtastic_MeshPacket &p, uint8_t attempts) + { + auto *copy = packetPool.allocCopy(p); + TEST_ASSERT_NOT_NULL(copy); + startRetransmission(copy, attempts); + } + + void makeRetryDue(NodeNum from, PacketId id) + { + PendingPacket *record = findPendingPacket(from, id); + TEST_ASSERT_NOT_NULL(record); + record->nextTxMsec = 0; + } + + int32_t runDueRetries() { return doRetransmissions(); } + void sniffForTest(const meshtastic_MeshPacket *p, const meshtastic_Routing *routing) + { + ReliableRouter::sniffReceived(p, routing); + } + + void clearPendingForTest() + { + while (!pending.empty()) + stopRetransmission(pending.begin()->first); + } +}; + +class MockRoutingModule : public RoutingModule +{ + public: + void sendAckNak(meshtastic_Routing_Error err, NodeNum to, PacketId idFrom, ChannelIndex chIndex, uint8_t hopLimit = 0, + bool ackWantsAck = false) override + { + ackNaks.emplace_back(err, to, idFrom, chIndex, hopLimit, ackWantsAck); + } + + std::list> ackNaks; +}; + +class ScopedAirTimeFixture +{ + public: + ScopedAirTimeFixture() : previous(airTime) { airTime = &instance; } + ~ScopedAirTimeFixture() { airTime = previous; } + + private: + AirTime instance; + AirTime *previous; +}; + static MockNodeDB *mockNodeDB = nullptr; static NextHopRouterTestShim *shim = nullptr; +static ReliableRouterTestShim *reliableShim = nullptr; +static CaptureRadioInterface *nextHopRadio = nullptr; +static CaptureRadioInterface *reliableRadio = nullptr; +static MockRoutingModule *mockRoutingModule = nullptr; +static std::unique_ptr airTimeFixture; +static PacketId nextBehaviorPacketId = 0x70000000; static constexpr uint32_t TTL = NextHopRouter::ROUTE_TTL_MSEC; static constexpr uint8_t THRESH = NextHopRouter::ROUTE_FAILURE_THRESHOLD; @@ -114,12 +241,83 @@ static meshtastic_MeshPacket makeRelayedPacket(uint8_t relay, uint8_t hopsAway) return p; } +static meshtastic_Channel makeBehaviorChannel(meshtastic_Channel_Role role, const char *name) +{ + meshtastic_Channel channel = meshtastic_Channel_init_default; + channel.has_settings = true; + channel.role = role; + channel.settings.has_module_settings = true; + channel.settings.module_settings.position_precision = 16; + strcpy(channel.settings.name, name); + return channel; +} + +static void configureBehaviorChannels() +{ + memset(&channelFile, 0, sizeof(channelFile)); + channelFile.channels_count = 2; + + meshtastic_Channel eventChannel = makeBehaviorChannel(meshtastic_Channel_Role_PRIMARY, "everyone"); + eventChannel.index = 0; +#ifdef USERPREFS_CHANNEL_0_PSK + static const uint8_t eventPsk[] = USERPREFS_CHANNEL_0_PSK; + eventChannel.settings.psk.size = sizeof(eventPsk); + memcpy(eventChannel.settings.psk.bytes, eventPsk, sizeof(eventPsk)); +#endif + + meshtastic_Channel privateChannel = makeBehaviorChannel(meshtastic_Channel_Role_SECONDARY, "private"); + privateChannel.index = 1; + privateChannel.settings.psk.size = 32; + memset(privateChannel.settings.psk.bytes, 0xAB, privateChannel.settings.psk.size); + + channelFile.channels[0] = eventChannel; + channelFile.channels[1] = privateChannel; + channels.onConfigChanged(); +} + +static meshtastic_MeshPacket makeBehaviorPacket(meshtastic_PortNum portnum, NodeNum from, NodeNum to, uint8_t channel, + bool wantAck = false) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = from; + p.to = to; + p.id = nextBehaviorPacketId++; + p.channel = channel; + p.hop_start = 3; + p.hop_limit = 3; + p.relay_node = 0x22; + p.next_hop = NO_NEXT_HOP_PREFERENCE; + p.want_ack = wantAck; + p.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; + p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + p.decoded.portnum = portnum; + return p; +} + +static meshtastic_MeshPacket *allocBehaviorPacket(meshtastic_PortNum portnum, NodeNum to, uint8_t channel, bool wantAck) +{ + auto packet = makeBehaviorPacket(portnum, kLocalNode, to, channel, wantAck); + auto *allocated = packetPool.allocCopy(packet); + TEST_ASSERT_NOT_NULL(allocated); + return allocated; +} + void setUp(void) { myNodeInfo.my_node_num = kLocalNode; config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + config.device.rebroadcast_mode = meshtastic_Config_DeviceConfig_RebroadcastMode_ALL; + config.lora.override_duty_cycle = true; + config.security.private_key.size = 0; + owner.is_licensed = false; mockNodeDB->clearTestNodes(); shim->resetRouteHealthForTest(); + shim->clearPendingForTest(); + reliableShim->clearPendingForTest(); + nextHopRadio->reset(); + reliableRadio->reset(); + mockRoutingModule->ackNaks.clear(); + configureBehaviorChannels(); } void tearDown(void) {} @@ -409,6 +607,113 @@ void test_hoplimit_decrement_when_resolved_not_favorite(void) TEST_ASSERT_TRUE(shim->shouldDecrementHopLimit(&p)); // unique but not a favorite -> decrement } +// =========================================================================== +// Group 5 - event-coordinate routing behavior +// =========================================================================== + +void test_eventPolicy_reliableOriginSendSuppressesTxAndPending(void) +{ + ErrorCode result = reliableShim->send( + allocBehaviorPacket(meshtastic_PortNum_WAYPOINT_APP, NODENUM_BROADCAST, /*event channel=*/0, /*wantAck=*/true)); + + if (kEventPolicyEnabled) { + TEST_ASSERT_EQUAL_INT(meshtastic_Routing_Error_NOT_AUTHORIZED, result); + TEST_ASSERT_EQUAL_UINT32(0, reliableRadio->sentPackets.size()); + TEST_ASSERT_EQUAL_UINT32(0, reliableShim->pendingCount()); + } else { + TEST_ASSERT_EQUAL_INT(ERRNO_OK, result); + TEST_ASSERT_EQUAL_UINT32(1, reliableRadio->sentPackets.size()); + TEST_ASSERT_EQUAL_UINT32(1, reliableShim->pendingCount()); + } +} + +void test_eventPolicy_reliablePrivateCoordinateStillSends(void) +{ + ErrorCode result = reliableShim->send( + allocBehaviorPacket(meshtastic_PortNum_WAYPOINT_APP, NODENUM_BROADCAST, /*private channel=*/1, /*wantAck=*/true)); + + TEST_ASSERT_EQUAL_INT(ERRNO_OK, result); + TEST_ASSERT_EQUAL_UINT32(1, reliableRadio->sentPackets.size()); + TEST_ASSERT_EQUAL_UINT32(1, reliableShim->pendingCount()); +} + +void test_eventPolicy_floodingDuplicateSuppressesCoordinateButRelaysText(void) +{ + mockNodeDB->addNode(kRemoteNode, 0, true, 0); + auto coordinate = makeBehaviorPacket(meshtastic_PortNum_WAYPOINT_APP, kRemoteNode, NODENUM_BROADCAST, 0); + TEST_ASSERT_FALSE(shim->filterViaFlooding(&coordinate)); + TEST_ASSERT_TRUE(shim->filterViaFlooding(&coordinate)); + TEST_ASSERT_EQUAL_UINT32(kEventPolicyEnabled ? 0 : 1, nextHopRadio->sentPackets.size()); + + nextHopRadio->reset(); + auto text = makeBehaviorPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode, NODENUM_BROADCAST, 0); + TEST_ASSERT_FALSE(shim->filterViaFlooding(&text)); + TEST_ASSERT_TRUE(shim->filterViaFlooding(&text)); + TEST_ASSERT_EQUAL_UINT32(1, nextHopRadio->sentPackets.size()); +} + +void test_eventPolicy_nextHopDuplicateSuppressesEventButRelaysPrivateCoordinate(void) +{ + mockNodeDB->addNode(kRemoteNode, 0, true, 0); + auto eventCoordinate = makeBehaviorPacket(meshtastic_PortNum_WAYPOINT_APP, kRemoteNode, NODENUM_BROADCAST, 0); + TEST_ASSERT_FALSE(shim->filterViaNextHop(&eventCoordinate)); + TEST_ASSERT_TRUE(shim->filterViaNextHop(&eventCoordinate)); + TEST_ASSERT_EQUAL_UINT32(kEventPolicyEnabled ? 0 : 1, nextHopRadio->sentPackets.size()); + + nextHopRadio->reset(); + auto privateCoordinate = makeBehaviorPacket(meshtastic_PortNum_WAYPOINT_APP, kRemoteNode, NODENUM_BROADCAST, 1); + TEST_ASSERT_FALSE(shim->filterViaNextHop(&privateCoordinate)); + TEST_ASSERT_TRUE(shim->filterViaNextHop(&privateCoordinate)); + TEST_ASSERT_EQUAL_UINT32(1, nextHopRadio->sentPackets.size()); +} + +void test_eventPolicy_repeatedLocalPacketSuppressesCoordinateAckButKeepsTextAck(void) +{ + mockNodeDB->addNode(kRemoteNode, 0, true, 0); + auto coordinate = makeBehaviorPacket(meshtastic_PortNum_WAYPOINT_APP, kRemoteNode, kLocalNode, 0, /*wantAck=*/true); + TEST_ASSERT_FALSE(shim->filterViaNextHop(&coordinate)); + TEST_ASSERT_TRUE(shim->filterViaNextHop(&coordinate)); + TEST_ASSERT_EQUAL_UINT32(kEventPolicyEnabled ? 0 : 1, mockRoutingModule->ackNaks.size()); + + mockRoutingModule->ackNaks.clear(); + auto text = makeBehaviorPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode, kLocalNode, 0, /*wantAck=*/true); + TEST_ASSERT_FALSE(shim->filterViaNextHop(&text)); + TEST_ASSERT_TRUE(shim->filterViaNextHop(&text)); + TEST_ASSERT_EQUAL_UINT32(1, mockRoutingModule->ackNaks.size()); + const auto &ack = mockRoutingModule->ackNaks.front(); + TEST_ASSERT_EQUAL_INT(meshtastic_Routing_Error_NONE, std::get<0>(ack)); + TEST_ASSERT_EQUAL_HEX32(kRemoteNode, std::get<1>(ack)); + TEST_ASSERT_EQUAL_HEX32(text.id, std::get<2>(ack)); +} + +void test_eventPolicy_seededRetrySuppressesTxUntilGateOff(void) +{ + auto coordinate = makeBehaviorPacket(meshtastic_PortNum_WAYPOINT_APP, kLocalNode, NODENUM_BROADCAST, 0, /*wantAck=*/true); + reliableShim->seedRetry(coordinate, /*attempts=*/2); + reliableShim->makeRetryDue(kLocalNode, coordinate.id); + + reliableShim->runDueRetries(); + + TEST_ASSERT_EQUAL_UINT32(kEventPolicyEnabled ? 0 : 1, reliableRadio->sentPackets.size()); + TEST_ASSERT_EQUAL_UINT32(1, reliableShim->pendingCount()); +} + +void test_reliableAckStopsNormalPendingTransmission(void) +{ + auto original = makeBehaviorPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode, kRemoteNode, 1, /*wantAck=*/true); + reliableShim->seedRetry(original, NextHopRouter::NUM_RELIABLE_RETX); + TEST_ASSERT_EQUAL_UINT32(1, reliableShim->pendingCount()); + + auto ack = makeBehaviorPacket(meshtastic_PortNum_ROUTING_APP, kRemoteNode, kLocalNode, 1); + ack.decoded.request_id = original.id; + meshtastic_Routing routing = meshtastic_Routing_init_zero; + routing.error_reason = meshtastic_Routing_Error_NONE; + + reliableShim->sniffForTest(&ack, &routing); + + TEST_ASSERT_EQUAL_UINT32(0, reliableShim->pendingCount()); +} + // =========================================================================== void setup() @@ -416,10 +721,23 @@ void setup() initializeTestEnvironment(); UNITY_BEGIN(); + airTimeFixture = std::make_unique(); mockNodeDB = new MockNodeDB(); shim = new NextHopRouterTestShim(); + reliableShim = new ReliableRouterTestShim(); nodeDB = mockNodeDB; + auto nextRadio = std::make_unique(); + nextHopRadio = nextRadio.get(); + shim->addInterface(std::move(nextRadio)); + + auto reliableCapture = std::make_unique(); + reliableRadio = reliableCapture.get(); + reliableShim->addInterface(std::move(reliableCapture)); + + mockRoutingModule = new MockRoutingModule(); + routingModule = mockRoutingModule; + printf("\n=== resolveLastByte (M1) ===\n"); RUN_TEST(test_resolve_none_when_empty); RUN_TEST(test_resolve_zero_byte_is_none); @@ -459,7 +777,18 @@ void setup() RUN_TEST(test_hoplimit_decrement_on_colliding_favorites); RUN_TEST(test_hoplimit_decrement_when_resolved_not_favorite); - exit(UNITY_END()); + printf("\n=== event-coordinate routing behavior ===\n"); + RUN_TEST(test_eventPolicy_reliableOriginSendSuppressesTxAndPending); + RUN_TEST(test_eventPolicy_reliablePrivateCoordinateStillSends); + RUN_TEST(test_eventPolicy_floodingDuplicateSuppressesCoordinateButRelaysText); + RUN_TEST(test_eventPolicy_nextHopDuplicateSuppressesEventButRelaysPrivateCoordinate); + RUN_TEST(test_eventPolicy_repeatedLocalPacketSuppressesCoordinateAckButKeepsTextAck); + RUN_TEST(test_eventPolicy_seededRetrySuppressesTxUntilGateOff); + RUN_TEST(test_reliableAckStopsNormalPendingTransmission); + + int result = UNITY_END(); + airTimeFixture.reset(); + exit(result); } void loop() {} From 5274efefc6b7370f6dc2e1b526bc6970f55e8e5a Mon Sep 17 00:00:00 2001 From: ayysasha <19575937+ayysasha@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:31:12 -0400 Subject: [PATCH 11/12] Test MQTT event coordinate suppression Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- test/test_mqtt/MQTT.cpp | 124 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/test/test_mqtt/MQTT.cpp b/test/test_mqtt/MQTT.cpp index 3716e0a206f..8a265b497eb 100644 --- a/test/test_mqtt/MQTT.cpp +++ b/test/test_mqtt/MQTT.cpp @@ -329,6 +329,53 @@ const meshtastic_MeshPacket encrypted = { .encrypted = {.size = 0}, .id = 3, }; + +void configureCoordinatePolicyChannels(bool eventChannelIsPrimary = true) +{ + memset(&channelFile, 0, sizeof(channelFile)); + channelFile.channels_count = 2; + + auto &eventChannel = channelFile.channels[0]; + eventChannel.index = 0; + eventChannel.has_settings = true; + strcpy(eventChannel.settings.name, "everyone"); + eventChannel.settings.uplink_enabled = true; + eventChannel.settings.downlink_enabled = true; + eventChannel.role = eventChannelIsPrimary ? meshtastic_Channel_Role_PRIMARY : meshtastic_Channel_Role_SECONDARY; +#ifdef USERPREFS_CHANNEL_0_PSK + static const uint8_t configuredEventPsk[] = USERPREFS_CHANNEL_0_PSK; + eventChannel.settings.psk.size = sizeof(configuredEventPsk); + memcpy(eventChannel.settings.psk.bytes, configuredEventPsk, sizeof(configuredEventPsk)); +#endif + + auto &privateChannel = channelFile.channels[1]; + privateChannel.index = 1; + privateChannel.has_settings = true; + strcpy(privateChannel.settings.name, "private"); + privateChannel.settings.psk.size = 32; + memset(privateChannel.settings.psk.bytes, 0xab, privateChannel.settings.psk.size); + privateChannel.settings.uplink_enabled = true; + privateChannel.settings.downlink_enabled = true; + privateChannel.role = eventChannelIsPrimary ? meshtastic_Channel_Role_SECONDARY : meshtastic_Channel_Role_PRIMARY; + + channels.onConfigChanged(); +} + +meshtastic_MeshPacket makePositionPacket(ChannelIndex channel) +{ + meshtastic_MeshPacket packet = decoded; + packet.to = NODENUM_BROADCAST; + packet.channel = channel; + packet.decoded.portnum = meshtastic_PortNum_POSITION_APP; + return packet; +} + +void clearPublicationState() +{ + TEST_ASSERT_EQUAL(0, unitTest->queueSize()); + pubsub->published_.clear(); + mockMeshService->messages_.clear(); +} } // namespace // Initialize mocks and configuration before running each test. @@ -338,6 +385,7 @@ void setUp(void) meshtastic_ModuleConfig_MQTTConfig{.enabled = true, .map_reporting_enabled = true, .has_map_report_settings = true}; moduleConfig.mqtt.map_report_settings = meshtastic_ModuleConfig_MapReportSettings{ .publish_interval_secs = 0, .position_precision = 14, .should_report_location = true}; + memset(&channelFile, 0, sizeof(channelFile)); channelFile.channels[0] = meshtastic_Channel{ .index = 0, .has_settings = true, @@ -345,6 +393,7 @@ void setUp(void) .role = meshtastic_Channel_Role_PRIMARY, }; channelFile.channels_count = 1; + channels.onConfigChanged(); owner = meshtastic_User{.id = "!12345678"}; myNodeInfo = meshtastic_MyNodeInfo{.my_node_num = 0x12345678}; // Match the expected gateway ID in topic localPosition = @@ -402,6 +451,50 @@ void test_sendDirectlyConnectedEncrypted(void) TEST_ASSERT_EQUAL(encrypted.id, env.packet->id); } +void test_eventPositionPublicationFollowsCompileTimePolicy(void) +{ + configureCoordinatePolicyChannels(); + clearPublicationState(); + const meshtastic_MeshPacket position = makePositionPacket(0); + + mqtt->onSend(encrypted, position, 0); + +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && defined(USERPREFS_CHANNEL_0_PSK) + TEST_ASSERT_TRUE(pubsub->published_.empty()); + TEST_ASSERT_EQUAL(0, unitTest->queueSize()); +#else + TEST_ASSERT_EQUAL(1, pubsub->published_.size()); +#endif +} + +void test_privatePositionStillPublishesWithEventPolicy(void) +{ + configureCoordinatePolicyChannels(); + clearPublicationState(); + const meshtastic_MeshPacket position = makePositionPacket(1); + + mqtt->onSend(encrypted, position, 1); + + TEST_ASSERT_EQUAL(1, pubsub->published_.size()); + TEST_ASSERT_EQUAL_STRING("msh/2/e/private/!12345678", pubsub->published_.front().first.c_str()); +} + +void test_explicitPkiPositionStillPublishesWithEventPolicy(void) +{ + configureCoordinatePolicyChannels(); + clearPublicationState(); + meshtastic_MeshPacket position = makePositionPacket(0); + meshtastic_MeshPacket encryptedPki = encrypted; + position.to = 2; + position.pki_encrypted = true; + encryptedPki.pki_encrypted = true; + + mqtt->onSend(encryptedPki, position, 0); + + TEST_ASSERT_EQUAL(1, pubsub->published_.size()); + TEST_ASSERT_EQUAL_STRING("msh/2/e/PKI/!12345678", pubsub->published_.front().first.c_str()); +} + // Verify that the decoded MeshPacket is proxied through the MeshService when encryption_enabled = false. void test_proxyToMeshServiceDecoded(void) { @@ -822,6 +915,32 @@ void test_reportToMapDefaultImprecise(void) TEST_ASSERT_EQUAL_STRING("msh/2/map/", topic.c_str()); } +void test_eventPrimaryMapReportFollowsCompileTimePolicy(void) +{ + configureCoordinatePolicyChannels(); + clearPublicationState(); + + unitTest->reportToMap(); + +#if USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL && defined(USERPREFS_CHANNEL_0_PSK) + TEST_ASSERT_TRUE(pubsub->published_.empty()); + TEST_ASSERT_EQUAL(0, unitTest->queueSize()); +#else + TEST_ASSERT_EQUAL(1, pubsub->published_.size()); +#endif +} + +void test_privatePrimaryMapReportStillPublishesWithEventPolicy(void) +{ + configureCoordinatePolicyChannels(false); + clearPublicationState(); + + unitTest->reportToMap(); + + TEST_ASSERT_EQUAL(1, pubsub->published_.size()); + TEST_ASSERT_EQUAL_STRING("msh/2/map/", pubsub->published_.front().first.c_str()); +} + // Location is sent over the phone proxy. void test_reportToMapImpreciseProxied(void) { @@ -1039,6 +1158,9 @@ void setup() UNITY_BEGIN(); RUN_TEST(test_sendDirectlyConnectedDecoded); RUN_TEST(test_sendDirectlyConnectedEncrypted); + RUN_TEST(test_eventPositionPublicationFollowsCompileTimePolicy); + RUN_TEST(test_privatePositionStillPublishesWithEventPolicy); + RUN_TEST(test_explicitPkiPositionStillPublishesWithEventPolicy); RUN_TEST(test_proxyToMeshServiceDecoded); RUN_TEST(test_proxyToMeshServiceEncrypted); RUN_TEST(test_dontMqttMeOnPublicServer); @@ -1070,6 +1192,8 @@ void setup() RUN_TEST(test_publishTextMessageDirect); RUN_TEST(test_publishTextMessageWithProxy); RUN_TEST(test_reportToMapDefaultImprecise); + RUN_TEST(test_eventPrimaryMapReportFollowsCompileTimePolicy); + RUN_TEST(test_privatePrimaryMapReportStillPublishesWithEventPolicy); RUN_TEST(test_reportToMapImpreciseProxied); RUN_TEST(test_usingDefaultServer); RUN_TEST(test_usingDefaultServerWithPort); From 470c40b80c14234a867aa56f0c0df25f9f7a5c49 Mon Sep 17 00:00:00 2001 From: ayysasha <19575937+ayysasha@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:31:42 -0400 Subject: [PATCH 12/12] Run event policy behavioral suites in native CI Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- test/native-suite-count | 2 +- variants/native/portduino/platformio.ini | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/test/native-suite-count b/test/native-suite-count index 8f92bfdd497..a2720097dcc 100644 --- a/test/native-suite-count +++ b/test/native-suite-count @@ -1 +1 @@ -35 +39 diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini index 71f06c5b96e..366456796af 100644 --- a/variants/native/portduino/platformio.ini +++ b/variants/native/portduino/platformio.ini @@ -128,7 +128,12 @@ extends = env:coverage build_flags = ${env:coverage.build_flags} -DUSERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL=1 -DUSERPREFS_CHANNEL_0_PSK='{0x00,0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x08,0x09,0x0a,0x0b,0x0c,0x0d,0x0e,0x0f}' -test_filter = test_position_precision +test_filter = + test_position_precision + test_event_channel_router + test_nexthop_routing + test_event_channel_phone_api + test_mqtt ; --------------------------------------------------------------------------- ; Native build for macOS (Darwin / arm64 + x86_64). Headless meshtasticd that