Merge branch 'develop' into codex/packet-auth-policy
Resolve conflicts against the NodeDB signer/key primitives (#11050) and the admin-key PKI decrypt budget (#11100). - NodeDB: drop this branch's hasSeenXeddsaSigner in favour of develop's isKnownXeddsaSigner. They answer the same question, but develop's reads the dedicated warm signer bit (warmSignerOf) rather than the WarmProtected category, and TrafficManagementModule already depends on it. Keep develop's copyPublicKey/copyPublicKeyAuthoritative, isVerifiedSignerForKey and commitRemoteKey/KeyCommitTrust. - checkXeddsaReceivePolicy: keep this branch's Strict/Balanced/Compatible policy, which is a superset of develop's balanced-only downgrade gate, and call isKnownXeddsaSigner from it. develop's !pki_encrypted term is dropped because the policy returns early for PKI packets before that check. - perhapsDecode: keep develop's key resolution (NodeDB then pending-key, only for real PKI candidates) plus its admin-key token bucket, and re-apply this branch's pkiAttempted flag feeding the DECODE_OPAQUE verdict. Keep both passesRoutingAuthGate and adminKeyFallbackAllowed/Refund. - test_A17: model eviction the way NodeDB actually does it, passing the warm signer bit as well as the XeddsaSigner category, since isKnownXeddsaSigner reads the former. Native suite: 38 suites, 743/743 cases, no sanitizer findings.
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
#include "graphics/emotes.h"
|
||||
#include "main.h"
|
||||
#include "meshUtils.h"
|
||||
#include "modules/CannedMessageModule.h"
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -1073,6 +1074,7 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht
|
||||
{
|
||||
if (packet.from != 0) {
|
||||
hasUnreadMessage = true;
|
||||
const bool suppressBanner = cannedMessageModule && cannedMessageModule->isFreeTextActive();
|
||||
|
||||
// Determine if message belongs to a muted channel
|
||||
bool isChannelMuted = false;
|
||||
@@ -1163,11 +1165,13 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht
|
||||
// Shorter banner if already in a conversation (Channel or Direct)
|
||||
bool inThread = (getThreadMode() != ThreadMode::ALL);
|
||||
|
||||
if (shouldWakeOnReceivedMessage()) {
|
||||
if (!suppressBanner && shouldWakeOnReceivedMessage()) {
|
||||
screen->setOn(true);
|
||||
}
|
||||
|
||||
screen->showSimpleBanner(banner, inThread ? 1000 : 3000);
|
||||
if (!suppressBanner) {
|
||||
screen->showSimpleBanner(banner, inThread ? 1000 : 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// Always focus into the correct conversation thread when a message with real text arrives
|
||||
|
||||
@@ -42,9 +42,9 @@ static inline void drawSatelliteIcon(OLEDDisplay *display, int16_t x, int16_t y)
|
||||
{
|
||||
int yOffset = (currentResolution == ScreenResolution::High) ? -5 : 1;
|
||||
if (currentResolution == ScreenResolution::High) {
|
||||
NodeListRenderer::drawScaledXBitmap16x16(x, y + yOffset, imgSatellite_width, imgSatellite_height, imgSatellite, display);
|
||||
NodeListRenderer::drawScaledXBitmap16x16(x, y + yOffset, imgGPS_width, imgGPS_height, imgGPS, display);
|
||||
} else {
|
||||
display->drawXbm(x + 1, y + yOffset, imgSatellite_width, imgSatellite_height, imgSatellite);
|
||||
display->drawXbm(x + 1, y + yOffset, imgGPS_width, imgGPS_height, imgGPS);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -521,9 +521,9 @@ void UIRenderer::drawGps(OLEDDisplay *display, int16_t x, int16_t y, const mesht
|
||||
{
|
||||
// Draw satellite image
|
||||
if (currentResolution == ScreenResolution::High) {
|
||||
NodeListRenderer::drawScaledXBitmap16x16(x, y - 2, imgSatellite_width, imgSatellite_height, imgSatellite, display);
|
||||
NodeListRenderer::drawScaledXBitmap16x16(x, y - 2, imgGPS_width, imgGPS_height, imgGPS, display);
|
||||
} else {
|
||||
display->drawXbm(x + 1, y + 1, imgSatellite_width, imgSatellite_height, imgSatellite);
|
||||
display->drawXbm(x + 1, y + 1, imgGPS_width, imgGPS_height, imgGPS);
|
||||
}
|
||||
char textString[10];
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ const uint8_t SATELLITE_IMAGE[] PROGMEM = {0x00, 0x08, 0x00, 0x1C, 0x00, 0x0E, 0
|
||||
const uint8_t imgSatellite[] PROGMEM = {
|
||||
0b00000000, 0b00000000, 0b00000000, 0b00011000, 0b11011011, 0b11111111, 0b11011011, 0b00011000,
|
||||
};
|
||||
#define imgGPS_width 8
|
||||
#define imgGPS_height 8
|
||||
const unsigned char imgGPS[] PROGMEM = {0x00, 0x07, 0x39, 0x2D, 0xFF, 0x48, 0x48, 0x60};
|
||||
|
||||
const uint8_t imgUSB[] PROGMEM = {0x00, 0xfc, 0xf0, 0xfc, 0x88, 0xff, 0x86, 0xfe, 0x85, 0xfe, 0x89, 0xff, 0xf1, 0xfc, 0x00, 0xfc};
|
||||
const uint8_t imgUSB_HighResolution[] PROGMEM = {0x00, 0x3e, 0xf8, 0x80, 0x43, 0xf8, 0xc0, 0xc2, 0xff, 0x60, 0x42, 0xfc,
|
||||
|
||||
@@ -2614,6 +2614,8 @@ uint16_t InkHUD::MenuApplet::getSystemInfoPanelHeight()
|
||||
void InkHUD::MenuApplet::sendText(NodeNum dest, ChannelIndex channel, const char *message)
|
||||
{
|
||||
meshtastic_MeshPacket *p = router->allocForSending();
|
||||
if (!p)
|
||||
return;
|
||||
p->decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP;
|
||||
p->to = dest;
|
||||
p->channel = channel;
|
||||
@@ -2622,7 +2624,7 @@ void InkHUD::MenuApplet::sendText(NodeNum dest, ChannelIndex channel, const char
|
||||
memcpy(p->decoded.payload.bytes, message, p->decoded.payload.size);
|
||||
|
||||
// Tack on a bell character if requested
|
||||
if (moduleConfig.canned_message.send_bell && p->decoded.payload.size < meshtastic_Constants_DATA_PAYLOAD_LEN) {
|
||||
if (moduleConfig.canned_message.send_bell && p->decoded.payload.size + 1 < meshtastic_Constants_DATA_PAYLOAD_LEN) {
|
||||
p->decoded.payload.bytes[p->decoded.payload.size] = 7; // Bell character
|
||||
p->decoded.payload.bytes[p->decoded.payload.size + 1] = '\0'; // Append Null Terminator
|
||||
p->decoded.payload.size++;
|
||||
|
||||
+17
-4
@@ -18,7 +18,7 @@ uint8_t MeshModule::numPeriodicModules = 0;
|
||||
*/
|
||||
meshtastic_MeshPacket *MeshModule::currentReply;
|
||||
|
||||
MeshModule::MeshModule(const char *_name) : name(_name)
|
||||
MeshModule::MeshModule(const char *_name, meshtastic_PortNum _ourPortNum) : name(_name), ourPortNum(_ourPortNum)
|
||||
{
|
||||
// Can't trust static initializer order, so we check each time
|
||||
if (!modules)
|
||||
@@ -27,6 +27,12 @@ MeshModule::MeshModule(const char *_name) : name(_name)
|
||||
modules->push_back(this);
|
||||
}
|
||||
|
||||
bool MeshModule::replyPortMatches(meshtastic_PortNum modulePort, const meshtastic_MeshPacket &mp)
|
||||
{
|
||||
return modulePort != meshtastic_PortNum_UNKNOWN_APP && mp.which_payload_variant == meshtastic_MeshPacket_decoded_tag &&
|
||||
mp.decoded.portnum == modulePort;
|
||||
}
|
||||
|
||||
void MeshModule::setup() {}
|
||||
|
||||
MeshModule::~MeshModule()
|
||||
@@ -57,6 +63,8 @@ meshtastic_MeshPacket *MeshModule::allocAckNak(meshtastic_Routing_Error err, Nod
|
||||
// So we manually call pb_encode_to_bytes and specify routing port number
|
||||
// auto p = allocDataProtobuf(c);
|
||||
meshtastic_MeshPacket *p = router->allocForSending();
|
||||
if (!p)
|
||||
return nullptr;
|
||||
p->decoded.portnum = meshtastic_PortNum_ROUTING_APP;
|
||||
p->decoded.payload.size =
|
||||
pb_encode_to_bytes(p->decoded.payload.bytes, sizeof(p->decoded.payload.bytes), &meshtastic_Routing_msg, &c);
|
||||
@@ -105,6 +113,7 @@ void MeshModule::callModules(meshtastic_MeshPacket &mp, RxSource src)
|
||||
auto &pi = **i;
|
||||
|
||||
pi.currentRequest = ∓
|
||||
pi.ignoreRequest = false;
|
||||
|
||||
/// We only call modules that are interested in the packet (and the message is destined to us or we are promiscious)
|
||||
bool wantsPacket = (isDecoded || pi.encryptedOk) && (pi.isPromiscuous || toUs) && pi.wantPacket(&mp);
|
||||
@@ -155,9 +164,13 @@ void MeshModule::callModules(meshtastic_MeshPacket &mp, RxSource src)
|
||||
// better solution (FIXME) would be to let phones have their own distinct addresses and we 'route' to them like
|
||||
// any other node.
|
||||
if (isDecoded && mp.decoded.want_response && toUs && (!isFromUs(&mp) || isToUs(&mp)) && !currentReply) {
|
||||
pi.sendResponse(mp);
|
||||
if (replyPortMatches(pi.ourPortNum, mp)) {
|
||||
pi.sendResponse(mp);
|
||||
LOG_INFO("Asked module '%s' to send a response", pi.name);
|
||||
} else {
|
||||
LOG_DEBUG("Module '%s' cannot respond on portnum=%d", pi.name, mp.decoded.portnum);
|
||||
}
|
||||
ignoreRequest = ignoreRequest || pi.ignoreRequest; // If at least one module asks it, we may ignore a request
|
||||
LOG_INFO("Asked module '%s' to send a response", pi.name);
|
||||
} else {
|
||||
LOG_DEBUG("Module '%s' considered", pi.name);
|
||||
}
|
||||
@@ -311,4 +324,4 @@ bool MeshModule::isRequestingFocus()
|
||||
} else
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -68,10 +68,12 @@ class MeshModule
|
||||
/** Constructor
|
||||
* name is for debugging output
|
||||
*/
|
||||
MeshModule(const char *_name);
|
||||
MeshModule(const char *_name, meshtastic_PortNum _ourPortNum = meshtastic_PortNum_UNKNOWN_APP);
|
||||
|
||||
virtual ~MeshModule();
|
||||
|
||||
static bool replyPortMatches(meshtastic_PortNum modulePort, const meshtastic_MeshPacket &mp);
|
||||
|
||||
/** For use only by MeshService
|
||||
*/
|
||||
static void callModules(meshtastic_MeshPacket &mp, RxSource src = RX_SRC_RADIO);
|
||||
@@ -88,6 +90,7 @@ class MeshModule
|
||||
#endif
|
||||
protected:
|
||||
const char *name;
|
||||
meshtastic_PortNum ourPortNum;
|
||||
|
||||
/** Most modules only care about packets that are destined for their node (i.e. broadcasts or has their node as the specific
|
||||
recipient) But some plugs might want to 'sniff' packets that are merely being routed (passing through the current node). Those
|
||||
@@ -103,7 +106,7 @@ class MeshModule
|
||||
* flag */
|
||||
bool encryptedOk = false;
|
||||
|
||||
/* We allow modules to ignore a request without sending an error if they have a specific reason for it. */
|
||||
/* Per-packet flag cleared by callModules(); modules can suppress an error response for a specific request. */
|
||||
bool ignoreRequest = false;
|
||||
|
||||
/**
|
||||
|
||||
+142
-12
@@ -31,6 +31,9 @@
|
||||
#if HAS_VARIABLE_HOPS
|
||||
#include "modules/HopScalingModule.h"
|
||||
#endif
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
#include "modules/TrafficManagementModule.h"
|
||||
#endif
|
||||
#include "xmodem.h"
|
||||
#include <ErriezCRC32.h>
|
||||
#include <algorithm>
|
||||
@@ -767,6 +770,12 @@ bool NodeDB::factoryReset(bool eraseBleBonds)
|
||||
warmStore.clear();
|
||||
warmStore.saveIfDirty();
|
||||
#endif
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
// Factory reset forgets everything; TMM's RAM caches must not survive to resurrect
|
||||
// identities (the device usually reboots after this, but don't rely on it).
|
||||
if (trafficManagementModule)
|
||||
trafficManagementModule->purgeAll();
|
||||
#endif
|
||||
|
||||
// second, install default state (this will deal with the duplicate mac address issue)
|
||||
installDefaultNodeDatabase();
|
||||
@@ -1597,6 +1606,11 @@ void NodeDB::resetNodes(bool keepFavorites)
|
||||
#if WARM_NODE_COUNT > 0
|
||||
warmStore.clear(); // warm entries are never favorites; a DB reset clears them too
|
||||
#endif
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
// A user-initiated DB reset forgets everything; TMM's caches must not resurrect it.
|
||||
if (trafficManagementModule)
|
||||
trafficManagementModule->purgeAll();
|
||||
#endif
|
||||
|
||||
devicestate.has_rx_waypoint = false;
|
||||
saveNodeDatabaseToDisk();
|
||||
@@ -1629,6 +1643,12 @@ void NodeDB::removeNodeByNum(NodeNum nodeNum)
|
||||
// Explicit user removal: don't let the warm tier resurrect the node
|
||||
warmStore.remove(nodeNum);
|
||||
#endif
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
// Explicit removal is full removal: the TrafficManagement caches (unified slot +
|
||||
// NodeInfo identity cache) must not keep serving or resurrect the node either.
|
||||
if (trafficManagementModule)
|
||||
trafficManagementModule->purgeNode(nodeNum);
|
||||
#endif
|
||||
|
||||
LOG_DEBUG("NodeDB::removeNodeByNum purged %d entries. Save changes", removed);
|
||||
saveNodeDatabaseToDisk();
|
||||
@@ -3122,6 +3142,9 @@ size_t NodeDB::getNumOnlineMeshNodes(bool localOnly)
|
||||
#include "MeshModule.h"
|
||||
#include "Throttle.h"
|
||||
|
||||
// Minimum spacing between evictions once the node database is full.
|
||||
#define NODEDB_FULL_EVICTION_INTERVAL_MS (2 * 1000UL)
|
||||
|
||||
static constexpr uint32_t HOPSTART_DROP_LOG_INTERVAL_MS = 15000;
|
||||
|
||||
void logHopStartDrop(const meshtastic_MeshPacket &p, const char *context)
|
||||
@@ -3314,15 +3337,16 @@ void NodeDB::addFromContact(meshtastic_SharedContact contact)
|
||||
*/
|
||||
bool NodeDB::updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelIndex, bool xeddsaSigned)
|
||||
{
|
||||
meshtastic_NodeInfoLite *info = getOrCreateMeshNode(nodeId);
|
||||
if (!info) {
|
||||
// Only a signed update may change the identity of a node that has proven it signs; our own record is
|
||||
// exempt. Checked before getOrCreateMeshNode so a refused update cannot evict or write the warm tier.
|
||||
const meshtastic_NodeInfoLite *existing = getMeshNode(nodeId);
|
||||
if (nodeId != getNodeNum() && existing && nodeInfoLiteHasXeddsaSigned(existing) && !xeddsaSigned) {
|
||||
LOG_WARN("Refusing unsigned identity update for node 0x%08x that previously signed", nodeId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Once a node has proven it signs, only a signed update may change its identity. The public-key guard
|
||||
// below is no help - an attacker can replay the victim's real (public) key. Our own record is exempt.
|
||||
if (nodeId != getNodeNum() && nodeInfoLiteHasXeddsaSigned(info) && !xeddsaSigned) {
|
||||
LOG_WARN("Refusing unsigned identity update for node 0x%08x that previously signed", nodeId);
|
||||
meshtastic_NodeInfoLite *info = getOrCreateMeshNode(nodeId);
|
||||
if (!info) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -3402,6 +3426,20 @@ bool NodeDB::updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelInde
|
||||
}
|
||||
}
|
||||
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
// Write-through: every accepted remote-identity commit lands here (NodeInfoModule,
|
||||
// MeshService, and TMM's requester learning all funnel through updateUser; the two
|
||||
// key-write sites that bypass it call onNodeKeyCommitted instead), so TMM's NodeInfo
|
||||
// cache reflects the commit immediately rather than at the next reconcile pass. Runs on
|
||||
// acceptance, not on `changed`: an identical update still proves the identity is
|
||||
// current. `p` is the post-hygiene payload; signerKnown transfers only key-matched
|
||||
// verified-signer status (isVerifiedSignerForKey semantics), never a bare node flag.
|
||||
if (nodeId != getNodeNum() && trafficManagementModule) {
|
||||
const bool signerKnown = p.public_key.size == 32 && isVerifiedSignerForKey(nodeId, p.public_key.bytes);
|
||||
trafficManagementModule->onNodeIdentityCommitted(nodeId, p, signerKnown);
|
||||
}
|
||||
#endif
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
@@ -3416,7 +3454,19 @@ void NodeDB::updateFrom(const meshtastic_MeshPacket &mp)
|
||||
if (mp.which_payload_variant == meshtastic_MeshPacket_decoded_tag && mp.from) {
|
||||
LOG_DEBUG("Update DB node 0x%08x, rx_time=%u", mp.from, mp.rx_time);
|
||||
|
||||
meshtastic_NodeInfoLite *info = getOrCreateMeshNode(getFrom(&mp));
|
||||
// mp.from is unauthenticated, so rate-limit admission once the database is full: otherwise
|
||||
// invented node numbers churn it at packet rate and push real neighbours out.
|
||||
meshtastic_NodeInfoLite *info = getMeshNode(getFrom(&mp));
|
||||
if (!info) {
|
||||
if (isFull()) {
|
||||
if (Throttle::isWithinTimespanMs(lastFullEvictionMs, NODEDB_FULL_EVICTION_INTERVAL_MS)) {
|
||||
LOG_DEBUG("Node database full, defer admitting 0x%08x", mp.from);
|
||||
return;
|
||||
}
|
||||
lastFullEvictionMs = millis();
|
||||
}
|
||||
info = getOrCreateMeshNode(getFrom(&mp));
|
||||
}
|
||||
if (!info) {
|
||||
return;
|
||||
}
|
||||
@@ -3700,7 +3750,7 @@ uint32_t NodeDB::hotNodeLastHeard(NodeNum n) const
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool NodeDB::copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out)
|
||||
bool NodeDB::copyPublicKeyAuthoritative(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out)
|
||||
{
|
||||
const meshtastic_NodeInfoLite *info = getMeshNode(n);
|
||||
if (info && info->public_key.size == 32) {
|
||||
@@ -3716,18 +3766,81 @@ bool NodeDB::copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out)
|
||||
return false;
|
||||
}
|
||||
|
||||
bool NodeDB::hasSeenXeddsaSigner(NodeNum n)
|
||||
bool NodeDB::copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out)
|
||||
{
|
||||
if (nodeInfoLiteHasXeddsaSigned(getMeshNode(n)))
|
||||
if (copyPublicKeyAuthoritative(n, out))
|
||||
return true;
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
// Last resort: a key the TrafficManagement NodeInfo cache learned from an observed frame
|
||||
// for a node no longer in either NodeDB tier. This extends the pool of peers we can
|
||||
// encrypt to. Keys here may be trust-on-first-use (see copyPublicKey's signerProven), the
|
||||
// same first-contact trust NodeDB itself applies via updateUser().
|
||||
if (trafficManagementModule && trafficManagementModule->copyPublicKey(n, out.bytes)) {
|
||||
out.size = 32;
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
return false;
|
||||
}
|
||||
|
||||
bool NodeDB::isVerifiedSignerForKey(NodeNum n, const uint8_t *key32)
|
||||
{
|
||||
if (!key32)
|
||||
return false;
|
||||
// Hot store is authoritative when present; a node lives in the hot XOR warm tier, so if the
|
||||
// hot store holds it the warm tier does not, and we decide entirely from the hot entry.
|
||||
const meshtastic_NodeInfoLite *info = getMeshNode(n);
|
||||
if (info)
|
||||
return info->public_key.size == 32 && nodeInfoLiteHasXeddsaSigned(info) && memcmp(info->public_key.bytes, key32, 32) == 0;
|
||||
#if WARM_NODE_COUNT > 0
|
||||
uint8_t role = 0, prot = 0;
|
||||
return warmStore.lookupMeta(n, role, prot) && prot == static_cast<uint8_t>(WarmProtected::XeddsaSigner);
|
||||
uint8_t warmKey[32];
|
||||
if (warmStore.copyKey(n, warmKey) && memcmp(warmKey, key32, 32) == 0)
|
||||
return warmStore.isVerifiedSigner(n);
|
||||
#endif
|
||||
return false;
|
||||
}
|
||||
|
||||
bool NodeDB::isKnownXeddsaSigner(NodeNum n)
|
||||
{
|
||||
// A node lives in the hot XOR warm tier, so the hot verdict is final when present.
|
||||
const meshtastic_NodeInfoLite *info = getMeshNode(n);
|
||||
if (info)
|
||||
return nodeInfoLiteHasXeddsaSigned(info);
|
||||
#if WARM_NODE_COUNT > 0
|
||||
return warmStore.isVerifiedSigner(n);
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
void NodeDB::commitRemoteKey(NodeNum n, const uint8_t key32[32], KeyCommitTrust trust)
|
||||
{
|
||||
if (!key32 || n == 0)
|
||||
return;
|
||||
// Local copy first: callers may pass the node's own key bytes back in (e.g. manual
|
||||
// verification re-committing an already-stored key), and memcpy forbids overlap.
|
||||
uint8_t key[32];
|
||||
memcpy(key, key32, 32);
|
||||
|
||||
meshtastic_NodeInfoLite *info = getOrCreateMeshNode(n);
|
||||
if (!info)
|
||||
return;
|
||||
// Unconditional overwrite - deliberately NOT updateUser()'s "don't replace a known key" pin.
|
||||
// That pin protects against unauthenticated NodeInfo broadcasts; the only callers here are
|
||||
// possession/authority-proven (ManuallyVerified = user confirmed the key; AdminChannelProven =
|
||||
// decrypted via the admin key with p->from bound into the AEAD nonce), i.e. exactly the paths
|
||||
// meant to establish or rotate a key. Keep new call sites to that same trust bar.
|
||||
memcpy(info->public_key.bytes, key, 32);
|
||||
info->public_key.size = 32;
|
||||
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
// Write-through, mirroring updateUser()'s identity hook: without it the TrafficManagement
|
||||
// NodeInfo cache diverges until the next hourly reconcile.
|
||||
if (trafficManagementModule)
|
||||
trafficManagementModule->onNodeKeyCommitted(n, key, trust == KeyCommitTrust::ManuallyVerified);
|
||||
#endif
|
||||
}
|
||||
|
||||
meshtastic_Config_DeviceConfig_Role NodeDB::getNodeRole(NodeNum n)
|
||||
{
|
||||
const meshtastic_NodeInfoLite *info = getMeshNode(n);
|
||||
@@ -3827,6 +3940,23 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n)
|
||||
nodeInfoLiteSetBit(lite, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true);
|
||||
LOG_MIGRATION("Rehydrated node 0x%08x from warm tier (key=%d)", n, lite->public_key.size == 32);
|
||||
}
|
||||
#endif
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
// Name rehydration: the warm tier keeps a node's key but not its name, so a re-admitted
|
||||
// long-tail node is nameless until its next NodeInfo. The TrafficManagement NodeInfo
|
||||
// cache is much larger and often still holds the full User. Restore it - but only when
|
||||
// its cached key matches the key we just restored from warm, so a name never attaches to
|
||||
// a different identity than the one we encrypt to. No-op without the TMM NodeInfo cache
|
||||
// or when no key is present (key-matched by design). CopyUserToNodeInfoLite sets only the
|
||||
// user-related bits, so the warm-restored signer bit survives.
|
||||
if (lite->public_key.size == 32 && !nodeInfoLiteHasUser(lite) && trafficManagementModule) {
|
||||
meshtastic_User tmmUser = meshtastic_User_init_zero;
|
||||
if (trafficManagementModule->copyUser(n, tmmUser) && tmmUser.public_key.size == 32 &&
|
||||
memcmp(tmmUser.public_key.bytes, lite->public_key.bytes, 32) == 0) {
|
||||
TypeConversions::CopyUserToNodeInfoLite(lite, tmmUser);
|
||||
LOG_INFO("Rehydrated node 0x%08x identity from TMM NodeInfo cache", n);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
LOG_INFO("Adding node to database with %i nodes and %u bytes free!", numMeshNodes, memGet.getFreeHeap());
|
||||
}
|
||||
|
||||
+30
-6
@@ -360,9 +360,32 @@ class NodeDB
|
||||
/// tier. Returns false if we don't know a key for n.
|
||||
bool copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out);
|
||||
|
||||
/// Whether this node has produced a verified XEdDSA signature, including while its
|
||||
/// identity is resident only in the warm tier.
|
||||
bool hasSeenXeddsaSigner(NodeNum n);
|
||||
/// Copy the 32-byte key for n from the AUTHORITATIVE tiers only (hot, then warm; never
|
||||
/// opportunistic caches) - the pin reference for caches that mirror NodeDB's key hygiene.
|
||||
bool copyPublicKeyAuthoritative(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out);
|
||||
|
||||
/// True if n is a known XEdDSA signer for exactly `key32` (hot signed bitfield or warm
|
||||
/// signer bit); the key match stops a rotated key inheriting a stale signer verdict.
|
||||
bool isVerifiedSignerForKey(NodeNum n, const uint8_t *key32);
|
||||
|
||||
/// Key-agnostic "should n's signable traffic arrive signed", per hot bitfield or warm signer
|
||||
/// bit - hot-only gates would let a warm-evicted signer be impersonated with unsigned frames.
|
||||
bool isKnownXeddsaSigner(NodeNum n);
|
||||
|
||||
/// Provenance of a bare-key commit that deliberately bypasses updateUser()'s
|
||||
/// User-payload / TOFU-pin path. Maps to the TrafficManagement cache's `proven` flag:
|
||||
/// only ManuallyVerified vouches for possession of exactly this key.
|
||||
enum class KeyCommitTrust : uint8_t {
|
||||
AdminChannelProven, // possession shown to the admin channel (AEAD) - TOFU-grade for signing
|
||||
ManuallyVerified, // the user confirmed possession of exactly this key
|
||||
};
|
||||
|
||||
/// THE primitive for key writes that bypass updateUser() (no User payload; provenance
|
||||
/// differs from a received NodeInfo): writes the 32-byte key to the hot store and
|
||||
/// write-through to the TrafficManagement NodeInfo cache. Any future direct key-write
|
||||
/// site must call this rather than assigning info->public_key, or the TrafficManagement
|
||||
/// cache silently diverges until the next hourly reconcile.
|
||||
void commitRemoteKey(NodeNum n, const uint8_t key32[32], KeyCommitTrust trust);
|
||||
|
||||
/// Resolve a node's device role - hot store (with user) first, then the role
|
||||
/// cached in the warm tier, else CLIENT. Lets role-aware policy keep firing for
|
||||
@@ -533,9 +556,10 @@ class NodeDB
|
||||
/// skip boot keygen and skip persisting defaults, so a transient read failure can't change our NodeNum
|
||||
/// or overwrite the on-disk config. Cleared at the top of every loadFromDisk() run.
|
||||
bool configDecodeFailed = false;
|
||||
uint32_t lastNodeDbSave = 0; // when we last saved our db to flash
|
||||
uint32_t lastBackupAttempt = 0; // when we last tried a backup automatically or manually
|
||||
uint32_t lastSort = 0; // When last sorted the nodeDB
|
||||
uint32_t lastNodeDbSave = 0; // when we last saved our db to flash
|
||||
uint32_t lastFullEvictionMs = 0; // when we last evicted to admit a new node, once the db is full
|
||||
uint32_t lastBackupAttempt = 0; // when we last tried a backup automatically or manually
|
||||
uint32_t lastSort = 0; // When last sorted the nodeDB
|
||||
|
||||
/*
|
||||
* Internal boolean to track sorting paused
|
||||
|
||||
@@ -44,6 +44,8 @@ template <class T> class ProtobufModule : protected SinglePortModule
|
||||
{
|
||||
// Update our local node info with our position (even if we don't decide to update anyone else)
|
||||
meshtastic_MeshPacket *p = allocDataPacket();
|
||||
if (!p)
|
||||
return nullptr;
|
||||
|
||||
p->decoded.payload.size =
|
||||
pb_encode_to_bytes(p->decoded.payload.bytes, sizeof(p->decoded.payload.bytes), fields, &payload);
|
||||
|
||||
+100
-24
@@ -16,7 +16,6 @@
|
||||
#include <pb_decode.h>
|
||||
#include <pb_encode.h>
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
#include "modules/TrafficManagementModule.h"
|
||||
#endif
|
||||
#if HAS_VARIABLE_HOPS
|
||||
#include "modules/HopScalingModule.h"
|
||||
@@ -271,6 +270,8 @@ PacketId generatePacketId()
|
||||
meshtastic_MeshPacket *Router::allocForSending()
|
||||
{
|
||||
meshtastic_MeshPacket *p = packetPool.allocZeroed();
|
||||
if (!p)
|
||||
return nullptr;
|
||||
|
||||
p->which_payload_variant = meshtastic_MeshPacket_decoded_tag; // Assume payload is decoded at start.
|
||||
p->from = nodeDB->getNodeNum();
|
||||
@@ -663,11 +664,15 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p)
|
||||
if (compatible)
|
||||
return true;
|
||||
|
||||
// In Balanced, preserve legacy unsigned-unicast compatibility and only reject a signable
|
||||
// unsigned broadcast from a known signer. Canonical sizing removes unknown protobuf
|
||||
// fields before mirroring the sender-side signedDataFits() gate. Oversized broadcasts
|
||||
// remain compatible.
|
||||
if (nodeDB->hasSeenXeddsaSigner(p->from) && isBroadcast(p->to)) {
|
||||
// In Balanced, preserve legacy unsigned-unicast compatibility and only reject the class a
|
||||
// signing node always signs: a non-PKI broadcast whose signed encoding would still fit the
|
||||
// LoRa frame. Canonical sizing removes unknown protobuf fields before mirroring the
|
||||
// sender-side signedDataFits() gate, so this counts the same fields that gate counted.
|
||||
// Unicast packets and broadcasts too big to carry a signature are never signed, so they
|
||||
// must not be hard-failed here even for a known signer (PKI already returned above).
|
||||
// isKnownXeddsaSigner consults the warm tier too: a signer evicted from the hot store
|
||||
// must not become impersonatable via unsigned broadcasts until it is re-heard.
|
||||
if (nodeDB->isKnownXeddsaSigner(p->from) && isBroadcast(p->to)) {
|
||||
size_t canonicalSize;
|
||||
if (!canonicalSignableSize(&p->decoded, &canonicalSize))
|
||||
return true; // can't size it; never drop on a sizing failure
|
||||
@@ -730,6 +735,54 @@ RoutingAuthVerdict passesRoutingAuthGate(meshtastic_MeshPacket *p)
|
||||
return RoutingAuthVerdict::ACCEPT;
|
||||
}
|
||||
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI)
|
||||
// The fallback costs three X25519 ops before the AEAD tag is checked. Budget is global because p->from is
|
||||
// attacker-controlled; successful runs refund, and their key is then persisted for the fast path.
|
||||
#define ADMIN_KEY_FALLBACK_BURST 8
|
||||
#define ADMIN_KEY_FALLBACK_REFILL_MS 250
|
||||
|
||||
static uint32_t adminKeyFallbackTokens = ADMIN_KEY_FALLBACK_BURST;
|
||||
static uint32_t adminKeyFallbackRefillMs = 0;
|
||||
|
||||
static bool adminKeyFallbackAllowed()
|
||||
{
|
||||
bool haveAdminKey = false;
|
||||
for (int i = 0; i < 3; i++) {
|
||||
if (config.security.admin_key[i].size == 32) {
|
||||
haveAdminKey = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!haveAdminKey)
|
||||
return false; // nothing to try, so do not spend a token
|
||||
|
||||
uint32_t now = millis();
|
||||
if (adminKeyFallbackRefillMs == 0)
|
||||
adminKeyFallbackRefillMs = now;
|
||||
uint32_t elapsed = now - adminKeyFallbackRefillMs;
|
||||
if (elapsed >= ADMIN_KEY_FALLBACK_REFILL_MS) {
|
||||
uint32_t refill = elapsed / ADMIN_KEY_FALLBACK_REFILL_MS;
|
||||
adminKeyFallbackRefillMs += refill * ADMIN_KEY_FALLBACK_REFILL_MS;
|
||||
if (refill >= ADMIN_KEY_FALLBACK_BURST - adminKeyFallbackTokens)
|
||||
adminKeyFallbackTokens = ADMIN_KEY_FALLBACK_BURST;
|
||||
else
|
||||
adminKeyFallbackTokens += refill;
|
||||
}
|
||||
|
||||
if (adminKeyFallbackTokens == 0)
|
||||
return false;
|
||||
|
||||
adminKeyFallbackTokens--;
|
||||
return true;
|
||||
}
|
||||
|
||||
static void adminKeyFallbackRefund()
|
||||
{
|
||||
if (adminKeyFallbackTokens < ADMIN_KEY_FALLBACK_BURST)
|
||||
adminKeyFallbackTokens++;
|
||||
}
|
||||
#endif
|
||||
|
||||
DecodeState perhapsDecode(meshtastic_MeshPacket *p)
|
||||
{
|
||||
concurrency::LockGuard g(cryptLock);
|
||||
@@ -757,33 +810,48 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
|
||||
bool matchedChannel = false;
|
||||
ChannelIndex chIndex = 0;
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI)
|
||||
// Resolve the sender's public key: prefer the one stored in NodeDB (hot store or warm tier), else
|
||||
// fall back to a not-yet-committed key held during an in-progress key-verification handshake.
|
||||
meshtastic_NodeInfoLite_public_key_t remotePublic = {0, {0}};
|
||||
bool haveRemoteKey = nodeDB->copyPublicKey(p->from, remotePublic) || crypto->getPendingPublicKey(p->from, remotePublic);
|
||||
|
||||
meshtastic_NodeInfoLite *ourNode = nullptr;
|
||||
if (p->channel == 0 && isToUs(p) && p->to > 0 && !isBroadcast(p->to) && rawSize > MESHTASTIC_PKC_OVERHEAD &&
|
||||
(ourNode = nodeDB->getMeshNode(p->to)) != nullptr && ourNode->public_key.size > 0) {
|
||||
pkiAttempted = true;
|
||||
LOG_DEBUG("Attempt PKI decryption");
|
||||
// Resolve the sender's public key only for actual PKI-decrypt candidates: prefer NodeDB
|
||||
// (hot store or warm tier), else a not-yet-committed key held during an in-progress
|
||||
// key-verification handshake. On a full NodeDB miss, copyPublicKey() falls through to a
|
||||
// linear scan of TrafficManagement's large NodeInfo cache, so it must not run for every
|
||||
// encrypted channel packet from an unknown sender - only for packets we might decrypt.
|
||||
meshtastic_NodeInfoLite_public_key_t remotePublic = {0, {0}};
|
||||
bool haveRemoteKey = nodeDB->copyPublicKey(p->from, remotePublic);
|
||||
// A pending key is an unverified identity claim supplied by whoever opened the handshake, so it is
|
||||
// accepted only for the exchange itself (checked after decode). perhapsEncode applies the same rule.
|
||||
bool havePendingKey = false;
|
||||
if (!haveRemoteKey) {
|
||||
havePendingKey = crypto->getPendingPublicKey(p->from, remotePublic);
|
||||
haveRemoteKey = havePendingKey;
|
||||
}
|
||||
// Try the sender's known key first, then each configured admin key so an authorized admin can
|
||||
// reach a node that has not yet learned their key. AES-CCM AEAD rejects wrong candidates.
|
||||
bool viaAdminKey = false;
|
||||
bool viaPendingKey = false;
|
||||
if (haveRemoteKey && crypto->decryptCurve25519(p->from, remotePublic, p->id, rawSize, p->encrypted.bytes, bytes)) {
|
||||
decrypted = true;
|
||||
viaPendingKey = havePendingKey;
|
||||
}
|
||||
for (int i = 0; i < 3 && !decrypted; i++) {
|
||||
if (config.security.admin_key[i].size != 32)
|
||||
continue;
|
||||
remotePublic.size = 32;
|
||||
memcpy(remotePublic.bytes, config.security.admin_key[i].bytes, 32);
|
||||
if (!decrypted && adminKeyFallbackAllowed()) {
|
||||
for (int i = 0; i < 3 && !decrypted; i++) {
|
||||
if (config.security.admin_key[i].size != 32)
|
||||
continue;
|
||||
remotePublic.size = 32;
|
||||
memcpy(remotePublic.bytes, config.security.admin_key[i].bytes, 32);
|
||||
|
||||
if (crypto->decryptCurve25519(p->from, remotePublic, p->id, rawSize, p->encrypted.bytes, bytes)) {
|
||||
decrypted = true;
|
||||
viaAdminKey = true;
|
||||
break; // stop after first successful decryption
|
||||
if (crypto->decryptCurve25519(p->from, remotePublic, p->id, rawSize, p->encrypted.bytes, bytes)) {
|
||||
decrypted = true;
|
||||
viaAdminKey = true;
|
||||
break; // stop after first successful decryption
|
||||
}
|
||||
}
|
||||
if (decrypted)
|
||||
adminKeyFallbackRefund();
|
||||
}
|
||||
if (decrypted) {
|
||||
LOG_INFO("PKI Decryption worked!");
|
||||
@@ -792,6 +860,12 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
|
||||
size_t payloadSize = rawSize - MESHTASTIC_PKC_OVERHEAD;
|
||||
if (pb_decode_from_bytes(bytes, payloadSize, &meshtastic_Data_msg, &decodedtmp) &&
|
||||
decodedtmp.portnum != meshtastic_PortNum_UNKNOWN_APP) {
|
||||
if (viaPendingKey && decodedtmp.portnum != meshtastic_PortNum_KEY_VERIFICATION_APP) {
|
||||
// The pending key only proves the handshake initiator holds it, not that they are
|
||||
// p->from. Beyond the exchange it would let them send DMs that look authenticated.
|
||||
LOG_WARN("Refusing pending-key decrypt of port %u from 0x%08x", (unsigned)decodedtmp.portnum, p->from);
|
||||
return DecodeState::DECODE_FAILURE;
|
||||
}
|
||||
decrypted = true;
|
||||
rawSize = payloadSize; // commit the overhead subtraction only on full success
|
||||
LOG_INFO("Packet decrypted using PKI!");
|
||||
@@ -802,10 +876,12 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
|
||||
p->which_payload_variant = meshtastic_MeshPacket_decoded_tag; // change type to decoded
|
||||
if (viaAdminKey) {
|
||||
// Persist the admin key for the sender so future packets take the fast path and we can
|
||||
// PKI-reply; p->from is bound into the AEAD nonce, so the trusted admin authenticated it.
|
||||
meshtastic_NodeInfoLite *fromNode = nodeDB->getOrCreateMeshNode(p->from);
|
||||
if (fromNode != nullptr)
|
||||
fromNode->public_key = remotePublic;
|
||||
// PKI-reply; p->from is bound into the AEAD nonce, so the trusted admin authenticated
|
||||
// it. commitRemoteKey is the bare-key commit primitive: it bypasses updateUser's
|
||||
// User-payload path deliberately and handles the TrafficManagement write-through.
|
||||
// AdminChannelProven = possession shown to the admin channel, not via an XEdDSA
|
||||
// NodeInfo signature, so the key stays TOFU-grade for signing purposes.
|
||||
nodeDB->commitRemoteKey(p->from, remotePublic.bytes, NodeDB::KeyCommitTrust::AdminChannelProven);
|
||||
}
|
||||
} else {
|
||||
// AEAD already authenticated this ciphertext, so no other candidate could decode it -
|
||||
|
||||
@@ -8,14 +8,11 @@
|
||||
*/
|
||||
class SinglePortModule : public MeshModule
|
||||
{
|
||||
protected:
|
||||
meshtastic_PortNum ourPortNum;
|
||||
|
||||
public:
|
||||
/** Constructor
|
||||
* name is for debugging output
|
||||
*/
|
||||
SinglePortModule(const char *_name, meshtastic_PortNum _ourPortNum) : MeshModule(_name), ourPortNum(_ourPortNum) {}
|
||||
SinglePortModule(const char *_name, meshtastic_PortNum _ourPortNum) : MeshModule(_name, _ourPortNum) {}
|
||||
|
||||
protected:
|
||||
/**
|
||||
@@ -32,8 +29,10 @@ class SinglePortModule : public MeshModule
|
||||
{
|
||||
// Update our local node info with our position (even if we don't decide to update anyone else)
|
||||
meshtastic_MeshPacket *p = router->allocForSending();
|
||||
if (!p)
|
||||
return nullptr;
|
||||
p->decoded.portnum = ourPortNum;
|
||||
|
||||
return p;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -161,6 +161,12 @@ bool WarmNodeStore::lookupMeta(NodeNum num, uint8_t &role, uint8_t &protectedCat
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WarmNodeStore::isVerifiedSigner(NodeNum num) const
|
||||
{
|
||||
const WarmNodeEntry *e = find(num);
|
||||
return e && warmSignerOf(*e);
|
||||
}
|
||||
|
||||
bool WarmNodeStore::take(NodeNum num, WarmNodeEntry &out)
|
||||
{
|
||||
WarmNodeEntry *e = find(num);
|
||||
|
||||
@@ -119,6 +119,10 @@ class WarmNodeStore
|
||||
/// @return false if the node is not in the warm tier.
|
||||
bool lookupMeta(NodeNum num, uint8_t &role, uint8_t &protectedCat) const;
|
||||
|
||||
/// True if the warm tier holds this node with its signer bit set (an XEdDSA signature
|
||||
/// was verified from it before eviction).
|
||||
bool isVerifiedSigner(NodeNum num) const;
|
||||
|
||||
/// Find and remove an entry (used when the node is re-admitted to the hot store).
|
||||
bool take(NodeNum num, WarmNodeEntry &out);
|
||||
|
||||
@@ -131,6 +135,15 @@ class WarmNodeStore
|
||||
size_t count() const;
|
||||
size_t capacity() const { return entries ? WARM_NODE_COUNT : 0; }
|
||||
|
||||
/// Slot-indexed read for whole-tier reconciliation: the entry in slot i, or nullptr
|
||||
/// when the slot is empty or i >= capacity().
|
||||
const WarmNodeEntry *entryAt(size_t i) const
|
||||
{
|
||||
if (!entries || i >= WARM_NODE_COUNT || entries[i].num == 0)
|
||||
return nullptr;
|
||||
return &entries[i];
|
||||
}
|
||||
|
||||
#if MESHTASTIC_NODEDB_MIGRATION_VERBOSE
|
||||
/// Debug: dump every live warm entry (num / last_heard / has-key) to the
|
||||
/// console. Compiled out unless MESHTASTIC_NODEDB_MIGRATION_VERBOSE.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "AdminModule.h"
|
||||
#include "Channels.h"
|
||||
#include "CryptoEngine.h"
|
||||
#include "DisplayFormatters.h"
|
||||
#include "HardwareRNG.h"
|
||||
#include "MeshService.h"
|
||||
@@ -928,11 +929,15 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers)
|
||||
config.power.on_battery_shutdown_after_secs = 30;
|
||||
}
|
||||
break;
|
||||
case meshtastic_Config_network_tag:
|
||||
case meshtastic_Config_network_tag: {
|
||||
LOG_INFO("Set config: WiFi");
|
||||
config.has_network = true;
|
||||
char prevPsk[sizeof(config.network.wifi_psk)];
|
||||
memcpy(prevPsk, config.network.wifi_psk, sizeof(prevPsk));
|
||||
config.network = c.payload_variant.network;
|
||||
writeSecret(config.network.wifi_psk, sizeof(config.network.wifi_psk), prevPsk);
|
||||
break;
|
||||
}
|
||||
case meshtastic_Config_display_tag:
|
||||
LOG_INFO("Set config: Display");
|
||||
config.has_display = true;
|
||||
@@ -1203,7 +1208,12 @@ bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c)
|
||||
// Disable Bluetooth to prevent interference during MQTT configuration
|
||||
disableBluetooth();
|
||||
moduleConfig.has_mqtt = true;
|
||||
moduleConfig.mqtt = c.payload_variant.mqtt;
|
||||
{
|
||||
char prevPass[sizeof(moduleConfig.mqtt.password)];
|
||||
memcpy(prevPass, moduleConfig.mqtt.password, sizeof(prevPass));
|
||||
moduleConfig.mqtt = c.payload_variant.mqtt;
|
||||
writeSecret(moduleConfig.mqtt.password, sizeof(moduleConfig.mqtt.password), prevPass);
|
||||
}
|
||||
#endif
|
||||
break;
|
||||
case meshtastic_ModuleConfig_serial_tag:
|
||||
@@ -1426,6 +1436,9 @@ void AdminModule::handleGetOwner(const meshtastic_MeshPacket &req)
|
||||
res.which_payload_variant = meshtastic_AdminMessage_get_owner_response_tag;
|
||||
setPassKey(&res);
|
||||
myReply = allocDataProtobuf(res);
|
||||
if (!myReply) {
|
||||
return;
|
||||
}
|
||||
if (req.pki_encrypted) {
|
||||
myReply->pki_encrypted = true;
|
||||
}
|
||||
@@ -1457,8 +1470,9 @@ void AdminModule::handleGetConfig(const meshtastic_MeshPacket &req, const uint32
|
||||
LOG_INFO("Get config: Network");
|
||||
res.get_config_response.which_payload_variant = meshtastic_Config_network_tag;
|
||||
res.get_config_response.payload_variant.network = config.network;
|
||||
writeSecret(res.get_config_response.payload_variant.network.wifi_psk,
|
||||
sizeof(res.get_config_response.payload_variant.network.wifi_psk), config.network.wifi_psk);
|
||||
if (req.from != 0)
|
||||
strncpy(res.get_config_response.payload_variant.network.wifi_psk, secretReserved,
|
||||
sizeof(res.get_config_response.payload_variant.network.wifi_psk));
|
||||
break;
|
||||
case meshtastic_AdminMessage_ConfigType_DISPLAY_CONFIG:
|
||||
LOG_INFO("Get config: Display");
|
||||
@@ -1509,6 +1523,9 @@ void AdminModule::handleGetConfig(const meshtastic_MeshPacket &req, const uint32
|
||||
res.which_payload_variant = meshtastic_AdminMessage_get_config_response_tag;
|
||||
setPassKey(&res);
|
||||
myReply = allocDataProtobuf(res);
|
||||
if (!myReply) {
|
||||
return;
|
||||
}
|
||||
if (req.pki_encrypted) {
|
||||
myReply->pki_encrypted = true;
|
||||
}
|
||||
@@ -1526,6 +1543,9 @@ void AdminModule::handleGetModuleConfig(const meshtastic_MeshPacket &req, const
|
||||
configName = "MQTT";
|
||||
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_mqtt_tag;
|
||||
res.get_module_config_response.payload_variant.mqtt = moduleConfig.mqtt;
|
||||
if (req.from != 0)
|
||||
strncpy(res.get_module_config_response.payload_variant.mqtt.password, secretReserved,
|
||||
sizeof(res.get_module_config_response.payload_variant.mqtt.password));
|
||||
break;
|
||||
case meshtastic_AdminMessage_ModuleConfigType_SERIAL_CONFIG:
|
||||
configName = "Serial";
|
||||
@@ -1618,6 +1638,9 @@ void AdminModule::handleGetModuleConfig(const meshtastic_MeshPacket &req, const
|
||||
res.which_payload_variant = meshtastic_AdminMessage_get_module_config_response_tag;
|
||||
setPassKey(&res);
|
||||
myReply = allocDataProtobuf(res);
|
||||
if (!myReply) {
|
||||
return;
|
||||
}
|
||||
if (req.pki_encrypted) {
|
||||
myReply->pki_encrypted = true;
|
||||
}
|
||||
@@ -1645,6 +1668,9 @@ void AdminModule::handleGetNodeRemoteHardwarePins(const meshtastic_MeshPacket &r
|
||||
}
|
||||
setPassKey(&r);
|
||||
myReply = allocDataProtobuf(r);
|
||||
if (!myReply) {
|
||||
return;
|
||||
}
|
||||
if (req.pki_encrypted) {
|
||||
myReply->pki_encrypted = true;
|
||||
}
|
||||
@@ -1664,6 +1690,9 @@ void AdminModule::handleGetDeviceMetadata(const meshtastic_MeshPacket &req)
|
||||
r.which_payload_variant = meshtastic_AdminMessage_get_device_metadata_response_tag;
|
||||
setPassKey(&r);
|
||||
myReply = allocDataProtobuf(r);
|
||||
if (!myReply) {
|
||||
return;
|
||||
}
|
||||
if (req.pki_encrypted) {
|
||||
myReply->pki_encrypted = true;
|
||||
}
|
||||
@@ -1739,6 +1768,9 @@ void AdminModule::handleGetDeviceConnectionStatus(const meshtastic_MeshPacket &r
|
||||
r.which_payload_variant = meshtastic_AdminMessage_get_device_connection_status_response_tag;
|
||||
setPassKey(&r);
|
||||
myReply = allocDataProtobuf(r);
|
||||
if (!myReply) {
|
||||
return;
|
||||
}
|
||||
if (req.pki_encrypted) {
|
||||
myReply->pki_encrypted = true;
|
||||
}
|
||||
@@ -1753,6 +1785,9 @@ void AdminModule::handleGetChannel(const meshtastic_MeshPacket &req, uint32_t ch
|
||||
r.which_payload_variant = meshtastic_AdminMessage_get_channel_response_tag;
|
||||
setPassKey(&r);
|
||||
myReply = allocDataProtobuf(r);
|
||||
if (!myReply) {
|
||||
return;
|
||||
}
|
||||
if (req.pki_encrypted) {
|
||||
myReply->pki_encrypted = true;
|
||||
}
|
||||
@@ -1765,6 +1800,9 @@ void AdminModule::handleGetDeviceUIConfig(const meshtastic_MeshPacket &req)
|
||||
r.which_payload_variant = meshtastic_AdminMessage_get_ui_config_response_tag;
|
||||
r.get_ui_config_response = uiconfig;
|
||||
myReply = allocDataProtobuf(r);
|
||||
if (!myReply) {
|
||||
return;
|
||||
}
|
||||
if (req.pki_encrypted) {
|
||||
myReply->pki_encrypted = true;
|
||||
}
|
||||
@@ -1859,8 +1897,14 @@ void AdminModule::setPassKey(meshtastic_AdminMessage *res)
|
||||
if (!sessionPasskeyValid || !Throttle::isWithinTimespanMs(session_time, 150 * 1000UL)) {
|
||||
// Session passkey authenticates admin replies, so it must be unpredictable: prefer the
|
||||
// hardware RNG, falling back to the seeded CSPRNG only when no hardware source exists.
|
||||
if (!HardwareRNG::fill(session_passkey, sizeof(session_passkey)))
|
||||
CryptRNG.rand(session_passkey, sizeof(session_passkey));
|
||||
// Hold cryptLock like the signing path does: this runs on the admin receive path, which on
|
||||
// nRF52 is the BLE task, and the fill toggles the CC310 that packet crypto also uses while
|
||||
// the CryptRNG state is shared with signing.
|
||||
{
|
||||
concurrency::LockGuard g(cryptLock);
|
||||
if (!HardwareRNG::fill(session_passkey, sizeof(session_passkey)))
|
||||
CryptRNG.rand(session_passkey, sizeof(session_passkey));
|
||||
}
|
||||
session_time = millis();
|
||||
sessionPasskeyValid = true;
|
||||
}
|
||||
|
||||
@@ -1022,6 +1022,8 @@ void CannedMessageModule::sendText(NodeNum dest, ChannelIndex channel, const cha
|
||||
lastDestSet = true;
|
||||
|
||||
meshtastic_MeshPacket *p = allocDataPacket();
|
||||
if (!p)
|
||||
return;
|
||||
p->to = dest;
|
||||
p->channel = channel;
|
||||
p->want_ack = true;
|
||||
@@ -1054,7 +1056,7 @@ void CannedMessageModule::sendText(NodeNum dest, ChannelIndex channel, const cha
|
||||
p->decoded.payload.size = strlen(message);
|
||||
memcpy(p->decoded.payload.bytes, message, p->decoded.payload.size);
|
||||
|
||||
if (moduleConfig.canned_message.send_bell && p->decoded.payload.size < meshtastic_Constants_DATA_PAYLOAD_LEN) {
|
||||
if (moduleConfig.canned_message.send_bell && p->decoded.payload.size + 1 < meshtastic_Constants_DATA_PAYLOAD_LEN) {
|
||||
p->decoded.payload.bytes[p->decoded.payload.size++] = 7;
|
||||
p->decoded.payload.bytes[p->decoded.payload.size] = '\0';
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ class CannedMessageModule : public SinglePortModule, public Observable<const UIF
|
||||
void resetSearch();
|
||||
void updateDestinationSelectionList();
|
||||
void drawDestinationSelectionScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
|
||||
bool isFreeTextActive() const { return runState == CANNED_MESSAGE_RUN_STATE_FREETEXT; }
|
||||
String drawWithCursor(String text, int cursor);
|
||||
|
||||
// === Emote Picker ===
|
||||
|
||||
@@ -131,10 +131,14 @@ void DetectionSensorModule::sendDetectionMessage()
|
||||
char *message = new char[40];
|
||||
sprintf(message, "%s detected", moduleConfig.detection_sensor.name);
|
||||
meshtastic_MeshPacket *p = allocDataPacket();
|
||||
if (!p) {
|
||||
delete[] message;
|
||||
return;
|
||||
}
|
||||
p->want_ack = false;
|
||||
p->decoded.payload.size = strlen(message);
|
||||
memcpy(p->decoded.payload.bytes, message, p->decoded.payload.size);
|
||||
if (moduleConfig.detection_sensor.send_bell && p->decoded.payload.size < meshtastic_Constants_DATA_PAYLOAD_LEN) {
|
||||
if (moduleConfig.detection_sensor.send_bell && p->decoded.payload.size + 1 < meshtastic_Constants_DATA_PAYLOAD_LEN) {
|
||||
p->decoded.payload.bytes[p->decoded.payload.size] = 7; // Bell character
|
||||
p->decoded.payload.bytes[p->decoded.payload.size + 1] = '\0'; // Bell character
|
||||
p->decoded.payload.size++;
|
||||
@@ -153,6 +157,10 @@ void DetectionSensorModule::sendCurrentStateMessage(bool state)
|
||||
char *message = new char[40];
|
||||
sprintf(message, "%s state: %i", moduleConfig.detection_sensor.name, state);
|
||||
meshtastic_MeshPacket *p = allocDataPacket();
|
||||
if (!p) {
|
||||
delete[] message;
|
||||
return;
|
||||
}
|
||||
p->want_ack = false;
|
||||
p->decoded.payload.size = strlen(message);
|
||||
memcpy(p->decoded.payload.bytes, message, p->decoded.payload.size);
|
||||
|
||||
@@ -68,6 +68,8 @@ meshtastic_MeshPacket *DropzoneModule::sendConditions()
|
||||
// the dropzone is open
|
||||
auto dropzoneStatus = analogRead(A1) < 100 ? "OPEN" : "CLOSED";
|
||||
auto reply = allocDataPacket();
|
||||
if (!reply)
|
||||
return nullptr;
|
||||
|
||||
auto node = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||||
if (sensor.hasSensor()) {
|
||||
|
||||
@@ -6,12 +6,19 @@
|
||||
#include "gps/RTC.h"
|
||||
#include "graphics/draw/MenuHandler.h"
|
||||
#include "main.h"
|
||||
#include "mesh/Throttle.h"
|
||||
#include "meshUtils.h"
|
||||
#include "modules/AdminModule.h"
|
||||
#include "modules/NodeInfoModule.h"
|
||||
#include <RNG.h>
|
||||
#include <SHA256.h>
|
||||
|
||||
#define KEY_VERIFICATION_TIMEOUT_SECS 60
|
||||
// Hard cap on one session, independent of the refreshable idle timeout above.
|
||||
#define KEY_VERIFICATION_MAX_SESSION_MS (3 * 60 * 1000UL)
|
||||
// Minimum spacing between remote-initiated sessions.
|
||||
#define KEY_VERIFICATION_REMOTE_COOLDOWN_MS (60 * 1000UL)
|
||||
|
||||
KeyVerificationModule *keyVerificationModule;
|
||||
|
||||
namespace
|
||||
@@ -64,7 +71,8 @@ AdminMessageHandleResult KeyVerificationModule::handleAdminMessageForModule(cons
|
||||
|
||||
bool KeyVerificationModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_KeyVerification *r)
|
||||
{
|
||||
updateState();
|
||||
// No refresh here: this runs before the sender is checked, so any node could hold the session open.
|
||||
updateState(false);
|
||||
// Note: pki_encrypted is not required here. The first response (M2) may arrive channel-encrypted in
|
||||
// the bootstrap case; the follow-on hash1 packet (M3) is required to be PKI in its branch below.
|
||||
if (mp.from != currentRemoteNode) { // because the inital connection request is handled in allocReply()
|
||||
@@ -98,6 +106,7 @@ bool KeyVerificationModule::handleReceivedProtobuf(const meshtastic_MeshPacket &
|
||||
service->sendClientNotification(cn);
|
||||
}
|
||||
LOG_INFO("Received hash2");
|
||||
currentNonceTimestamp = getTime(); // the protocol advanced, so extend the deadline
|
||||
currentState = KEY_VERIFICATION_SENDER_AWAITING_NUMBER;
|
||||
return true;
|
||||
|
||||
@@ -134,6 +143,7 @@ bool KeyVerificationModule::handleReceivedProtobuf(const meshtastic_MeshPacket &
|
||||
service->sendClientNotification(cn);
|
||||
}
|
||||
|
||||
currentNonceTimestamp = getTime(); // the protocol advanced, so extend the deadline
|
||||
currentState = KEY_VERIFICATION_RECEIVER_AWAITING_USER;
|
||||
return true;
|
||||
}
|
||||
@@ -151,8 +161,16 @@ bool KeyVerificationModule::sendInitialRequest(NodeNum remoteNode)
|
||||
return false;
|
||||
}
|
||||
updateState(true);
|
||||
currentNonce = random();
|
||||
// The nonce binds the handshake, so draw it from the hardware RNG (falling back to the CSPRNG)
|
||||
// under cryptLock, as allocReply does for the security number. random() is both predictable and
|
||||
// only 32 bits wide, leaving half of this nonce zero.
|
||||
{
|
||||
concurrency::LockGuard g(cryptLock);
|
||||
if (!HardwareRNG::fill((uint8_t *)¤tNonce, sizeof(currentNonce)))
|
||||
CryptRNG.rand((uint8_t *)¤tNonce, sizeof(currentNonce));
|
||||
}
|
||||
currentNonceTimestamp = getTime();
|
||||
sessionStartedMs = millis();
|
||||
currentRemoteNode = remoteNode;
|
||||
meshtastic_KeyVerification KeyVerification = meshtastic_KeyVerification_init_zero;
|
||||
KeyVerification.nonce = currentNonce;
|
||||
@@ -162,6 +180,8 @@ bool KeyVerificationModule::sendInitialRequest(NodeNum remoteNode)
|
||||
KeyVerification.hash1.size = 32;
|
||||
memcpy(KeyVerification.hash1.bytes, owner.public_key.bytes, 32);
|
||||
meshtastic_MeshPacket *p = allocDataProtobuf(KeyVerification);
|
||||
if (!p)
|
||||
return false;
|
||||
p->to = remoteNode;
|
||||
p->channel = 0;
|
||||
// Only request PKI when we already hold the destination's key. Otherwise this first message goes out
|
||||
@@ -181,10 +201,19 @@ meshtastic_MeshPacket *KeyVerificationModule::allocReply()
|
||||
SHA256 hash;
|
||||
NodeNum ourNodeNum = nodeDB->getNodeNum();
|
||||
updateState();
|
||||
if (currentState != KEY_VERIFICATION_IDLE) { // TODO: cooldown period
|
||||
if (currentState != KEY_VERIFICATION_IDLE) {
|
||||
LOG_WARN("Key Verification requested, but already in a request");
|
||||
ignoreRequest = true; // do not let the busy path emit a NAK back to the requester
|
||||
return nullptr;
|
||||
}
|
||||
// Opening a session raises a banner and locks the only slot, before the peer has authenticated.
|
||||
if (lastRemoteSessionMs != 0 && Throttle::isWithinTimespanMs(lastRemoteSessionMs, KEY_VERIFICATION_REMOTE_COOLDOWN_MS)) {
|
||||
LOG_WARN("Key Verification requested, but within cooldown");
|
||||
ignoreRequest = true;
|
||||
return nullptr;
|
||||
}
|
||||
sessionStartedMs = millis();
|
||||
sessionFromRemote = true;
|
||||
currentState = KEY_VERIFICATION_RECEIVER_AWAITING_HASH1;
|
||||
|
||||
auto req = *currentRequest;
|
||||
@@ -318,6 +347,8 @@ void KeyVerificationModule::processSecurityNumber(uint32_t incomingNumber)
|
||||
KeyVerification.hash1.size = 32;
|
||||
memcpy(KeyVerification.hash1.bytes, hash1, 32);
|
||||
meshtastic_MeshPacket *p = allocDataProtobuf(KeyVerification);
|
||||
if (!p)
|
||||
return;
|
||||
p->to = currentRemoteNode;
|
||||
p->channel = 0;
|
||||
p->pki_encrypted = true;
|
||||
@@ -346,11 +377,14 @@ void KeyVerificationModule::processSecurityNumber(uint32_t incomingNumber)
|
||||
void KeyVerificationModule::updateState(bool resetTimer)
|
||||
{
|
||||
if (currentState != KEY_VERIFICATION_IDLE) {
|
||||
// check for the 60 second timeout
|
||||
if (currentNonceTimestamp < getTime() - 60) {
|
||||
uint32_t now = getTime();
|
||||
// Absolute cap first: it is millis() based, so it bounds the session even when the RTC is unset.
|
||||
if (!Throttle::isWithinTimespanMs(sessionStartedMs, KEY_VERIFICATION_MAX_SESSION_MS)) {
|
||||
resetToIdle();
|
||||
} else if (now - currentNonceTimestamp >= KEY_VERIFICATION_TIMEOUT_SECS) {
|
||||
resetToIdle();
|
||||
} else if (resetTimer) {
|
||||
currentNonceTimestamp = getTime();
|
||||
currentNonceTimestamp = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -359,8 +393,12 @@ void KeyVerificationModule::resetToIdle()
|
||||
{
|
||||
memset(hash1, 0, 32);
|
||||
memset(hash2, 0, 32);
|
||||
if (sessionFromRemote)
|
||||
lastRemoteSessionMs = millis(); // start the cooldown when the session ends, not when it opened
|
||||
sessionFromRemote = false;
|
||||
currentNonce = 0;
|
||||
currentNonceTimestamp = 0;
|
||||
sessionStartedMs = 0;
|
||||
currentSecurityNumber = 0;
|
||||
currentRemoteNode = 0;
|
||||
currentState = KEY_VERIFICATION_IDLE;
|
||||
@@ -383,6 +421,11 @@ void KeyVerificationModule::commitVerifiedRemoteNode()
|
||||
if (node->public_key.size != 32 && crypto->getPendingPublicKey(currentRemoteNode, pending))
|
||||
node->public_key = pending;
|
||||
node->bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK;
|
||||
// Re-commit via the bare-key primitive: writing the same bytes back is a no-op for the hot
|
||||
// store, but it routes the TrafficManagement write-through. ManuallyVerified: the user just
|
||||
// confirmed possession of exactly this key - the strongest provenance that cache can carry.
|
||||
if (node->public_key.size == 32)
|
||||
nodeDB->commitRemoteKey(currentRemoteNode, node->public_key.bytes, NodeDB::KeyCommitTrust::ManuallyVerified);
|
||||
LOG_INFO("Node 0x%08x manually verified with security number %u", currentRemoteNode, currentSecurityNumber);
|
||||
if (nodeInfoModule)
|
||||
nodeInfoModule->sendOurNodeInfo(currentRemoteNode, false, node->channel, true);
|
||||
|
||||
@@ -84,6 +84,12 @@ class KeyVerificationModule : public ProtobufModule<meshtastic_KeyVerification>
|
||||
private:
|
||||
uint64_t currentNonce = 0;
|
||||
uint32_t currentNonceTimestamp = 0;
|
||||
// millis() the session opened, never refreshed, so a peer cannot hold the slot open indefinitely.
|
||||
uint32_t sessionStartedMs = 0;
|
||||
// millis() a remote-initiated session last ended. Spacing sessions bounds the slot DoS and the
|
||||
// banner spam; stamped at the end rather than the start so the cooldown is a real gap.
|
||||
uint32_t lastRemoteSessionMs = 0;
|
||||
bool sessionFromRemote = false;
|
||||
NodeNum currentRemoteNode = 0;
|
||||
uint32_t currentSecurityNumber = 0;
|
||||
KeyVerificationState currentState = KEY_VERIFICATION_IDLE;
|
||||
|
||||
@@ -110,6 +110,8 @@ void NeighborInfoModule::sendNeighborInfo(NodeNum dest, bool wantReplies)
|
||||
// only send neighbours if we have some to send
|
||||
if (neighborInfo.neighbors_count > 0) {
|
||||
meshtastic_MeshPacket *p = allocDataProtobuf(neighborInfo);
|
||||
if (!p)
|
||||
return;
|
||||
p->to = dest;
|
||||
p->decoded.want_response = wantReplies;
|
||||
p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;
|
||||
|
||||
@@ -292,6 +292,8 @@ meshtastic_MeshPacket *PositionModule::allocAtakPli()
|
||||
{
|
||||
LOG_INFO("Send TAK V2 PLI packet");
|
||||
meshtastic_MeshPacket *mp = allocDataPacket();
|
||||
if (!mp)
|
||||
return nullptr;
|
||||
mp->decoded.portnum = meshtastic_PortNum_ATAK_PLUGIN_V2;
|
||||
|
||||
meshtastic_TAKPacketV2 takPacket = meshtastic_TAKPacketV2_init_zero;
|
||||
@@ -572,6 +574,8 @@ int32_t PositionModule::runOnce()
|
||||
void PositionModule::sendLostAndFoundText()
|
||||
{
|
||||
meshtastic_MeshPacket *p = allocDataPacket();
|
||||
if (!p)
|
||||
return;
|
||||
p->to = NODENUM_BROADCAST;
|
||||
char message[128];
|
||||
int written = snprintf(message, sizeof(message), "🚨I'm lost! Lat / Lon: %f, %f\a", (lastGpsLatitude * 1e-7),
|
||||
|
||||
@@ -114,6 +114,8 @@ int32_t RangeTestModule::runOnce()
|
||||
void RangeTestModuleRadio::sendPayload(NodeNum dest, bool wantReplies)
|
||||
{
|
||||
meshtastic_MeshPacket *p = allocDataPacket();
|
||||
if (!p)
|
||||
return;
|
||||
p->to = dest;
|
||||
p->decoded.want_response = wantReplies;
|
||||
p->hop_limit = 0;
|
||||
|
||||
@@ -168,6 +168,8 @@ void ReplyBotModule::sendDm(const meshtastic_MeshPacket &rx, const char *text)
|
||||
if (!text)
|
||||
return;
|
||||
meshtastic_MeshPacket *p = allocDataPacket();
|
||||
if (!p)
|
||||
return;
|
||||
p->to = rx.from;
|
||||
p->channel = rx.channel;
|
||||
p->want_ack = false;
|
||||
|
||||
@@ -16,7 +16,9 @@ meshtastic_MeshPacket *ReplyModule::allocReply()
|
||||
#endif
|
||||
|
||||
const char *replyStr = "Message Received";
|
||||
auto reply = allocDataPacket(); // Allocate a packet for sending
|
||||
auto reply = allocDataPacket(); // Allocate a packet for sending
|
||||
if (!reply)
|
||||
return nullptr;
|
||||
reply->decoded.payload.size = strlen(replyStr); // You must specify how many bytes are in the reply
|
||||
memcpy(reply->decoded.payload.bytes, replyStr, reply->decoded.payload.size);
|
||||
|
||||
|
||||
@@ -51,6 +51,8 @@ void RoutingModule::sendAckNak(meshtastic_Routing_Error err, NodeNum to, PacketI
|
||||
bool ackWantsAck)
|
||||
{
|
||||
auto p = allocAckNak(err, to, idFrom, chIndex, hopLimit);
|
||||
if (!p)
|
||||
return;
|
||||
|
||||
// Allow the caller to set want_ack on this ACK packet if it's important that the ACK be delivered reliably
|
||||
p->want_ack = ackWantsAck;
|
||||
|
||||
@@ -109,7 +109,7 @@ bool SerialModule::isValidConfig(const meshtastic_ModuleConfig_SerialConfig &con
|
||||
return true;
|
||||
}
|
||||
|
||||
SerialModuleRadio::SerialModuleRadio() : MeshModule("SerialModuleRadio")
|
||||
SerialModuleRadio::SerialModuleRadio() : SinglePortModule("SerialModuleRadio", meshtastic_PortNum_SERIAL_APP)
|
||||
{
|
||||
switch (moduleConfig.serial.mode) {
|
||||
case meshtastic_ModuleConfig_SerialConfig_Serial_Mode_TEXTMSG:
|
||||
@@ -314,6 +314,8 @@ int32_t SerialModule::runOnce()
|
||||
void SerialModule::sendTelemetry(meshtastic_Telemetry m)
|
||||
{
|
||||
meshtastic_MeshPacket *p = router->allocForSending();
|
||||
if (!p)
|
||||
return;
|
||||
p->decoded.portnum = meshtastic_PortNum_TELEMETRY_APP;
|
||||
p->decoded.payload.size =
|
||||
pb_encode_to_bytes(p->decoded.payload.bytes, sizeof(p->decoded.payload.bytes), &meshtastic_Telemetry_msg, &m);
|
||||
@@ -328,18 +330,6 @@ void SerialModule::sendTelemetry(meshtastic_Telemetry m)
|
||||
service->sendToMesh(p, RX_SRC_LOCAL, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocates a new mesh packet for use as a reply to a received packet.
|
||||
*
|
||||
* @return A pointer to the newly allocated mesh packet.
|
||||
*/
|
||||
meshtastic_MeshPacket *SerialModuleRadio::allocReply()
|
||||
{
|
||||
auto reply = allocDataPacket(); // Allocate a packet for sending
|
||||
|
||||
return reply;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a payload to a specified destination node.
|
||||
*
|
||||
@@ -349,7 +339,9 @@ meshtastic_MeshPacket *SerialModuleRadio::allocReply()
|
||||
void SerialModuleRadio::sendPayload(NodeNum dest, bool wantReplies)
|
||||
{
|
||||
const meshtastic_Channel *ch = (boundChannel != NULL) ? &channels.getByName(boundChannel) : NULL;
|
||||
meshtastic_MeshPacket *p = allocReply();
|
||||
meshtastic_MeshPacket *p = allocDataPacket();
|
||||
if (!p)
|
||||
return;
|
||||
p->to = dest;
|
||||
if (ch != NULL) {
|
||||
p->channel = ch->index;
|
||||
|
||||
@@ -40,7 +40,7 @@ extern SerialModule *serialModule;
|
||||
* Radio interface for SerialModule
|
||||
*
|
||||
*/
|
||||
class SerialModuleRadio : public MeshModule
|
||||
class SerialModuleRadio : public SinglePortModule
|
||||
{
|
||||
uint32_t lastRxID = 0;
|
||||
char outbuf[90] = "";
|
||||
@@ -54,29 +54,14 @@ class SerialModuleRadio : public MeshModule
|
||||
void sendPayload(NodeNum dest = NODENUM_BROADCAST, bool wantReplies = false);
|
||||
|
||||
protected:
|
||||
virtual meshtastic_MeshPacket *allocReply() override;
|
||||
|
||||
/** Called to handle a particular incoming message
|
||||
|
||||
@return ProcessMessage::STOP if you've guaranteed you've handled this message and no other handlers should be considered for
|
||||
it
|
||||
*/
|
||||
virtual ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override;
|
||||
|
||||
meshtastic_PortNum ourPortNum;
|
||||
|
||||
virtual bool wantPacket(const meshtastic_MeshPacket *p) override { return p->decoded.portnum == ourPortNum; }
|
||||
|
||||
meshtastic_MeshPacket *allocDataPacket()
|
||||
{
|
||||
// Update our local node info with our position (even if we don't decide to update anyone else)
|
||||
meshtastic_MeshPacket *p = router->allocForSending();
|
||||
p->decoded.portnum = ourPortNum;
|
||||
|
||||
return p;
|
||||
}
|
||||
};
|
||||
|
||||
extern SerialModuleRadio *serialModuleRadio;
|
||||
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -15,6 +15,8 @@ int32_t StatusMessageModule::runOnce()
|
||||
strncpy(ourStatus.status, moduleConfig.statusmessage.node_status, sizeof(ourStatus.status));
|
||||
ourStatus.status[sizeof(ourStatus.status) - 1] = '\0'; // ensure null termination
|
||||
meshtastic_MeshPacket *p = allocDataPacket();
|
||||
if (!p)
|
||||
return 1000 * 12 * 60 * 60;
|
||||
p->decoded.payload.size = pb_encode_to_bytes(p->decoded.payload.bytes, sizeof(p->decoded.payload.bytes),
|
||||
meshtastic_StatusMessage_fields, &ourStatus);
|
||||
p->to = NODENUM_BROADCAST;
|
||||
|
||||
@@ -248,6 +248,8 @@ meshtastic_MeshPacket *StoreForwardModule::preparePayload(NodeNum dest, uint32_t
|
||||
(this->packetHistory[i].to == NODENUM_BROADCAST || this->packetHistory[i].to == dest)) {
|
||||
|
||||
meshtastic_MeshPacket *p = allocDataPacket();
|
||||
if (!p)
|
||||
return nullptr;
|
||||
|
||||
p->to = local ? this->packetHistory[i].to : dest; // PhoneAPI can handle original `to`
|
||||
p->from = this->packetHistory[i].from;
|
||||
@@ -304,6 +306,8 @@ meshtastic_MeshPacket *StoreForwardModule::preparePayload(NodeNum dest, uint32_t
|
||||
void StoreForwardModule::sendMessage(NodeNum dest, const meshtastic_StoreAndForward &payload)
|
||||
{
|
||||
meshtastic_MeshPacket *p = allocDataProtobuf(payload);
|
||||
if (!p)
|
||||
return;
|
||||
|
||||
p->to = dest;
|
||||
|
||||
@@ -340,6 +344,8 @@ void StoreForwardModule::sendMessage(NodeNum dest, meshtastic_StoreAndForward_Re
|
||||
void StoreForwardModule::sendErrorTextMessage(NodeNum dest, bool want_response)
|
||||
{
|
||||
meshtastic_MeshPacket *pr = allocDataPacket();
|
||||
if (!pr)
|
||||
return;
|
||||
pr->to = dest;
|
||||
pr->priority = meshtastic_MeshPacket_Priority_BACKGROUND;
|
||||
pr->want_ack = false;
|
||||
|
||||
@@ -464,35 +464,39 @@ bool AirQualityTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly)
|
||||
}
|
||||
|
||||
meshtastic_MeshPacket *p = allocDataProtobuf(m);
|
||||
p->to = dest;
|
||||
p->decoded.want_response = false;
|
||||
if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR)
|
||||
p->priority = meshtastic_MeshPacket_Priority_RELIABLE;
|
||||
else
|
||||
p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;
|
||||
|
||||
// release previous packet before occupying a new spot
|
||||
if (lastMeasurementPacket != nullptr)
|
||||
packetPool.release(lastMeasurementPacket);
|
||||
|
||||
lastMeasurementPacket = packetPool.allocCopy(*p);
|
||||
if (phoneOnly) {
|
||||
LOG_INFO("Sending packet to phone");
|
||||
service->sendToPhone(p);
|
||||
if (!p) {
|
||||
validTelemetry = false;
|
||||
} else {
|
||||
LOG_INFO("Sending packet to mesh");
|
||||
service->sendToMesh(p, RX_SRC_LOCAL, true);
|
||||
p->to = dest;
|
||||
p->decoded.want_response = false;
|
||||
if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR)
|
||||
p->priority = meshtastic_MeshPacket_Priority_RELIABLE;
|
||||
else
|
||||
p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;
|
||||
|
||||
if (isPowerSavingSensor()) {
|
||||
meshtastic_ClientNotification *notification = clientNotificationPool.allocZeroed();
|
||||
if (notification) {
|
||||
notification->level = meshtastic_LogRecord_Level_INFO;
|
||||
notification->time = getValidTime(RTCQualityFromNet);
|
||||
sprintf(notification->message, "Sending telemetry and sleeping for %us interval in a moment",
|
||||
Default::getConfiguredOrDefaultMs(moduleConfig.telemetry.air_quality_interval,
|
||||
default_telemetry_broadcast_interval_secs) /
|
||||
1000U);
|
||||
service->sendClientNotification(notification);
|
||||
// release previous packet before occupying a new spot
|
||||
if (lastMeasurementPacket != nullptr)
|
||||
packetPool.release(lastMeasurementPacket);
|
||||
|
||||
lastMeasurementPacket = packetPool.allocCopy(*p);
|
||||
if (phoneOnly) {
|
||||
LOG_INFO("Sending packet to phone");
|
||||
service->sendToPhone(p);
|
||||
} else {
|
||||
LOG_INFO("Sending packet to mesh");
|
||||
service->sendToMesh(p, RX_SRC_LOCAL, true);
|
||||
|
||||
if (isPowerSavingSensor()) {
|
||||
meshtastic_ClientNotification *notification = clientNotificationPool.allocZeroed();
|
||||
if (notification) {
|
||||
notification->level = meshtastic_LogRecord_Level_INFO;
|
||||
notification->time = getValidTime(RTCQualityFromNet);
|
||||
sprintf(notification->message, "Sending telemetry and sleeping for %us interval in a moment",
|
||||
Default::getConfiguredOrDefaultMs(moduleConfig.telemetry.air_quality_interval,
|
||||
default_telemetry_broadcast_interval_secs) /
|
||||
1000U);
|
||||
service->sendClientNotification(notification);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,6 +172,8 @@ meshtastic_Telemetry DeviceTelemetryModule::getLocalStatsTelemetry()
|
||||
void DeviceTelemetryModule::sendLocalStatsToPhone()
|
||||
{
|
||||
meshtastic_MeshPacket *p = allocDataProtobuf(getLocalStatsTelemetry());
|
||||
if (!p)
|
||||
return;
|
||||
p->to = NODENUM_BROADCAST;
|
||||
p->decoded.want_response = false;
|
||||
p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;
|
||||
@@ -191,6 +193,8 @@ bool DeviceTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly)
|
||||
meshtastic_MeshPacket *p = allocDataProtobuf(telemetry);
|
||||
DEBUG_HEAP_AFTER("DeviceTelemetryModule::sendTelemetry", p);
|
||||
|
||||
if (!p)
|
||||
return false;
|
||||
p->to = dest;
|
||||
p->decoded.want_response = false;
|
||||
p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;
|
||||
|
||||
@@ -661,34 +661,38 @@ bool EnvironmentTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly)
|
||||
m.variant.environment_metrics.soil_moisture);
|
||||
|
||||
meshtastic_MeshPacket *p = allocDataProtobuf(m);
|
||||
p->to = dest;
|
||||
p->decoded.want_response = false;
|
||||
if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR)
|
||||
p->priority = meshtastic_MeshPacket_Priority_RELIABLE;
|
||||
else
|
||||
p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;
|
||||
// release previous packet before occupying a new spot
|
||||
if (lastMeasurementPacket != nullptr)
|
||||
packetPool.release(lastMeasurementPacket);
|
||||
|
||||
lastMeasurementPacket = packetPool.allocCopy(*p);
|
||||
if (phoneOnly) {
|
||||
LOG_INFO("Send packet to phone");
|
||||
service->sendToPhone(p);
|
||||
if (!p) {
|
||||
validTelemetry = false;
|
||||
} else {
|
||||
LOG_INFO("Send packet to mesh");
|
||||
service->sendToMesh(p, RX_SRC_LOCAL, true);
|
||||
p->to = dest;
|
||||
p->decoded.want_response = false;
|
||||
if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR)
|
||||
p->priority = meshtastic_MeshPacket_Priority_RELIABLE;
|
||||
else
|
||||
p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;
|
||||
// release previous packet before occupying a new spot
|
||||
if (lastMeasurementPacket != nullptr)
|
||||
packetPool.release(lastMeasurementPacket);
|
||||
|
||||
if (isPowerSavingSensor()) {
|
||||
meshtastic_ClientNotification *notification = clientNotificationPool.allocZeroed();
|
||||
if (notification) {
|
||||
notification->level = meshtastic_LogRecord_Level_INFO;
|
||||
notification->time = getValidTime(RTCQualityFromNet);
|
||||
sprintf(notification->message, "Sending telemetry and sleeping for %us interval in a moment",
|
||||
Default::getConfiguredOrDefaultMs(moduleConfig.telemetry.environment_update_interval,
|
||||
default_telemetry_broadcast_interval_secs) /
|
||||
1000U);
|
||||
service->sendClientNotification(notification);
|
||||
lastMeasurementPacket = packetPool.allocCopy(*p);
|
||||
if (phoneOnly) {
|
||||
LOG_INFO("Send packet to phone");
|
||||
service->sendToPhone(p);
|
||||
} else {
|
||||
LOG_INFO("Send packet to mesh");
|
||||
service->sendToMesh(p, RX_SRC_LOCAL, true);
|
||||
|
||||
if (isPowerSavingSensor()) {
|
||||
meshtastic_ClientNotification *notification = clientNotificationPool.allocZeroed();
|
||||
if (notification) {
|
||||
notification->level = meshtastic_LogRecord_Level_INFO;
|
||||
notification->time = getValidTime(RTCQualityFromNet);
|
||||
sprintf(notification->message, "Sending telemetry and sleeping for %us interval in a moment",
|
||||
Default::getConfiguredOrDefaultMs(moduleConfig.telemetry.environment_update_interval,
|
||||
default_telemetry_broadcast_interval_secs) /
|
||||
1000U);
|
||||
service->sendClientNotification(notification);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,23 +248,27 @@ bool HealthTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly)
|
||||
sensor_read_error_count = 0;
|
||||
|
||||
meshtastic_MeshPacket *p = allocDataProtobuf(m);
|
||||
p->to = dest;
|
||||
p->decoded.want_response = false;
|
||||
if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR)
|
||||
p->priority = meshtastic_MeshPacket_Priority_RELIABLE;
|
||||
else
|
||||
p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;
|
||||
// release previous packet before occupying a new spot
|
||||
if (lastMeasurementPacket != nullptr)
|
||||
packetPool.release(lastMeasurementPacket);
|
||||
|
||||
lastMeasurementPacket = packetPool.allocCopy(*p);
|
||||
if (phoneOnly) {
|
||||
LOG_INFO("Send packet to phone");
|
||||
service->sendToPhone(p);
|
||||
if (!p) {
|
||||
validTelemetry = false;
|
||||
} else {
|
||||
LOG_INFO("Send packet to mesh");
|
||||
service->sendToMesh(p, RX_SRC_LOCAL, true);
|
||||
p->to = dest;
|
||||
p->decoded.want_response = false;
|
||||
if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR)
|
||||
p->priority = meshtastic_MeshPacket_Priority_RELIABLE;
|
||||
else
|
||||
p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;
|
||||
// release previous packet before occupying a new spot
|
||||
if (lastMeasurementPacket != nullptr)
|
||||
packetPool.release(lastMeasurementPacket);
|
||||
|
||||
lastMeasurementPacket = packetPool.allocCopy(*p);
|
||||
if (phoneOnly) {
|
||||
LOG_INFO("Send packet to phone");
|
||||
service->sendToPhone(p);
|
||||
} else {
|
||||
LOG_INFO("Send packet to mesh");
|
||||
service->sendToMesh(p, RX_SRC_LOCAL, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -128,6 +128,8 @@ bool HostMetricsModule::sendMetrics()
|
||||
// telemetry.variant.host_metrics.has_user_string ? telemetry.variant.host_metrics.user_string : "");
|
||||
|
||||
meshtastic_MeshPacket *p = allocDataProtobuf(telemetry);
|
||||
if (!p)
|
||||
return false;
|
||||
p->to = NODENUM_BROADCAST;
|
||||
p->decoded.want_response = false;
|
||||
p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;
|
||||
|
||||
@@ -275,23 +275,27 @@ bool PowerTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly)
|
||||
sensor_read_error_count = 0;
|
||||
|
||||
meshtastic_MeshPacket *p = allocDataProtobuf(m);
|
||||
p->to = dest;
|
||||
p->decoded.want_response = false;
|
||||
if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR)
|
||||
p->priority = meshtastic_MeshPacket_Priority_RELIABLE;
|
||||
else
|
||||
p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;
|
||||
// release previous packet before occupying a new spot
|
||||
if (lastMeasurementPacket != nullptr)
|
||||
packetPool.release(lastMeasurementPacket);
|
||||
|
||||
lastMeasurementPacket = packetPool.allocCopy(*p);
|
||||
if (phoneOnly) {
|
||||
LOG_INFO("Send packet to phone");
|
||||
service->sendToPhone(p);
|
||||
if (!p) {
|
||||
validTelemetry = false;
|
||||
} else {
|
||||
LOG_INFO("Send packet to mesh");
|
||||
service->sendToMesh(p, RX_SRC_LOCAL, true);
|
||||
p->to = dest;
|
||||
p->decoded.want_response = false;
|
||||
if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR)
|
||||
p->priority = meshtastic_MeshPacket_Priority_RELIABLE;
|
||||
else
|
||||
p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;
|
||||
// release previous packet before occupying a new spot
|
||||
if (lastMeasurementPacket != nullptr)
|
||||
packetPool.release(lastMeasurementPacket);
|
||||
|
||||
lastMeasurementPacket = packetPool.allocCopy(*p);
|
||||
if (phoneOnly) {
|
||||
LOG_INFO("Send packet to phone");
|
||||
service->sendToPhone(p);
|
||||
} else {
|
||||
LOG_INFO("Send packet to mesh");
|
||||
service->sendToMesh(p, RX_SRC_LOCAL, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -305,6 +305,15 @@ void TraceRouteModule::updateNextHops(const meshtastic_MeshPacket &p, meshtastic
|
||||
}
|
||||
uint8_t nextHopByte = nodeDB->getLastByteOfNodeNum(nextHop);
|
||||
|
||||
// The route array is unauthenticated payload, so only learn from it when the node it names as our
|
||||
// next hop is the one that actually relayed this packet to us. Otherwise a forged response could
|
||||
// point any node's next_hop anywhere. relay_node is 0 for MQTT-sourced packets, which cannot
|
||||
// corroborate an RF route either.
|
||||
if (p.relay_node == NO_RELAY_NODE || nextHopByte != p.relay_node) {
|
||||
LOG_DEBUG("Ignore traceroute next-hop 0x%02x, packet was relayed by 0x%02x", nextHopByte, p.relay_node);
|
||||
return;
|
||||
}
|
||||
|
||||
// For the rest of the nodes in the route, set their next-hop
|
||||
// Note: if we are the last in the route, this loop will not run
|
||||
for (int8_t i = nextHopIndex; i < r->route_count; i++) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,25 +8,32 @@
|
||||
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
|
||||
/**
|
||||
* TrafficManagementModule - Packet inspection and traffic shaping for mesh networks.
|
||||
*
|
||||
* This module provides:
|
||||
* - Position deduplication (drop redundant position broadcasts)
|
||||
* - Per-node rate limiting (throttle chatty nodes)
|
||||
* - Unknown packet filtering (drop undecoded packets from repeat offenders)
|
||||
* - NodeInfo direct response (answer queries from cache to reduce mesh chatter)
|
||||
* - Local-only telemetry/position (exhaust hop_limit for local broadcasts)
|
||||
* - Router hop preservation (maintain hop_limit for router-to-router traffic)
|
||||
*
|
||||
* Memory Optimization:
|
||||
* Uses one flat unified cache (plain array, linear scan) shared by all
|
||||
* per-node features instead of separate per-feature caches. Timestamps are
|
||||
* stored as free-running modular tick counters (pos: 8-bit 360 s/tick;
|
||||
* rate+unknown: paired 4-bit nibbles in one byte) for a 10-byte entry.
|
||||
* LoRa packet rates are low enough that an O(n) scan of ~1000 entries is
|
||||
* negligible next to packet processing.
|
||||
*/
|
||||
// Replay provenance gate: when 1 (default), direct responses are spoofed only for nodes whose
|
||||
// cached key is signer-proven (XEdDSA-verified), not for trust-on-first-use identities.
|
||||
// Define as 0 to also serve fresh TOFU-only nodes; bypassed entirely when PKI is excluded.
|
||||
#ifndef TMM_NODEINFO_REPLAY_REQUIRE_SIGNED
|
||||
#define TMM_NODEINFO_REPLAY_REQUIRE_SIGNED 1
|
||||
#endif
|
||||
|
||||
// Effective gate: only meaningful when PKI is compiled in.
|
||||
#if TMM_NODEINFO_REPLAY_REQUIRE_SIGNED && !(MESHTASTIC_EXCLUDE_PKI)
|
||||
#define TMM_NODEINFO_REPLAY_SIGNED_GATE 1
|
||||
#else
|
||||
#define TMM_NODEINFO_REPLAY_SIGNED_GATE 0
|
||||
#endif
|
||||
|
||||
// NodeInfo cache availability. Production home is ESP32+PSRAM (the 2000-entry array is too big
|
||||
// for MCU internal RAM); native unit-test builds enable it on the plain heap so the cache paths
|
||||
// run in CI (tests needing the NodeDB fallback call dropNodeInfoCacheForTest()).
|
||||
#if (defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM)) || (defined(ARCH_PORTDUINO) && defined(PIO_UNIT_TESTING))
|
||||
#define TMM_HAS_NODEINFO_CACHE 1
|
||||
#else
|
||||
#define TMM_HAS_NODEINFO_CACHE 0
|
||||
#endif
|
||||
|
||||
/// Packet inspection and traffic shaping: position dedup, per-node rate limiting, unknown-packet
|
||||
/// filtering, NodeInfo direct response, and the next-hop/role overflow caches. One flat 10-byte
|
||||
/// unified cache backs all per-node features; see docs/node_info_stores.md for the store overview.
|
||||
class TrafficManagementModule : public MeshModule, private concurrency::OSThread
|
||||
{
|
||||
public:
|
||||
@@ -37,41 +44,66 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
|
||||
TrafficManagementModule(const TrafficManagementModule &) = delete;
|
||||
TrafficManagementModule &operator=(const TrafficManagementModule &) = delete;
|
||||
|
||||
/// Snapshot of the module's counters (thread-safe).
|
||||
meshtastic_TrafficManagementStats getStats() const;
|
||||
/// Zero all counters (thread-safe).
|
||||
void resetStats();
|
||||
/// Placeholder for the removed router_preserve_hops stat.
|
||||
void recordRouterHopPreserved();
|
||||
|
||||
// Next-hop overflow cache (routing hint).
|
||||
// setNextHop: store a confirmed last-byte next hop for `dest`. Called by
|
||||
// NextHopRouter from its ACK-confirmed decision (see sniffReceived). The
|
||||
// byte must come from a bidirectionally-verified relay, not one-way inference.
|
||||
// getNextHopHint: return the cached next-hop byte for `dest`, 0 if unknown.
|
||||
// clearNextHop: forget any cached next hop for `dest` (setNextHop refuses to store
|
||||
// 0, so this is the way NextHopRouter decays a stale/failing overflow route).
|
||||
/// Store a confirmed last-byte next hop for `dest`. Called only from NextHopRouter's
|
||||
/// ACK-confirmed decision - the byte must come from a bidirectionally-verified relay.
|
||||
void setNextHop(NodeNum dest, uint8_t nextHopByte);
|
||||
/// Cached next-hop byte for `dest`, 0 if unknown.
|
||||
uint8_t getNextHopHint(NodeNum dest);
|
||||
/// Forget the cached next hop for `dest` (how NextHopRouter decays a failing route).
|
||||
void clearNextHop(NodeNum dest);
|
||||
|
||||
// Warm-start the next-hop cache from persisted NodeInfoLite hints so confirmed
|
||||
// hops survive later hot-store (NodeDB) eviction. Idempotent; runs once after
|
||||
// nodeDB is populated (lazily on first maintenance pass).
|
||||
// @return true if it actually ran (prereqs met / nothing to do); false if
|
||||
// prerequisites (cache, nodeDB) weren't ready yet, so the caller should retry.
|
||||
/// Warm-start the next-hop cache from persisted NodeInfoLite hints so confirmed hops survive
|
||||
/// hot-store eviction. @return true if it ran; false if prerequisites (cache, nodeDB) weren't
|
||||
/// ready and the caller should retry on a later pass.
|
||||
bool preloadNextHopsFromNodeDB();
|
||||
|
||||
/**
|
||||
* Check if this packet should have its hops exhausted.
|
||||
* Called from perhapsRebroadcast() to force hop_limit = 0 regardless of
|
||||
* router_preserve_hops or favorite node logic.
|
||||
*/
|
||||
/// Last-resort key source for NodeDB::copyPublicKey() after the hot and warm tiers miss.
|
||||
/// Copies the 32-byte key for `node` into out[32]; `signerProven` (optional) reports whether
|
||||
/// the key was XEdDSA-verified vs trust-on-first-use. Thread-safe.
|
||||
bool copyPublicKey(NodeNum node, uint8_t out[32], bool *signerProven = nullptr) const;
|
||||
|
||||
/// Copy the full cached User for `node` (used by NodeDB to rehydrate a re-admitted node's
|
||||
/// name - the warm tier keeps keys but not names). False on miss or key-only records.
|
||||
/// `signerProven` (optional) reports the cached key's provenance. Thread-safe.
|
||||
bool copyUser(NodeNum node, meshtastic_User &out, bool *signerProven = nullptr) const;
|
||||
|
||||
/// Write-through hook from NodeDB::updateUser(): upsert the committed identity immediately
|
||||
/// (the reconcile sweep remains the backstop). NodeDB's key is authoritative, but a keyless
|
||||
/// commit keeps a TOFU key this cache already holds; never touches the observation stamp.
|
||||
/// No-op while the module is disabled in moduleConfig (maintenance is gated the same way).
|
||||
void onNodeIdentityCommitted(NodeNum node, const meshtastic_User &user, bool signerKnown);
|
||||
|
||||
/// Key-only commit hook for key writes that bypass updateUser (admin-key learn, manual key
|
||||
/// verification). A changed key resets provenance; pass proven=true only when the commit
|
||||
/// itself established possession. Never touches the observation stamp. Thread-safe.
|
||||
/// No-op while the module is disabled in moduleConfig (maintenance is gated the same way).
|
||||
void onNodeKeyCommitted(NodeNum node, const uint8_t key32[32], bool proven);
|
||||
|
||||
/// Zero one node's slots in both caches (identity, key, provenance, role, next-hop, dedup
|
||||
/// state). Called by NodeDB removal so no TMM tier resurrects a deliberately deleted node;
|
||||
/// passive eviction is unaffected. Thread-safe.
|
||||
void purgeNode(NodeNum node);
|
||||
/// Clear both cache tables outright (resetNodes / factory reset). Thread-safe.
|
||||
void purgeAll();
|
||||
|
||||
/// True when perhapsRebroadcast() must force hop_limit=0 for this packet, regardless of
|
||||
/// router_preserve_hops or favorite-node logic (set by alterReceived()).
|
||||
bool shouldExhaustHops(const meshtastic_MeshPacket &mp) const
|
||||
{
|
||||
return exhaustRequested && exhaustRequestedFrom == getFrom(&mp) && exhaustRequestedId == mp.id;
|
||||
}
|
||||
|
||||
// Injectable monotonic clock (ms). All TMM time reads go through clockMs() so unit tests can
|
||||
// advance a virtual timebase instead of sleeping real seconds across the 6 min/360 s tick.
|
||||
// Mirrors HopScalingModule::s_testNowMs. Writable from tests as TrafficManagementModule::s_testNowMs;
|
||||
// ignored in production (clockMs() returns millis()).
|
||||
// Injectable monotonic clock (ms): tests advance s_testNowMs instead of sleeping across
|
||||
// ticks (mirrors HopScalingModule); production reads millis().
|
||||
inline static uint32_t s_testNowMs = 0;
|
||||
/// Monotonic module clock in ms (virtual under PIO_UNIT_TESTING).
|
||||
#ifdef PIO_UNIT_TESTING
|
||||
static uint32_t clockMs() { return s_testNowMs; }
|
||||
#else
|
||||
@@ -79,43 +111,40 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
|
||||
#endif
|
||||
|
||||
protected:
|
||||
/// Inspect a received packet; may consume it (STOP) for dedup/rate/unknown/direct-response.
|
||||
ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override;
|
||||
/// Promiscuous: this module inspects every packet.
|
||||
bool wantPacket(const meshtastic_MeshPacket *p) override { return true; }
|
||||
/// Mutate relayed packets in place (position precision clamp).
|
||||
void alterReceived(meshtastic_MeshPacket &mp) override;
|
||||
/// 60 s maintenance sweep: expire timed state, saturate tick stamps, reconcile with NodeDB.
|
||||
int32_t runOnce() override;
|
||||
// Protected so test shims can flush per-node traffic state.
|
||||
/// Clear all per-node traffic state (protected for test shims).
|
||||
void flushCache();
|
||||
// Introspection for tests: the cached device role for a node, or -1 if the node has
|
||||
// no cache entry (distinguishes "not tracked / evicted" from CLIENT == 0).
|
||||
/// Test introspection: the cached role for `node`, or -1 when it has no entry
|
||||
/// (distinguishes "not tracked" from CLIENT == 0).
|
||||
int peekCachedRole(NodeNum node);
|
||||
|
||||
/// Test hook: force a cached NodeInfo entry's key to signer-proven so replay-gate tests
|
||||
/// can skip a full XEdDSA verification. No-op if absent.
|
||||
void markKeySignerProvenForTest(NodeNum node);
|
||||
|
||||
/// Test hook: free the NodeInfo cache so the NodeDB fallback path can be exercised in
|
||||
/// builds where the cache is compiled in. No-op when already absent.
|
||||
void dropNodeInfoCacheForTest();
|
||||
|
||||
/// Test introspection: NodeInfo flag bits for `node` (-1 if absent): bit0 hasObserved,
|
||||
/// bit1 isMember, bit2 hasFullUser, bit3 keySignerProven.
|
||||
int peekNodeInfoFlagsForTest(NodeNum node);
|
||||
|
||||
/// Test introspection: NodeInfo cache capacity (kNodeInfoCacheEntries), so tests can
|
||||
/// fill the cache exactly and force the tiered-LRU eviction paths.
|
||||
static constexpr uint16_t nodeInfoCacheCapacityForTest() { return kNodeInfoCacheEntries; }
|
||||
|
||||
private:
|
||||
// =========================================================================
|
||||
// Unified Cache Entry (10 bytes) - Same for ALL platforms
|
||||
// =========================================================================
|
||||
//
|
||||
// Layout:
|
||||
// [0-3] node - NodeNum (4 bytes, 0 = empty slot)
|
||||
// [4] pos_fingerprint - 4 bits lat + 4 bits lon (0 = no position seen)
|
||||
// [5] rate_count - [7:6] role[3:2] | [5:0] packets in rate window (0 = no window active)
|
||||
// [6] unknown_count - [7:6] role[1:0] | [5:0] unknown packets in window (0 = no window active)
|
||||
// [7] pos_time - Position tick (uint8, free-running 360 s/tick)
|
||||
// [8] rate_unknown_time - [7:4] rate nibble (300 s/tick) | [3:0] unknown nibble (60 s/tick)
|
||||
// [9] next_hop - Last-byte relay to reach `node` (0 = none)
|
||||
//
|
||||
// The 4-bit device role (bits [7:6] of rate_count paired with [7:6] of unknown_count)
|
||||
// caches the sender's meshtastic_Config_DeviceConfig_Role as a third fallback after the
|
||||
// hot store and warm store, for nodes evicted from both. Read/written via
|
||||
// resolveSenderRole(). Max encodable value is 15.
|
||||
//
|
||||
// Presence sentinels (no epoch, no +1 offset needed):
|
||||
// pos active: pos_fingerprint != 0
|
||||
// rate active: getRateCount() != 0 (low 6 bits only)
|
||||
// unknown active: getUnknownCount() != 0 (low 6 bits only)
|
||||
//
|
||||
// next_hop: routing hint written only from ACK-confirmed NextHopRouter decisions.
|
||||
// No TTL - keeps the slot alive across maintenance sweeps.
|
||||
//
|
||||
// 10-byte packed entry, all platforms. Tick stamps are free-running modular counters with
|
||||
// non-zero presence sentinels; the 4-bit cached role rides the top bits of the two count
|
||||
// bytes (tier-3 role fallback). Full layout and rationale: docs/node_info_stores.md.
|
||||
#if _meshtastic_Config_DeviceConfig_Role_MAX > 15
|
||||
#warning "Device role enum max exceeds 15 - TMM 4-bit role cache (rate_count[7:6]/unknown_count[7:6]) will truncate new values"
|
||||
#endif
|
||||
@@ -128,80 +157,72 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
|
||||
uint8_t rate_unknown_time;
|
||||
uint8_t next_hop;
|
||||
|
||||
/// Packets seen in the current rate window (low 6 bits).
|
||||
uint8_t getRateCount() const { return rate_count & 0x3F; }
|
||||
/// Set the rate-window count, preserving the role bits.
|
||||
void setRateCount(uint8_t c) { rate_count = static_cast<uint8_t>((rate_count & 0xC0) | (c & 0x3F)); }
|
||||
/// Unknown packets seen in the current window (low 6 bits).
|
||||
uint8_t getUnknownCount() const { return unknown_count & 0x3F; }
|
||||
/// Set the unknown-window count, preserving the role bits.
|
||||
void setUnknownCount(uint8_t c) { unknown_count = static_cast<uint8_t>((unknown_count & 0xC0) | (c & 0x3F)); }
|
||||
/// Cached 4-bit device role, reassembled from the two count bytes' top bits.
|
||||
uint8_t getCachedRole() const { return static_cast<uint8_t>(((rate_count >> 6) << 2) | (unknown_count >> 6)); }
|
||||
/// Store a 4-bit device role across the two count bytes' top bits.
|
||||
void setCachedRole(uint8_t role)
|
||||
{
|
||||
rate_count = static_cast<uint8_t>((rate_count & 0x3F) | ((role >> 2) << 6));
|
||||
unknown_count = static_cast<uint8_t>((unknown_count & 0x3F) | ((role & 0x03) << 6));
|
||||
}
|
||||
/// Rate-window tick nibble.
|
||||
uint8_t getRateTime() const { return (rate_unknown_time >> 4) & 0x0F; }
|
||||
/// Unknown-window tick nibble.
|
||||
uint8_t getUnknownTime() const { return rate_unknown_time & 0x0F; }
|
||||
/// Set the rate-window tick nibble.
|
||||
void setRateTime(uint8_t t) { rate_unknown_time = static_cast<uint8_t>((rate_unknown_time & 0x0F) | ((t & 0x0F) << 4)); }
|
||||
/// Set the unknown-window tick nibble.
|
||||
void setUnknownTime(uint8_t t) { rate_unknown_time = static_cast<uint8_t>((rate_unknown_time & 0xF0) | (t & 0x0F)); }
|
||||
};
|
||||
static_assert(sizeof(UnifiedCacheEntry) == 10, "UnifiedCacheEntry should be 10 bytes");
|
||||
|
||||
// =========================================================================
|
||||
// Flat unified cache
|
||||
// =========================================================================
|
||||
//
|
||||
// Plain array, linear scan (same idiom as WarmNodeStore). A lookup walks at
|
||||
// most cacheSize() × 10 B - microseconds at LoRa packet rates, not worth a
|
||||
// hash table. Insertion on a full cache evicts the stalest entry,
|
||||
// preferring entries without a next_hop hint (those are the long-tail
|
||||
// routing state this cache exists to keep).
|
||||
//
|
||||
/// Unified cache capacity. Plain array, linear scan (same idiom as WarmNodeStore); insertion
|
||||
/// on a full cache evicts the stalest entry, preferring ones without a next_hop hint.
|
||||
static constexpr uint16_t cacheSize() { return TRAFFIC_MANAGEMENT_CACHE_SIZE; }
|
||||
|
||||
// NodeInfo cache configuration (PSRAM path): a flat PSRAM array of payload
|
||||
// entries, linear scan keyed by `node`, LRU eviction by lastObservedMs.
|
||||
// NodeInfo traffic is low-rate, so a full scan per lookup/insert is fine.
|
||||
// NodeInfo cache (PSRAM-backed on hardware, heap in native tests): flat payload array,
|
||||
// linear scan, trust/membership-tiered LRU eviction on insert. NodeInfo traffic is
|
||||
// low-rate, so full scans are fine.
|
||||
static constexpr uint16_t kNodeInfoCacheEntries = 2000;
|
||||
/// NodeInfo cache capacity.
|
||||
static constexpr uint16_t nodeInfoTargetEntries() { return kNodeInfoCacheEntries; }
|
||||
|
||||
// =========================================================================
|
||||
// Free-Running Tick Counters
|
||||
// =========================================================================
|
||||
//
|
||||
// Timestamps are stored as free-running modular tick counters derived from
|
||||
// millis(). No epoch anchor needed: modular subtraction gives correct age
|
||||
// as long as the true age stays below the counter period.
|
||||
//
|
||||
// pos_time : uint8 (256 ticks × 360 s = 25.6 h period; max window 12 h = 120 ticks)
|
||||
// rate_time : nibble (16 ticks × 300 s = 80 min period; max window 1 h = 12 ticks)
|
||||
// unknown_time: nibble (16 ticks × 60 s = 16 min period; max window 12 min = 12 ticks)
|
||||
//
|
||||
// Presence sentinels (no +1 offset needed; count fields serve as guards):
|
||||
// pos active: pos_fingerprint != 0 (0 is reserved sentinel; computePositionFingerprint() remaps computed-0 → 0xFF)
|
||||
// rate active: getRateCount() != 0 (low 6 bits; high 2 bits are cached role)
|
||||
// unknown active: getUnknownCount() != 0
|
||||
//
|
||||
static constexpr uint32_t kPosTimeTickMs = 360'000UL; // 6 min/tick
|
||||
static constexpr uint32_t kRateTimeTickMs = 300'000UL; // 5 min/tick
|
||||
static constexpr uint32_t kUnknownTimeTickMs = 60'000UL; // 1 min/tick
|
||||
// Free-running modular tick clocks derived from clockMs(); modular subtraction gives correct
|
||||
// age while true age stays below the counter period. Presence is carried by non-zero
|
||||
// sentinels (unified cache) or explicit validity bits (NodeInfo cache).
|
||||
static constexpr uint32_t kPosTimeTickMs = 360'000UL; // 6 min/tick (uint8: 25.6 h period)
|
||||
static constexpr uint32_t kRateTimeTickMs = 300'000UL; // 5 min/tick (nibble: 80 min period)
|
||||
static constexpr uint32_t kUnknownTimeTickMs = 60'000UL; // 1 min/tick (nibble: 16 min period)
|
||||
|
||||
/// Current position-clock tick (6 min/tick).
|
||||
static uint8_t currentPosTick() { return static_cast<uint8_t>(clockMs() / kPosTimeTickMs); }
|
||||
/// Current rate-clock tick nibble (5 min/tick).
|
||||
static uint8_t currentRateTick() { return static_cast<uint8_t>((clockMs() / kRateTimeTickMs) & 0x0F); }
|
||||
/// Current unknown-clock tick nibble (1 min/tick).
|
||||
static uint8_t currentUnknownTick() { return static_cast<uint8_t>((clockMs() / kUnknownTimeTickMs) & 0x0F); }
|
||||
// =========================================================================
|
||||
// Position Fingerprint
|
||||
// =========================================================================
|
||||
//
|
||||
// Computes 8-bit fingerprint from truncated lat/lon coordinates.
|
||||
// Extracts lower 4 significant bits from each coordinate.
|
||||
//
|
||||
// fingerprint = (lat_low4 << 4) | lon_low4
|
||||
//
|
||||
// Unlike a hash, adjacent grid cells have sequential fingerprints,
|
||||
// so nearby positions never collide. Collisions only occur for
|
||||
// positions 16+ grid cells apart in both dimensions.
|
||||
//
|
||||
// Guards: If precision < 4 bits, uses min(precision, 4) bits.
|
||||
//
|
||||
|
||||
// NodeInfo observation tick (same idiom). The 60 s sweep clears the presence bit once the serve
|
||||
// window passes, so the stamp is never read near its uint8 aliasing horizon. (Response throttling
|
||||
// no longer lives here - it is the fixed per-requester/per-target RAM tables below, which use
|
||||
// wrap-safe uint32 ms compares and so need no tick clock or sweep.)
|
||||
static constexpr uint32_t kNodeInfoObsTickMs = 180000UL; // 3 min/tick (12.8 h period)
|
||||
static constexpr uint8_t kNodeInfoMaxServeAgeTicks = 120; // 6 h serve window
|
||||
|
||||
/// Current NodeInfo observation tick (3 min/tick).
|
||||
static uint8_t currentObsTick() { return static_cast<uint8_t>(clockMs() / kNodeInfoObsTickMs); }
|
||||
static_assert(kNodeInfoMaxServeAgeTicks * kNodeInfoObsTickMs == 6UL * 60UL * 60UL * 1000UL,
|
||||
"cache serve window must equal the fallback path's 6 h");
|
||||
|
||||
/// 8-bit position fingerprint from truncated lat/lon: low 4 significant bits of each, so
|
||||
/// adjacent grid cells never collide (collisions need 16+ cells apart in both dimensions).
|
||||
static uint8_t computePositionFingerprint(int32_t lat_truncated, int32_t lon_truncated, uint8_t precision);
|
||||
|
||||
// =========================================================================
|
||||
@@ -213,42 +234,60 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
|
||||
bool cacheFromPsram = false; // Tracks allocator for correct deallocation
|
||||
|
||||
struct NodeInfoPayloadEntry {
|
||||
// Node identifier associated with this payload slot.
|
||||
// 0 means the slot is currently unused.
|
||||
// Node identifier for this slot; 0 means unused.
|
||||
NodeNum node;
|
||||
|
||||
// Cached NODEINFO_APP payload body. This is separate from NodeDB and is only
|
||||
// used by the PSRAM-backed direct-response path in this module.
|
||||
// Cached NODEINFO_APP payload, independent of NodeDB; serves the PSRAM-backed
|
||||
// direct-response path and the last-resort pubkey pool.
|
||||
meshtastic_User user;
|
||||
|
||||
// Extra response metadata captured from the latest observed NODEINFO_APP
|
||||
// packet for this node. shouldRespondToNodeInfo() uses this metadata when
|
||||
// building spoofed replies for requesting clients.
|
||||
|
||||
// Last local uptime tick (millis) when this entry was refreshed.
|
||||
uint32_t lastObservedMs;
|
||||
|
||||
// Last RTC/packet timestamp (seconds) observed for this NodeInfo frame.
|
||||
// If unavailable in packet, remains 0.
|
||||
uint32_t lastObservedRxTime;
|
||||
// Tick of the last genuinely HEARD NODEINFO frame (kNodeInfoObsTickMs clock). Drives the
|
||||
// replay staleness gate and LRU age; seeding/write-through never touch it, so a spoofed
|
||||
// reply is only ever backed by genuine recent observation. Validity: hasObserved.
|
||||
uint8_t obsTick;
|
||||
|
||||
// Channel where we most recently heard this node's NodeInfo.
|
||||
uint8_t sourceChannel;
|
||||
|
||||
// Cached decoded bitfield metadata from the source packet.
|
||||
// We preserve non-OK_TO_MQTT bits in direct replies when available.
|
||||
bool hasDecodedBitfield;
|
||||
// Cached decoded bitfield from the source packet (non-OK_TO_MQTT bits are preserved
|
||||
// in direct replies). Validity: hasDecodedBitfield.
|
||||
uint8_t decodedBitfield;
|
||||
};
|
||||
|
||||
NodeInfoPayloadEntry *nodeInfoPayload = nullptr; // NodeInfo payloads in PSRAM (flat array, linear scan)
|
||||
// 1-bit flags, packed into one byte (6 spare bits; add future booleans here rather
|
||||
// than new bytes - the array is 2000 entries).
|
||||
|
||||
// The source packet carried a decoded bitfield (so decodedBitfield is meaningful).
|
||||
uint8_t hasDecodedBitfield : 1;
|
||||
|
||||
// Key provenance: set once an XEdDSA signature was verified for user.public_key
|
||||
// (directly, or inherited from NodeDB via isVerifiedSignerForKey). Monotonic per slot;
|
||||
// the key-pin checks forbid the key changing underneath it. TOFU keys start at 0.
|
||||
uint8_t keySignerProven : 1;
|
||||
|
||||
// obsTick is valid: a NODEINFO frame was actually heard within the observation clock's
|
||||
// horizon. Cleared by the sweep once the serve window passes (saturation).
|
||||
uint8_t hasObserved : 1;
|
||||
|
||||
// `user` carries a real User payload (from an observed frame or hot-store seed) rather
|
||||
// than a key-only warm-tier record. copyUser()/name-rehydration require it.
|
||||
uint8_t hasFullUser : 1;
|
||||
|
||||
// Node currently exists in NodeDB (hot or warm), per the last hourly reconcile pass
|
||||
// (write-through hooks set it immediately on commit; purgeNode clears immediately on
|
||||
// removal; a passive NodeDB eviction may lag up to an hour). Member entries are
|
||||
// stickiest under LRU; the bit is the keep-alive (no TTL).
|
||||
uint8_t isMember : 1;
|
||||
};
|
||||
// No exact-size static_assert: sizeof(meshtastic_User) and its padding vary by platform, so
|
||||
// any fixed byte count would fail the build on some boards.
|
||||
|
||||
NodeInfoPayloadEntry *nodeInfoPayload = nullptr; // NodeInfo payloads (flat array; PSRAM on hardware, heap in tests)
|
||||
bool nodeInfoPayloadFromPsram = false; // Tracks allocator for correct deallocation
|
||||
|
||||
meshtastic_TrafficManagementStats stats;
|
||||
|
||||
// Flag set during alterReceived() when packet should be exhausted.
|
||||
// Checked by perhapsRebroadcast() to force hop_limit = 0 only for the
|
||||
// matching packet key (from + id). Reset at start of handleReceived().
|
||||
// Set during alterReceived() when the packet's hops should be exhausted; checked by
|
||||
// perhapsRebroadcast() for the matching packet key. Reset at start of handleReceived().
|
||||
bool exhaustRequested = false;
|
||||
NodeNum exhaustRequestedFrom = 0;
|
||||
PacketId exhaustRequestedId = 0;
|
||||
@@ -256,48 +295,97 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
|
||||
// One-shot guard: warm-start next-hop cache from NodeDB on first maintenance pass.
|
||||
bool nextHopPreloaded = false;
|
||||
|
||||
// Reconcile cadence: full boot seed on the first maintenance pass, then hourly. The
|
||||
// write-through hooks give immediacy; this periodic repair self-heals anything they miss.
|
||||
static constexpr uint8_t kNodeInfoReconcileSweeps = 60; // sweeps between reconciliations (60 x 60 s = 1 h)
|
||||
bool nodeInfoSeeded = false;
|
||||
uint8_t sweepsSinceNodeInfoReconcile = 0;
|
||||
|
||||
// =========================================================================
|
||||
// Cache Operations
|
||||
// =========================================================================
|
||||
|
||||
// Find or create entry for node (linear scan; stalest-first eviction when full)
|
||||
/// Find or create the unified-cache entry for `node` (stalest-first eviction when full).
|
||||
UnifiedCacheEntry *findOrCreateEntry(NodeNum node, bool *isNew);
|
||||
|
||||
// Find existing entry (no creation)
|
||||
/// Find an existing unified-cache entry (no creation).
|
||||
UnifiedCacheEntry *findEntry(NodeNum node);
|
||||
|
||||
// Resolve a sender's advertised device role for the position hot path. The tier-3
|
||||
// cache (this entry's getCachedRole) is authoritative and is kept fresh by
|
||||
// updateCachedRoleFromNodeInfo() - updated when NodeDB learns a role, not re-derived
|
||||
// per packet. Only on first tracking (isNew) do we scan NodeDB (hot store → warm
|
||||
// store, via getNodeRole) to seed the cache, so a resident special-role node is
|
||||
// correct from its first position; after that the read is O(1) and survives the node
|
||||
// aging out of both NodeDB stores. Caller must hold cacheLock; entry may be null
|
||||
// (→ NodeDB scan only).
|
||||
/// Resolve a sender's device role for the position hot path. The tier-3 cache is
|
||||
/// authoritative once seeded (NodeDB is scanned only on first tracking), so the read is O(1)
|
||||
/// and survives the node aging out of both NodeDB stores. Caller must hold cacheLock.
|
||||
meshtastic_Config_DeviceConfig_Role resolveSenderRole(NodeNum from, UnifiedCacheEntry *entry, bool isNew);
|
||||
|
||||
// Refresh the tier-3 role cache from an observed NodeInfo (the same event that updates
|
||||
// NodeDB's role). Reads role from the packet's User payload; updates only nodes already
|
||||
// tracked (no entry creation). Takes cacheLock.
|
||||
/// Refresh the tier-3 role cache from an observed NodeInfo (the same event that updates
|
||||
/// NodeDB's role). Updates only nodes already tracked. Takes cacheLock.
|
||||
void updateCachedRoleFromNodeInfo(const meshtastic_MeshPacket &mp);
|
||||
|
||||
// NodeInfo cache operations (flat PSRAM payload array, linear scan)
|
||||
/// Find an existing NodeInfo cache entry (no creation).
|
||||
const NodeInfoPayloadEntry *findNodeInfoEntry(NodeNum node) const;
|
||||
NodeInfoPayloadEntry *findOrCreateNodeInfoEntry(NodeNum node, bool *usedEmptySlot);
|
||||
/// Mutable variant of findNodeInfoEntry().
|
||||
NodeInfoPayloadEntry *findNodeInfoEntryMutable(NodeNum node)
|
||||
{
|
||||
return const_cast<NodeInfoPayloadEntry *>(findNodeInfoEntry(node));
|
||||
}
|
||||
/// Find or create a NodeInfo cache entry, evicting by trust/membership tier when full.
|
||||
/// With spareMembers, returns nullptr instead of evicting an isMember entry (the seeding
|
||||
/// pass never churns one NodeDB-tier node out for another; the packet path may).
|
||||
NodeInfoPayloadEntry *findOrCreateNodeInfoEntry(NodeNum node, bool *usedEmptySlot, bool spareMembers = false);
|
||||
/// Number of occupied NodeInfo cache slots. Caller must hold cacheLock.
|
||||
uint16_t countNodeInfoEntriesLocked() const;
|
||||
|
||||
/// 60 s NodeInfo-cache maintenance under cacheLock: saturate the expired obsTick stamp (wrap-safety
|
||||
/// for the modular clock) and run the boot/hourly reconcile. Guarded by TMM_HAS_NODEINFO_CACHE alone
|
||||
/// (never the unified cache size); see docs/node_info_stores.md "Tick clocks and wrap safety".
|
||||
void maintainNodeInfoCacheLocked();
|
||||
|
||||
/// Anti-entropy under cacheLock: upsert hot-store + warm-tier records this cache lacks (never sets
|
||||
/// hasObserved - seeding is knowledge, not observation), and refresh isMember from both NodeDB
|
||||
/// tiers. Cost/lag: docs/node_info_stores.md "Consistency with NodeDB (anti-entropy)".
|
||||
void reconcileNodeInfoFromNodeDBLocked();
|
||||
/// Learn an observed NODEINFO frame into the cache (key hygiene + provenance rules apply).
|
||||
void cacheNodeInfoPacket(const meshtastic_MeshPacket &mp);
|
||||
|
||||
// =========================================================================
|
||||
// Traffic Management Logic
|
||||
// =========================================================================
|
||||
|
||||
/// True when this position broadcast duplicates the sender's last one within the dedup window.
|
||||
bool shouldDropPosition(const meshtastic_MeshPacket *p, const meshtastic_Position *pos, uint32_t nowMs);
|
||||
/// Decide (and with sendResponse, emit) a spoofed direct NodeInfo reply for a unicast request.
|
||||
bool shouldRespondToNodeInfo(const meshtastic_MeshPacket *p, bool sendResponse);
|
||||
|
||||
// Direct-response throttles bounding the reflector risk of spoofed replies: three fixed bounds
|
||||
// (per requester, per target, 1 s global airtime floor) via 8-slot LRU RAM tables, wrap-safe and
|
||||
// PSRAM-agnostic. Design & rationale: docs/traffic_management_module.md "Throttling direct responses".
|
||||
static constexpr uint32_t kDirectResponsePerRequesterMs = 60'000UL;
|
||||
static constexpr uint32_t kDirectResponsePerTargetMs = 60'000UL;
|
||||
static constexpr uint32_t kDirectResponseGlobalMs = 1'000UL;
|
||||
static constexpr size_t kDirectResponseTrackedNodes = 8;
|
||||
struct DirectResponseThrottleEntry {
|
||||
NodeNum key; // requester or target node; 0 = unused slot
|
||||
uint32_t lastReplyMs; // clockMs() of our last reply keyed on this node
|
||||
};
|
||||
DirectResponseThrottleEntry directRequesterSeen[kDirectResponseTrackedNodes] = {};
|
||||
DirectResponseThrottleEntry directTargetSeen[kDirectResponseTrackedNodes] = {};
|
||||
uint32_t lastDirectResponseMs = 0;
|
||||
/// True (and records the send) when a spoofed direct reply to `requester` for `target` is within
|
||||
/// every throttle window; false throttles it. Caller must NOT hold cacheLock (this takes it).
|
||||
bool directResponseAllowed(NodeNum requester, NodeNum target, uint32_t nowMs);
|
||||
/// Slot in `table` to stamp for `key` at `nowMs`, or nullptr if `key` is still within `windowMs`
|
||||
/// of its last reply (throttled). Does not stamp - the caller stamps only once all windows pass.
|
||||
static DirectResponseThrottleEntry *directResponseSlot(DirectResponseThrottleEntry *table, NodeNum key, uint32_t nowMs,
|
||||
uint32_t windowMs);
|
||||
/// True when the requestor is within the role-clamped hop limit for direct responses.
|
||||
bool isMinHopsFromRequestor(const meshtastic_MeshPacket *p) const;
|
||||
/// True when `from` exceeded the configured packet budget for the current rate window.
|
||||
bool isRateLimited(NodeNum from, uint32_t nowMs);
|
||||
/// True when `p`'s sender exceeded the undecodable-packet threshold for the current window.
|
||||
bool shouldDropUnknown(const meshtastic_MeshPacket *p, uint32_t nowMs);
|
||||
|
||||
/// Log a traffic action (drop/respond/clamp) with port name and packet routing context.
|
||||
void logAction(const char *action, const meshtastic_MeshPacket *p, const char *reason) const;
|
||||
/// Increment a stats counter under cacheLock.
|
||||
void incrementStat(uint32_t *field);
|
||||
};
|
||||
|
||||
|
||||
@@ -258,6 +258,8 @@ bool AudioModule::shouldDraw()
|
||||
void AudioModule::sendPayload(NodeNum dest, bool wantReplies)
|
||||
{
|
||||
meshtastic_MeshPacket *p = allocReply();
|
||||
if (!p)
|
||||
return;
|
||||
p->to = dest;
|
||||
p->decoded.want_response = wantReplies;
|
||||
|
||||
|
||||
@@ -81,6 +81,8 @@ bool PaxcounterModule::sendInfo(NodeNum dest)
|
||||
pl.uptime = millis() / 1000;
|
||||
|
||||
meshtastic_MeshPacket *p = allocDataProtobuf(pl);
|
||||
if (!p)
|
||||
return false;
|
||||
p->to = dest;
|
||||
p->decoded.want_response = false;
|
||||
p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;
|
||||
|
||||
@@ -105,6 +105,8 @@ void GamesModule::announceHighScore(const char *initials, uint32_t score)
|
||||
if (!initials || initials[0] == '\0')
|
||||
return;
|
||||
meshtastic_MeshPacket *p = allocDataPacket();
|
||||
if (!p)
|
||||
return;
|
||||
p->to = NODENUM_BROADCAST;
|
||||
p->channel = 0; // primary channel
|
||||
p->decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP;
|
||||
|
||||
+16
-1
@@ -121,6 +121,8 @@ inline void onReceiveProto(char *topic, byte *payload, size_t length)
|
||||
// receives it when we get our own packet back. Then we'll stop our retransmissions.
|
||||
if (isFromUs(e.packet)) {
|
||||
auto pAck = routingModule->allocAckNak(meshtastic_Routing_Error_NONE, getFrom(e.packet), e.packet->id, ch.index);
|
||||
if (!pAck)
|
||||
return;
|
||||
pAck->transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT;
|
||||
if (router->sendLocal(pAck) == ERRNO_SHOULD_RELEASE)
|
||||
packetPool.release(pAck);
|
||||
@@ -332,7 +334,20 @@ void MQTT::mqttCallback(char *topic, byte *payload, unsigned int length)
|
||||
|
||||
void MQTT::onClientProxyReceive(meshtastic_MqttClientProxyMessage msg)
|
||||
{
|
||||
onReceive(msg.topic, msg.payload_variant.data.bytes, msg.payload_variant.data.size);
|
||||
// payload_variant is a union: on the text variant, data.size aliases the first bytes of the
|
||||
// string, so reading it unconditionally let a client name any length up to PB_SIZE_MAX.
|
||||
switch (msg.which_payload_variant) {
|
||||
case meshtastic_MqttClientProxyMessage_data_tag:
|
||||
onReceive(msg.topic, msg.payload_variant.data.bytes, msg.payload_variant.data.size);
|
||||
break;
|
||||
case meshtastic_MqttClientProxyMessage_text_tag:
|
||||
onReceive(msg.topic, (byte *)msg.payload_variant.text,
|
||||
strnlen(msg.payload_variant.text, sizeof(msg.payload_variant.text)));
|
||||
break;
|
||||
default:
|
||||
LOG_WARN("MQTT proxy message carries no payload, topic %s", msg.topic);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void MQTT::onReceive(char *topic, byte *payload, size_t length)
|
||||
|
||||
Reference in New Issue
Block a user