diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 6fbb0f7428..c68d318f76 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -62,6 +62,14 @@ #define CMD_SET_DEFAULT_FLOOD_SCOPE 63 #define CMD_GET_DEFAULT_FLOOD_SCOPE 64 #define CMD_SEND_RAW_PACKET 65 +#define CMD_SET_CLIENT_REPEAT_MODE 66 // non-radio-params variant; SELECTIVE bypasses isValidClientRepeatFreq + +#define CLIENT_REPEAT_OFF 0 +#define CLIENT_REPEAT_ALL 1 +#define CLIENT_REPEAT_SELECTIVE 2 + +// Existing ContactInfo.flags bit; doubles as the SELECTIVE allowlist gate. +#define FLAG_FAVOURITE 0x01 // Stats sub-types for CMD_GET_STATS #define STATS_TYPE_CORE 0 @@ -480,7 +488,84 @@ bool MyMesh::filterRecvFloodPacket(mesh::Packet* packet) { } bool MyMesh::allowPacketForward(const mesh::Packet* packet) { - return _prefs.client_repeat != 0; + switch (_prefs.client_repeat) { + case CLIENT_REPEAT_OFF: + return false; + case CLIENT_REPEAT_ALL: + return true; + case CLIENT_REPEAT_SELECTIVE: + // A DIRECT-routed packet only reaches allowPacketForward() after the core + // (Mesh::routeRecvPacket) has confirmed this node is the recorded next hop on its + // path: ACK / PATH-return / response packets travelling back along a path this node was + // inserted into while flood-relaying the outbound. Forward them unconditionally -- + // filtering by source here would black-hole the route, since the return's source is + // the far contact rather than the allowlisted node. + if (packet->isRouteDirect()) return true; + + // Re-flood (full-hop) when an outbound flood matches the allowlist. Returning true + // lets Mesh::routeRecvPacket() append this node's hash to the path and retransmit, + // which propagates the packet to its destination and places this node on the path so + // the destination's reverse-routed ACK comes back through it. Selectivity (the + // allowlist gate below) is what keeps this node from becoming a next-hop for + // unrelated mesh traffic. + if (shouldSelectivelyRelay(packet)) { + _selective_relays++; + return true; + } + return false; + } + return false; +} + +bool MyMesh::shouldSelectivelyRelay(const mesh::Packet* packet) { + uint8_t pt = packet->getPayloadType(); + + // Chat-type flood layout: payload[0]=dest_hash, payload[1]=src_hash. + // Match a favourite at either endpoint so both directions of its conversation relay: + // outbound floods it sends (src), and floods headed back to it (dest). When the + // favourite is shielded behind this relayer, its peer has no path home yet, so the + // returns come back as floods rather than direct routes. Without the dest match the + // return leg is dropped and the message is never ACKed. + bool chat = (pt == PAYLOAD_TYPE_TXT_MSG || pt == PAYLOAD_TYPE_REQ || + pt == PAYLOAD_TYPE_RESPONSE || pt == PAYLOAD_TYPE_PATH); + // ANON_REQ (e.g. first contact / room login to a shielded favourite) is dest_hash at + // payload[0] then the sender's full pubkey -- no 1-byte src_hash -- so dest-match only. + if (chat || pt == PAYLOAD_TYPE_ANON_REQ) { + if (packet->payload_len < (chat ? 2 : 1)) return false; + uint8_t dst_hash = packet->payload[0]; + int n = getNumContacts(); + for (int i = 0; i < n; i++) { + ContactInfo ci; + if (!getContactByIdx(i, ci)) continue; + if (!(ci.flags & FLAG_FAVOURITE)) continue; + // Only relay for conversational peers; repeaters/sensors get starred for + // convenience and shouldn't pull their traffic (or their hash) into the allowlist. + if (ci.type != ADV_TYPE_CHAT && ci.type != ADV_TYPE_ROOM) continue; + // 1-byte hash collisions (~favourite_count/256) are intrinsic to the on-wire hash. + if (ci.id.isHashMatch(&dst_hash, 1)) return true; + if (chat && ci.id.isHashMatch(&packet->payload[1], 1)) return true; + } + return false; + } + +#ifdef MAX_GROUP_CHANNELS + // Group packets carry the channel_hash at payload[0] (matches the core's group dedup key). + if (pt == PAYLOAD_TYPE_GRP_TXT || pt == PAYLOAD_TYPE_GRP_DATA) { + if (packet->payload_len < 1) return false; + uint8_t ch_hash = packet->payload[0]; + for (int i = 0; i < MAX_GROUP_CHANNELS; i++) { + ChannelDetails cd; + if (!getChannel(i, cd)) continue; + // After remove_channel, the slot's name is empty but hash is sha256(zeros), + // which would otherwise produce a 1-in-256 false positive. + if (cd.name[0] == '\0') continue; + if (cd.channel.hash[0] == ch_hash) return true; + } + return false; + } +#endif + + return false; } void MyMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis) { @@ -863,6 +948,8 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe next_ack_idx = 0; sign_data = NULL; dirty_contacts_expiry = 0; + _selective_relays = 0; + _selective_drops = 0; memset(advert_paths, 0, sizeof(advert_paths)); memset(send_scope.key, 0, sizeof(send_scope.key)); send_unscoped = false; @@ -1377,7 +1464,11 @@ void MyMesh::handleCmdFrame(size_t len) { repeat = cmd_frame[i++]; // FIRMWARE_VER_CODE 9+ } - if (repeat && !isValidClientRepeatFreq(freq)) { + // Only CLIENT_REPEAT_ALL is tied to a designated repeater freq; SELECTIVE is + // exempt here too, matching CMD_SET_CLIENT_REPEAT_MODE. + if (repeat > CLIENT_REPEAT_SELECTIVE) { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + } else if (repeat == CLIENT_REPEAT_ALL && !isValidClientRepeatFreq(freq)) { writeErrFrame(ERR_CODE_ILLEGAL_ARG); } else if (freq >= 150000 && freq <= 2500000 && sf >= 5 && sf <= 12 && cr >= 5 && cr <= 8 && bw >= 7000 && bw <= 500000) { @@ -1889,6 +1980,9 @@ void MyMesh::handleCmdFrame(size_t len) { memcpy(&out_frame[i], &n_recv_flood, 4); i += 4; memcpy(&out_frame[i], &n_recv_direct, 4); i += 4; memcpy(&out_frame[i], &n_recv_errors, 4); i += 4; + // Extra 8 bytes; parsers that don't know about these stop at recv_errors. + memcpy(&out_frame[i], &_selective_relays, 4); i += 4; + memcpy(&out_frame[i], &_selective_drops, 4); i += 4; _serial->writeFrame(out_frame, i); } else { writeErrFrame(ERR_CODE_ILLEGAL_ARG); // invalid stats sub-type @@ -1986,6 +2080,18 @@ void MyMesh::handleCmdFrame(size_t len) { } else { writeErrFrame(ERR_CODE_TABLE_FULL); } + } else if (cmd_frame[0] == CMD_SET_CLIENT_REPEAT_MODE && len >= 2) { + uint8_t mode = cmd_frame[1]; + if (mode > CLIENT_REPEAT_SELECTIVE) { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + } else if (mode == CLIENT_REPEAT_ALL && !isValidClientRepeatFreq((uint32_t)(_prefs.freq * 1000))) { + // ALL mode still requires a designated repeater freq; SELECTIVE bypasses the gate + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + } else { + _prefs.client_repeat = mode; + savePrefs(); + writeOKFrame(); + } } else { writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); MESH_DEBUG_PRINTLN("ERROR: unknown command: %02X", cmd_frame[0]); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 43d3950beb..58c082edb2 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -111,6 +111,7 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { uint8_t getExtraAckTransmitCount() const override; bool filterRecvFloodPacket(mesh::Packet* packet) override; bool allowPacketForward(const mesh::Packet* packet) override; + bool shouldSelectivelyRelay(const mesh::Packet* packet); void sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis); void sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis=0) override; @@ -209,6 +210,9 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { uint32_t pending_status; uint32_t pending_telemetry, pending_discovery; // pending _TELEMETRY_REQ uint32_t pending_req; // pending _BINARY_REQ + uint32_t _selective_relays; // count of allowlisted floods relayed + uint32_t _selective_drops; // reserved; kept for STATS wire compat + BaseSerialInterface *_serial; AbstractUITask* _ui;