From 7df52f5260e6524c012b61e885c45b7a02636c41 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Thu, 15 Oct 2026 23:39:01 -0700 Subject: [PATCH] Add opt-in decoded-channel Meshtext rebroadcasting --- docs/cli_commands.md | 16 + docs/radio_profiles.md | 73 ++++- .../companion_radio/MeshtextCompanion.cpp | 140 ++++++-- examples/companion_radio/MyMesh.cpp | 2 + examples/companion_radio/MyMesh.h | 6 +- src/helpers/MeshtextChat.cpp | 5 +- src/helpers/MeshtextChat.h | 10 +- src/helpers/MeshtextDelivery.h | 18 +- test/fixtures/companion_meshtext.cpp | 310 +++++++++++++++++- test/test_companion_meshtext.py | 50 ++- test/test_meshtext_chat.py | 124 ++++++- 11 files changed, 692 insertions(+), 62 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index ec7cbbd54..9a90d6a7c 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -955,6 +955,22 @@ ACKs and addressed responses. See [Companion routing details and examples](radio_profiles.md#companion-tx-routing-by-contact-or-channel) for temporary profiles, persistence, and the traffic covered by each setting. +## Companion Meshtext rebroadcasting + +```text +get mt.rebroadcast +set mt.rebroadcast on|off +``` + +Choose `on` or `off` after `set`. This saved option defaults to `off` and is +also shown in `get mt.status`. Only broadcast text successfully decoded with +a configured channel key can be relayed; unknown-key, non-text and direct +messages are excluded. Relays wait five additional actual packet airtimes +plus flood jitter, on top of existing radio waits. Hearing another forwarded +copy cancels the pending relay. `mt.maybe_rebroadcast` is an equivalent alias. +See [optional decoded-text rebroadcasting](radio_profiles.md#optional-decoded-text-rebroadcasting) +for hop limits, cancellation and queue behavior. + ## Set Companion media and SSH application TX profiles ```text diff --git a/docs/radio_profiles.md b/docs/radio_profiles.md index 2c12b7016..611631ca3 100644 --- a/docs/radio_profiles.md +++ b/docs/radio_profiles.md @@ -486,6 +486,7 @@ get mt.region get mt.slot get mt.hops get mt.retry +get mt.rebroadcast get mt.node get mt.shortname set mt.node !12345678 @@ -494,6 +495,7 @@ set mt.region auto set mt.region us set mt.slot 0 set mt.hops 7 +set mt.rebroadcast off set mt.shortname HALO set mt.shortname default send mt.nodeinfo 0 @@ -512,8 +514,8 @@ does not mean the MT backend is active. Outgoing MT messages default to a hop limit of **7**. `set mt.hops <0..7>` changes this budget, and `get mt.hops` or `get mt.status` shows it. `0` sends only the initial over-air hop with no forwarding budget; larger values permit -protocol-compatible peers to forward within that budget. The Companion itself still -does not relay MT packets. +protocol-compatible peers to forward within that budget. The Companion itself +does not relay MT packets unless explicitly enabled as described below. `get mt.node` shows the effective Meshtext sender node ID. By default it is SHA-derived from the MeshCore public identity and remains stable for that @@ -556,8 +558,9 @@ successful chat announces the updated name without sending from the rename. the Companion channel index, not `mt.slot` (the frequency slot). The packet contains the actual sender ID, the global MeshCore node name as its long name (UTF-8-safe truncated to 24 bytes), the effective short name, `CLIENT_MUTE` -(no relaying), and the no-DM flag. Both automatic and explicit NodeInfo omit -GPS/coordinates, MAC, public keys and unsupported metadata. The usual radio2 +by default (`CLIENT` when `mt.rebroadcast` is on), and the no-DM flag. +Both automatic and explicit NodeInfo omit GPS/coordinates, MAC, public keys +and unsupported metadata. The usual radio2 TX-mode, pending-TX, airtime and profile guards apply. The reply says **queued**, not delivered or acknowledged. @@ -569,13 +572,14 @@ Outgoing Meshtext chat and automatic or explicitly requested NodeInfo set This does not start an MQTT client, bridge networks, request a reply, or mark the packet as having arrived through MQTT. The 228-byte text limit is unchanged. -Region, frequency slot, hop limit, shortname and node-ID override are saved -atomically together; changing one preserves the others. Current 40-byte `MT03` -settings include both overrides. New firmware accepts valid legacy -`MT01`/`MT02` settings and defaults the shortname to the sender-ID suffix and +Region, frequency slot, hop limit, shortname, node-ID override and rebroadcast +choice are saved atomically together; changing one preserves the others. +Current 40-byte `MT04` settings include all of them. New firmware accepts valid +legacy `MT01`/`MT02` settings and defaults the shortname to the sender-ID suffix and the node ID to the MeshCore identity; `MT01` also defaults the hop limit to 7. -Older MT firmware cannot read `MT03`, so downgrading does not preserve access -to these settings. Invalid or unsupported stored settings block MT operation +Valid `MT03` settings preserve both overrides. All legacy versions default +rebroadcasting to off. Older MT firmware cannot read `MT04`, so downgrading +does not preserve access to these settings. Invalid or unsupported stored settings block MT operation rather than silently selecting another network. Private channels can be provisioned directly through the Companion CLI: @@ -667,8 +671,8 @@ future NodeInfo, not chat or the packet's sender ID; `set mt.node` changes the actual Meshtext sender ID. Neither setting change emits advertisements. A successful send response means **queued**, not acknowledged by a remote node. This backend -does not relay MT packets or provide ACKs, administrative commands, direct -messages, periodic node-info adverts or a node database. MeshCore emergency forwarding +does not relay MT packets by default. It does not provide ACKs, administrative +commands, direct messages, periodic node-info adverts or a node database. MeshCore emergency forwarding does not apply to MT traffic. Interoperability is with stock peers' PSK channel text using AES-CTR, not AES-CCM. AES-CTR channel traffic is not authenticated; successful decoding is not proof of sender identity. @@ -721,9 +725,48 @@ one minute to the airtime-based delay, matching the conservative self-advert policy. Turning retry count off cancels retained retries. Channel, identity, settings or radio-profile changes invalidate retained work rather than sending it on a different network. The bounded pending-frame storage may reject a new -send when full. Other people's MT packets are never forwarded by this backend; -MeshCore repeater prefix/bridge IDs and flood-filter rules are not MT routing -rules. +send when full. Forwarding other people's decoded channel text is a separate, +default-off option below. MeshCore repeater prefix/bridge IDs and flood-filter +rules are not MT routing rules. + +#### Optional decoded-text rebroadcasting + +```text +get mt.rebroadcast +set mt.rebroadcast on +set mt.rebroadcast off +``` + +This saved option defaults to **off**. `mt.maybe_rebroadcast` is an alias for +`mt.rebroadcast`; `get mt.status` also shows the current choice. + +When enabled, only broadcast **text successfully decrypted and parsed with a +configured channel's key**, and admitted to the local receive queue, is eligible. +Unknown channels, wrong keys, malformed packets, DMs, NodeInfo, GPS and other +non-text traffic are not relayed. A packet must have a valid remaining hop +budget. AES-CTR is unauthenticated: decoding does not authenticate its sender. + +Before queuing a relay, the Companion waits **five additional packet airtimes** +calculated from that packet's actual length and the current radio2 settings, +plus the normal flood jitter. Existing CAD, receive-busy and airtime-budget +waits still apply; this does not replace or shorten them. Hearing another +forwarded copy while waiting cancels the redundant relay. An unchanged-hop +retry from the original sender does not cancel it. + +The forwarded frame retains the original sender, packet ID, channel and +ciphertext; only remaining hops and the relay-node byte change. It is sent +at most once, without originated-message retries or an automatic local NodeInfo +announcement. At most one relay occupies the existing bounded chat delivery +slots, with no additional frame-sized buffer. A new local chat can displace +an unsent relay. Full queues skip the relay, and work delayed more than a +minute past its due time expires. Changes to the channel/key, identity or +radio profile cancel stale work instead of forwarding it on another network. +Turning the option off cancels pending relay work; settings edits are refused +while a radio transmission is already queued or in progress. + +Relays stay on Meshtext radio2. This is not a MeshCore bridge, and ordinary +MeshCore forwarding rules remain unchanged. Enabling it does not add periodic +traffic or cause NodeInfo to be sent just because something was received. ## Choose whether traffic crosses between profiles diff --git a/examples/companion_radio/MeshtextCompanion.cpp b/examples/companion_radio/MeshtextCompanion.cpp index 825dc3d23..1732d2a43 100644 --- a/examples/companion_radio/MeshtextCompanion.cpp +++ b/examples/companion_radio/MeshtextCompanion.cpp @@ -50,11 +50,13 @@ bool validNodeOverride(uint32_t node) { return node == 0 || (node >= 4 && node != UINT32_MAX); } void makeSettings(uint8_t image[SettingsSize], mesh::mt::Region region, uint32_t slot, - uint8_t hops, const char* short_name, uint32_t node_override) { + uint8_t hops, const char* short_name, uint32_t node_override, + bool rebroadcast = false) { memset(image, 0, SettingsSize); - memcpy(image, "MT03", 4); + memcpy(image, "MT04", 4); image[4] = static_cast(region); image[5] = hops; + image[6] = rebroadcast ? 1 : 0; for (unsigned i = 0; i < 4; ++i) image[8 + i] = slot >> (8 * i); memcpy(image + 12, short_name, strlen(short_name)); for (unsigned i = 0; i < 4; ++i) image[32 + i] = node_override >> (8 * i); @@ -145,6 +147,8 @@ void MyMesh::beginMeshtext() { const auto region = static_cast(image[4]); const bool legacy = size == LegacySettingsSize; const bool legacy_v1 = legacy && memcmp(image, "MT01", 4) == 0; + const bool legacy_v3 = !legacy && memcmp(image, "MT03", 4) == 0; + const uint8_t rebroadcast = legacy || legacy_v3 ? 0 : image[6]; const uint8_t hops = legacy_v1 ? 7 : image[5]; const uint32_t slot = uint32_t(image[8]) | (uint32_t(image[9]) << 8) | (uint32_t(image[10]) << 16) | (uint32_t(image[11]) << 24); @@ -156,13 +160,20 @@ void MyMesh::beginMeshtext() { } else if (validShortName(reinterpret_cast(image + 12))) { const uint32_t node_override = uint32_t(image[32]) | (uint32_t(image[33]) << 8) | (uint32_t(image[34]) << 16) | (uint32_t(image[35]) << 24); - if (validNodeOverride(node_override)) - makeSettings(expected, region, slot, hops, reinterpret_cast(image + 12), node_override); + if (validNodeOverride(node_override) && rebroadcast <= 1) { + makeSettings(expected, region, slot, hops, reinterpret_cast(image + 12), + node_override, rebroadcast); + if (legacy_v3) { + memcpy(expected, "MT03", 4); + mesh::Utils::sha256(expected + 36, 4, expected, 36); + } + } } _mt_settings_valid = region <= mesh::mt::Region::ANZ && slot <= 1024 && hops <= 7 && memcmp(image, expected, size) == 0; if (_mt_settings_valid) { _mt_region = region; _mt_slot = slot; _mt_hop_limit = hops; + _mt_rebroadcast = rebroadcast != 0; if (!legacy) { memcpy(_mt_short_name, image + 12, sizeof(_mt_short_name)); _mt_node_override = uint32_t(image[32]) | (uint32_t(image[33]) << 8) @@ -179,14 +190,15 @@ void MyMesh::beginMeshtext() { } bool MyMesh::saveMeshtextSettings(mesh::mt::Region region, uint32_t slot, uint8_t hops, - const char* short_name, uint32_t node_override) { + const char* short_name, uint32_t node_override, uint8_t rebroadcast) { if (hops == UINT8_MAX) hops = _mt_hop_limit; if (!short_name) short_name = _mt_short_name; if (node_override == UINT32_MAX) node_override = _mt_node_override; + if (rebroadcast == UINT8_MAX) rebroadcast = _mt_rebroadcast ? 1 : 0; if (region > mesh::mt::Region::ANZ || slot > 1024 || hops > 7 || !validShortName(short_name) - || !validNodeOverride(node_override) || hasForeignRadioFrame()) return false; + || !validNodeOverride(node_override) || rebroadcast > 1 || hasForeignRadioFrame()) return false; uint8_t image[SettingsSize]; - makeSettings(image, region, slot, hops, short_name, node_override); + makeSettings(image, region, slot, hops, short_name, node_override, rebroadcast); #if defined(NRF52_PLATFORM) const bool ok = _store->saveMeshtextSettings(image); #else @@ -209,8 +221,10 @@ bool MyMesh::saveMeshtextSettings(mesh::mt::Region region, uint32_t slot, uint8_ return false; } const bool announcement_changed = region != _mt_region || slot != _mt_slot - || node_override != _mt_node_override || strcmp(short_name, _mt_short_name) != 0; + || node_override != _mt_node_override || strcmp(short_name, _mt_short_name) != 0 + || (rebroadcast != 0) != _mt_rebroadcast; _mt_region = region; _mt_slot = slot; _mt_hop_limit = hops; _mt_settings_valid = true; + _mt_rebroadcast = rebroadcast != 0; for (auto& frame : _mt_delivery) frame.cancelled = true; if (announcement_changed) memset(_mt_nodeinfo_announced, 0, sizeof(_mt_nodeinfo_announced)); @@ -227,6 +241,7 @@ bool MyMesh::saveMeshtextSettings(mesh::mt::Region region, uint32_t slot, uint8_ } bool MyMesh::eraseMeshtextSettings() { + _mt_rebroadcast = false; for (auto& frame : _mt_delivery) frame.cancelled = true; memset(_mt_nodeinfo_pending, 0, sizeof(_mt_nodeinfo_pending)); // The filesystem erase also removes channels/identity on the next boot. @@ -439,6 +454,7 @@ bool MyMesh::meshtextDeliveryCurrent(const mesh::mt::PendingFrame& frame) { } uint8_t MyMesh::meshtextRetryLimit(const mesh::mt::PendingFrame& frame) const { + if (frame.relayed) return 0; // One opportunistic relay, never origin flood retries. // These are local, zero-hop origins: any general path gate admits them. // GRP_DATA-only gates do not apply to channel text. No forwarding echo can // exist when mt.hops is zero, so don't force all configured duplicates. @@ -489,6 +505,54 @@ bool MyMesh::queueMeshtextDelivery(const ChannelDetails& channel, const uint8_t* return false; } +bool MyMesh::preemptMeshtextRelay() { + // Explicit user chat has priority over an optional, not-yet-sent relay. + for (unsigned i = 0; i < mesh::mt::ChatDeliverySlots; ++i) { + auto& frame = _mt_delivery[i]; + if (!frame.length || !frame.relayed) continue; + if (_mt_tx_slot == static_cast(i)) { + frame.cancelled = true; + if (!discardQueuedForeignRadioFrame(mesh::RadioProfileProtocol::Meshtext)) return false; + } else frame.clear(); + } + return !hasForeignRadioFrame(); +} + +void MyMesh::maybeRebroadcastMeshtext(const ChannelDetails& channel, const uint8_t* bytes, + size_t length) { + // Called only after successful text decryption/parsing and local admission. + // Never forward unknown channels, opaque frames, DMs, NodeInfo or GPS. + if (!_mt_rebroadcast || !_mt_active || !bytes || length <= mesh::mt::HeaderSize + || length > mesh::mt::MaxFrameSize || !(bytes[12] & 7) + || (bytes[12] >> 5) < (bytes[12] & 7) || bytes[14] != 0) return; + const auto* profiles = _radio->profiles(); + if (!profiles || !profiles->canTransmit(1) + || profiles->secondary.params.protocol != mesh::RadioProfileProtocol::Meshtext) return; + for (const auto& pending : _mt_delivery) if (pending.length && pending.relayed) return; + uint8_t fingerprint[8]; + const int index = meshtextChannelIndex(channel, fingerprint); + if (index < 0) return; + const uint64_t airtime = _radio->getProfileAirtime(1, length); + if (!airtime) return; + // Five ADDITIONAL exact packet airtimes, plus flood jitter. This does not + // replace the Dispatcher's existing CAD, RX-busy or airtime-budget waits. + uint64_t delay = 5 * airtime + airtime * getRNG()->nextInt(0, 201) / 100; + if (delay > 0x7fffffffULL) delay = 0x7fffffffULL; + for (unsigned i = 0; i < mesh::mt::ChatDeliverySlots; ++i) { + auto& frame = _mt_delivery[i]; + if (frame.length) continue; + frame = {}; + memcpy(frame.bytes, bytes, length); + frame.bytes[12] = (bytes[12] & 0xf8) | ((bytes[12] & 7) - 1); + frame.bytes[15] = static_cast(_mt_node); + memcpy(frame.fingerprint, fingerprint, sizeof(fingerprint)); + frame.length = length; frame.channel = index; frame.relayed = true; + frame.generation = profiles->generation[1]; + frame.due = millis() + static_cast(delay); + return; // Reuse one chat slot; no additional frame-sized RAM allocation. + } +} + void MyMesh::loopMeshtextDelivery() { const auto* profiles = _radio->profiles(); const uint32_t now = millis(); @@ -502,6 +566,8 @@ void MyMesh::loopMeshtextDelivery() { if (!frame.length) continue; if (!usable || profiles->generation[1] != frame.generation || (_mt_delivery_config_dirty && !meshtextDeliveryCurrent(frame))) frame.cancelled = true; + if (frame.relayed && (!_mt_rebroadcast || static_cast(now - frame.due) > 60000)) + frame.cancelled = true; // Do not emit an opportunistic relay minutes later. if (frame.cancelled || (frame.sent && !meshtextRetryLimit(frame))) { if (_mt_tx_slot == static_cast(i)) { frame.cancelled = true; @@ -536,8 +602,13 @@ void MyMesh::loopMeshtextDelivery() { } for (unsigned i = 0; i < mesh::mt::DeliverySlots; ++i) { auto& frame = _mt_delivery[i]; - if (!frame.length || !frame.sent || static_cast(now - frame.due) < 0) continue; + if (!frame.length || static_cast(now - frame.due) < 0) continue; if (!meshtextDeliveryCurrent(frame)) { frame.clear(); continue; } + if (frame.relayed) { + if (queueForeignRadioFrame(frame.bytes, frame.length, 1, frame.generation)) _mt_tx_slot = i; + return; + } + if (!frame.sent) continue; if (frame.retries >= meshtextRetryLimit(frame)) { ++_mt_exhausted; frame.clear(); continue; } @@ -550,7 +621,7 @@ void MyMesh::loopMeshtextDelivery() { bool MyMesh::sendMeshtextText(const ChannelDetails& channel, const char* text, size_t length) { refreshMeshtextProfile(); - if (!_mt_active || !text || !length || hasForeignRadioFrame()) return false; + if (!_mt_active || !text || !length) return false; const auto* profiles = _radio->profiles(); if (!profiles || !profiles->canTransmit(1) || profiles->secondary.params.protocol != mesh::RadioProfileProtocol::Meshtext) return false; @@ -563,10 +634,12 @@ bool MyMesh::sendMeshtextText(const ChannelDetails& channel, const char* text, s const uint32_t id = _mt_packet_id++; uint8_t raw[mesh::mt::MaxFrameSize]; // Outbound Meshtext channel chat defaults to seven hops; the CLI can reduce it. - // This does not enable forwarding or ACK responses in this Companion. + // Forwarding remains a separate, default-off setting; no ACK responses. const size_t size = mesh::mt::encodeText(_mt_node, id, _mt_hop_limit, wire.name, wire.key, wire.key_length, text, length, raw); - return size && queueMeshtextDelivery(channel, raw, size, false); + // A rejected local message must not evict a valid pending relay. Validate + // and encode first, then give an admitted user chat priority over it. + return size && preemptMeshtextRelay() && queueMeshtextDelivery(channel, raw, size, false); } bool MyMesh::sendMeshtextNodeInfo(const ChannelDetails& channel) { @@ -588,7 +661,8 @@ bool MyMesh::sendMeshtextNodeInfo(const ChannelDetails& channel) { const uint32_t id = _mt_packet_id++; uint8_t raw[mesh::mt::MaxFrameSize]; const size_t size = mesh::mt::encodeNodeInfo(_mt_node, id, _mt_hop_limit, wire.name, wire.key, - wire.key_length, _prefs.node_name, long_length, short_name, strlen(short_name), raw); + wire.key_length, _prefs.node_name, long_length, short_name, strlen(short_name), raw, + _mt_rebroadcast); // Explicit CLI or first successfully transmitted chat, never a GPS packet. return size && queueMeshtextDelivery(channel, raw, size, true); } @@ -606,6 +680,14 @@ bool MyMesh::onForeignRadioFrame(const uint8_t* bytes, size_t length, uint8_t pr if (!_mt_active || !profiles || profile != 1 || generation != profiles->generation[1]) return false; for (unsigned i = 0; i < mesh::mt::DeliverySlots; ++i) { auto& frame = _mt_delivery[i]; + if (frame.generation == generation && frame.matchesRelayCopy(bytes, length) + && meshtextDeliveryCurrent(frame)) { + frame.heard = frame.cancelled = true; + if (_mt_tx_slot == static_cast(i)) + discardQueuedForeignRadioFrame(mesh::RadioProfileProtocol::Meshtext); + else frame.clear(); + return true; // Cancel before the received-packet dedup early return. + } if (frame.generation != generation || !frame.matchesEcho(bytes, length) || !meshtextDeliveryCurrent(frame)) continue; if (!frame.heard) ++_mt_heard; @@ -635,6 +717,7 @@ bool MyMesh::onForeignRadioFrame(const uint8_t* bytes, size_t length, uint8_t pr if (queueChannelText(i, policy_channel.channel.secret, snr, decoded.hops, timestamp, text, false)) { _mt_seen.remember(decoded.from, decoded.id); ++_mt_received; + maybeRebroadcastMeshtext(channel, bytes, length); } return true; } @@ -657,6 +740,12 @@ void MyMesh::onForeignRadioSendComplete(bool success) { return; // e.g. an explicitly configured text beacon, not delivery work. } auto& frame = _mt_delivery[slot]; + if (frame.relayed) { + if (success) ++_mt_sent; + else if (!frame.heard && !frame.cancelled) ++_mt_failed; + frame.clear(); + return; // No local packet ID, NodeInfo, origin retry or announcement state. + } if (frame.nodeinfo && !success) _mt_nodeinfo_pending[frame.channel] = false; if (success) { ++_mt_sent; @@ -782,22 +871,32 @@ bool MyMesh::handleMeshtextCommand(const char* command, char* reply, size_t capa const bool get_region = !strcmp(command, "get mt.region"); const bool get_slot = !strcmp(command, "get mt.slot"); const bool get_hops = !strcmp(command, "get mt.hops"); + const bool get_rebroadcast = !strcmp(command, "get mt.rebroadcast") + || !strcmp(command, "get mt.maybe_rebroadcast"); const bool get_node = !strcmp(command, "get mt.node"); const bool get_short_name = !strcmp(command, "get mt.shortname"); const char* region_value = !strncmp(command, "set mt.region ", 14) ? command + 14 : nullptr; const char* slot_value = !strncmp(command, "set mt.slot ", 12) ? command + 12 : nullptr; const char* hops_value = !strncmp(command, "set mt.hops ", 12) ? command + 12 : nullptr; + const char* rebroadcast_value = !strncmp(command, "set mt.rebroadcast ", 19) ? command + 19 + : !strncmp(command, "set mt.maybe_rebroadcast ", 25) ? command + 25 : nullptr; const char* node_value = !strncmp(command, "set mt.node ", 12) ? command + 12 : nullptr; const char* short_name_value = !strncmp(command, "set mt.shortname ", 17) ? command + 17 : nullptr; if (!strcmp(command, "set mt.node")) node_value = ""; if (!strcmp(command, "set mt.shortname")) short_name_value = ""; - if (!status && !get_region && !get_slot && !get_hops && !get_node && !get_short_name - && !region_value && !slot_value && !hops_value && !node_value && !short_name_value) return false; - if (region_value || slot_value || hops_value || node_value || short_name_value) { + if (!status && !get_region && !get_slot && !get_hops && !get_node && !get_short_name && !get_rebroadcast + && !region_value && !slot_value && !hops_value && !node_value && !short_name_value && !rebroadcast_value) return false; + if (region_value || slot_value || hops_value || node_value || short_name_value || rebroadcast_value) { auto region = _mt_region; uint32_t slot = _mt_slot; uint32_t hops = _mt_hop_limit; uint32_t node_override = _mt_node_override; const char* short_name = _mt_short_name; + uint8_t rebroadcast = _mt_rebroadcast ? 1 : 0; + if (rebroadcast_value) { + if (!strcmp(rebroadcast_value, "on")) rebroadcast = 1; + else if (!strcmp(rebroadcast_value, "off")) rebroadcast = 0; + else { snprintf(reply, capacity, "Error: use set mt.rebroadcast on|off"); return true; } + } if (region_value && !mesh::mt::parseRegion(region_value, region)) { snprintf(reply, capacity, "Error: use set mt.region auto|us|eu868|eu433|anz"); return true; } @@ -828,8 +927,10 @@ bool MyMesh::handleMeshtextCommand(const char* command, char* reply, size_t capa snprintf(reply, capacity, "Error: mt.shortname must be 1..4 UTF-8 characters (no spaces/control/colon), or default"); return true; } } - if (!saveMeshtextSettings(region, slot, hops, short_name, node_override)) + if (!saveMeshtextSettings(region, slot, hops, short_name, node_override, rebroadcast)) snprintf(reply, capacity, "Error: Meshtext settings not saved (pending TX or storage error)"); + else if (rebroadcast_value) + snprintf(reply, capacity, "OK - mt.rebroadcast=%s; extra wait=5 packet airtimes", rebroadcast ? "on" : "off"); else if (node_value) snprintf(reply, capacity, "OK - mt.node=!%08lx (%s); choose a unique ID", (unsigned long)_mt_node, _mt_node_override ? "saved override" : "identity default"); @@ -843,6 +944,7 @@ bool MyMesh::handleMeshtextCommand(const char* command, char* reply, size_t capa if (get_region) snprintf(reply, capacity, "> %s; effective=%s", mesh::mt::regionName(_mt_region), mesh::mt::regionName(meshtextRegion())); else if (get_slot) snprintf(reply, capacity, "> %lu%s", (unsigned long)_mt_slot, _mt_slot ? "" : " (preset default)"); else if (get_hops) snprintf(reply, capacity, "> %u", _mt_hop_limit); + else if (get_rebroadcast) snprintf(reply, capacity, "> %s; extra wait=5 packet airtimes", _mt_rebroadcast ? "on" : "off"); else if (get_node) snprintf(reply, capacity, "> !%08lx (%s)", (unsigned long)_mt_node, _mt_node_override ? "saved override" : "identity default"); else if (get_short_name) { @@ -853,8 +955,8 @@ bool MyMesh::handleMeshtextCommand(const char* command, char* reply, size_t capa else { const auto* profiles = _radio->profiles(); const auto p = profiles ? profiles->secondary.params : mesh::RadioProfileParams{}; - snprintf(reply, capacity, "> Meshtext !%08lx; %s; %s; %.3f/%g/SF%u/CR%u; hops=%u; RX=%lu TX=%lu fail=%lu; RX time-shared", - (unsigned long)_mt_node, _mt_status, _radio_profiles.autoSecondaryActive() ? "auto radio2" : "manual radio2", + snprintf(reply, capacity, "> Meshtext !%08lx; rebroadcast=%s; %s; %s; %.3f/%g/SF%u/CR%u; hops=%u; RX=%lu TX=%lu fail=%lu; RX time-shared", + (unsigned long)_mt_node, _mt_rebroadcast ? "on" : "off", _mt_status, _radio_profiles.autoSecondaryActive() ? "auto radio2" : "manual radio2", p.freq, p.bw, p.sf, p.cr, _mt_hop_limit, (unsigned long)_mt_received, (unsigned long)_mt_sent, (unsigned long)_mt_failed); } return true; diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index bb0bde18d..81aa801cb 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -10209,6 +10209,8 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt, #if MESH_ENABLE_MESHTEXT_CHAT terminalOutput().print(" get mt.status / get mt.region / get mt.slot / get mt.hops\r\n" " get mt.node / get mt.shortname\r\n" + " get mt.rebroadcast / set mt.rebroadcast (saved; default off)\r\n" + " Relay decoded channel text only; adds 5 packet airtimes before TX\r\n" " set mt.region \r\n" " set mt.slot <0|1-based frequency slot>\r\n" " set mt.hops <0..7> (saved; default 7)\r\n" diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 005ac4458..7dd57d0bc 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -427,12 +427,15 @@ protected: bool validateMeshtextChannelChange(int index, const ChannelDetails& channel); bool handleMeshtextCommand(const char* command, char* reply, size_t capacity); bool saveMeshtextSettings(mesh::mt::Region region, uint32_t slot, uint8_t hops = UINT8_MAX, - const char* short_name = nullptr, uint32_t node_override = UINT32_MAX); + const char* short_name = nullptr, uint32_t node_override = UINT32_MAX, + uint8_t rebroadcast = UINT8_MAX); bool eraseMeshtextSettings(); bool sendMeshtextText(const ChannelDetails& channel, const char* text, size_t length); bool sendMeshtextNodeInfo(const ChannelDetails& channel); bool queueMeshtextDelivery(const ChannelDetails& channel, const uint8_t* bytes, size_t length, bool nodeinfo); + void maybeRebroadcastMeshtext(const ChannelDetails& channel, const uint8_t* bytes, size_t length); + bool preemptMeshtextRelay(); int meshtextChannelIndex(const ChannelDetails& channel, uint8_t fingerprint[8]); bool meshtextDeliveryCurrent(const mesh::mt::PendingFrame& pending); uint8_t meshtextRetryLimit(const mesh::mt::PendingFrame& pending) const; @@ -762,6 +765,7 @@ private: uint32_t _mt_default_node = 0, _mt_node_override = 0; char _mt_short_name[17] = {}; uint8_t _mt_hop_limit = 7; + bool _mt_rebroadcast = false; uint32_t _mt_sent = 0, _mt_failed = 0, _mt_received = 0; mesh::mt::SeenPackets _mt_seen; mesh::mt::PendingFrame _mt_delivery[mesh::mt::DeliverySlots]; diff --git a/src/helpers/MeshtextChat.cpp b/src/helpers/MeshtextChat.cpp index 000514a89..5bc31d479 100644 --- a/src/helpers/MeshtextChat.cpp +++ b/src/helpers/MeshtextChat.cpp @@ -291,7 +291,8 @@ size_t encodeText(uint32_t from, uint32_t id, uint8_t hop_limit, const char* cha size_t encodeNodeInfo(uint32_t from, uint32_t id, uint8_t hop_limit, const char* channel_name, const uint8_t* key, size_t key_len, const char* long_name, size_t long_name_len, - const char* short_name, size_t short_name_len, uint8_t out[MaxFrameSize]) { + const char* short_name, size_t short_name_len, uint8_t out[MaxFrameSize], + bool rebroadcast) { size_t short_characters = 0; if (out == nullptr || key == nullptr || (key_len != 16 && key_len != 32) || !validName(channel_name) || from <= 3 || from == Broadcast || id == 0 || hop_limit > 7 @@ -319,7 +320,7 @@ size_t encodeNodeInfo(uint32_t from, uint32_t id, uint8_t hop_limit, const char* } data[count++] = 0x1a; data[count++] = static_cast(short_name_len); memcpy(data + count, short_name, short_name_len); count += short_name_len; - data[count++] = 0x38; data[count++] = 1; // Config.DeviceConfig.Role.CLIENT_MUTE. + data[count++] = 0x38; data[count++] = rebroadcast ? 0 : 1; // CLIENT / CLIENT_MUTE. data[count++] = 0x48; data[count++] = 1; // User.is_unmessagable explicitly true. data[3] = static_cast(count - 4); data[count++] = 0x48; data[count++] = 1; // Data.bitfield: MQTT upload consent. diff --git a/src/helpers/MeshtextChat.h b/src/helpers/MeshtextChat.h index be0574ad2..a59728b85 100644 --- a/src/helpers/MeshtextChat.h +++ b/src/helpers/MeshtextChat.h @@ -54,7 +54,9 @@ size_t encodeText(uint32_t from, uint32_t id, uint8_t hop_limit, const char* cha const uint8_t* key, size_t key_len, const char* text, size_t text_len, uint8_t out[MaxFrameSize]); // Explicit broadcast NODEINFO_APP containing only User id, long/short names, -// CLIENT_MUTE (no forwarding), and is_unmessagable=true (no direct messages). +// CLIENT_MUTE by default (no forwarding), and is_unmessagable=true (no DMs). +// Explicitly enabled rebroadcast advertises CLIENT instead, without adding +// any fields or changing encryption, names, hop policy or MQTT consent. // Names use explicit UTF-8 byte lengths, without their terminating NUL. Long // names may be empty; short names require 1..4 UTF-8 bytes (stock wire bound). // This can hold four ASCII characters but fewer multi-byte characters. Longer @@ -63,9 +65,11 @@ size_t encodeText(uint32_t from, uint32_t id, uint8_t hop_limit, const char* cha // explicit and first-successful-chat announcements and bounded retries. size_t encodeNodeInfo(uint32_t from, uint32_t id, uint8_t hop_limit, const char* channel_name, const uint8_t* key, size_t key_len, const char* long_name, size_t long_name_len, - const char* short_name, size_t short_name_len, uint8_t out[MaxFrameSize]); + const char* short_name, size_t short_name_len, uint8_t out[MaxFrameSize], + bool rebroadcast = false); // Only broadcast TEXT_MESSAGE_APP is delivered. No DMs, ACKs, administrative -// handling, relaying or automatic transmissions. AES-CTR is not authenticated; +// handling or automatic transmissions occur in this decoder; a caller can +// separately opt into bounded relay handling. AES-CTR is not authenticated; // a successful parse is not a proof of sender identity or PSK authenticity. // On failure, decoded is left unchanged. Caller records dedup only on success. bool decodeText(const uint8_t* raw, size_t length, const char* channel_name, diff --git a/src/helpers/MeshtextDelivery.h b/src/helpers/MeshtextDelivery.h index 6e2d27a76..f7783c37b 100644 --- a/src/helpers/MeshtextDelivery.h +++ b/src/helpers/MeshtextDelivery.h @@ -5,11 +5,12 @@ namespace mesh { namespace mt { -// Three retained chats and one metadata slot. No heap, packet-pool entries, -// keys, positions or forwarding state are allocated by the delivery policy. +// Three retained chats and one metadata slot. Optional relays reuse a retained +// frame; no heap, packet-pool entry, key or additional payload buffer is needed. struct PendingFrame { uint8_t bytes[MaxFrameSize] = {}; uint8_t fingerprint[8] = {}; + bool relayed = false; // Occupies the existing padding before generation. uint32_t generation = 0, due = 0; uint16_t length = 0; uint8_t channel = 0, retries = 0; @@ -20,12 +21,23 @@ struct PendingFrame { // The sender/id/channel/ciphertext are immutable. Only remaining hops // and next-hop/relay bytes may change. A copy at the original hop limit // is not evidence that another node forwarded the transmission. - return length && sent && raw && size == length && size > HeaderSize + return !relayed && length && sent && raw && size == length && size > HeaderSize && memcmp(raw, bytes, 12) == 0 && raw[13] == bytes[13] && (raw[12] & 0xf8) == (bytes[12] & 0xf8) && (raw[12] & 7) < (bytes[12] & 7) && memcmp(raw + HeaderSize, bytes + HeaderSize, size - HeaderSize) == 0; } + bool matchesRelayCopy(const uint8_t* raw, size_t size) const { + // A planned relay already decremented remaining hops. Another relay at + // that same or a lower remaining hop count makes ours redundant, even + // before we transmit. Original-sender retries retain more hops and must + // not cancel it. Preserve/check all immutable flags and ciphertext. + return relayed && raw && length > HeaderSize && length <= sizeof(bytes) + && size == length && memcmp(raw, bytes, 12) == 0 && raw[13] == bytes[13] + && (raw[12] & 0xf8) == (bytes[12] & 0xf8) + && (raw[12] & 7) <= (bytes[12] & 7) + && memcmp(raw + HeaderSize, bytes + HeaderSize, size - HeaderSize) == 0; + } }; static constexpr unsigned DeliverySlots = 4; diff --git a/test/fixtures/companion_meshtext.cpp b/test/fixtures/companion_meshtext.cpp index d1f1f3ba8..d3d431697 100644 --- a/test/fixtures/companion_meshtext.cpp +++ b/test/fixtures/companion_meshtext.cpp @@ -60,8 +60,16 @@ struct BaseChatMesh { }; struct MtRadio : Radio { bool supported=true; + bool physical_airtime=false; + uint32_t fixed_airtime=10; + std::vector> airtime_calls; + mesh::RadioProfiles& _profiles=p; bool supportsMeshtext() const override { return supported; } - uint32_t getProfileAirtime(uint8_t,int,uint8_t=0) override { return 10; } + @PROFILE_AIRTIME@ + uint32_t getProfileAirtime(uint8_t profile,int length,uint8_t cr=0) override { + airtime_calls.emplace_back(profile,length); + return physical_airtime ? actualProfileAirtime(profile,length,cr):fixed_airtime; + } }; struct Serial { bool connected=false; @@ -99,6 +107,7 @@ public: uint32_t _mt_slot=0,_mt_node=0,_mt_packet_id=0,_mt_sent=0,_mt_failed=0,_mt_received=0; uint32_t _mt_default_node=0,_mt_node_override=0; uint8_t _mt_hop_limit=7; + bool _mt_rebroadcast=false; char _mt_short_name[17]={}; mesh::mt::SeenPackets _mt_seen; mesh::mt::PendingFrame _mt_delivery[mesh::mt::DeliverySlots]; @@ -138,7 +147,8 @@ public: void refreshMeshtextIdentity(); void beginMeshtext(); bool saveMeshtextSettings(mesh::mt::Region,uint32_t,uint8_t hops=UINT8_MAX, - const char* short_name=nullptr,uint32_t node_override=UINT32_MAX); + const char* short_name=nullptr,uint32_t node_override=UINT32_MAX, + uint8_t rebroadcast=UINT8_MAX); bool eraseMeshtextSettings(); bool isMeshtextChannel(const ChannelDetails&) const; bool validateMeshtextChannelChange(int,const ChannelDetails&); @@ -146,6 +156,8 @@ public: bool sendMeshtextText(const ChannelDetails&,const char*,size_t); bool sendMeshtextNodeInfo(const ChannelDetails&); bool queueMeshtextDelivery(const ChannelDetails&,const uint8_t*,size_t,bool); + void maybeRebroadcastMeshtext(const ChannelDetails&,const uint8_t*,size_t); + bool preemptMeshtextRelay(); int meshtextChannelIndex(const ChannelDetails&,uint8_t[8]); bool meshtextDeliveryCurrent(const mesh::mt::PendingFrame&); uint8_t meshtextRetryLimit(const mesh::mt::PendingFrame&) const; @@ -544,7 +556,7 @@ static void hops() { char command[32]; snprintf(command,sizeof(command),"set mt.hops %u",limit); assert(node.handleMeshtextCommand(command,reply,sizeof(reply)) && !strncmp(reply,"OK",2)); assert(node._mt_hop_limit==limit && node._mt_region==mesh::mt::Region::Unset && node._mt_slot==0); - assert(Preferences::blob.size()==40 && !memcmp(Preferences::blob.data(),"MT03",4)); + assert(Preferences::blob.size()==40 && !memcmp(Preferences::blob.data(),"MT04",4)); assert(Preferences::blob[5]==limit && Preferences::blob[6]==0 && Preferences::blob[7]==0); MyMesh reboot; reboot.stored[0]=node.stored[0]; reboot.beginMeshtext(); assert(reboot._mt_hop_limit==limit && reboot._mt_active && reboot._mt_region==node._mt_region && reboot._mt_slot==node._mt_slot); @@ -592,7 +604,7 @@ static void hops() { assert(migrated._mt_region==mesh::mt::Region::US && migrated._mt_slot==2); assert(Preferences::writes==0 && Preferences::blob==legacy_blob); assert(migrated.handleMeshtextCommand("set mt.hops 3",reply,sizeof(reply)) && !strncmp(reply,"OK",2)); - assert(Preferences::writes==1 && !memcmp(Preferences::blob.data(),"MT03",4) && Preferences::blob[5]==3); + assert(Preferences::writes==1 && !memcmp(Preferences::blob.data(),"MT04",4) && Preferences::blob[5]==3); assert(migrated._mt_region==mesh::mt::Region::US && migrated._mt_slot==2); MyMesh migrated_reboot; migrated_reboot.stored[0]=migrated.stored[0]; migrated_reboot.beginMeshtext(); assert(migrated_reboot._mt_active && migrated_reboot._mt_hop_limit==3); @@ -627,7 +639,7 @@ static void shortname() { assert(node.handleMeshtextCommand("set mt.shortname MIKE",reply,sizeof(reply)) && !strncmp(reply,"OK",2)); assert(!strcmp(node._mt_short_name,"MIKE") && node._mt_node==default_node && node._mt_packet_id==original_packet); assert(!memcmp(meshcore_identity,node.self_id.pub_key,32)); - assert(Preferences::blob.size()==40 && !memcmp(Preferences::blob.data(),"MT03",4)); + assert(Preferences::blob.size()==40 && !memcmp(Preferences::blob.data(),"MT04",4)); assert(!memcmp(Preferences::blob.data()+12,"MIKE\0",5)); for(unsigned i=17;i<36;++i) assert(Preferences::blob[i]==0); assert(node.handleMeshtextCommand("get mt.shortname",reply,sizeof(reply)) && strstr(reply,"MIKE")); @@ -694,7 +706,7 @@ static void shortname() { assert(legacy._mt_node==default_node && legacy._mt_hop_limit==(version?3:7)); assert(Preferences::writes==0 && Preferences::blob==before); assert(legacy.handleMeshtextCommand("set mt.shortname TEST",reply,sizeof(reply)) && !strncmp(reply,"OK",2)); - assert(Preferences::writes==1 && Preferences::blob.size()==40 && !memcmp(Preferences::blob.data(),"MT03",4)); + assert(Preferences::writes==1 && Preferences::blob.size()==40 && !memcmp(Preferences::blob.data(),"MT04",4)); assert(legacy._mt_region==mesh::mt::Region::US && legacy._mt_slot==2 && legacy._mt_hop_limit==(version?3:7)); assertUntouched(legacy); } @@ -826,6 +838,9 @@ static void nodeinfo() { assert(node.handleMeshtextCommand("send mt.nodeinfo 0",reply,sizeof(reply)) && !strncmp(reply,"Error",5)); assert(node._mt_packet_id==pending_id && node.foreign.size()==pending_count); assert(!node.sendMeshtextText(node.stored[0],"busy",4)); + // A fully encoded local attempt can lose TX admission, but its CTR nonce + // must remain consumed rather than being reused by the following metadata. + assert(node._mt_packet_id==pending_id+1 && node.foreign.size()==pending_count); assert(!node.saveMeshtextSettings(mesh::mt::Region::US,0)); node.releaseForeign(); assert(node.handleMeshtextCommand("set mt.node !12345678",reply,sizeof(reply)) && !strncmp(reply,"OK",2)); @@ -835,7 +850,7 @@ static void nodeinfo() { assert(node.foreign.back()==std::vector(expected_named,expected_named+sizeof(expected_named))); node.releaseForeign(); auto text=decodeOwnText(node,"hello",5); - assert(text.id==0x1234567a && !strcmp(text.text,"hello")); + assert(text.id==0x1234567b && !strcmp(text.text,"hello")); const char* emojis="\xf0\x9f\x98\x80\xf0\x9f\x98\x80\xf0\x9f\x98\x80\xf0\x9f\x98\x80"; assert(node.saveMeshtextSettings(node._mt_region,node._mt_slot,UINT8_MAX,emojis)); memset(node._prefs.node_name,'A',22); strcpy(node._prefs.node_name+22,"\xe2\x82\xac" "rest"); @@ -1311,6 +1326,282 @@ static void readinessCache() { } puts("PASS readiness-cache"); } +static const uint8_t relay_short[]=@RELAY_SHORT@,relay_long[]=@RELAY_LONG@, + relay_nodeinfo[]=@RELAY_NODEINFO@,relay_private128[]=@RELAY_PRIVATE128@, + relay_private256[]=@RELAY_PRIVATE256@; +static mesh::mt::PendingFrame* pendingRelay(MyMesh& node) { + mesh::mt::PendingFrame* found=nullptr; + for(auto& frame:node._mt_delivery) if(frame.length && frame.relayed) { + assert(!found);found=&frame; // Retained forwarding is bounded to ONE copy. + } + return found; +} +static void enableRelay(MyMesh& node) { + char reply[256];assert(node.handleMeshtextCommand("set mt.rebroadcast on",reply,sizeof(reply))); + assert(!strncmp(reply,"OK",2) && node._mt_rebroadcast);node.advance(0); +} +static std::vector plannedRelay(MyMesh& node,const uint8_t* raw,size_t length) { + std::vector expected(raw,raw+length); + expected[12]=(raw[12]&0xf8)|((raw[12]&7)-1);expected[15]=uint8_t(node._mt_node); + return expected; +} +static void assertRelayBytes(MyMesh& node,const uint8_t* raw,size_t length) { + const auto* frame=pendingRelay(node);assert(frame && frame->length==length); + const auto expected=plannedRelay(node,raw,length); + assert(!memcmp(frame->bytes,expected.data(),length)); + assert(!frame->nodeinfo && !frame->sent && !frame->retries && !frame->cancelled); + assert(frame->generation==node.radio.p.generation[1]); +} +static void rebroadcastSettings() { + Preferences::reset();MyMesh node;active(node);char reply[256]; + assert(!node._mt_rebroadcast); + for(const char* get:{"get mt.rebroadcast","get mt.maybe_rebroadcast"}) { + assert(node.handleMeshtextCommand(get,reply,sizeof(reply)) && strstr(reply,"> off;")); + } + assert(node.handleMeshtextCommand("get mt.status",reply,sizeof(reply)) && strstr(reply,"rebroadcast=off")); + const auto original=Preferences::blob;const auto packet=node._mt_packet_id; + for(const char* invalid:{"set mt.rebroadcast ","set mt.rebroadcast yes","set mt.rebroadcast true", + "set mt.rebroadcast 1","set mt.rebroadcast ON","set mt.rebroadcast on junk", + "set mt.maybe_rebroadcast -1"}) { + assert(node.handleMeshtextCommand(invalid,reply,sizeof(reply)) && !strncmp(reply,"Error",5)); + assert(!node._mt_rebroadcast && Preferences::blob==original); + } + assert(!node.saveMeshtextSettings(node._mt_region,node._mt_slot,UINT8_MAX,nullptr,UINT32_MAX,2)); + assert(node.handleMeshtextCommand("set mt.maybe_rebroadcast on",reply,sizeof(reply)) && !strncmp(reply,"OK",2)); + assert(node._mt_rebroadcast && Preferences::blob.size()==40 && !memcmp(Preferences::blob.data(),"MT04",4)); + assert(Preferences::blob[6]==1 && Preferences::blob[7]==0 && node._mt_packet_id==packet); + uint8_t digest[4];mesh::Utils::sha256(digest,4,Preferences::blob.data(),36); + assert(!memcmp(digest,Preferences::blob.data()+36,4)); + assert(node.handleMeshtextCommand("set mt.hops 3",reply,sizeof(reply)) && !strncmp(reply,"OK",2)); + assert(node._mt_rebroadcast && node._mt_hop_limit==3 && Preferences::blob[6]==1); + MyMesh reboot;reboot.stored[0]=node.stored[0];reboot.beginMeshtext(); + assert(reboot._mt_active && reboot._mt_rebroadcast && reboot._mt_hop_limit==3);assertUntouched(reboot); + const auto valid=Preferences::blob; + for(unsigned fault=0;fault<3;++fault) { + Preferences::blob=valid; + if(fault==0) Preferences::blob[6]=2; + if(fault==1) Preferences::blob[7]=1; + if(fault==2) {memcpy(Preferences::blob.data(),"MT03",4);Preferences::blob[6]=1;} + mesh::Utils::sha256(Preferences::blob.data()+36,4,Preferences::blob.data(),36); + MyMesh invalid;invalid.stored[0]=node.stored[0];invalid.beginMeshtext(); + assert(!invalid._mt_settings_valid && !invalid._mt_active && !invalid._mt_rebroadcast);assertUntouched(invalid); + } + Preferences::blob=valid;Preferences::fail_write=true; + assert(node.handleMeshtextCommand("set mt.rebroadcast off",reply,sizeof(reply)) && !strncmp(reply,"Error",5)); + assert(node._mt_rebroadcast && Preferences::blob==valid && !node._mt_active && !node._mt_settings_valid); + Preferences::fail_write=false;Preferences::fail_read=true; + assert(!node.saveMeshtextSettings(node._mt_region,node._mt_slot,UINT8_MAX,nullptr,UINT32_MAX,0)); + assert(node._mt_rebroadcast && !node._mt_active && !node._mt_settings_valid && Preferences::blob[6]==0); + Preferences::fail_read=false;MyMesh recovered;recovered.stored[0]=node.stored[0];recovered.beginMeshtext(); + assert(recovered._mt_active && recovered._mt_settings_valid && !recovered._mt_rebroadcast); + assert(recovered.handleMeshtextCommand("set mt.rebroadcast on",reply,sizeof(reply)) && !strncmp(reply,"OK",2)); + assert(recovered.eraseMeshtextSettings() && !recovered._mt_rebroadcast && Preferences::blob.empty()); + const uint8_t mt01[]=@LEGACY_SETTINGS@,mt02[]=@LEGACY_HOPS_SETTINGS@,mt03[]=@LEGACY_MT03_SETTINGS@, + mt03_named[]=@LEGACY_MT03_NAMED@; + const std::vector> legacy={ + {mt01,mt01+sizeof(mt01)},{mt02,mt02+sizeof(mt02)},{mt03,mt03+sizeof(mt03)}, + {mt03_named,mt03_named+sizeof(mt03_named)}}; + for(const auto& image:legacy) { + Preferences::reset();Preferences::blob=image;MyMesh old;old.stored[0]=channel("#mt-longfast");old.beginMeshtext(); + assert(old._mt_active && old._mt_settings_valid && !old._mt_rebroadcast && Preferences::writes==0); + assert(Preferences::blob==image && receiveFrame(old,relay_short,sizeof(relay_short))); + old.advance(100000);assertUntouched(old);assert(!pendingRelay(old)); + if(image.size()==40 && image[12]) { + assert(!strcmp(old._mt_short_name,"OLD") && old._mt_node_override==0x12345678 && old._mt_node==0x12345678); + } + const uint32_t old_node=old._mt_node,old_override=old._mt_node_override,old_slot=old._mt_slot; + const uint8_t old_hops=old._mt_hop_limit;const std::string old_name=old._mt_short_name; + assert(old.handleMeshtextCommand("set mt.rebroadcast on",reply,sizeof(reply)) && !strncmp(reply,"OK",2)); + assert(old._mt_rebroadcast && !memcmp(Preferences::blob.data(),"MT04",4) && Preferences::writes==1); + assert(old._mt_node==old_node && old._mt_node_override==old_override && old._mt_slot==old_slot + && old._mt_hop_limit==old_hops && old_name==old._mt_short_name);assertUntouched(old); + } + puts("PASS rebroadcast-settings"); +} +static void rebroadcastTiming() { + Preferences::reset();MyMesh off;active(off); + assert(receiveFrame(off,relay_short,sizeof(relay_short)));off.advance(100000); + assert(!pendingRelay(off) && off.offline.size()==1);assertUntouched(off); + for(uint32_t jitter:{0U,200U}) { + Preferences::reset();MyMesh node;active(node);enableRelay(node);node.random.jitter=jitter; + const auto packet=node._mt_packet_id; + assert(receiveFrame(node,relay_short,sizeof(relay_short)));assertRelayBytes(node,relay_short,sizeof(relay_short)); + const auto due=pendingRelay(node)->due;assert(uint32_t(due-millis())==50+jitter/10); + assert(node.radio.airtime_calls.back()==std::make_pair(uint8_t(1),int(sizeof(relay_short)))); + node.advance(uint32_t(due-millis())-1);assertUntouched(node); + node.advance(1);assert(node.foreign_pending && node.foreign.size()==1); + assert(node.foreign.back()==plannedRelay(node,relay_short,sizeof(relay_short))); + assert(node.foreign_profile==1 && node.foreign_generation==node.radio.p.generation[1]); + assert(node._mt_packet_id==packet && node._mt_received==1); + node.completeForeign();assert(!pendingRelay(node) && node._mt_sent==1 && !node._mt_retried && !node._mt_exhausted); + for(bool pending:node._mt_nodeinfo_pending) assert(!pending); + for(bool announced:node._mt_nodeinfo_announced) assert(!announced); + node.advance(3600000);assert(node.foreign.size()==1 && node._mt_packet_id==packet && !node.meshcore_sends); + } + Preferences::reset();MyMesh wrap;active(wrap);enableRelay(wrap);g_mock_millis=UINT32_MAX-20; + assert(receiveFrame(wrap,relay_short,sizeof(relay_short)));assert(uint32_t(pendingRelay(wrap)->due-millis())==50); + wrap.advance(49);assertUntouched(wrap);wrap.advance(1);assert(wrap.foreign_pending);wrap.completeForeign(); + Preferences::reset();MyMesh zero;active(zero);enableRelay(zero);zero.radio.fixed_airtime=0; + assert(receiveFrame(zero,relay_short,sizeof(relay_short)));zero.advance(100000);assert(!pendingRelay(zero));assertUntouched(zero); + // A later explicit chat, rather than relaying by itself, announces the + // standard CLIENT role with no position / direct-message capability. + const uint8_t expected_info[]=@NODEINFO_REBROADCAST@; + Preferences::reset();MyMesh named;active(named);enableRelay(named);named._prefs.flood_retry_attempts=0; + assert(receiveFrame(named,relay_short,sizeof(relay_short)));advanceTo(named,pendingRelay(named)->due);named.completeForeign(); + named.advance(1000);assert(named.foreign.size()==1); + assert(named.sendMeshtextText(named.stored[0],"human",5));named.completeForeign();named.advance(250); + assert(named.foreign.size()==3 && named.foreign.back()==std::vector(expected_info,expected_info+sizeof(expected_info))); + puts("PASS rebroadcast-timing"); +} +static void rebroadcastDuplicates() { + for(uint8_t remaining:{uint8_t(2),uint8_t(0)}) { + Preferences::reset();MyMesh node;active(node);enableRelay(node); + assert(receiveFrame(node,relay_short,sizeof(relay_short)));const auto due=pendingRelay(node)->due; + assert(receiveFrame(node,relay_short,sizeof(relay_short))); // Original sender's retry is not a relay. + assert(pendingRelay(node) && pendingRelay(node)->due==due && node.offline.size()==1); + auto peer=plannedRelay(node,relay_short,sizeof(relay_short));peer[12]=(peer[12]&0xf8)|remaining; + peer[14]=0x42;peer[15]=0x43;assert(receiveFrame(node,peer.data(),peer.size())); + assert(!pendingRelay(node) && node.offline.size()==1 && !node._mt_heard); + assert(receiveFrame(node,relay_short,sizeof(relay_short)));node.advance(100000); + assert(!pendingRelay(node));assertUntouched(node); + } + Preferences::reset();MyMesh exact;active(exact);enableRelay(exact); + assert(receiveFrame(exact,relay_short,sizeof(relay_short))); + const auto expected=plannedRelay(exact,relay_short,sizeof(relay_short)); + for(unsigned changed:{0U,4U,8U,12U,13U,16U}) { + auto wrong=expected;wrong[changed]^=changed==12 ? 0x10:1; + receiveFrame(exact,wrong.data(),wrong.size());assert(pendingRelay(exact)); + } + auto shorter=expected;shorter.pop_back();receiveFrame(exact,shorter.data(),shorter.size());assert(pendingRelay(exact)); + assert(!exact.onForeignRadioFrame(expected.data(),expected.size(),0,exact.radio.p.generation[1],3,-90)); + assert(!exact.onForeignRadioFrame(expected.data(),expected.size(),1,exact.radio.p.generation[1]-1,3,-90)); + assert(pendingRelay(exact)); + for(bool inflight:{false,true}) { + Preferences::reset();MyMesh node;active(node);enableRelay(node); + assert(receiveFrame(node,relay_short,sizeof(relay_short)));advanceTo(node,pendingRelay(node)->due); + assert(node.foreign_pending);node.foreign_sending=inflight; + auto peer=plannedRelay(node,relay_short,sizeof(relay_short));const auto failed=node._mt_failed; + assert(receiveFrame(node,peer.data(),peer.size())); + assert(node.foreign_pending==inflight); + if(inflight) {assert(pendingRelay(node)->cancelled);node.completeForeign();} + assert(!pendingRelay(node) && node._mt_failed==failed && !node._mt_retried); + node.advance(100000);assert(node.foreign.size()==1); + for(bool pending:node._mt_nodeinfo_pending) assert(!pending); + } + puts("PASS rebroadcast-duplicates"); +} +static void rebroadcastLifecycle() { + for(unsigned change=0;change<7;++change) { + Preferences::reset();MyMesh node;active(node);enableRelay(node); + assert(receiveFrame(node,relay_short,sizeof(relay_short)));assert(pendingRelay(node)); + char reply[256]; + switch(change) { + case 0:assert(node.handleMeshtextCommand("set mt.rebroadcast off",reply,sizeof(reply)) && !strncmp(reply,"OK",2));break; + case 1:node.commit(0,{});break; + case 2:node.commit(0,channel("#mt-longfast",32));break; + case 3:node.self_id.pub_key[0]=43;node.refreshMeshtextIdentity();break; + case 4:node._radio_available=false;break; + case 5:node.cli("set radio2 off");break; + case 6:node.cli("set tempradio2 909.5,500,5,5,rx,1,32");break; + } + node.advance(2000);node.advance(100000); + assert(!pendingRelay(node) && !node.foreign_pending && !node._mt_retried && !node._mt_exhausted);assertUntouched(node); + } + Preferences::reset();MyMesh expired;active(expired);enableRelay(expired); + assert(receiveFrame(expired,relay_short,sizeof(relay_short)));expired.admit_foreign=false; + advanceTo(expired,pendingRelay(expired)->due);assert(pendingRelay(expired)); + expired.advance(59999);assert(pendingRelay(expired));expired.advance(2);assert(!pendingRelay(expired)); + expired.admit_foreign=true;expired.advance(100000);assertUntouched(expired); + Preferences::reset();MyMesh priority;active(priority);enableRelay(priority); + assert(receiveFrame(priority,relay_short,sizeof(relay_short)));const auto packet=priority._mt_packet_id; + assert(priority.sendMeshtextText(priority.stored[0],"human",5)); + assert(!pendingRelay(priority) && priority.foreign_pending && priority.foreign.size()==1 && priority._mt_packet_id==packet+1); + mesh::mt::DecodedText decoded;const auto& raw=priority.foreign.back(); + assert(mesh::mt::decodeText(raw.data(),raw.size(),"LongFast",mesh::mt::DefaultPsk,16,1,decoded)); + assert(!strcmp(decoded.text,"human") && decoded.from==priority._mt_node); + for(unsigned invalid=0;invalid<3;++invalid) { + Preferences::reset();MyMesh node;active(node);enableRelay(node); + assert(receiveFrame(node,relay_short,sizeof(relay_short))); + const auto retained=*pendingRelay(node);std::string oversized(229,'x'); + const char invalid_utf8[]={char(0xc0),char(0xaf)};const char embedded_nul[]={'x',0,'y'}; + const char* text=invalid==0 ? oversized.data():invalid==1 ? invalid_utf8:embedded_nul; + const size_t length=invalid==0 ? oversized.size():invalid==1 ? sizeof(invalid_utf8):sizeof(embedded_nul); + assert(!node.sendMeshtextText(node.stored[0],text,length)); + const auto* current=pendingRelay(node); + assert(current && current->length==retained.length && current->due==retained.due && !current->cancelled); + assert(!memcmp(current->bytes,retained.bytes,retained.length));assertUntouched(node); + } + for(bool inflight:{false,true}) { + Preferences::reset();MyMesh node;active(node);enableRelay(node); + assert(receiveFrame(node,relay_short,sizeof(relay_short)));advanceTo(node,pendingRelay(node)->due); + node.foreign_sending=inflight;const auto packet=node._mt_packet_id,failed=node._mt_failed; + const bool accepted=node.sendMeshtextText(node.stored[0],"priority",8); + const uint32_t after_attempt=node._mt_packet_id; + assert(after_attempt>=packet && after_attempt<=packet+1); + assert(accepted==!inflight); + if(inflight) { + assert(node.foreign_pending && pendingRelay(node)->cancelled); + node.completeForeign();assert(!pendingRelay(node)); + assert(node.sendMeshtextText(node.stored[0],"priority",8)); + } + assert(node.foreign_pending && !pendingRelay(node) && node._mt_packet_id==after_attempt+unsigned(inflight) && node._mt_failed==failed); + mesh::mt::DecodedText sent;const auto& bytes=node.foreign.back(); + assert(mesh::mt::decodeText(bytes.data(),bytes.size(),"LongFast",mesh::mt::DefaultPsk,16,1,sent)); + assert(!strcmp(sent.text,"priority") && sent.from==node._mt_node && sent.id==node._mt_packet_id-1); + } + Preferences::reset();MyMesh bounded;active(bounded);enableRelay(bounded); + assert(receiveFrame(bounded,relay_short,sizeof(relay_short)));const auto retained=pendingRelay(bounded)->length; + assert(receiveFrame(bounded,relay_long,sizeof(relay_long))); + assert(pendingRelay(bounded)->length==retained && bounded.offline.size()==2); + Preferences::reset();MyMesh full;active(full);enableRelay(full); + for(unsigned i=0;i raw(relay_short,relay_short+sizeof(relay_short)); + if(invalid==0) raw[12]&=0xf8; // No hops left. + if(invalid==1) raw[12]=(1U<<5)|3; // Malformed hop_start < hop_limit. + if(invalid==2) raw[14]=1; // Explicit next-hop routing is not a broadcast relay. + if(invalid==3) raw[0]=1; // A DM / non-broadcast destination. + if(invalid==4) raw.assign(relay_nodeinfo,relay_nodeinfo+sizeof(relay_nodeinfo)); + if(invalid==5) raw.assign(rx_other,rx_other+sizeof(rx_other)); + if(invalid==6) raw.resize(mesh::mt::HeaderSize); // Opaque/truncated ciphertext. + receiveFrame(node,raw.data(),raw.size());node.advance(100000); + assert(!pendingRelay(node));assertUntouched(node); + } + Preferences::reset();MyMesh capacity;active(capacity);enableRelay(capacity);capacity.offline_capacity=0; + assert(receiveFrame(capacity,relay_short,sizeof(relay_short))); + assert(!pendingRelay(capacity) && !capacity._mt_seen.seen(0x11223344,201)); + capacity.offline_capacity=4;assert(receiveFrame(capacity,relay_short,sizeof(relay_short)));assert(pendingRelay(capacity)); + for(unsigned key_bytes:{16U,32U}) { + Preferences::reset();MyMesh node;node.beginMeshtext(); + node.cli(key_bytes==16 ? "set radio2 909.5,500,7,5,rxtx,32":"set radio2 909.5,125,9,5,rxtx,32"); + node.advance(2000);node.commit(0,channel("mt-Team",key_bytes));assert(node._mt_active);enableRelay(node); + node.radio.physical_airtime=true;node.radio.airtime_calls.clear();node.random.jitter=key_bytes==16 ? 0:200; + const auto* raw=key_bytes==16 ? relay_private128:relay_private256; + const size_t length=key_bytes==16 ? sizeof(relay_private128):sizeof(relay_private256); + const uint32_t airtime=node.radio.actualProfileAirtime(1,length); + const auto packet=node._mt_packet_id; + assert(receiveFrame(node,raw,length));assertRelayBytes(node,raw,length); + assert((node.radio.airtime_calls==std::vector>({{1,int(length)}}))); + const uint32_t delay=airtime*(key_bytes==16 ? 5:7); + assert(uint32_t(pendingRelay(node)->due-millis())==delay && delay>0); + node.advance(delay-1);assertUntouched(node);node.advance(1); + assert(node.foreign_pending && node.foreign.back()==plannedRelay(node,raw,length)); + node.completeForeign();node.advance(100000); + assert(node.foreign.size()==1 && node._mt_packet_id==packet && !node._mt_retried && !node._mt_exhausted); + } + Preferences::reset();MyMesh wrong_key;active(wrong_key);enableRelay(wrong_key); + assert(!receiveFrame(wrong_key,relay_private256,sizeof(relay_private256))); + wrong_key.advance(100000);assert(!pendingRelay(wrong_key));assertUntouched(wrong_key); + puts("PASS rebroadcast-guards-phy"); +} int main(int argc,char** argv) { assert(argc==2); if(!strcmp(argv[1],"keys"))keys(); @@ -1336,5 +1627,10 @@ int main(int argc,char** argv) { else if(!strcmp(argv[1],"announcement-lifecycle"))announcementLifecycle(); else if(!strcmp(argv[1],"advert-name-delivery"))advertNameDelivery(); else if(!strcmp(argv[1],"readiness-cache"))readinessCache(); + else if(!strcmp(argv[1],"rebroadcast-settings"))rebroadcastSettings(); + else if(!strcmp(argv[1],"rebroadcast-timing"))rebroadcastTiming(); + else if(!strcmp(argv[1],"rebroadcast-duplicates"))rebroadcastDuplicates(); + else if(!strcmp(argv[1],"rebroadcast-lifecycle"))rebroadcastLifecycle(); + else if(!strcmp(argv[1],"rebroadcast-guards-phy"))rebroadcastGuardsAndPhy(); else assert(!"unknown test case"); } diff --git a/test/test_companion_meshtext.py b/test/test_companion_meshtext.py index b54aa7819..05bc47f13 100644 --- a/test/test_companion_meshtext.py +++ b/test/test_companion_meshtext.py @@ -82,6 +82,10 @@ class CompanionMeshtextTest(unittest.TestCase): fixture = (ROOT / "test/fixtures/radio_profiles/cli_test.cpp").read_text(encoding="utf-8") fixture = fixture[:fixture.index("static void permanentScheduleOrder()")] text = (ROOT / "test/fixtures/companion_meshtext.cpp").read_text(encoding="utf-8") + radio_source = (ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text(encoding="utf-8") + airtime = body(radio_source, "uint32_t RadioLibWrapper::getProfileAirtime(") + airtime = "uint32_t actualProfileAirtime(uint8_t profile, int len, uint8_t cr=0) " + airtime[airtime.index("{"):] + text = text.replace("@PROFILE_AIRTIME@", airtime) payload = ("A" * 153 + "\u20ac" + "B" * 69).encode() # 225 bytes, truncation splits a scalar vectors = { "@RX_FRAME@": reference_frame(data_text(payload), packet_id=101), @@ -89,30 +93,40 @@ class CompanionMeshtextTest(unittest.TestCase): "@RX_DM@": reference_frame(data_text(b"dm"), to=0x99887766, packet_id=103), "@RX_ACK@": reference_frame(data_text(b"want ack") + b"\x18\x01", flags=0x6B, packet_id=104), "@RX_OTHER@": reference_frame(b"\x08\x02\x12\x01x", packet_id=105), + "@RELAY_SHORT@": reference_frame(data_text(b"relay short"), packet_id=201), + "@RELAY_LONG@": reference_frame(data_text(b"L" * 180), packet_id=202), + "@RELAY_NODEINFO@": reference_frame(b"\x08\x04\x12\x00", packet_id=203), + "@RELAY_PRIVATE128@": reference_frame(data_text(b"private relay"), "Team", bytes(range(1, 17)), packet_id=204), + "@RELAY_PRIVATE256@": reference_frame(data_text(b"L" * 180), "Team", bytes(range(1, 33)), packet_id=205), } # Independent standard User/NodeInfo wire expectations; never encode # an expected NodeInfo through the production codec under test. - def nodeinfo_frame(sender, packet_id, long_name, short_name, name="LongFast", key=None): + def nodeinfo_frame(sender, packet_id, long_name, short_name, name="LongFast", key=None, + rebroadcast=False): def field(number, value): return varint((number << 3) | 2) + varint(len(value)) + value user = field(1, ("!%08x" % sender).encode()) if long_name: user += field(2, long_name) - user += field(3, short_name) + b"\x38\x01\x48\x01" # CLIENT_MUTE; no DMs + user += field(3, short_name) + b"\x38" + varint(0 if rebroadcast else 1) + b"\x48\x01" data = b"\x08\x04" + field(2, user) + b"\x48\x01" # NODEINFO_APP; MQTT consent only args = {} if key is None else {"key": key} return reference_frame(data, name, sender=sender, packet_id=packet_id, flags=0xE7, **args) default_node = int.from_bytes(hashlib.sha256(bytes([42]) + bytes(31)).digest()[:4], "little") vectors.update({ + "@RELAY_NODEINFO@": nodeinfo_frame(0x11223344, 203, b"Peer", b"PEER"), "@NODEINFO_DEFAULT@": nodeinfo_frame(default_node, 0x12345678, b"Test", ("%04x" % (default_node & 0xFFFF)).encode()), "@NODEINFO_AFTER_CHAT@": nodeinfo_frame(default_node, 0x12345679, b"Test", ("%04x" % (default_node & 0xFFFF)).encode()), + "@NODEINFO_REBROADCAST@": nodeinfo_frame(default_node, 0x12345679, b"Test", + ("%04x" % (default_node & 0xFFFF)).encode(), + rebroadcast=True), "@NODEINFO_RENAMED@": nodeinfo_frame(default_node, 0x1234567A, b"Renamed", ("%04x" % (default_node & 0xFFFF)).encode()), - "@NODEINFO_NAMED@": nodeinfo_frame(0x12345678, 0x12345679, b"Mike Carper", b"MIKE"), - "@NODEINFO_UTF8@": nodeinfo_frame(0x12345678, 0x1234567B, b"A" * 22, "\U0001f600".encode()), - "@NODEINFO_PRIVATE@": nodeinfo_frame(0x12345678, 0x1234567C, b"A" * 22, + "@NODEINFO_NAMED@": nodeinfo_frame(0x12345678, 0x1234567A, b"Mike Carper", b"MIKE"), + "@NODEINFO_UTF8@": nodeinfo_frame(0x12345678, 0x1234567C, b"A" * 22, "\U0001f600".encode()), + "@NODEINFO_PRIVATE@": nodeinfo_frame(0x12345678, 0x1234567D, b"A" * 22, "\U0001f600".encode(), "Team", bytes(range(1, 33))), }) text = text.replace("@FIXTURE@", fixture).replace("@CONSTANTS@", "\n".join(constants)) @@ -130,6 +144,17 @@ class CompanionMeshtextTest(unittest.TestCase): legacy_hops[5] = 3 legacy_hops[12:] = hashlib.sha256(legacy_hops[:12]).digest()[:4] text = text.replace("@LEGACY_HOPS_SETTINGS@", array(legacy_hops)) + mt03 = bytearray(40) + mt03[:4] = b"MT03" + mt03[4:6] = bytes((1, 3)) + mt03[8:12] = (2).to_bytes(4, "little") + mt03[36:] = hashlib.sha256(mt03[:36]).digest()[:4] + text = text.replace("@LEGACY_MT03_SETTINGS@", array(mt03)) + mt03_named = bytearray(mt03) + mt03_named[12:15] = b"OLD" + mt03_named[32:36] = (0x12345678).to_bytes(4, "little") + mt03_named[36:] = hashlib.sha256(mt03_named[:36]).digest()[:4] + text = text.replace("@LEGACY_MT03_NAMED@", array(mt03_named)) public_key = bytes([42]) + bytes(31) text = text.replace("@LOCAL_NODE@", str(int.from_bytes(hashlib.sha256(public_key).digest()[:4], "little"))) for first in (43, 44): @@ -246,6 +271,21 @@ class CompanionMeshtextTest(unittest.TestCase): def test_same_tuple_publish_busy_release_refreshes_without_stealing_external_owner(self): self.run_case("readiness-cache") + def test_rebroadcast_is_opt_in_persisted_atomically_and_legacy_settings_default_off(self): + self.run_case("rebroadcast-settings") + + def test_rebroadcast_waits_five_exact_airtimes_plus_jitter_and_never_arms_local_metadata(self): + self.run_case("rebroadcast-timing") + + def test_rebroadcast_duplicates_cancel_before_seen_shortcut_and_inflight_is_not_unsent(self): + self.run_case("rebroadcast-duplicates") + + def test_rebroadcast_stale_work_expires_or_cancels_and_explicit_chat_has_priority(self): + self.run_case("rebroadcast-lifecycle") + + def test_rebroadcast_requires_decoded_broadcast_text_capacity_and_uses_secondary_phy(self): + self.run_case("rebroadcast-guards-phy") + def test_delivery_service_is_wired_before_radio_processing_and_paused_for_morse(self): source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text(encoding="utf-8") loop = body(source, "void MyMesh::loop()") diff --git a/test/test_meshtext_chat.py b/test/test_meshtext_chat.py index eb0a5ece2..a16dfec39 100644 --- a/test/test_meshtext_chat.py +++ b/test/test_meshtext_chat.py @@ -32,14 +32,14 @@ def data_text(text): return b"\x08\x01\x12" + varint(len(text)) + text -def data_nodeinfo(long_name, short_name, sender): +def data_nodeinfo(long_name, short_name, sender, rebroadcast=False): # Independent handwritten schema oracle: User fields 1/2/3/7/9 only. user_id = ("!%08x" % sender).encode("ascii") user = b"\x0a" + varint(len(user_id)) + user_id if long_name: user += b"\x12" + varint(len(long_name)) + long_name user += b"\x1a" + varint(len(short_name)) + short_name - user += b"\x38\x01\x48\x01" # CLIENT_MUTE=1, is_unmessagable=true. + user += b"\x38" + bytes([0 if rebroadcast else 1]) + b"\x48\x01" return b"\x08\x04\x12" + varint(len(user)) + user + b"\x48\x01" @@ -64,7 +64,9 @@ def literal(value): HARNESS = r''' #include +#include #include +#include #include #include #include @@ -84,6 +86,7 @@ struct NodeInfoGolden { uint32_t from, id; uint8_t hops; uint8_t frame[255]; size_t frame_len; }; static const NodeInfoGolden node_info_golden[] = { @NODEINFO_GOLDEN@ }; +static const NodeInfoGolden relay_node_info_golden[] = { @RELAY_NODEINFO_GOLDEN@ }; struct Malformed { const char* label; uint8_t frame[255]; size_t length; }; static const Malformed malformed[] = { @MALFORMED@ }; struct Modem { @@ -260,6 +263,100 @@ static void node_info_bounds() { assert(encodeNodeInfo(4, 1, 3, "LongFast", DefaultPsk, 16, "\xc3\xa9", 2, "\xf0\x9f\x9a\x80", 4, frame)); puts("PASS nodeinfo_bounds"); } +static void node_info_roles() { + for (size_t i = 0; i < sizeof(node_info_golden) / sizeof(node_info_golden[0]); ++i) { + const auto& original = node_info_golden[i]; + const auto& expected = relay_node_info_golden[i]; + uint8_t passive[MaxFrameSize], explicit_false[MaxFrameSize], forwarding[MaxFrameSize]; + const size_t count = encodeNodeInfo(original.from, original.id, original.hops, + original.name, original.key, original.key_len, original.long_name, + original.long_name_len, original.short_name, original.short_name_len, passive); + assert(count == original.frame_len && !memcmp(passive, original.frame, count)); + assert(encodeNodeInfo(original.from, original.id, original.hops, original.name, + original.key, original.key_len, original.long_name, original.long_name_len, + original.short_name, original.short_name_len, explicit_false, false) == count); + assert(!memcmp(passive, explicit_false, count)); + assert(encodeNodeInfo(expected.from, expected.id, expected.hops, expected.name, + expected.key, expected.key_len, expected.long_name, expected.long_name_len, + expected.short_name, expected.short_name_len, forwarding, true) == count); + assert(count == expected.frame_len && !memcmp(forwarding, expected.frame, count)); + assert(!memcmp(passive, forwarding, HeaderSize)); + size_t changes = 0; + for (size_t offset = HeaderSize; offset < count; ++offset) { + if (passive[offset] != forwarding[offset]) { + ++changes; + assert((passive[offset] ^ forwarding[offset]) == 1); + } + } + assert(changes == 1); // Only User.role changes; CTR leaves all other bytes identical. + DecodedText decoded; + assert(!decodeText(forwarding, count, expected.name, expected.key, + expected.key_len, 0x99887766, decoded)); // Still not text/GPS/DM. + } + puts("PASS nodeinfo_roles"); +} +static void relay_matching() { + struct PreviousPendingFrame { + uint8_t bytes[MaxFrameSize] = {}, fingerprint[8] = {}; + uint32_t generation = 0, due = 0; + uint16_t length = 0; + uint8_t channel = 0, retries = 0; + bool nodeinfo = false, sent = false, heard = false, cancelled = false; + }; + static_assert(sizeof(PendingFrame) == sizeof(PreviousPendingFrame), "relay flag must reuse padding"); + static_assert(offsetof(PendingFrame, generation) == offsetof(PreviousPendingFrame, generation), + "relay flag must not move existing aligned state"); + static_assert(offsetof(PendingFrame, relayed) == offsetof(PendingFrame, fingerprint) + 8, + "relay flag belongs immediately after fingerprint"); + const auto& golden_frame = golden[0]; + PendingFrame pending; + pending.length = golden_frame.frame_len; + memcpy(pending.bytes, golden_frame.frame, pending.length); + pending.sent = true; + uint8_t copy[MaxFrameSize]; memcpy(copy, pending.bytes, pending.length); + assert(!pending.relayed && !pending.matchesRelayCopy(copy, pending.length)); + assert(!pending.matchesEcho(copy, pending.length)); // Original copy has not been relayed. + copy[12] = (copy[12] & 0xf8) | 2; + assert(pending.matchesEcho(copy, pending.length)); + pending.relayed = true; + assert(!pending.matchesEcho(copy, pending.length)); // Relays are not originated delivery ACKs. + pending.bytes[12] = (pending.bytes[12] & 0xf8) | 2; // Planned relay remaining hops. + pending.sent = false; // Cancellation is allowed before the first relay TX. + assert(pending.matchesRelayCopy(copy, pending.length)); + for (uint8_t remaining : {0, 1, 2}) { + copy[12] = (pending.bytes[12] & 0xf8) | remaining; + copy[14] = 0x12; copy[15] = 0x34; // Mutable routing bytes do not defeat cancellation. + assert(pending.matchesRelayCopy(copy, pending.length)); + } + for (uint8_t remaining : {3, 4, 5, 6, 7}) { + copy[12] = (pending.bytes[12] & 0xf8) | remaining; + assert(!pending.matchesRelayCopy(copy, pending.length)); + } + for (size_t offset = 0; offset < pending.length; ++offset) { + memcpy(copy, pending.bytes, pending.length); + if (offset == 12 || offset == 14 || offset == 15) continue; + copy[offset] ^= 1; + assert(!pending.matchesRelayCopy(copy, pending.length)); + } + for (uint8_t flag : {0x08, 0x10, 0x20, 0x40, 0x80}) { + memcpy(copy, pending.bytes, pending.length); copy[12] ^= flag; + assert(!pending.matchesRelayCopy(copy, pending.length)); + } + memcpy(copy, pending.bytes, pending.length); + assert(!pending.matchesRelayCopy(nullptr, pending.length)); + assert(!pending.matchesRelayCopy(copy, pending.length - 1)); + assert(!pending.matchesRelayCopy(copy, pending.length + 1)); + pending.relayed = false; + assert(!pending.matchesRelayCopy(copy, pending.length)); + pending.relayed = true; + pending.length = HeaderSize; + assert(!pending.matchesRelayCopy(copy, pending.length)); + pending.length = MaxFrameSize + 1; + assert(!pending.matchesRelayCopy(copy, pending.length)); + pending.clear(); + assert(!pending.matchesRelayCopy(copy, 0)); + puts("PASS relay_matching"); +} static void argument_bounds() { uint8_t frame[MaxFrameSize] = {}; char text[MaxTextSize + 2]; memset(text, 'x', sizeof(text)); @@ -347,6 +444,8 @@ int main(int argc, char** argv) { else if (!strcmp(argv[1], "malformed")) malformed_frames(); else if (!strcmp(argv[1], "nodeinfo")) node_info_frames(); else if (!strcmp(argv[1], "nodeinfo_bounds")) node_info_bounds(); + else if (!strcmp(argv[1], "nodeinfo_roles")) node_info_roles(); + else if (!strcmp(argv[1], "relay_matching")) relay_matching(); else if (!strcmp(argv[1], "bounds")) argument_bounds(); else if (!strcmp(argv[1], "extensions")) extensions(); else if (!strcmp(argv[1], "stock_zero_bitfield")) stock_zero_bitfield_frames(); @@ -386,15 +485,19 @@ def vectors(): node_inputs += [(DEFAULT_KEY, "LongFast", "\u6d4b\u8bd5".encode(), "\u6d4bA".encode(), FROM, hops) for hops in (2, 4, 5, 6)] node_info = [] + relay_node_info = [] for offset, (key, name, long_name, short_name, sender, hops) in enumerate(node_inputs): packet_id = PACKET_ID + offset frame = reference_frame(data_nodeinfo(long_name, short_name, sender), name, key, sender=sender, packet_id=packet_id, flags=hops | (hops << 5)) - node_info.append("{" + array(key) + "," + str(len(key)) + ',"' + name + '",' - + literal(long_name) + "," + str(len(long_name)) + "," - + literal(short_name) + "," + str(len(short_name)) + "," - + str(sender) + "U," + str(packet_id) + "U," + str(hops) + "," - + array(frame) + "," + str(len(frame)) + "}") + prefix = ("{" + array(key) + "," + str(len(key)) + ',"' + name + '",' + + literal(long_name) + "," + str(len(long_name)) + "," + + literal(short_name) + "," + str(len(short_name)) + "," + + str(sender) + "U," + str(packet_id) + "U," + str(hops) + ",") + node_info.append(prefix + array(frame) + "," + str(len(frame)) + "}") + relay = reference_frame(data_nodeinfo(long_name, short_name, sender, rebroadcast=True), + name, key, sender=sender, packet_id=packet_id, flags=hops | (hops << 5)) + relay_node_info.append(prefix + array(relay) + "," + str(len(relay)) + "}") bad_data = { "empty Data": b"", "missing port": b"\x12\x01x", "missing text": b"\x08\x01", "empty text": b"\x08\x01\x12\x00", "other app": b"\x08\x02\x12\x01x", @@ -441,6 +544,7 @@ def vectors(): return (HARNESS.replace("@GOLDEN@", ",\n".join(golden)).replace("@MALFORMED@", ",\n".join(malformed)) .replace("@STOCK_ZERO_BITFIELD@", ",\n".join(stock_zero_bitfield)) .replace("@NODEINFO_GOLDEN@", ",\n".join(node_info)) + .replace("@RELAY_NODEINFO_GOLDEN@", ",\n".join(relay_node_info)) .replace("@MODEMS@", ",\n".join(modems)).replace("@EXTENSION_FRAME@", array(extension)) .replace("@REQUEST_FRAME@", array(request))) @@ -536,6 +640,12 @@ class MeshtextChatTest(unittest.TestCase): def test_nodeinfo_stock_name_utf8_and_argument_bounds(self): self.run_case("nodeinfo_bounds") + def test_default_nodeinfo_unchanged_and_opt_in_changes_only_client_role(self): + self.run_case("nodeinfo_roles") + + def test_bounded_relay_copy_match_and_origin_echo_are_disjoint_without_ram_growth(self): + self.run_case("relay_matching") + def test_argument_and_text_bounds(self): self.run_case("bounds")