Files
firmware/src/mesh/NextHopRouter.cpp
T
TomandBen Meadors 20f7ab1be9 Relay a PKI unicast with a known party in LOCAL_ONLY and KNOWN_ONLY (#11898)
* fix(router): relay a PKI unicast with a known party in LOCAL_ONLY and KNOWN_ONLY

A frame the relay cannot decrypt takes the OPAQUE_RELAY_ONLY path in
Router::perhapsHandleReceived() and returns before handleReceived(), so it never
reaches a module. The rebroadcast_mode rule for opaque traffic is therefore the
IS_ONE_OF list in relayOpaquePacket(), and LOCAL_ONLY and KNOWN_ONLY were not on
it: a node in either mode dropped every opaque frame, including a PKI unicast
with a party it knows. The gate in RoutingModule::handleReceivedProtobuf() that
used to allow exactly that is unreachable for these packets and no longer
decides anything.

Add both modes to the list, with the identity rule the unreachable gate carried:
a PKI-shaped unicast (channel 0, not broadcast) with `from` or `to` known to us.
An unreadable broadcast and a unicast between two strangers stay dropped in
these modes, which is what the proto documents - they ignore foreign meshes.

This is not only direct messages. Remote administration and key verification are
PKI unicasts too, and a KNOWN_ONLY relay was black-holing those between two
other nodes just the same.

CORE_PORTNUMS_ONLY reached this list the same way in #11843; this is the
remaining pair.

* test(rebroadcast_mode): pin the relay decision per mode, in both directions

What this node carries for other nodes, per DeviceConfig.rebroadcast_mode, for
packets it can read and packets it cannot. The harness pushes a real PKI frame
through Router::perhapsHandleReceived() and counts what reaches the radio.

Both directions are load-bearing, and each is guarded by cases the other leaves
green. Measured by rebuilding the firmware three ways:

  - as shipped: 9/9 pass.
  - with the two modes taken back out of relayOpaquePacket(): the known-party
    and remote-admin cases fail, the stranger and foreign-mesh cases still pass.
  - with the modes listed but the identity qualifier deleted: the stranger and
    foreign-mesh cases fail, the known-party cases still pass.

So a revert and an over-broadening each fail their own tests, and neither can be
satisfied by breaking the other.

The suite and its harness come from the opaque-packet-handling branch and
compile against develop unmodified. Two assertions were dropped because they
pin behaviour this branch does not add: delivery of unreadable frames to the
phone, and a queued copy of our own suppressing the originator's repeat, which
needs relayOpaquePacket() to consult the TX queue.

* fix(test): guard the harness include, drop a comment its test outlived

Two review findings on the imported suite.

The harness builds real PKI frames through CryptoEngine entry points a
MESHTASTIC_EXCLUDE_PKI build does not declare, but it was included above the
guard, so the empty-suite branch that exists for those builds could not compile.
Move the include inside the guard and pull the base includes the stub branch
needs above it.

The phone-delivery case was dropped when the suite came across - this branch
does not deliver unreadable frames to the phone - but its comment stayed behind
and now described the test below it, which is about the signature policy.

* test(rebroadcast_mode): make the from-known and channel-0 operands load-bearing

The qualifier has three operands, and the suite only exercised one of them.

Every existing case that a known party carries has a known DESTINATION: the
remote-admin case marks both parties, the known-destination case marks the
target. Rewriting the identity test to consult p->to alone passed all nine. And
the only non-PKI-shaped frame in the suite was a broadcast, which
!isBroadcast(p->to) rejects before p->channel is ever read, so deleting the
channel gate passed all nine too.

Add the two cases that close it: a known SOURCE with a destination we have never
heard of, which must relay in both modes, and a known party on a channel hash we
do not hold, which must not - that is someone else's channel traffic, addressed,
not PKI.

Measured both ways. With the from operand and the channel gate removed from
relayOpaquePacket(), the two new cases fail and the other nine pass; with the
shipping code, 11/11.

Raised by CodeRabbit on #11898.

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-09-20 10:29:48 +00:00

667 lines
31 KiB
C++

#include "NextHopRouter.h"
#include "Default.h"
#include "MeshTypes.h"
#include "Throttle.h"
#include "UptimeClock.h"
#include "meshUtils.h"
#if !MESHTASTIC_EXCLUDE_TRACEROUTE
#include "modules/TraceRouteModule.h"
#endif
#if HAS_TRAFFIC_MANAGEMENT
#include "modules/TrafficManagementModule.h"
#endif
#include "NodeDB.h"
#if USERPREFS_EVENT_MODE
static void capEventRelayHops(meshtastic_MeshPacket *packet)
{
if (packet->hop_limit <= Default::eventModeRelayHopLimit)
return;
const uint8_t reduction = packet->hop_limit - Default::eventModeRelayHopLimit;
packet->hop_start = reduction <= packet->hop_start ? packet->hop_start - reduction : 0;
packet->hop_limit = Default::eventModeRelayHopLimit;
}
#endif
NextHopRouter::NextHopRouter() {}
bool NextHopRouter::relayOpaquePacket(const meshtastic_MeshPacket *p)
{
// Opaque traffic is never admitted to PacketHistory, NodeDB, modules, phone, MQTT, or ACK
// handling. Relay only from the immutable outer routing header and let hop exhaustion bound it.
const auto mode = config.device.rebroadcast_mode;
if (!iface || isToUs(p) || isFromUs(p) || p->id == 0 || p->hop_limit == 0 || !isRebroadcaster() || owner.is_licensed ||
!IS_ONE_OF(mode, meshtastic_Config_DeviceConfig_RebroadcastMode_ALL,
meshtastic_Config_DeviceConfig_RebroadcastMode_ALL_SKIP_DECODING,
meshtastic_Config_DeviceConfig_RebroadcastMode_CORE_PORTNUMS_ONLY,
meshtastic_Config_DeviceConfig_RebroadcastMode_LOCAL_ONLY,
meshtastic_Config_DeviceConfig_RebroadcastMode_KNOWN_ONLY) ||
(p->next_hop != NO_NEXT_HOP_PREFERENCE && p->next_hop != nodeDB->getLastByteOfNodeNum(getNodeNum())))
return false;
// LOCAL_ONLY and KNOWN_ONLY gate on identity, and the only opaque frame carrying its parties in
// the header is a PKI-shaped unicast: relay one just when a party is known, which is the rule
// RoutingModule applied before opaque frames stopped reaching modules.
if (IS_ONE_OF(mode, meshtastic_Config_DeviceConfig_RebroadcastMode_LOCAL_ONLY,
meshtastic_Config_DeviceConfig_RebroadcastMode_KNOWN_ONLY) &&
!(p->channel == 0 && !isBroadcast(p->to) &&
(nodeInfoLiteHasUser(nodeDB->getMeshNode(p->from)) || nodeInfoLiteHasUser(nodeDB->getMeshNode(p->to)))))
return false;
// Dedup opaque relays. Opaque frames deliberately never enter PacketHistory (so unauthenticated
// traffic can't influence routing/ACK/next-hop) - but with NO dedup at all, a dense mesh re-relays
// every copy of every frame, multiplying at each hop into an unbounded broadcast storm ("let hop
// exhaustion bound it" caps depth, not count). Suppress duplicate opaque rebroadcasts with a small,
// routing-isolated seen-set. Genuine originator (re)transmissions (hop_start == hop_limit) are
// always relayed so reliable opaque unicast still propagates (mirrors FloodingRouter's isRepeated).
const bool isOriginatorTx = p->hop_start > 0 && p->hop_start == p->hop_limit;
if (opaqueWasSeenRecently(getFrom(p), p->id) && !isOriginatorTx) {
LOG_TRACE("Drop duplicate opaque relay from 0x%08x id 0x%08x", getFrom(p), p->id);
return false;
}
meshtastic_MeshPacket *relay = packetPool.allocCopy(*p);
if (!relay)
return false;
relay->hop_limit--;
#if USERPREFS_EVENT_MODE
capEventRelayHops(relay);
#endif
relay->relay_node = nodeDB->getLastByteOfNodeNum(getNodeNum());
// The interface declines some packets (NODENUM_BROADCAST_NO_LORA) with ERRNO_SHOULD_RELEASE,
// which leaves the copy ours to free. Dropping it here would leak a pool slot per opaque frame.
ErrorCode res = Router::send(relay);
if (res == ERRNO_SHOULD_RELEASE)
packetPool.release(relay);
return res == ERRNO_OK;
}
// Isolated dedup for opaque relays (see relayOpaquePacket). Returns true if (from,id) is already in the
// ring; otherwise records it (round-robin eviction) and returns false. A separate table from
// PacketHistory on purpose: opaque frames must never influence routing/ACK/next-hop. No timestamps -
// a stale (from,id) can't false-match a later packet because ids are effectively random.
bool NextHopRouter::opaqueWasSeenRecently(NodeNum from, PacketId id)
{
for (uint8_t i = 0; i < OPAQUE_SEEN_MAX; i++) {
if (opaqueSeen[i].sender == from && opaqueSeen[i].id == id)
return true;
}
// Not seen: record it, overwriting the oldest-written slot (FIFO). Empty slots hold id 0, which a
// real entry never has (relayOpaquePacket drops id 0), so they simply never match above.
opaqueSeen[opaqueSeenNext].sender = from;
opaqueSeen[opaqueSeenNext].id = id;
opaqueSeenNext = (uint8_t)((opaqueSeenNext + 1) % OPAQUE_SEEN_MAX);
return false;
}
PendingPacket::PendingPacket(meshtastic_MeshPacket *p, uint8_t numRetransmissions)
{
packet = p;
this->numRetransmissions = numRetransmissions - 1; // We subtract one, because we assume the user just did the first send
this->initialNumRetransmissions = this->numRetransmissions;
}
/**
* Send a packet
*/
ErrorCode NextHopRouter::send(meshtastic_MeshPacket *p)
{
return sendWithNextHop(p, true);
}
ErrorCode NextHopRouter::sendWithNextHop(meshtastic_MeshPacket *p, bool trackRetransmission)
{
// Add any messages _we_ send to the seen message list (so we will ignore all retransmissions we see)
p->relay_node = nodeDB->getLastByteOfNodeNum(getNodeNum()); // First set the relayer to us
wasSeenRecently(p); // FIXME, move this to a sniffSent method
p->next_hop = getNextHop(p->to, p->relay_node).value_or(NO_NEXT_HOP_PREFERENCE); // set the next hop
LOG_TRACE("Set next hop for dest 0x%08x to 0x%x", p->to, p->next_hop);
// If it's from us, ReliableRouter already handles retransmissions if want_ack is set. If a next hop is set and hop limit is
// not 0 or want_ack is set, start retransmissions
if (trackRetransmission && (!isFromUs(p) || !p->want_ack) && p->next_hop != NO_NEXT_HOP_PREFERENCE &&
(p->hop_limit > 0 || p->want_ack)) {
if (auto *copy = packetPool.allocCopy(*p))
startRetransmission(copy); // start retransmission for relayed packet
}
return Router::send(p);
}
bool NextHopRouter::shouldFilterReceived(const meshtastic_MeshPacket *p)
{
bool wasFallback = false;
bool weWereNextHop = false;
bool wasUpgraded = false;
bool seenRecently = wasSeenRecently(p, true, &wasFallback, &weWereNextHop,
&wasUpgraded); // Updates history; returns false when an upgrade is detected
// Handle hop_limit upgrade scenario for rebroadcasters
if (wasUpgraded && perhapsHandleUpgradedPacket(p)) {
return true; // we handled it, so stop processing
}
if (seenRecently) {
printPacket("Ignore dupe incoming msg", p);
if (p->transport_mechanism == meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA) {
rxDupe++;
stopRetransmission(p->from, p->id);
}
// If it was a fallback to flooding, try to relay again
if (wasFallback) {
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)) {
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)) {
if (reprocessPacket(p) && !isBlockedEventCoordinatePacket(p) && !perhapsRebroadcast(p) && isToUs(p) &&
p->want_ack) {
sendAckNak(meshtastic_Routing_Error_NONE, getFrom(p), p->id, p->channel, 0);
}
}
} else if (!weWereNextHop) {
perhapsCancelDupe(p); // If it's a dupe, cancel relay if we were not explicitly asked to relay
}
}
return true;
}
return Router::shouldFilterReceived(p);
}
void NextHopRouter::sniffReceived(const meshtastic_MeshPacket *p, const meshtastic_Routing *c)
{
NodeNum ourNodeNum = getNodeNum();
uint8_t ourRelayID = nodeDB->getLastByteOfNodeNum(ourNodeNum);
bool isAckorReply = (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag) &&
(p->decoded.request_id != 0 || p->decoded.reply_id != 0);
if (isAckorReply) {
// Update next-hop for the original transmitter of this successful transmission to the relay node, but ONLY if "from"
// is not 0 (means implicit ACK) and original packet was also relayed by this node, or we sent it directly to the
// destination
if (p->from != 0) {
meshtastic_NodeInfoLite *origTx = nodeDB->getMeshNode(p->from);
// Either relayer of ACK was also a relayer of the packet, or we were the *only* relayer and the ACK came
// directly from the destination. checkRelayers is read-only on PacketHistory and O(1), so we run it even
// when origTx is absent - that lets us still capture the confirmed hop into the TMM overflow cache below.
// Single lookup for both relayer checks on the same (request_id, to) pair
bool wasAlreadyRelayer = false;
bool weWereSoleRelayer = false;
bool weWereRelayer = false;
checkRelayers(p->relay_node, ourRelayID, p->decoded.request_id, p->to, &wasAlreadyRelayer, &weWereRelayer,
&weWereSoleRelayer);
if ((weWereRelayer && wasAlreadyRelayer) || (getHopsAway(*p) == 0 && weWereSoleRelayer)) {
// M1/M2: only learn a next hop whose last byte maps to a single plausible relay. On a dense
// mesh the byte may be ambiguous; storing it would aim future DMs at the wrong node. This gate
// now protects BOTH the hot-store route (NodeInfoLite.next_hop) AND the TMM overflow cache -
// the overflow cache deliberately holds many more next-hop bytes (long-tail nodes), so it is
// even more collision-prone and must never store an ambiguous byte either. Ambiguous/unknown
// -> store nothing and keep flooding (safe).
if (nodeDB->resolveUniqueLastByte(p->relay_node, /*requireDirectNeighbor=*/false)) {
if (origTx && origTx->next_hop != p->relay_node) { // Not already set
LOG_INFO("Update next hop of 0x%08x to 0x%x from ACK/reply (was relayer %d we were sole %d)", p->from,
p->relay_node, wasAlreadyRelayer, weWereSoleRelayer);
origTx->next_hop = p->relay_node;
}
noteRouteLearned(p->from, p->relay_node, Time::stampMillis()); // M3: anchor freshness (hot or overflow route)
#if HAS_TRAFFIC_MANAGEMENT
// Mirror the confirmed (and now unique-resolved) hop into the TMM overflow cache so it
// survives even when the source isn't (or is no longer) in the hot NodeDB.
if (trafficManagementModule)
trafficManagementModule->setNextHop(p->from, p->relay_node);
#endif
} else {
LOG_DEBUG("Not learning next hop for 0x%08x: relay byte 0x%x ambiguous/unknown; keep flooding", p->from,
p->relay_node);
}
}
}
if (!isToUs(p)) {
Router::cancelSending(p->to, p->decoded.request_id); // cancel rebroadcast for this DM
// stop retransmission for the original packet
stopRetransmission(p->to, p->decoded.request_id); // for original packet, from = to and id = request_id
}
}
perhapsRebroadcast(p);
// handle the packet as normal
Router::sniffReceived(p, c);
}
/* 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
if (trafficManagementModule && trafficManagementModule->shouldExhaustHops(*p)) {
exhaustHops = true;
}
#endif
if (p->to == NODENUM_BROADCAST_NO_LORA)
return false;
// Allow rebroadcast if hop_limit > 0 OR if we're exhausting hops (which sets hop_limit = 0 but still needs one relay)
if (!isToUs(p) && !isFromUs(p) && (p->hop_limit > 0 || exhaustHops)) {
if (p->id != 0) {
if (isRebroadcaster()) {
// NOTE: this is a self-identity match (is the addressed next_hop OUR last byte?), so it
// cannot be hardened with resolveLastByte() - a remote node that legitimately shares our
// last byte will also match here and rebroadcast. That residual collision needs a wider
// on-wire field to fix. M1/M2 instead shrink the blast radius by reducing how often an
// ambiguous next_hop byte is ever learned (sniffReceived) or originated (getNextHop).
if (p->next_hop == NO_NEXT_HOP_PREFERENCE || p->next_hop == nodeDB->getLastByteOfNodeNum(getNodeNum())) {
meshtastic_MeshPacket *tosend = packetPool.allocCopy(*p); // keep a copy because we will be sending it
if (!tosend)
return true;
LOG_INFO("Rebroadcast msg from %x", p->relay_node);
// If exhausting hops, force hop_limit = 0 regardless of other logic
if (exhaustHops) {
tosend->hop_limit = 0;
LOG_INFO("Traffic management: exhaust hops for 0x%08x, hop_limit=0", getFrom(p));
} else if (shouldDecrementHopLimit(p)) {
// Use shared logic to determine if hop_limit should be decremented
tosend->hop_limit--; // bump down the hop count
} else {
LOG_INFO("favorite-ROUTER/CLIENT_BASE-to-ROUTER/CLIENT_BASE rebroadcast: keep hop_limit");
}
#if USERPREFS_EVENT_MODE
capEventRelayHops(tosend);
#endif
ErrorCode res =
(p->next_hop == NO_NEXT_HOP_PREFERENCE) ? FloodingRouter::send(tosend) : NextHopRouter::send(tosend);
if (res == ERRNO_SHOULD_RELEASE)
packetPool.release(tosend);
return true;
}
} else {
LOG_DEBUG("No rebroadcast: Role = CLIENT_MUTE or Rebroadcast Mode = NONE");
}
} else {
LOG_DEBUG("Ignore 0 id broadcast");
}
}
return false;
}
/**
* Get the next hop for a destination, given the relay node
* @return the node number of the next hop, 0 if no preference (fallback to FloodingRouter)
*/
std::optional<uint8_t> NextHopRouter::getNextHop(NodeNum to, uint8_t relay_node)
{
if (isBroadcast(to))
return std::nullopt;
// Hot store first: a direct array hit on the live NodeDB entry.
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(to);
if (node && node->next_hop) {
// M3: proactively decay a stale or repeatedly-failing route back to flooding, so a dead hop
// isn't trusted on the next DM's first (and on dense meshes, slowest) attempt. We only act on
// a health record that still matches the stored byte; a next_hop set by another path (e.g.
// TraceRouteModule) with no matching record is left authoritative.
const RouteHealth *h = findRouteHealth(to);
if (h && h->lastNextHop == node->next_hop && isRouteStale(*h, Time::stampMillis())) {
LOG_INFO("Next hop 0x%x for 0x%08x stale (age/fails); flood and clear", node->next_hop, to);
node->next_hop = NO_NEXT_HOP_PREFERENCE; // clear persisted route
clearRouteHealth(to); // clear RAM health
return std::nullopt;
}
// We are careful not to return the relay node as the next hop
if (node->next_hop != relay_node) {
// M1/M2: only emit a stored next_hop if its last byte still maps to a UNIQUE, currently
// reachable direct neighbor. On a dense mesh the last byte collides, so an ambiguous byte
// would unicast a hint toward the wrong physical node; if the neighbor has gone away we'd
// unicast into a void. In both cases flood instead (managed flooding still delivers).
ResolvedNode r = nodeDB->resolveLastByte(node->next_hop, /*requireDirectNeighbor=*/true);
if (r.status == LastByteResolution::Unique)
return node->next_hop;
LOG_WARN("Next hop 0x%x for 0x%08x %s; set no pref", node->next_hop, to,
r.status == LastByteResolution::Ambiguous ? "ambiguous among neighbors" : "not a known neighbor");
} else
LOG_WARN("Next hop for 0x%08x is 0x%x, same as relayer; set no pref", to, node->next_hop);
}
#if HAS_TRAFFIC_MANAGEMENT
// Fallback: TMM overflow cache holds confirmed hops for nodes that have aged out of the hot store.
// It is the same byte source/confidence as NodeInfoLite.next_hop, so it gets the same M1/M2/M3
// protection: decay a stale/failing route, then only emit a byte that still resolves to a unique
// reachable neighbor. Without this the overflow cache (which holds MORE bytes for MORE nodes) would
// reintroduce exactly the silent-misroute that M1/M2 closes on the hot path.
if (trafficManagementModule) {
uint8_t hint = trafficManagementModule->getNextHopHint(to);
if (hint && hint != relay_node) {
const RouteHealth *h = findRouteHealth(to);
if (h && h->lastNextHop == hint && isRouteStale(*h, Time::stampMillis())) {
LOG_INFO("TMM next hop 0x%x for 0x%08x stale (age/fails); flood and clear", hint, to);
trafficManagementModule->clearNextHop(to); // clear overflow route (setNextHop won't store 0)
clearRouteHealth(to); // clear RAM health
return std::nullopt;
}
ResolvedNode r = nodeDB->resolveLastByte(hint, /*requireDirectNeighbor=*/true);
if (r.status == LastByteResolution::Unique) {
LOG_TRACE("Next hop for 0x%08x is 0x%x (TMM cache)", to, hint);
return hint;
}
LOG_WARN("TMM next hop 0x%x for 0x%08x %s; set no pref", hint, to,
r.status == LastByteResolution::Ambiguous ? "ambiguous among neighbors" : "not a known neighbor");
}
}
#endif
return std::nullopt;
}
PendingPacket *NextHopRouter::findPendingPacket(GlobalPacketId key)
{
auto old = pending.find(key); // If we have an old record, someone messed up because id got reused
if (old != pending.end()) {
return &old->second;
} else
return NULL;
}
/**
* Stop any retransmissions we are doing of the specified node/packet ID pair
*/
bool NextHopRouter::stopRetransmission(NodeNum from, PacketId id)
{
auto key = GlobalPacketId(from, id);
return stopRetransmission(key);
}
bool NextHopRouter::roleAllowsCancelingFromTxQueue(const meshtastic_MeshPacket *p)
{
// Return true if we're allowed to cancel a packet in the txQueue (so we may never transmit it even once)
// Return false for roles like ROUTER, ROUTER_LATE which should always transmit the packet at least once.
return roleAllowsCancelingDupe(p); // same logic as FloodingRouter::roleAllowsCancelingDupe
}
bool NextHopRouter::stopRetransmission(GlobalPacketId key)
{
auto old = findPendingPacket(key);
if (old) {
auto p = old->packet;
/* Only when we already transmitted a packet via LoRa, we will cancel the packet in the Tx queue
to avoid canceling a transmission if it was ACKed super fast via MQTT */
if (old->numRetransmissions < old->initialNumRetransmissions) {
// We only cancel it if we are the original sender or if we're not a router(_late)
if (isFromUs(p) || roleAllowsCancelingFromTxQueue(p)) {
// remove the 'original' (identified by originator and packet->id) from the txqueue and free it
cancelSending(getFrom(p), p->id);
}
}
// Regardless of whether or not we canceled this packet from the txQueue, remove it from our pending list so it
// doesn't get scheduled again. (This is the core of stopRetransmission.)
auto numErased = pending.erase(key);
assert(numErased == 1);
// When we remove an entry from pending, always be sure to release the copy of the packet that was allocated in the
// call to startRetransmission.
packetPool.release(p);
return true;
} else
return false;
}
/**
* Add p to the list of packets to retransmit occasionally. We will free it once we stop retransmitting.
*/
PendingPacket *NextHopRouter::startRetransmission(meshtastic_MeshPacket *p, uint8_t numReTx)
{
auto id = GlobalPacketId(p);
auto rec = PendingPacket(p, numReTx);
stopRetransmission(getFrom(p), p->id);
setNextTx(&rec);
pending[id] = rec;
return &pending[id];
}
/**
* Do any retransmissions that are scheduled (FIXME - for the time being called from loop)
*/
int32_t NextHopRouter::doRetransmissions()
{
// Same clock Throttle reads, so setNextTx() deadlines and this test can't diverge under an
// injected test clock.
uint32_t now = Time::stampMillis();
int32_t d = INT32_MAX;
// FIXME, we should use a better datastructure rather than walking through this map.
// for(auto el: pending) {
for (auto it = pending.begin(), nextIt = it; it != pending.end(); it = nextIt) {
++nextIt; // we use this odd pattern because we might be deleting it...
auto &p = it->second;
bool stillValid = true; // assume we'll keep this record around
// Judged against the snapshot above, so one pass sees one instant and the 49.7 day wrap
// can't stall retransmission.
if (Throttle::deadlinePassedAt(now, p.nextTxMsec)) {
if (p.numRetransmissions == 0) {
if (isFromUs(p.packet)) {
LOG_DEBUG("Reliable send failed, return nak fr=0x%08x,to=0x%08x,id=0x%08x", p.packet->from, p.packet->to,
p.packet->id);
sendAckNak(meshtastic_Routing_Error_MAX_RETRANSMIT, getFrom(p.packet), p.packet->id, p.packet->channel);
}
// Note: we don't stop retransmission here, instead the Nak packet gets processed in sniffReceived
stopRetransmission(it->first);
stillValid = false; // just deleted it
} else {
LOG_DEBUG("Send retransmission fr=0x%08x,to=0x%08x,id=0x%08x, tries left=%d", p.packet->from, p.packet->to,
p.packet->id, p.numRetransmissions);
if (!isBroadcast(p.packet->to)) {
if (p.numRetransmissions == 1) {
// Last retransmission: this directed delivery went un-ACKed. Record the failure
// (M3 - accumulates across DMs to age out a flapping/dead route) and reset
// next_hop so the final try falls back to FloodingRouter.
noteRouteFailure(p.packet->to);
p.packet->next_hop = NO_NEXT_HOP_PREFERENCE;
// Also reset it in the nodeDB
meshtastic_NodeInfoLite *sentTo = nodeDB->getMeshNode(p.packet->to);
if (sentTo) {
LOG_INFO("Reset next hop for dest 0x%08x", p.packet->to);
sentTo->next_hop = NO_NEXT_HOP_PREFERENCE;
}
#if HAS_TRAFFIC_MANAGEMENT
if (trafficManagementModule) {
trafficManagementModule->clearNextHop(p.packet->to);
}
#endif
if (auto *copy = packetPool.allocCopy(*p.packet)) {
if (FloodingRouter::send(copy) == ERRNO_SHOULD_RELEASE)
packetPool.release(copy);
}
} else {
#if NEXTHOP_EARLY_FLOOD_ON_UNVERIFIED
// M4 (gated): if the route isn't proven healthy, don't spend a second directed
// attempt - start flooding one retry sooner to cut recovery latency. A verified
// route (fresh, zero recent failures) keeps the unchanged directed-retry path so
// the sparse-mesh happy path is untouched.
RouteHealth *h = findRouteHealth(p.packet->to);
bool verified = h && h->consecutiveFailures == 0 && !isRouteStale(*h, now);
if (!verified) {
p.packet->next_hop = NO_NEXT_HOP_PREFERENCE;
meshtastic_NodeInfoLite *sentTo = nodeDB->getMeshNode(p.packet->to);
if (sentTo)
sentTo->next_hop = NO_NEXT_HOP_PREFERENCE;
if (auto *copy = packetPool.allocCopy(*p.packet)) {
if (FloodingRouter::send(copy) == ERRNO_SHOULD_RELEASE)
packetPool.release(copy);
}
} else {
if (auto *copy = packetPool.allocCopy(*p.packet)) {
if (sendWithNextHop(copy, false) == ERRNO_SHOULD_RELEASE)
packetPool.release(copy);
}
}
#else
if (auto *copy = packetPool.allocCopy(*p.packet)) {
if (sendWithNextHop(copy, false) == ERRNO_SHOULD_RELEASE)
packetPool.release(copy);
}
#endif
}
} else {
// Note: we call the superclass version because we don't want to have our version of send() add a new
// retransmission record
if (auto *copy = packetPool.allocCopy(*p.packet)) {
if (FloodingRouter::send(copy) == ERRNO_SHOULD_RELEASE)
packetPool.release(copy);
}
}
// Queue again
--p.numRetransmissions;
setNextTx(&p);
}
}
if (stillValid) {
// Update our desired sleep delay
int32_t t = p.nextTxMsec - now;
d = min(t, d);
}
}
return d;
}
void NextHopRouter::setNextTx(PendingPacket *pending)
{
assert(iface);
auto d = iface->getRetransmissionMsec(pending->packet);
pending->nextTxMsec = Time::getMillis() + d;
LOG_TRACE("Next retransmission in %u msecs", d);
printPacket("", pending->packet);
setReceivedMessage(); // Run ASAP, so we can figure out our correct sleep time
}
// ---------------------------------------------------------------------------
// M3: RAM route-health table. Bounded array with reuse-oldest eviction (same discipline as
// PacketHistory). All age comparisons use unsigned subtraction so they survive the 49.7-day millis()
// rollover. dest == 0 marks an empty slot; learnedAtMsec is normalized to 1 on write so an occupied
// slot is never read as infinitely old.
// ---------------------------------------------------------------------------
RouteHealth *NextHopRouter::findRouteHealth(NodeNum dest)
{
if (dest == 0)
return nullptr;
for (auto &h : routeHealth)
if (h.dest == dest)
return &h;
return nullptr;
}
RouteHealth *NextHopRouter::getOrAllocRouteHealth(NodeNum dest, uint32_t now)
{
if (dest == 0)
return nullptr;
RouteHealth *oldest = &routeHealth[0];
RouteHealth *freeSlot = nullptr;
for (auto &h : routeHealth) {
if (h.dest == dest)
return &h; // existing record
if (h.dest == 0) {
if (!freeSlot)
freeSlot = &h; // remember the first free slot; prefer it over evicting
continue;
}
// Track the oldest occupied slot in case the table is full (rollover-safe).
if ((uint32_t)(now - h.learnedAtMsec) > (uint32_t)(now - oldest->learnedAtMsec))
oldest = &h;
}
// Claim the free slot if there is one, else reuse the oldest. Reset before use and stamp the dest
// so the record is findable.
RouteHealth *slot = freeSlot ? freeSlot : oldest;
*slot = RouteHealth{};
slot->dest = dest;
return slot;
}
void NextHopRouter::noteRouteLearned(NodeNum dest, uint8_t nextHop, uint32_t now)
{
if (dest == 0 || nextHop == NO_NEXT_HOP_PREFERENCE)
return;
RouteHealth *h = getOrAllocRouteHealth(dest, now);
if (!h)
return;
// A genuinely new next hop earns a clean slate; re-learning the SAME hop keeps the accumulated
// failure count so an asymmetric reverse path that keeps re-teaching a dead forward hop still ages
// out instead of resetting the counter every time.
if (h->lastNextHop != nextHop) {
h->lastNextHop = nextHop;
h->consecutiveFailures = 0;
}
// `now` is a parameter, so guard at the store too: 0 is the empty-slot marker.
h->learnedAtMsec = Time::skipZero(now);
}
void NextHopRouter::noteRouteSuccess(NodeNum dest, uint32_t now)
{
RouteHealth *h = findRouteHealth(dest);
if (!h)
return; // only routes we actually learned have health to refresh
h->consecutiveFailures = 0;
h->learnedAtMsec = Time::skipZero(now); // a parameter, so guard at the store too
}
void NextHopRouter::noteRouteFailure(NodeNum dest)
{
RouteHealth *h = findRouteHealth(dest);
if (!h)
return; // nothing to penalize (we were flooding, or never learned a route here)
if (h->consecutiveFailures < 255)
h->consecutiveFailures++;
}
bool NextHopRouter::isRouteStale(const RouteHealth &h, uint32_t now) const
{
if (h.consecutiveFailures >= ROUTE_FAILURE_THRESHOLD)
return true;
return (uint32_t)(now - h.learnedAtMsec) >= ROUTE_TTL_MSEC;
}
void NextHopRouter::clearRouteHealth(NodeNum dest)
{
RouteHealth *h = findRouteHealth(dest);
if (h)
*h = RouteHealth{};
}