mirror of
https://github.com/alexhopeoconnor/firmware.git
synced 2026-10-04 03:18:10 +10:00
* Harden TMM NodeInfo direct-response: staleness, key hygiene, throttle The NodeInfo direct-response path answered queries on behalf of other nodes from an unauthenticated, never-expiring cache while suppressing the genuine request (STOP). That let a long-gone or forged identity be served as a fresh-looking, authoritative reply indefinitely. Three fixes: - Staleness gate: refuse to spoof a reply for a node not actually heard within ~6h (PSRAM cache via lastObservedMs; NodeDB fallback via sinceLastSeen, which tolerates last_heard==0 when the clock is unset). A stale entry now falls through so the real request propagates instead of being answered for a dead node. - Key hygiene: mirror NodeDB::updateUser() PKI checks when caching overheard NodeInfo - reject a packet advertising our own public key, and pin keys (drop a NodeInfo whose key mismatches an already-known key from NodeDB or from our own cache). Prevents cache poisoning / key substitution via the spoofed-reply path. - Response throttle: at most one spoofed reply per target node per 30s. Direct responses bypass the per-sender rate limiter (they STOP the request first) and the reply target is attacker-controlled, so this bounds airtime exhaustion and reflected floods. Recorded in a new NodeInfoPayloadEntry.lastResponseMs (PSRAM; self-expiring timestamp compare, no sweep needed). Tests: native NodeDB-fallback staleness (stale drops, fresh serves) plus PSRAM-guarded staleness, throttle, and key-mismatch cases. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs * Add TODO(T1-T9) markers for code-review findings on NodeInfo hardening Comment-only. Tags each site flagged in the review of the direct-response throttle/staleness/key-hygiene change so the follow-ups are visible in-context: T1 throttle black-holes distinct requestors; T2 per-target key doesn't bound aggregate TX; T3 non-PSRAM fallback unthrottled; T4 millis-wrap defeats the staleness gate; T5 redundant second O(n) scan for the throttle stamp; T6 duplicated 32-byte key compares; T7 sinceLastSeen() called twice; T8 two 6h constants can desync; T9 clockMs()==0 collides with the 0 sentinel. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs * Address code-review cleanups T6, T7, T8 in NodeInfo direct-response T8: derive kNodeInfoMaxServeAgeSecs from kNodeInfoMaxServeAgeMs so the PSRAM and NodeDB-fallback staleness windows cannot desync. T6: collapse the three duplicated 32-byte key compares in cacheNodeInfoPacket (owner-impersonation + NodeDB pin + cache TOFU pin) into one pubKeysEqual() helper, so the compare lives in one place. T7: compute sinceLastSeen(node) once on the fallback staleness path so the tested age and the logged age cannot diverge (it calls getTime() each time). Remaining review items still marked in-code: T1/T2/T3 (throttle design), T4/T9 (millis-wrap + 0-sentinel), T5 (redundant throttle-stamp scan). Native test_traffic_management suite: 48/48 passing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs * Throttle the NodeDB fallback direct-response path (T3) The per-target NodeInfo response throttle lived only in the PSRAM NodeInfoPayloadEntry, so non-PSRAM boards - which answer from the NodeDB fallback path - emitted spoofed direct replies with no rate limit at all, leaving the airtime/reflection surface the throttle exists to close. Add a single module-global fallback stamp (nodeInfoFallbackLastResponseMs, 4 bytes, guarded by cacheLock) that throttles the fallback path to one spoofed reply per window across all targets. Coarser than the PSRAM per-target throttle, but the fallback path has no per-node slot to stamp, and a global cap still bounds spoofed transmissions - which is the point. Add a native test exercising the fallback throttle window. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs * Make NodeInfo staleness/throttle wrap- and sentinel-safe (T4, T9) T9: the millis-based fields that use 0 as a "never" sentinel (lastObservedMs, lastResponseMs, nodeInfoFallbackLastResponseMs) could be stamped with a literal 0 during the one-millisecond clockMs()==0 instant at each ~49.7-day wrap, momentarily colliding with the sentinel and disabling the staleness/throttle gate for that entry. Route all such stamps through a new nowStampMs() that maps 0 -> 1; the 1 ms skew is irrelevant to every window these fields feed. T4: the staleness gate's modular age compare is only unambiguous while true age < 2^32 ms (~49.7 days); an entry that lingered unrefreshed that long could wrap to a small age and read as fresh, defeating the gate. Add a NodeInfo eviction pass to the maintenance sweep that drops entries past the serve window, so an entry is removed long before its age can approach the wrap boundary. This also frees slots holding stale identities. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs * Stamp PSRAM throttle entry by captured index, not a rescan (T5) The post-send throttle stamp did a second full O(n) scan of the PSRAM NodeInfo array under a fresh lock, even though findNodeInfoEntry() had already located the slot at the top of shouldRespondToNodeInfo(). Capture the slot index during that initial lookup and address the entry directly on the stamp path. Because the cache lock is released between the two accesses, the slot could have been evicted or reused, so re-validate node == p->to under the lock before stamping. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs * Document accepted per-target throttle tradeoffs (T1, T2) Convert the T1/T2 TODO markers into a NOTE: the per-target PSRAM throttle keying and its lack of an aggregate bound are deliberate design choices, not pending work. A distinct requestor being throttled for one window is harmless on a redundant mesh, and keeping the PSRAM path per-target preserves throughput for legitimate multi-target responders (the fallback path already bounds aggregate via its global stamp). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs * Track signed-provenance of cached NodeInfo public keys Add keySignerProven to NodeInfoPayloadEntry, set when an observed NODEINFO frame's XEdDSA signature was verified (mp.xeddsa_signed). This distinguishes a trust-on-first-use key from one proven to belong to a signer. The flag is monotonic - once proven it stays proven, and the existing key-pin checks forbid the underlying key from changing - so a later unsigned frame cannot downgrade it. A signature can only be verified against a key we already held, so a first-contact key is always TOFU until a later signed frame upgrades it. Groundwork for using the TMM cache as a last-resort public-key source, where this flag serves as a trust tier. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs * Draw public keys from the TMM NodeInfo cache as a last resort Add TrafficManagementModule::copyPublicKey() and consult it from NodeDB::copyPublicKey() after the hot (NodeInfoLite) and warm (WarmNodeStore) tiers miss. This extends the pool of peers the node can encrypt to: a key the NodeInfo direct-response cache overheard for a node that has since aged out of both NodeDB tiers can still be used. The getter reports whether the key is signer-proven or trust-on-first-use. NodeDB serves TOFU keys here too - the same first-contact trust NodeDB already applies in updateUser() - so the pool actually expands to new long-tail nodes rather than only re-confirming known keys. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs * Retain keyed NodeInfo entries as a pubkey pool; trust-tier eviction Now that NodeDB::copyPublicKey() draws keys from the NodeInfo cache, the flat 6 h serve-window eviction would discard useful keys the moment a node stops being servable. Split retention: an entry carrying a 32-byte public key is kept for kNodeInfoKeyRetentionMs (7 d), while a keyless entry still expires at the serve window. Both windows stay well under the ~49.7-day millis wrap, preserving the T4 wrap-safety guarantee. Make LRU victim selection trust-tiered to match: a keyless slot is sacrificed before any keyed one, and a trust-on-first-use key before a signer-proven key; within a tier the oldest loses. Mirrors WarmNodeStore's keyed-first admission so the most valuable keys are the stickiest under memory pressure. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs * Test signed-provenance flag and copyPublicKey key-pool source PSRAM-gated tests (run on the ESP32 build, alongside the existing NodeInfo PSRAM tests): - copyPublicKey serves a TOFU key learned from an unsigned NodeInfo and reports signerProven=false - a later signature-verified NodeInfo upgrades provenance to signer-proven while the pinned key bytes stay unchanged - copyPublicKey reports a miss for an uncached node Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs * Inherit signer provenance from NodeDB when caching a re-found node When the TrafficManagement NodeInfo cache re-caches a node, mark its key signer-proven if NodeDB already knows the node as a verified signer for that same key - even if this particular (unicast/unsigned) frame carried no signature. Previously the flag only upgraded on a frame we verified ourselves, so a node we had already proven elsewhere looked TOFU here. Add NodeDB::isVerifiedSignerForKey(), which checks both tiers - the hot store's signed bitfield and the warm tier's cached signer bit - and requires the key to match so a rotated/mismatched key never inherits a stale verdict. Add WarmNodeStore::isVerifiedSigner() to expose the warm signer bit (the rebase onto develop added the bit itself; this surfaces it for lookups). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs * Lock NodeInfoPayloadEntry packing with a size assert keySignerProven cost zero bytes: sourceChannel, the two bools, and decodedBitfield are four 1-byte fields that fill a single 4-byte tail word (struct alignment is 4), so the flag consumed former padding rather than growing the 2000-entry PSRAM array. Add a static_assert pinning the entry to sizeof(meshtastic_User) + 20 so a future 5th trailing byte - which would open a fresh word (~8 KB PSRAM across the array) - fails the build instead of silently costing memory, prompting new flags to be packed into existing bytes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs * Pack NodeInfo cache booleans into 1-bit fields Fold hasDecodedBitfield and keySignerProven into adjacent uint8_t:1 bitfields so they share a single byte, reserving 6 spare bits for future flags without growing the 2000-entry PSRAM array. sizeof is unchanged (still one packed tail word, sizeof(meshtastic_User) + 20); access is by name exactly as before, so no call sites change. Reorders decodedBitfield ahead of the flags so the two 1-bit members stay adjacent and the compiler packs them together. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs * Gate NodeInfo replay on signer-proven provenance (compile-time, default on) Add TMM_NODEINFO_REPLAY_REQUIRE_SIGNED (default 1): the direct-response path now only spoofs a reply for a node whose key is signer-proven, in addition to the staleness gate - so replay is based on flagging AND staleness. Replay is a courtesy feature, and vouching for an unverified (trust-on-first-use) identity to other nodes is the risk this closes, so signed-only is the safer default. Define the macro to 0 at build time to also serve fresh TOFU-only nodes. Both paths are gated: the PSRAM path checks the cached keySignerProven flag, the NodeDB fallback checks nodeInfoLiteHasXeddsaSigned(node). The effective gate (TMM_NODEINFO_REPLAY_SIGNED_GATE) is bypassed when PKI is excluded from the build, since nothing can be signed there and the courtesy feature would otherwise be disabled outright. Tests: existing reply-expecting tests establish signer-proven state (a new markKeySignerProvenForTest hook for the PSRAM cache, the NodeDB signed bit for the fallback path); add fallback and PSRAM tests asserting an unsigned/TOFU node is withheld under the default gate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs * Rehydrate re-admitted node names from the TMM NodeInfo cache The warm tier keeps an evicted node's key but not its name (the 40-byte record has no room), so a re-admitted long-tail node is nameless until its next NodeInfo broadcast. The TrafficManagement NodeInfo cache is much larger (2000 PSRAM entries) and commonly still holds the full User, so use it as a name reservoir the warm tier structurally cannot be. On re-admission (getOrCreateMeshNode), after the warm-tier restore, copy the cached identity from the TMM cache via a new copyUser() getter - but only when its cached key matches the key just restored from warm, so a name never attaches to a different identity than the one we encrypt to (key-matched trust). Guarded by HAS_TRAFFIC_MANAGEMENT and a null check; a no-op without the PSRAM cache or when no key is present. CopyUserToNodeInfoLite sets only user-related bits, so the warm-restored signer bit is preserved. Limitations (by design): PSRAM-only, and the TMM cache is RAM-only, so this helps within an uptime session, not across reboot. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs * Drop non-portable NodeInfoPayloadEntry size assert (fixes pico build) The static_assert pinned sizeof(NodeInfoPayloadEntry) to sizeof(meshtastic_User) + 20, but nanopb packs the generated User struct differently per platform: on pico its size is not a multiple of 4, so alignment padding before the uint32 timestamps makes the overhead 22, not 20, and the assert failed the build (native happened to be +20 and passed, hiding it). The bitfield packing it was guarding still stands; replace the fixed-count assert with a comment, since no portable byte count exists. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs * Run the NodeInfo cache paths in native tests (TMM_HAS_NODEINFO_CACHE) The NodeInfo direct-response cache and everything layered on it - key pinning, signer provenance, staleness, throttle - was compiled only for ARCH_ESP32 + BOARD_HAS_PSRAM, so its entire test set was skipped by the native CI suite and ran nowhere but real hardware. Fold the scattered platform guards into one TMM_HAS_NODEINFO_CACHE macro that also enables the cache (plain heap, delete[] path already existed) for ARCH_PORTDUINO unit-test builds. Production portduino and embedded test builds are unchanged. Tests that exercise the NodeDB fallback path drop the cache explicitly via a new dropNodeInfoCacheForTest() hook, so both response paths are covered in one binary. Native suite: 60/60 (was 50 with the 10 cache tests compiled out). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT (cherry picked from commit b2dafbbbd6501b42fc27175e599fb38be292b0e3) * Pin NodeInfo cache keys against the warm tier, not just the hot store cacheNodeInfoPacket() mirrored NodeDB::updateUser()'s key pin but checked only getMeshNode() - the hot store. updateUser's own pin effectively covers the warm tier too (getOrCreateMeshNode rehydrates the warm key before the check), so the mirror had a gap: for a node evicted to the warm tier whose cache slot was also gone, an attacker's NodeInfo with a bogus key passed the pin, and the cache's TOFU pin then locked the genuine node's frames out until the poisoned entry aged away. Split the authoritative lookup out of NodeDB::copyPublicKey() as copyPublicKeyAuthoritative() (hot store, then warm tier - never the opportunistic TMM tier, which would compare the cache against itself) and pin against that. Test: warm-tier-only key rejects a mismatching NodeInfo and accepts the matching one. Native suite: 61/61. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT (cherry picked from commit 28e880875ef1f8bf16bdf301a4e6036537a84b4b) * Convert NodeInfo cache clocks to uint8 tick counters Replace the entry's uint32 millis stamps (lastObservedMs, lastResponseMs) with three uint8 free-running tick clocks - the same modular scheme the UnifiedCache counters already use: obsTick 3 min/tick (12.8 h period) - replay staleness gate respTick 5 s/tick (21.3 min) - per-target response throttle retTick 1 h/tick (10.67 d) - retention TTL + LRU age Validity is an explicit flag bit (hasObserved / hasResponded), not a 0 sentinel, and the maintenance sweep saturates a stamp (clears its flag) once its window passes, so no stamp can age toward its ~256-tick aliasing horizon. That retires the wrap/sentinel special cases (T4, T9) - nowStampMs() survives only for the fallback path's module-global millis stamp. obsTick is separated from retention by design: only a genuinely heard NODEINFO frame stamps it, so later membership-based retention refresh can never make a silent node look servable. Also drops lastObservedRxTime, which was only echoed in a debug log. Entry shrinks 136 -> 128 B; the 2000-entry array 272 kB -> 256 kB, 8 kB below the original develop footprint while keeping the throttle and retention features. Tick granularity costs at most one tick per window (+-5 s on 30 s, +-3 min on 6 h, +-1 h on 7 d). Native suite: 61/61. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT (cherry picked from commit e48ed9cfacfed0131b77ed2f1992e8720720d387) * Keep the NodeInfo cache a superset of NodeDB (seed + keep-alive) The cache learned identities only from overheard NODEINFO frames, so its membership was opportunistic: a NodeDB-tier node whose frame was never heard this boot had no entry (no key pin to protect it, no name to rehydrate), and the retention TTL evicted entries for nodes that still lived in the hot store or warm tier. Add an anti-entropy pass to the maintenance sweep (reconcileNodeInfoMembershipLocked): every hot-store / warm-tier node gets an entry - full identity from the hot store (hasFullUser), key-only from the warm tier - with signer provenance inherited key-matched, and NodeDB's key adopted wholesale on conflict. Member entries (isMember) are re-stamped each sweep, so they never age toward the retention TTL; when a node leaves both tiers the keep-alive stops and its entry ages out from its final member re-stamp. Membership also outranks key trust in LRU victim selection, and the reconcile pass skips seeding rather than churn one member out for another when hot+warm exceeds the cache. Two properties are load-bearing: - seeding/keep-alive never touch obsTick/hasObserved, so a seeded or retained entry is never served as a spoofed reply - only genuinely heard frames make a node servable; - copyUser() now requires hasFullUser, so a key-only warm seed can never stamp HAS_USER onto a nameless node via name-rehydration. Native suite: 64/64. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT (cherry picked from commit 4f1b30d838b2f7b11173373b8f1ba97cf91bef6b) * Write-through NodeDB identity commits into the NodeInfo cache updateUser() is the single chokepoint through which every remote identity/key write enters NodeDB (key-verification keys stay in CryptoEngine's pending buffer until a NodeInfo carries them here), so one hook at its tail lets the NodeInfo cache reflect a commit immediately instead of waiting up to a minute for the reconcile sweep - which stays in place as the anti-entropy backstop. onNodeIdentityCommitted() upserts the full User: NodeDB's key is authoritative (a conflicting cached key is stale residue - replaced, provenance dropped), a keyless commit keeps an already-cached TOFU key, and signer provenance transfers only for the committed key. The observation stamp is never touched: knowledge is not observation, so a hook write can never make a never-heard node servable - covered by the new test alongside immediate copyUser/copyPublicKey visibility. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT (cherry picked from commit e160b899728be8d951d4e7bdf5106c777832b917) * Purge TrafficManagement caches on explicit node removal NodeDB::removeNodeByNum() already forgets the node everywhere NodeDB owns - hot store, satellite stores, warm tier - but the TrafficManagement caches kept the deleted identity: the NodeInfo entry went on feeding the key pool (NodeDB::copyPublicKey) and name rehydration, and the unified slot kept its role/next-hop/dedup state, resurrecting the node on next contact. Add purgeNode(): clears both the unified cache slot and the NodeInfo cache entry, called from removeNodeByNum() alongside the warm-tier removal. Removal means full removal; passive eviction never calls this, and the reconcile sweep will not re-seed a node absent from both NodeDB tiers. Test: an observed identity plus a next-hop hint both vanish after removeNodeByNum(). Left for CI to execute per request. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT (cherry picked from commit b5564c173337de99e15ef998a5c698550238fd3c) * Document the tmm-fix-2 / tmm-fix-superset reconciliation decisions Two parallel implementations of the same superset design are being combined on this branch. This decision matrix records every divergence and which side each reconciled feature takes, ahead of the code commits that apply them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT * Reconcile retention: no timed eviction, obsTick LRU, hourly seeding Adopt tmm-fix-superset's retention model (reconciliation decisions #3, #4, #9 in .notes/tmm-super-superset-reconciliation.md): - Entries are never evicted on a timer. The 7-day retention TTL and its third tick clock (retTick) are gone; wrap-safety rests entirely on the sweep's presence-bit saturation, and a quiet entry keeps its value (pubkey pool, name rehydration). Slots are reclaimed only by trust/membership-tiered LRU on insert - age scored by obsTick, with never-observed entries counting oldest - or by an explicit purge. - Membership refresh (entry -> NodeDB contains checks) runs every sweep; the heavy seeding pass runs at boot and then hourly, with the write-through hooks carrying immediacy in between. - Tick constants and helpers move into the header beside the existing UnifiedCache tick idiom, with static_asserts tying the tick windows to the fallback path's seconds/ms forms. Kept from tmm-fix-2 (decision #4): the warm tier is still seeded (key-only records via WarmNodeStore::entryAt) so the invariant remains cache superset-of hot AND warm, and the spareMembers guard still stops seeding from churning one member out for another when hot+warm exceeds the cache. Entry shrinks to 128 B (2000 entries = 256 kB). The TTL-based retention test is superseded by a no-timed-eviction test: a quiet keyed entry survives 9 days of sweeps while the serve gate saturates as before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT * Reconcile hooks: key-commit write-through, purgeAll, key-matched signer Adopt tmm-fix-superset's hook coverage (reconciliation decisions #5-#8): - onNodeKeyCommitted(): write-through for the two key-write sites that bypass NodeDB::updateUser - the Router's admin-key learn (TOFU-grade) and KeyVerificationModule's manual-verification commit (proven=true, the strongest provenance the cache can carry). Both were coverage gaps in the tmm-fix-2 write-through design. - updateUser's hook call now transfers signer status key-matched (isVerifiedSignerForKey) instead of the node's bare signed flag, and runs on acceptance rather than only on change. - purgeAll(): factoryReset() and resetNodes() clear both TMM tables - removal-is-full-removal applies to bulk resets too, without relying on the usual post-reset reboot. - purgeNode() reuses the existing finders instead of open-coded scans and logs the (user-initiated) purge. - peekNodeInfoFlagsForTest(): flag introspection so saturation and membership tests can assert sweep effects directly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT * Merge the two branches' test suites for the reconciled design Port tmm-fix-superset's unique tests, adapted to run natively under TMM_HAS_NODEINFO_CACHE (reconciliation decision #11): - keyHook_upsertsAndGovernsProvenance: TOFU learn -> manual-verification upgrade -> NodeDB-senior rotation resets provenance. - tickSaturation_sweepClearsObserved: the sweep saturates hasObserved past the serve window, the entry persists (no TTL), and a full 256-tick clock wrap cannot alias a saturated stamp back to fresh. - sweepMembershipMarking: reconciliation seeds a hot identity as member+fullUser+unobserved; the next sweep clears membership once the node leaves NodeDB, while the entry itself persists. tmm-fix-2's tests (warm pin, hot/warm seeding, updateUser write-through, removal purge - now also asserting the entry is gone via the new peek hook - and the no-timed-eviction rewrite) were already on this branch. Suite now counts 70 test functions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT * Adapt cherry-picked and seeding tests to the reconciled semantics Two tests needed updating for interactions the validation run surfaced: - The #11035 test (ignoresUnsignedSignerIdentity) was written for a world without the native NodeInfo cache: it needs the NodeDB fallback path (dropNodeInfoCacheForTest) and the signed replay gate satisfied for its target, like the other fallback tests on this branch. - The hot-seed test's "observed frame makes it servable" step now sends a signature-verified frame: its node is a known signer, and per #11035 an unsigned frame from a signer must not (and does not) drive cache writes - the gate working as designed. Full native suite: 70/70 under ASan. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT * trunk fml * Doc tidyup * TrafficManagement: key NodeInfo-cache maintenance to its own guard runOnce() nested the entire NodeInfo maintenance block (tick-stamp saturation, membership refresh, boot/hourly reconcile) inside #if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0, so a variant overriding the unified cache to 0 on a PSRAM board would compile the NodeInfo cache without its maintenance: hasObserved would never saturate, obsTick would alias past its 12.8 h wrap, and the 6 h staleness gate - whose wrap-safety explicitly depends on the sweep - would serve spoofed replies for long-gone nodes. Extract maintainNodeInfoCacheLocked(), guarded by TMM_HAS_NODEINFO_CACHE alone, and give runOnce() the same independent-guard structure purgeAll() already has. Also close the runtime cousin of the same mismatch: the write-through hooks (onNodeIdentityCommitted / onNodeKeyCommitted) now no-op while moduleConfig.has_traffic_management is off. Previously they kept filling the cache from NodeDB commits while runOnce() returned INT32_MAX, accumulating entries that were never swept or reconciled. Purges and reads stay ungated: removal must always work, and reads just miss an empty cache. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * TrafficManagement: forward throttled NodeInfo requests instead of consuming them The direct-response throttle returned true, which made handleReceived() STOP the request: within the 30 s window a repeat request was neither answered nor forwarded toward the genuine target, and the call site still logged "respond" and bumped nodeinfo_cache_hits for a reply that was never sent. A requester whose first reply was lost on a noisy link got silence for the whole window. Return false instead: the request flows through normal relay handling, so the genuine node (or another cache-holder) can answer, while our own spoofed TX stays bounded. Repeats of the same packet id are already absorbed by the router's duplicate detection, and the stats counter now only counts replies actually sent. Also log purgeNode() only when a slot was actually cleared - it runs for every NodeDB removal, including nodes the caches never tracked. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * TrafficManagement: consolidate NodeInfo-cache #if sprawl into one region Fourteen scattered #if TMM_HAS_NODEINFO_CACHE guards (one per function, each with its own #else stub of (void) casts) collapse into a single guarded region holding every NodeInfo-cache-only function, terminated by one #else block of no-op stub definitions. Because the stubs now exist on every build, the call sites in runOnce(), purgeNode() and purgeAll() drop their guards too; inner guards remain only for orthogonal features (PKI, warm tier) and for real conditional work (constructor allocation, PSRAM paths in shouldRespondToNodeInfo). No behavior change. Compile-checked on both sides of the macro: the native app build (stubs) and the native unit-test build (region). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Router: resolve sender key only for PKI-decrypt candidates perhapsDecode() resolved the sender's public key unconditionally for every encrypted packet, before even checking whether the packet could be PKI-decrypted (channel 0, unicast to us). Since copyPublicKey() grew its TrafficManagement fallback tier, a full hot+warm miss - the common case for channel traffic from senders outside both NodeDB tiers - additionally walked the 2000-entry NodeInfo cache under its lock, per packet, for a key that was then discarded. Move the resolution inside the PKI-candidate branch; remotePublic and haveRemoteKey had no consumers outside it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * TrafficManagement: refresh NodeInfo membership hourly, not per-sweep The 60 s sweep re-derived isMember with a per-entry NodeDB lookup: O(entries x members) - about 700k node-number comparisons per minute on a full PSRAM cache (2000 entries x 250 hot + 100 warm), with strided PSRAM reads, all while holding cacheLock against the packet path. Move the refresh into the hourly reconcile pass, which is already O(members x entries): after the seeding loops (so the upsert pass still sees last pass's bits for spareMembers protection), clear every isMember bit and re-mark from both NodeDB tiers - including keyless warm records, which seed nothing but are still members. Accepted tradeoff, now documented on the field: membership lags a passive NodeDB eviction by up to an hour (the entry just stays LRU-sticky slightly longer). Additions stay immediate via the write-through hooks, explicit removals via purgeNode(). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * NodeDB: centralize bare-key commits in commitRemoteKey() The two key-write sites that bypass updateUser() (admin-channel learn in Router::perhapsDecode, manual verification in KeyVerificationModule) each carried their own #if HAS_TRAFFIC_MANAGEMENT write-through boilerplate - the pattern this PR itself had to retrofit twice, and which any future direct key write would silently miss, leaving the TrafficManagement cache divergent until the next hourly reconcile. Add NodeDB::commitRemoteKey(n, key32, KeyCommitTrust): writes the key to the hot store and routes the TrafficManagement write-through in one place, with provenance explicit at the call site (AdminChannelProven maps to TOFU-grade, ManuallyVerified to proven). The bypass sites keep their reason for existing - bare-key commits with provenance that updateUser's User-payload/TOFU-pin path cannot express - but no longer know about TrafficManagement at all; both stale includes are dropped (Router's was already unused on develop). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: tick wrap-safety analysis, updated cadences, CodeRabbit doc fixes node_info_stores.md gains a "Tick clocks and wrap safety" section recording which mechanism keeps each modular tick clock honest: the unified-cache clocks pair the 60 s sweep with read-time window resets, the NodeInfo obs/resp clocks are sweep-only (hence the compile-time invariant that maintainNodeInfoCacheLocked() is guarded by TMM_HAS_NODEINFO_CACHE alone), and the warm tier is immune by design - absolute unix-seconds, no wrap until 2106. Also brought current with this series: membership refresh moved to the hourly reconcile, throttled direct-response requests forward instead of being consumed, the commitRemoteKey() bare-key funnel, and the module-disabled gate on the write-through hooks. CodeRabbit doc review (PR #11050): present the NodeInfo payload cache as the third *identity* tier with the unified cache beside the chain, and describe NodeInfoLite's flattened fields / satellite copy-out accessors instead of the removed nested members. Two stale docs/tmm_node_stores.md references in the header now point at the real file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * TrafficManagement: adapt tests to forwarded throttles and hourly membership Throttle tests (PSRAM and NodeDB-fallback paths): a request inside the 30 s window now CONTINUEs into normal relay handling instead of being consumed - assert no spoofed TX, no NAK suppression, and (PSRAM path) that nodeinfo_cache_hits counts only replies actually sent. Membership test (renamed reconcileMembershipMarking): the per-minute sweep no longer refreshes isMember, so the test now pins down both halves of the new contract - the very next sweep after a passive NodeDB eviction still shows the stale member bit (the documented up-to-an-hour lag), and a reconcile interval's worth of sweeps clears it. Disabled-module test additionally proves the write-through hooks share the has_traffic_management gate: a key commit while disabled must not land in the NodeInfo cache. Not covered here: a build permutation with TRAFFIC_MANAGEMENT_CACHE_SIZE overridden to 0 (the configuration the maintenance-guard fix protects) would need its own PlatformIO env plus guards on every unified-cache test - left as a follow-up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * expand test coverage * nitpicks and docs update * more comment fixes * nitpicks * test: make TMM fixture cleanup abort-safe in tearDown() Several tests reset per-test global state (trafficManagementModule pointing at a stack `module`, owner.public_key, the RTC fake clock) only after their assertions. A TEST_ASSERT_* failure longjmps out and skips that trailing cleanup, leaving the state to dangle into later cases - concretely, the next setUp()'s resetNodes() would call trafficManagementModule->purgeAll() on a destroyed object. Move the resets into tearDown(), which runs unconditionally between tests, and drop the now-redundant per-test cleanups. Addresses a CodeRabbit review comment on PR #11050. clod helped too * docs tidy * Throttle NodeInfo direct responses A direct response is addressed to the requesting packet's from field, which is unauthenticated, and is sent by every neighbour that holds the target in cache. One request therefore makes several nodes transmit at an address the requester chose, and nothing limited how often that could be repeated. Replies are now spaced per requester, which bounds how much any single node can be made to receive, plus a global floor on how much airtime the feature can consume. The check sits where a reply is about to be sent, so requests declined for other reasons do not consume the budget. * test: reconcile TMM direct-response tests with #11104 throttle Makes the suite green for now. #11104's per-requester + global-floor throttle (60 s) is layered over the branch's per-target throttle, so the three existing "served again" checks (psram, fallback, sweep) now use a fresh requester to avoid the per-requester window masking the mechanism each one actually exercises. Adds a dedicated test for the new per-requester + 1 s global-floor behaviour (the reflector-flood gap the per-target throttle leaves open, and the bound that survives on non-PSRAM builds). Deferred: the branch's now-redundant fallback global stamp (nodeInfoFallbackLastResponseMs) is left in place as a follow-up cleanup. Clod wants credit. * TMM: unify direct-response throttles into per-sender + per-target RAM tables Replace the two path-specific NodeInfo-spoof throttles (per-entry respTick on the PSRAM cache path, single global stamp on the fallback path) with three symmetric bounds that behave identically with and without PSRAM: - per requester (60 s): how much any one node can be made to receive; - per target (60 s): how often we vouch for the same identity; - global floor (1 s): total airtime, the backstop once an attacker cycles requester/target past the 8-slot tables. Both axes are fixed 8-slot LRU tables in internal RAM (not the PSRAM NodeInfo cache), compared by wrap-safe uint32 ms subtraction, so there is no tick clock and no sweep to maintain. directResponseAllowed() resolves both slots before stamping either, so a reply one axis throttles never consumes the other's budget, and records the send itself. Retires: nodeInfoFallbackLastResponseMs, kNodeInfoResponseThrottleMs, nowStampMs, the respTick byte + hasResponded bit on every cache entry, currentRespTick/kNodeInfoRespTickMs/kNodeInfoThrottleTicks, and the sweep's respTick clear. Renumbers peekNodeInfoFlagsForTest (drops the responded bit). Tests: the psram/fallback throttle tests now assert the per-target axis at 60 s, isolated via a different requester, proving it holds with and without PSRAM; perRequesterAndGlobalFloor isolates the other two axes; the obsolete respTick wrap-safety test is removed. 83/83 native cases pass. clod helped too * whats up, doc? * trimming the comments * test: bump native-suite-count to 38 after upstream rebase Upstream develop carries 38 test_* suite directories but its native-suite-count file still reads 37 (a suite was added without bumping the count). Rebasing onto develop inherits that stale file, so the runner flags AMBER. Correct the registered total to 38. clod helped too --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
4214 lines
170 KiB
C++
4214 lines
170 KiB
C++
#include "configuration.h"
|
|
#if !MESHTASTIC_EXCLUDE_GPS
|
|
#include "GPS.h"
|
|
#endif
|
|
#include "../detect/ScanI2C.h"
|
|
#include "Channels.h"
|
|
#include "CryptoEngine.h"
|
|
#include "Default.h"
|
|
#include "FSCommon.h"
|
|
#include "MeshRadio.h"
|
|
#include "MeshService.h"
|
|
#include "MessageStore.h"
|
|
#include "NodeDB.h"
|
|
#include "PacketHistory.h"
|
|
#include "PowerFSM.h"
|
|
#include "RadioInterface.h"
|
|
#include "Router.h"
|
|
#include "SPILock.h"
|
|
#include "SafeFile.h"
|
|
#include "TransmitHistory.h"
|
|
#include "TypeConversions.h"
|
|
#include "error.h"
|
|
#include "gps/RTC.h"
|
|
#include "main.h"
|
|
#include "memory/MemAudit.h"
|
|
#include "mesh-pb-constants.h"
|
|
#include "mesh/generated/meshtastic/deviceonly_legacy.pb.h"
|
|
#include "meshUtils.h"
|
|
#include "modules/NeighborInfoModule.h"
|
|
#include "target_specific.h"
|
|
#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>
|
|
#include <pb_decode.h>
|
|
#include <pb_encode.h>
|
|
#include <power/PowerHAL.h>
|
|
#include <vector>
|
|
|
|
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
|
#include "security/EncryptedStorage.h"
|
|
#include "security/SecureZero.h"
|
|
#endif
|
|
|
|
#ifdef ARCH_ESP32
|
|
#if HAS_WIFI
|
|
#include "mesh/wifi/WiFiAPClient.h"
|
|
#endif
|
|
#include "SPILock.h"
|
|
#include "modules/StoreForwardModule.h"
|
|
#include <Preferences.h>
|
|
#include <nvs_flash.h>
|
|
#endif
|
|
|
|
#ifdef ARCH_PORTDUINO
|
|
#include "modules/StoreForwardModule.h"
|
|
#include "platform/portduino/PortduinoGlue.h"
|
|
#endif
|
|
|
|
#ifdef ARCH_NRF52
|
|
#include <bluefruit.h>
|
|
#include <utility/bonding.h>
|
|
#endif
|
|
|
|
#ifdef ARCH_RP2040
|
|
#include <hardware/watchdog.h>
|
|
#endif
|
|
|
|
#if defined(ARCH_ESP32) && !MESHTASTIC_EXCLUDE_WIFI
|
|
#include <MeshtasticOTA.h>
|
|
#endif
|
|
|
|
NodeDB *nodeDB = nullptr;
|
|
|
|
// we have plenty of ram so statically alloc this tempbuf (for now)
|
|
EXT_RAM_BSS_ATTR meshtastic_DeviceState devicestate;
|
|
meshtastic_MyNodeInfo &myNodeInfo = devicestate.my_node;
|
|
meshtastic_NodeDatabase nodeDatabase;
|
|
meshtastic_LocalConfig config;
|
|
meshtastic_DeviceUIConfig uiconfig{.screen_brightness = 153, .screen_timeout = 30};
|
|
meshtastic_LocalModuleConfig moduleConfig;
|
|
meshtastic_ChannelFile channelFile;
|
|
|
|
#ifdef USERPREFS_USE_ADMIN_KEY_0
|
|
static unsigned char userprefs_admin_key_0[] = USERPREFS_USE_ADMIN_KEY_0;
|
|
#endif
|
|
#ifdef USERPREFS_USE_ADMIN_KEY_1
|
|
static unsigned char userprefs_admin_key_1[] = USERPREFS_USE_ADMIN_KEY_1;
|
|
#endif
|
|
#ifdef USERPREFS_USE_ADMIN_KEY_2
|
|
static unsigned char userprefs_admin_key_2[] = USERPREFS_USE_ADMIN_KEY_2;
|
|
#endif
|
|
|
|
// Weak empty variant initialization function.
|
|
// May be redefined by variant files.
|
|
void variantDefaultConfig() __attribute__((weak));
|
|
void variantDefaultConfig() {}
|
|
|
|
void variantDefaultModuleConfig() __attribute__((weak));
|
|
void variantDefaultModuleConfig() {}
|
|
|
|
#ifdef HELTEC_MESH_NODE_T114
|
|
|
|
uint32_t read8(uint8_t bits, uint8_t dummy, uint8_t cs, uint8_t sck, uint8_t mosi, uint8_t dc, uint8_t rst)
|
|
{
|
|
uint32_t ret = 0;
|
|
uint8_t SDAPIN = mosi;
|
|
pinMode(SDAPIN, INPUT_PULLUP);
|
|
digitalWrite(dc, HIGH);
|
|
for (int i = 0; i < dummy; i++) { // any dummy clocks
|
|
digitalWrite(sck, HIGH);
|
|
delay(1);
|
|
digitalWrite(sck, LOW);
|
|
delay(1);
|
|
}
|
|
for (int i = 0; i < bits; i++) { // read results
|
|
ret <<= 1;
|
|
delay(1);
|
|
if (digitalRead(SDAPIN))
|
|
ret |= 1;
|
|
;
|
|
digitalWrite(sck, HIGH);
|
|
delay(1);
|
|
digitalWrite(sck, LOW);
|
|
}
|
|
return ret;
|
|
}
|
|
|
|
void write9(uint8_t val, uint8_t dc_val, uint8_t cs, uint8_t sck, uint8_t mosi, uint8_t dc, uint8_t rst)
|
|
{
|
|
pinMode(mosi, OUTPUT);
|
|
digitalWrite(dc, dc_val);
|
|
for (int i = 0; i < 8; i++) { // send command
|
|
digitalWrite(mosi, (val & 0x80) != 0);
|
|
delay(1);
|
|
digitalWrite(sck, HIGH);
|
|
delay(1);
|
|
digitalWrite(sck, LOW);
|
|
val <<= 1;
|
|
}
|
|
}
|
|
|
|
uint32_t readwrite8(uint8_t cmd, uint8_t bits, uint8_t dummy, uint8_t cs, uint8_t sck, uint8_t mosi, uint8_t dc, uint8_t rst)
|
|
{
|
|
digitalWrite(cs, LOW);
|
|
write9(cmd, 0, cs, sck, mosi, dc, rst);
|
|
uint32_t ret = read8(bits, dummy, cs, sck, mosi, dc, rst);
|
|
digitalWrite(cs, HIGH);
|
|
return ret;
|
|
}
|
|
|
|
uint32_t get_st7789_id(uint8_t cs, uint8_t sck, uint8_t mosi, uint8_t dc, uint8_t rst)
|
|
{
|
|
pinMode(cs, OUTPUT);
|
|
digitalWrite(cs, HIGH);
|
|
pinMode(cs, OUTPUT);
|
|
pinMode(sck, OUTPUT);
|
|
pinMode(mosi, OUTPUT);
|
|
pinMode(dc, OUTPUT);
|
|
pinMode(rst, OUTPUT);
|
|
digitalWrite(rst, LOW); // Hardware Reset
|
|
delay(10);
|
|
digitalWrite(rst, HIGH);
|
|
delay(10);
|
|
|
|
readwrite8(0x04, 24, 1, cs, sck, mosi, dc, rst);
|
|
uint32_t ID = readwrite8(0x04, 24, 1, cs, sck, mosi, dc, rst); // ST7789 needs twice
|
|
return ID;
|
|
}
|
|
|
|
#endif
|
|
|
|
// When armed by loadFromDisk, the decode callback writes satellite entries
|
|
// straight into these maps instead of the temp vectors. Nullptr = legacy
|
|
// push_back-to-vector path for backup/restore and other decoders.
|
|
namespace
|
|
{
|
|
#if !MESHTASTIC_EXCLUDE_POSITIONDB
|
|
std::map<NodeNum, meshtastic_PositionLite> *s_decodePositionsTarget = nullptr;
|
|
#endif
|
|
#if !MESHTASTIC_EXCLUDE_TELEMETRYDB
|
|
std::map<NodeNum, meshtastic_DeviceMetrics> *s_decodeTelemetryTarget = nullptr;
|
|
#endif
|
|
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB
|
|
std::map<NodeNum, meshtastic_EnvironmentMetrics> *s_decodeEnvironmentTarget = nullptr;
|
|
#endif
|
|
#if !MESHTASTIC_EXCLUDE_STATUSDB
|
|
std::map<NodeNum, meshtastic_StatusMessage> *s_decodeStatusTarget = nullptr;
|
|
#endif
|
|
} // namespace
|
|
|
|
bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostream, const pb_field_t *field)
|
|
{
|
|
const auto *iter = reinterpret_cast<const pb_field_iter_t *>(field);
|
|
switch (iter->tag) {
|
|
case meshtastic_NodeDatabase_nodes_tag: {
|
|
if (ostream) {
|
|
const auto *vec = static_cast<const std::vector<meshtastic_NodeInfoLite> *>(iter->pData);
|
|
for (auto item : *vec) {
|
|
item.snr_q4 = (int32_t)(item.snr * 4.0f);
|
|
item.snr = 0.0f;
|
|
if (!pb_encode_tag_for_field(ostream, iter))
|
|
return false;
|
|
if (!pb_encode_submessage(ostream, meshtastic_NodeInfoLite_fields, &item))
|
|
return false;
|
|
}
|
|
}
|
|
if (istream && istream->bytes_left) {
|
|
meshtastic_NodeInfoLite node = meshtastic_NodeInfoLite_init_zero;
|
|
auto *vec = static_cast<std::vector<meshtastic_NodeInfoLite> *>(iter->pData);
|
|
if (pb_decode(istream, meshtastic_NodeInfoLite_fields, &node)) {
|
|
if (node.snr_q4)
|
|
node.snr = node.snr_q4 / 4.0f;
|
|
node.snr_q4 = 0;
|
|
vec->push_back(node);
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
case meshtastic_NodeDatabase_positions_tag: {
|
|
if (ostream) {
|
|
const auto *vec = static_cast<const std::vector<meshtastic_NodePositionEntry> *>(iter->pData);
|
|
for (auto item : *vec) {
|
|
if (!pb_encode_tag_for_field(ostream, iter))
|
|
return false;
|
|
if (!pb_encode_submessage(ostream, meshtastic_NodePositionEntry_fields, &item))
|
|
return false;
|
|
}
|
|
}
|
|
if (istream && istream->bytes_left) {
|
|
meshtastic_NodePositionEntry entry = meshtastic_NodePositionEntry_init_zero;
|
|
if (pb_decode(istream, meshtastic_NodePositionEntry_fields, &entry)) {
|
|
#if !MESHTASTIC_EXCLUDE_POSITIONDB
|
|
if (s_decodePositionsTarget) {
|
|
if (entry.has_position)
|
|
(*s_decodePositionsTarget)[entry.num] = entry.position;
|
|
return true;
|
|
}
|
|
#endif
|
|
auto *vec = static_cast<std::vector<meshtastic_NodePositionEntry> *>(iter->pData);
|
|
vec->push_back(entry);
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
case meshtastic_NodeDatabase_telemetry_tag: {
|
|
if (ostream) {
|
|
const auto *vec = static_cast<const std::vector<meshtastic_NodeTelemetryEntry> *>(iter->pData);
|
|
for (auto item : *vec) {
|
|
if (!pb_encode_tag_for_field(ostream, iter))
|
|
return false;
|
|
if (!pb_encode_submessage(ostream, meshtastic_NodeTelemetryEntry_fields, &item))
|
|
return false;
|
|
}
|
|
}
|
|
if (istream && istream->bytes_left) {
|
|
meshtastic_NodeTelemetryEntry entry = meshtastic_NodeTelemetryEntry_init_zero;
|
|
if (pb_decode(istream, meshtastic_NodeTelemetryEntry_fields, &entry)) {
|
|
#if !MESHTASTIC_EXCLUDE_TELEMETRYDB
|
|
if (s_decodeTelemetryTarget) {
|
|
if (entry.has_device_metrics)
|
|
(*s_decodeTelemetryTarget)[entry.num] = entry.device_metrics;
|
|
return true;
|
|
}
|
|
#endif
|
|
auto *vec = static_cast<std::vector<meshtastic_NodeTelemetryEntry> *>(iter->pData);
|
|
vec->push_back(entry);
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
case meshtastic_NodeDatabase_status_tag: {
|
|
if (ostream) {
|
|
const auto *vec = static_cast<const std::vector<meshtastic_NodeStatusEntry> *>(iter->pData);
|
|
for (auto item : *vec) {
|
|
if (!pb_encode_tag_for_field(ostream, iter))
|
|
return false;
|
|
if (!pb_encode_submessage(ostream, meshtastic_NodeStatusEntry_fields, &item))
|
|
return false;
|
|
}
|
|
}
|
|
if (istream && istream->bytes_left) {
|
|
meshtastic_NodeStatusEntry entry = meshtastic_NodeStatusEntry_init_zero;
|
|
if (pb_decode(istream, meshtastic_NodeStatusEntry_fields, &entry)) {
|
|
#if !MESHTASTIC_EXCLUDE_STATUSDB
|
|
if (s_decodeStatusTarget) {
|
|
if (entry.has_status)
|
|
(*s_decodeStatusTarget)[entry.num] = entry.status;
|
|
return true;
|
|
}
|
|
#endif
|
|
auto *vec = static_cast<std::vector<meshtastic_NodeStatusEntry> *>(iter->pData);
|
|
vec->push_back(entry);
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
case meshtastic_NodeDatabase_environment_tag: {
|
|
if (ostream) {
|
|
const auto *vec = static_cast<const std::vector<meshtastic_NodeEnvironmentEntry> *>(iter->pData);
|
|
for (auto item : *vec) {
|
|
if (!pb_encode_tag_for_field(ostream, iter))
|
|
return false;
|
|
if (!pb_encode_submessage(ostream, meshtastic_NodeEnvironmentEntry_fields, &item))
|
|
return false;
|
|
}
|
|
}
|
|
if (istream && istream->bytes_left) {
|
|
meshtastic_NodeEnvironmentEntry entry = meshtastic_NodeEnvironmentEntry_init_zero;
|
|
if (pb_decode(istream, meshtastic_NodeEnvironmentEntry_fields, &entry)) {
|
|
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB
|
|
if (s_decodeEnvironmentTarget) {
|
|
if (entry.has_environment_metrics)
|
|
(*s_decodeEnvironmentTarget)[entry.num] = entry.environment_metrics;
|
|
return true;
|
|
}
|
|
#endif
|
|
auto *vec = static_cast<std::vector<meshtastic_NodeEnvironmentEntry> *>(iter->pData);
|
|
vec->push_back(entry);
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
default:
|
|
return true;
|
|
}
|
|
}
|
|
|
|
void NodeDB::armNodeDatabaseDecodeTargets()
|
|
{
|
|
#if !MESHTASTIC_EXCLUDE_POSITIONDB
|
|
nodePositions.clear();
|
|
s_decodePositionsTarget = &nodePositions;
|
|
#endif
|
|
#if !MESHTASTIC_EXCLUDE_TELEMETRYDB
|
|
nodeTelemetry.clear();
|
|
s_decodeTelemetryTarget = &nodeTelemetry;
|
|
#endif
|
|
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB
|
|
nodeEnvironment.clear();
|
|
s_decodeEnvironmentTarget = &nodeEnvironment;
|
|
#endif
|
|
#if !MESHTASTIC_EXCLUDE_STATUSDB
|
|
nodeStatus.clear();
|
|
s_decodeStatusTarget = &nodeStatus;
|
|
#endif
|
|
}
|
|
|
|
void NodeDB::disarmNodeDatabaseDecodeTargets()
|
|
{
|
|
#if !MESHTASTIC_EXCLUDE_POSITIONDB
|
|
s_decodePositionsTarget = nullptr;
|
|
#endif
|
|
#if !MESHTASTIC_EXCLUDE_TELEMETRYDB
|
|
s_decodeTelemetryTarget = nullptr;
|
|
#endif
|
|
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB
|
|
s_decodeEnvironmentTarget = nullptr;
|
|
#endif
|
|
#if !MESHTASTIC_EXCLUDE_STATUSDB
|
|
s_decodeStatusTarget = nullptr;
|
|
#endif
|
|
}
|
|
|
|
/** The current change # for radio settings. Starts at 0 on boot and any time the radio settings
|
|
* might have changed is incremented. Allows others to detect they might now be on a new channel.
|
|
*/
|
|
uint32_t radioGeneration;
|
|
|
|
// getMacAddr() and getDeviceId() are the per-architecture hooks declared in target_specific.h.
|
|
|
|
/**
|
|
*
|
|
* Normally userids are unique and start with +country code to look like Signal phone numbers.
|
|
* But there are some special ids used when we haven't yet been configured by a user. In that case
|
|
* we use !macaddr (no colons).
|
|
*/
|
|
meshtastic_User &owner = devicestate.owner;
|
|
|
|
// The slim NodeInfoLite header defines the local long_name cap; the wire-facing
|
|
// meshtastic_User stays wider so names from senders built against the older
|
|
// 39-byte limit still decode (nanopb halts on string overflow).
|
|
static_assert(MAX_LONG_NAME_BYTES + 1 == sizeof(meshtastic_NodeInfoLite::long_name),
|
|
"MAX_LONG_NAME_BYTES must match the NodeInfoLite storage width");
|
|
static_assert(sizeof(meshtastic_User::long_name) > MAX_LONG_NAME_BYTES,
|
|
"wire User.long_name must be wider than the local cap so clampLongName stays in bounds");
|
|
|
|
meshtastic_Position localPosition = meshtastic_Position_init_default;
|
|
meshtastic_CriticalErrorCode error_code =
|
|
meshtastic_CriticalErrorCode_NONE; // For the error code, only show values from this boot (discard value from flash)
|
|
uint32_t error_address = 0;
|
|
|
|
static uint8_t ourMacAddr[6];
|
|
|
|
NodeDB::NodeDB()
|
|
{
|
|
LOG_INFO("Init NodeDB");
|
|
loadFromDisk();
|
|
cleanupMeshDB();
|
|
|
|
uint32_t devicestateCRC = crc32Buffer(&devicestate, sizeof(devicestate));
|
|
uint32_t nodeDatabaseCRC = crc32Buffer(&nodeDatabase, sizeof(nodeDatabase));
|
|
uint32_t configCRC = crc32Buffer(&config, sizeof(config));
|
|
uint32_t channelFileCRC = crc32Buffer(&channelFile, sizeof(channelFile));
|
|
|
|
int saveWhat = 0;
|
|
// Re-read the device id from silicon each boot via the per-arch getDeviceId(); clear the
|
|
// disk-loaded value first so a failed/empty derivation leaves it unset rather than stale.
|
|
myNodeInfo.device_id.size = 0;
|
|
memset(myNodeInfo.device_id.bytes, 0, sizeof(myNodeInfo.device_id.bytes));
|
|
if (getDeviceId(myNodeInfo.device_id.bytes)) {
|
|
myNodeInfo.device_id.size = sizeof(myNodeInfo.device_id.bytes);
|
|
}
|
|
|
|
// likewise - we always want the app requirements to come from the running appload
|
|
myNodeInfo.min_app_version = 30200; // format is Mmmss (where M is 1+the numeric major number. i.e. 30200 means 2.2.00
|
|
pickNewNodeNum();
|
|
|
|
// Set our board type so we can share it with others
|
|
owner.hw_model = HW_VENDOR;
|
|
// Ensure user (nodeinfo) role is set to whatever we're configured to
|
|
owner.role = config.device.role;
|
|
// Ensure macaddr is set to our macaddr as it will be copied in our info below
|
|
memcpy(owner.macaddr, ourMacAddr, sizeof(owner.macaddr));
|
|
// Ensure owner.id is always derived from the node number
|
|
snprintf(owner.id, sizeof(owner.id), "!%08x", getNodeNum());
|
|
|
|
if (!config.has_security) {
|
|
config.has_security = true;
|
|
config.security = meshtastic_Config_SecurityConfig_init_default;
|
|
config.security.serial_enabled = config.device.serial_enabled;
|
|
config.security.is_managed = config.device.is_managed;
|
|
}
|
|
|
|
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
|
|
// Generate crypto keys if needed using consolidated function
|
|
// Set my node num uint32 value to bytes from the public key (if we have one)
|
|
// Generate identity and crypto keys if needed; this will create a new identity if one does not exist.
|
|
// Skip on a degraded boot: the keypair isn't in RAM, so minting one would change our NodeNum.
|
|
if (!configDecodeFailed)
|
|
generateCryptoKeyPair(nullptr);
|
|
#elif !(MESHTASTIC_EXCLUDE_PKI)
|
|
// Calculate Curve25519 public and private keys
|
|
if (config.security.private_key.size == 32 && config.security.public_key.size == 32) {
|
|
owner.public_key.size = config.security.public_key.size;
|
|
memcpy(owner.public_key.bytes, config.security.public_key.bytes, config.security.public_key.size);
|
|
crypto->setDHPrivateKey(config.security.private_key.bytes);
|
|
// Set my node num uint32 value to bytes from the new public key
|
|
myNodeInfo.my_node_num = crc32Buffer(config.security.public_key.bytes, config.security.public_key.size);
|
|
}
|
|
#endif
|
|
// Identity is now established, so run the self-care pass on the store
|
|
// loadFromDisk() deliberately left untrimmed: confirm self, trim/demote only
|
|
// non-self overflow, pin self to index 0, rewrite once if healed.
|
|
nodeDBSelfCare();
|
|
|
|
// If we migrated from legacy during loadFromDisk(), persist the migrated DB
|
|
// only after identity and self-care are established.
|
|
if (migrationSavePending) {
|
|
saveNodeDatabaseToDisk();
|
|
migrationSavePending = false;
|
|
}
|
|
|
|
// If node database has not been saved for the first time, save it now
|
|
#ifdef FSCom
|
|
if (!FSCom.exists(nodeDatabaseFileName)) {
|
|
saveNodeDatabaseToDisk();
|
|
}
|
|
#endif
|
|
|
|
#ifdef ARCH_ESP32
|
|
Preferences preferences;
|
|
preferences.begin("meshtastic", false);
|
|
myNodeInfo.reboot_count = preferences.getUInt("rebootCounter", 0);
|
|
preferences.end();
|
|
LOG_DEBUG("Number of Device Reboots: %d", myNodeInfo.reboot_count);
|
|
#endif
|
|
|
|
// UA_868 is obsolete; migrate to EU_868 before resetRadioConfig() below validates the region.
|
|
if (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UA_868) {
|
|
LOG_INFO("UA_868 region is obsolete, migrating saved config to EU_868");
|
|
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
|
|
}
|
|
|
|
resetRadioConfig(); // If bogus settings got saved, then fix them
|
|
// nodeDB->LOG_DEBUG("region=%d, NODENUM=0x%x, dbsize=%d", config.lora.region, myNodeInfo.my_node_num, numMeshNodes);
|
|
|
|
// Uncomment below to always enable UDP broadcasts
|
|
// config.network.enabled_protocols = meshtastic_Config_NetworkConfig_ProtocolFlags_UDP_BROADCAST;
|
|
|
|
// If we are setup to broadcast on any default channel slot (with default frequency slot semantics),
|
|
// ensure that the telemetry intervals are coerced to the role-aware minimum value.
|
|
if (channels.hasDefaultChannel()) {
|
|
LOG_DEBUG("Coerce telemetry to role-aware minimum on defaults");
|
|
moduleConfig.telemetry.device_update_interval = Default::getConfiguredOrMinimumValue(
|
|
moduleConfig.telemetry.device_update_interval, min_default_telemetry_interval_secs);
|
|
moduleConfig.telemetry.environment_update_interval = Default::getConfiguredOrMinimumValue(
|
|
moduleConfig.telemetry.environment_update_interval, min_default_telemetry_interval_secs);
|
|
moduleConfig.telemetry.air_quality_interval = Default::getConfiguredOrMinimumValue(
|
|
moduleConfig.telemetry.air_quality_interval, min_default_telemetry_interval_secs);
|
|
moduleConfig.telemetry.power_update_interval = Default::getConfiguredOrMinimumValue(
|
|
moduleConfig.telemetry.power_update_interval, min_default_telemetry_interval_secs);
|
|
moduleConfig.telemetry.health_update_interval = Default::getConfiguredOrMinimumValue(
|
|
moduleConfig.telemetry.health_update_interval, min_default_telemetry_interval_secs);
|
|
}
|
|
// Enforce position broadcast minimums if we would send positions over a default channel
|
|
// Check channels the same way PositionModule::sendOurPosition() does - first channel with position_precision set
|
|
bool positionUsesDefaultChannel = false;
|
|
for (uint8_t i = 0; i < channels.getNumChannels(); i++) {
|
|
if (channels.getByIndex(i).settings.has_module_settings &&
|
|
channels.getByIndex(i).settings.module_settings.position_precision != 0) {
|
|
positionUsesDefaultChannel = channels.isDefaultChannel(i);
|
|
break;
|
|
}
|
|
}
|
|
if (positionUsesDefaultChannel) {
|
|
LOG_DEBUG("Coerce position broadcasts to role-aware minimum and smart broadcast min of 5 minutes on defaults");
|
|
config.position.position_broadcast_secs =
|
|
Default::getConfiguredOrMinimumValue(config.position.position_broadcast_secs, min_default_broadcast_interval_secs);
|
|
config.position.broadcast_smart_minimum_interval_secs = Default::getConfiguredOrMinimumValue(
|
|
config.position.broadcast_smart_minimum_interval_secs, min_default_broadcast_smart_minimum_interval_secs);
|
|
}
|
|
// FIXME: UINT32_MAX intervals overflows Apple clients until they are fully patched
|
|
if (config.device.node_info_broadcast_secs > MAX_INTERVAL)
|
|
config.device.node_info_broadcast_secs = MAX_INTERVAL;
|
|
if (config.position.position_broadcast_secs > MAX_INTERVAL)
|
|
config.position.position_broadcast_secs = MAX_INTERVAL;
|
|
if (config.position.gps_update_interval > MAX_INTERVAL)
|
|
config.position.gps_update_interval = MAX_INTERVAL;
|
|
if (config.position.gps_attempt_time > MAX_INTERVAL)
|
|
config.position.gps_attempt_time = MAX_INTERVAL;
|
|
if (config.position.position_flags > MAX_INTERVAL)
|
|
config.position.position_flags = MAX_INTERVAL;
|
|
if (config.position.rx_gpio > MAX_INTERVAL)
|
|
config.position.rx_gpio = MAX_INTERVAL;
|
|
if (config.position.tx_gpio > MAX_INTERVAL)
|
|
config.position.tx_gpio = MAX_INTERVAL;
|
|
if (config.position.broadcast_smart_minimum_distance > MAX_INTERVAL)
|
|
config.position.broadcast_smart_minimum_distance = MAX_INTERVAL;
|
|
if (config.position.broadcast_smart_minimum_interval_secs > MAX_INTERVAL)
|
|
config.position.broadcast_smart_minimum_interval_secs = MAX_INTERVAL;
|
|
if (config.position.gps_en_gpio > MAX_INTERVAL)
|
|
config.position.gps_en_gpio = MAX_INTERVAL;
|
|
if (moduleConfig.neighbor_info.update_interval > MAX_INTERVAL)
|
|
moduleConfig.neighbor_info.update_interval = MAX_INTERVAL;
|
|
if (moduleConfig.telemetry.device_update_interval > MAX_INTERVAL)
|
|
moduleConfig.telemetry.device_update_interval = MAX_INTERVAL;
|
|
if (moduleConfig.telemetry.environment_update_interval > MAX_INTERVAL)
|
|
moduleConfig.telemetry.environment_update_interval = MAX_INTERVAL;
|
|
if (moduleConfig.telemetry.air_quality_interval > MAX_INTERVAL)
|
|
moduleConfig.telemetry.air_quality_interval = MAX_INTERVAL;
|
|
if (moduleConfig.telemetry.health_update_interval > MAX_INTERVAL)
|
|
moduleConfig.telemetry.health_update_interval = MAX_INTERVAL;
|
|
|
|
if (moduleConfig.mqtt.has_map_report_settings &&
|
|
moduleConfig.mqtt.map_report_settings.publish_interval_secs < default_map_publish_interval_secs) {
|
|
moduleConfig.mqtt.map_report_settings.publish_interval_secs = default_map_publish_interval_secs;
|
|
}
|
|
|
|
// If a fixed position is configured, restore the persisted position into localPosition at boot.
|
|
// This keeps position broadcasts / MQTT map reports working after reboot on GPS-less nodes.
|
|
if (config.position.fixed_position) {
|
|
meshtastic_PositionLite fixedPos;
|
|
if (copyNodePosition(getNodeNum(), fixedPos) && (fixedPos.latitude_i != 0 || fixedPos.longitude_i != 0)) {
|
|
setLocalPosition(TypeConversions::ConvertToPosition(fixedPos));
|
|
LOG_INFO("Restored fixed position to localPosition: lat=%d lon=%d", fixedPos.latitude_i, fixedPos.longitude_i);
|
|
}
|
|
}
|
|
|
|
// Ensure that the neighbor info update interval is coerced to the minimum
|
|
moduleConfig.neighbor_info.update_interval =
|
|
Default::getConfiguredOrMinimumValue(moduleConfig.neighbor_info.update_interval, min_neighbor_info_broadcast_secs);
|
|
|
|
// Don't let licensed users to rebroadcast encrypted packets
|
|
if (owner.is_licensed) {
|
|
config.device.rebroadcast_mode = meshtastic_Config_DeviceConfig_RebroadcastMode_LOCAL_ONLY;
|
|
}
|
|
|
|
#if !HAS_TFT
|
|
if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_COLOR) {
|
|
// On a device without MUI, this display mode makes no sense, and will break logic.
|
|
config.display.displaymode = meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT;
|
|
config.bluetooth.enabled = true;
|
|
}
|
|
#endif
|
|
|
|
if (devicestateCRC != crc32Buffer(&devicestate, sizeof(devicestate)))
|
|
saveWhat |= SEGMENT_DEVICESTATE;
|
|
if (nodeDatabaseCRC != crc32Buffer(&nodeDatabase, sizeof(nodeDatabase)))
|
|
saveWhat |= SEGMENT_NODEDATABASE;
|
|
// Don't persist on a degraded boot: it would overwrite the unreadable-but-maybe-transient config file
|
|
// with no-key UNSET defaults. Runtime reconfiguration (admin set) still persists normally.
|
|
if (!configDecodeFailed && configCRC != crc32Buffer(&config, sizeof(config)))
|
|
saveWhat |= SEGMENT_CONFIG;
|
|
if (channelFileCRC != crc32Buffer(&channelFile, sizeof(channelFile)))
|
|
saveWhat |= SEGMENT_CHANNELS;
|
|
|
|
if (config.position.gps_enabled) {
|
|
config.position.gps_mode = meshtastic_Config_PositionConfig_GpsMode_ENABLED;
|
|
config.position.gps_enabled = 0;
|
|
}
|
|
#ifdef USERPREFS_FIRMWARE_EDITION
|
|
myNodeInfo.firmware_edition = USERPREFS_FIRMWARE_EDITION;
|
|
#endif
|
|
#ifdef USERPREFS_FIXED_GPS
|
|
if (myNodeInfo.reboot_count == 1) { // Check if First boot ever or after Factory Reset.
|
|
meshtastic_Position fixedGPS = meshtastic_Position_init_default;
|
|
#ifdef USERPREFS_FIXED_GPS_LAT
|
|
fixedGPS.latitude_i = (int32_t)(USERPREFS_FIXED_GPS_LAT * 1e7);
|
|
fixedGPS.has_latitude_i = true;
|
|
#endif
|
|
#ifdef USERPREFS_FIXED_GPS_LON
|
|
fixedGPS.longitude_i = (int32_t)(USERPREFS_FIXED_GPS_LON * 1e7);
|
|
fixedGPS.has_longitude_i = true;
|
|
#endif
|
|
#ifdef USERPREFS_FIXED_GPS_ALT
|
|
fixedGPS.altitude = USERPREFS_FIXED_GPS_ALT;
|
|
fixedGPS.has_altitude = true;
|
|
#endif
|
|
#if defined(USERPREFS_FIXED_GPS_LAT) && defined(USERPREFS_FIXED_GPS_LON)
|
|
fixedGPS.location_source = meshtastic_Position_LocSource_LOC_MANUAL;
|
|
config.has_position = true;
|
|
#if !MESHTASTIC_EXCLUDE_POSITIONDB
|
|
{
|
|
concurrency::LockGuard guard(&satelliteMutex);
|
|
nodePositions[info->num] = TypeConversions::ConvertToPositionLite(fixedGPS);
|
|
}
|
|
#endif
|
|
nodeDB->setLocalPosition(fixedGPS);
|
|
config.position.fixed_position = true;
|
|
#endif
|
|
}
|
|
#endif
|
|
sortMeshDB();
|
|
saveToDisk(saveWhat);
|
|
}
|
|
|
|
/**
|
|
* Most (but not always) of the time we want to treat packets 'from' the local phone (where from == 0), as if they originated on
|
|
* the local node. If from is zero this function returns our node number instead
|
|
*/
|
|
NodeNum getFrom(const meshtastic_MeshPacket *p)
|
|
{
|
|
return (p->from == 0) ? nodeDB->getNodeNum() : p->from;
|
|
}
|
|
|
|
// Returns true if the packet originated from the local node
|
|
bool isFromUs(const meshtastic_MeshPacket *p)
|
|
{
|
|
return p->from == 0 || p->from == nodeDB->getNodeNum();
|
|
}
|
|
|
|
// Returns true if the packet is destined to us
|
|
bool isToUs(const meshtastic_MeshPacket *p)
|
|
{
|
|
return p->to == nodeDB->getNodeNum();
|
|
}
|
|
|
|
bool isBroadcast(uint32_t dest)
|
|
{
|
|
return dest == NODENUM_BROADCAST || dest == NODENUM_BROADCAST_NO_LORA;
|
|
}
|
|
|
|
namespace
|
|
{
|
|
template <typename Map, typename Value> bool copySatelliteEntry(const Map &map, NodeNum n, Value &out)
|
|
{
|
|
auto it = map.find(n);
|
|
if (it == map.end())
|
|
return false;
|
|
out = it->second;
|
|
return true;
|
|
}
|
|
|
|
template <typename Map> std::vector<NodeNum> snapshotSatelliteNodeNums(const Map &map, NodeNum exclude)
|
|
{
|
|
std::vector<NodeNum> result;
|
|
result.reserve(map.size());
|
|
for (const auto &kv : map) {
|
|
if (kv.first != exclude)
|
|
result.push_back(kv.first);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// Drop the stalest entry of `map` (staleness proxied via the owner's
|
|
// last_heard; 0 = owner evicted, i.e. an orphan - first out). Never evicts our
|
|
// own node's entry. Caller holds satelliteMutex. Returns false if nothing
|
|
// could be evicted.
|
|
template <typename Map> bool evictStalestSatellite(NodeDB &db, Map &map)
|
|
{
|
|
auto victim = map.end();
|
|
uint32_t victimTs = UINT32_MAX;
|
|
for (auto it = map.begin(); it != map.end(); ++it) {
|
|
if (it->first == db.getNodeNum())
|
|
continue;
|
|
uint32_t ts = db.hotNodeLastHeard(it->first);
|
|
if (ts < victimTs) {
|
|
victimTs = ts;
|
|
victim = it;
|
|
}
|
|
}
|
|
if (victim == map.end())
|
|
return false;
|
|
map.erase(victim);
|
|
return true;
|
|
}
|
|
|
|
// Keep `map` within MAX_SATELLITE_NODES ahead of inserting `incoming` (the
|
|
// tier-1/tier-2 split: only the freshest MAX_SATELLITE_NODES nodes carry
|
|
// satellite payloads). Caller holds satelliteMutex.
|
|
template <typename Map> void evictSatelliteOverCap(NodeDB &db, Map &map, NodeNum incoming)
|
|
{
|
|
if (map.size() < MAX_SATELLITE_NODES || map.count(incoming))
|
|
return;
|
|
evictStalestSatellite(db, map);
|
|
}
|
|
} // namespace
|
|
|
|
void NodeDB::resetRadioConfig(bool is_fresh_install)
|
|
{
|
|
if (is_fresh_install) {
|
|
radioGeneration++;
|
|
}
|
|
|
|
if (channelFile.channels_count != MAX_NUM_CHANNELS) {
|
|
LOG_INFO("Set default channel and radio preferences!");
|
|
|
|
channels.initDefaults();
|
|
}
|
|
|
|
channels.onConfigChanged();
|
|
|
|
// Update the global myRegion
|
|
initRegion();
|
|
}
|
|
|
|
bool NodeDB::factoryReset(bool eraseBleBonds)
|
|
{
|
|
LOG_INFO("Perform factory reset!");
|
|
// first, remove the "/prefs" (this removes most prefs)
|
|
spiLock->lock();
|
|
rmDir("/prefs"); // this uses spilock internally...
|
|
|
|
#ifdef FSCom
|
|
if (FSCom.exists("/static/rangetest.csv") && !FSCom.remove("/static/rangetest.csv")) {
|
|
LOG_ERROR("Could not remove rangetest.csv file");
|
|
}
|
|
#endif
|
|
|
|
spiLock->unlock();
|
|
|
|
// rmDir above nuked the .dat file, but TransmitHistory's in-memory
|
|
// cache auto-flushes every 5 min and would resurrect it.
|
|
if (transmitHistory) {
|
|
transmitHistory->clear();
|
|
}
|
|
#if HAS_SCREEN
|
|
messageStore.clearAllMessages();
|
|
#endif
|
|
|
|
#if WARM_NODE_COUNT > 0
|
|
// On nRF52840 the warm tier lives in raw flash outside /prefs, so rmDir
|
|
// didn't touch it; clear it and persist the empty store.
|
|
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();
|
|
installDefaultDeviceState();
|
|
installDefaultConfig(!eraseBleBonds); // Also preserve the private key if we're not erasing BLE bonds
|
|
installDefaultModuleConfig();
|
|
installDefaultChannels();
|
|
// third, write everything to disk
|
|
saveToDisk();
|
|
if (eraseBleBonds) {
|
|
LOG_INFO("Erase BLE bonds");
|
|
#ifdef ARCH_ESP32
|
|
// This will erase what's in NVS including ssl keys, persistent variables and ble pairing
|
|
nvs_flash_erase();
|
|
#endif
|
|
|
|
#ifdef ARCH_NRF52
|
|
LOG_INFO("Clear bluetooth bonds!");
|
|
bond_print_list(BLE_GAP_ROLE_PERIPH);
|
|
bond_print_list(BLE_GAP_ROLE_CENTRAL);
|
|
Bluefruit.Periph.clearBonds();
|
|
Bluefruit.Central.clearBonds();
|
|
#endif
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void NodeDB::installDefaultNodeDatabase()
|
|
{
|
|
LOG_DEBUG("Install default NodeDatabase");
|
|
nodeDatabase.version = DEVICESTATE_CUR_VER;
|
|
nodeDatabase.nodes = std::vector<meshtastic_NodeInfoLite>(MAX_NUM_NODES);
|
|
numMeshNodes = 0;
|
|
meshNodes = &nodeDatabase.nodes;
|
|
concurrency::LockGuard satelliteGuard(&satelliteMutex);
|
|
#if !MESHTASTIC_EXCLUDE_POSITIONDB
|
|
nodePositions.clear();
|
|
#endif
|
|
|
|
#if !MESHTASTIC_EXCLUDE_TELEMETRYDB
|
|
nodeTelemetry.clear();
|
|
#endif
|
|
|
|
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB
|
|
nodeEnvironment.clear();
|
|
#endif
|
|
|
|
#if !MESHTASTIC_EXCLUDE_STATUSDB
|
|
nodeStatus.clear();
|
|
#endif
|
|
}
|
|
|
|
void NodeDB::installDefaultConfig(bool preserveKey = false)
|
|
{
|
|
uint8_t private_key_temp[32];
|
|
bool shouldPreserveKey = preserveKey && config.has_security && config.security.private_key.size > 0;
|
|
if (shouldPreserveKey) {
|
|
memcpy(private_key_temp, config.security.private_key.bytes, config.security.private_key.size);
|
|
}
|
|
LOG_INFO("Install default LocalConfig");
|
|
memset(&config, 0, sizeof(meshtastic_LocalConfig));
|
|
config.version = DEVICESTATE_CUR_VER;
|
|
config.has_device = true;
|
|
config.has_display = true;
|
|
config.has_lora = true;
|
|
config.has_position = true;
|
|
config.has_power = true;
|
|
config.has_network = true;
|
|
config.has_bluetooth = (HAS_BLUETOOTH ? true : false);
|
|
config.has_security = true;
|
|
config.device.rebroadcast_mode = meshtastic_Config_DeviceConfig_RebroadcastMode_ALL;
|
|
|
|
config.lora.sx126x_rx_boosted_gain = true;
|
|
config.lora.tx_enabled =
|
|
true; // FIXME: maybe false in the future, and setting region to enable it. (unset region forces it off)
|
|
config.lora.override_duty_cycle = false;
|
|
config.lora.config_ok_to_mqtt = false;
|
|
#if HAS_LORA_FEM
|
|
config.lora.fem_lna_mode = meshtastic_Config_LoRaConfig_FEM_LNA_Mode_ENABLED;
|
|
#else
|
|
config.lora.fem_lna_mode = meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT;
|
|
#endif
|
|
|
|
#if HAS_TFT // For the devices that support MUI, default to that
|
|
config.display.displaymode = meshtastic_Config_DisplayConfig_DisplayMode_COLOR;
|
|
#endif
|
|
|
|
#if defined(TFT_WIDTH) && defined(TFT_HEIGHT) && (TFT_WIDTH >= 200 || TFT_HEIGHT >= 200)
|
|
config.display.enable_message_bubbles = true;
|
|
#endif
|
|
|
|
#ifdef USERPREFS_CONFIG_DEVICE_ROLE
|
|
// Restrict ROUTER*, LOST AND FOUND roles for security reasons
|
|
if (IS_ONE_OF(USERPREFS_CONFIG_DEVICE_ROLE, meshtastic_Config_DeviceConfig_Role_ROUTER,
|
|
meshtastic_Config_DeviceConfig_Role_ROUTER_LATE, meshtastic_Config_DeviceConfig_Role_LOST_AND_FOUND)) {
|
|
LOG_WARN("ROUTER roles are restricted, falling back to CLIENT role");
|
|
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
|
} else {
|
|
config.device.role = USERPREFS_CONFIG_DEVICE_ROLE;
|
|
}
|
|
#else
|
|
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; // Default to client.
|
|
#endif
|
|
|
|
#ifdef USERPREFS_CONFIG_LORA_REGION
|
|
config.lora.region = USERPREFS_CONFIG_LORA_REGION;
|
|
#else
|
|
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
|
|
#endif
|
|
|
|
#ifdef USERPREFS_LORACONFIG_TX_POWER
|
|
config.lora.tx_power = USERPREFS_LORACONFIG_TX_POWER;
|
|
#endif
|
|
|
|
#ifdef USERPREFS_LORACONFIG_MODEM_PRESET
|
|
config.lora.modem_preset = USERPREFS_LORACONFIG_MODEM_PRESET;
|
|
#else
|
|
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
|
|
#endif
|
|
|
|
#ifdef USERPREFS_LORACONFIG_USE_PRESET
|
|
config.lora.use_preset = USERPREFS_LORACONFIG_USE_PRESET;
|
|
#else
|
|
config.lora.use_preset = true;
|
|
#endif
|
|
|
|
#ifdef USERPREFS_LORACONFIG_BANDWIDTH
|
|
config.lora.bandwidth = USERPREFS_LORACONFIG_BANDWIDTH;
|
|
#endif
|
|
|
|
#ifdef USERPREFS_LORACONFIG_SPREAD_FACTOR
|
|
config.lora.spread_factor = USERPREFS_LORACONFIG_SPREAD_FACTOR;
|
|
#endif
|
|
|
|
#ifdef USERPREFS_LORACONFIG_CODING_RATE
|
|
config.lora.coding_rate = USERPREFS_LORACONFIG_CODING_RATE;
|
|
#endif
|
|
|
|
#ifdef USERPREFS_LORACONFIG_OVERRIDE_FREQUENCY
|
|
config.lora.override_frequency = USERPREFS_LORACONFIG_OVERRIDE_FREQUENCY;
|
|
#endif
|
|
|
|
config.lora.hop_limit = HOP_RELIABLE;
|
|
#ifdef USERPREFS_CONFIG_LORA_IGNORE_MQTT
|
|
config.lora.ignore_mqtt = USERPREFS_CONFIG_LORA_IGNORE_MQTT;
|
|
#else
|
|
config.lora.ignore_mqtt = false;
|
|
#endif
|
|
|
|
// Initialize admin_key_count to zero
|
|
byte numAdminKeys = 0;
|
|
|
|
#ifdef USERPREFS_USE_ADMIN_KEY_0
|
|
// Check if USERPREFS_ADMIN_KEY_0 is non-empty
|
|
if (sizeof(userprefs_admin_key_0) > 0) {
|
|
memcpy(config.security.admin_key[0].bytes, userprefs_admin_key_0, 32);
|
|
config.security.admin_key[0].size = 32;
|
|
numAdminKeys++;
|
|
}
|
|
#endif
|
|
|
|
#ifdef USERPREFS_USE_ADMIN_KEY_1
|
|
// Check if USERPREFS_ADMIN_KEY_1 is non-empty
|
|
if (sizeof(userprefs_admin_key_1) > 0) {
|
|
memcpy(config.security.admin_key[1].bytes, userprefs_admin_key_1, 32);
|
|
config.security.admin_key[1].size = 32;
|
|
numAdminKeys++;
|
|
}
|
|
#endif
|
|
|
|
#ifdef USERPREFS_USE_ADMIN_KEY_2
|
|
// Check if USERPREFS_ADMIN_KEY_2 is non-empty
|
|
if (sizeof(userprefs_admin_key_2) > 0) {
|
|
memcpy(config.security.admin_key[2].bytes, userprefs_admin_key_2, 32);
|
|
config.security.admin_key[2].size = 32;
|
|
numAdminKeys++;
|
|
}
|
|
#endif
|
|
|
|
config.security.admin_key_count = numAdminKeys;
|
|
|
|
if (shouldPreserveKey) {
|
|
config.security.private_key.size = 32;
|
|
memcpy(config.security.private_key.bytes, private_key_temp, config.security.private_key.size);
|
|
printBytes("Restored key", config.security.private_key.bytes, config.security.private_key.size);
|
|
} else {
|
|
config.security.private_key.size = 0;
|
|
}
|
|
config.security.public_key.size = 0;
|
|
|
|
#ifdef PIN_GPS_EN
|
|
config.position.gps_en_gpio = PIN_GPS_EN;
|
|
#endif
|
|
|
|
#if defined(USERPREFS_CONFIG_GPS_MODE)
|
|
config.position.gps_mode = USERPREFS_CONFIG_GPS_MODE;
|
|
#elif !HAS_GPS || GPS_DEFAULT_NOT_PRESENT
|
|
config.position.gps_mode = meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT;
|
|
#elif !defined(GPS_RX_PIN)
|
|
if (config.position.rx_gpio == 0)
|
|
config.position.gps_mode = meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT;
|
|
else
|
|
config.position.gps_mode = meshtastic_Config_PositionConfig_GpsMode_DISABLED;
|
|
#else
|
|
config.position.gps_mode = meshtastic_Config_PositionConfig_GpsMode_ENABLED;
|
|
#endif
|
|
|
|
#ifdef USERPREFS_CONFIG_SMART_POSITION_ENABLED
|
|
config.position.position_broadcast_smart_enabled = USERPREFS_CONFIG_SMART_POSITION_ENABLED;
|
|
#else
|
|
config.position.position_broadcast_smart_enabled = true;
|
|
#endif
|
|
|
|
config.position.broadcast_smart_minimum_distance = 100;
|
|
config.position.broadcast_smart_minimum_interval_secs = default_broadcast_smart_minimum_interval_secs;
|
|
if (config.device.role != meshtastic_Config_DeviceConfig_Role_ROUTER &&
|
|
config.device.role != meshtastic_Config_DeviceConfig_Role_ROUTER_LATE)
|
|
config.device.node_info_broadcast_secs = default_node_info_broadcast_secs;
|
|
config.security.serial_enabled = true;
|
|
config.security.admin_channel_enabled = false;
|
|
resetRadioConfig(true); // This also triggers NodeInfo/Position requests since we're fresh
|
|
strncpy(config.network.ntp_server, "meshtastic.pool.ntp.org", 32);
|
|
|
|
#if (defined(T_DECK) || defined(T_WATCH_S3) || defined(UNPHONE) || defined(PICOMPUTER_S3) || defined(SENSECAP_INDICATOR) || \
|
|
defined(ELECROW_PANEL) || defined(HELTEC_V4_TFT) || defined(HELTEC_V4_R8_TFT) || defined(RAK_WISMESH_TAP_V2)) && \
|
|
HAS_TFT
|
|
// switch BT off by default; use TFT programming mode or hotkey to enable
|
|
config.bluetooth.enabled = false;
|
|
#else
|
|
// default to bluetooth capability of platform as default
|
|
config.bluetooth.enabled = true;
|
|
#endif
|
|
|
|
config.bluetooth.fixed_pin = defaultBLEPin;
|
|
|
|
#if defined(USE_EINK) || defined(HAS_SPI_TFT) || defined(USE_SPISSD1306)
|
|
bool hasScreen = true;
|
|
#ifdef HELTEC_MESH_NODE_T114
|
|
uint32_t st7789_id = get_st7789_id(ST7789_NSS, ST7789_SCK, ST7789_SDA, ST7789_RS, ST7789_RESET);
|
|
if (st7789_id == 0xFFFFFF) {
|
|
hasScreen = false;
|
|
}
|
|
#endif // HELTEC_MESH_NODE_T114
|
|
#elif ARCH_PORTDUINO
|
|
bool hasScreen = false;
|
|
if (portduino_config.displayPanel)
|
|
hasScreen = true;
|
|
else
|
|
hasScreen = screen_found.port != ScanI2C::I2CPort::NO_I2C;
|
|
#elif MESHTASTIC_INCLUDE_NICHE_GRAPHICS // See "src/graphics/niche"
|
|
bool hasScreen = true; // Use random pin for Bluetooth pairing
|
|
#else
|
|
bool hasScreen = screen_found.port != ScanI2C::I2CPort::NO_I2C;
|
|
#endif
|
|
|
|
#ifdef USERPREFS_FIXED_BLUETOOTH
|
|
config.bluetooth.fixed_pin = USERPREFS_FIXED_BLUETOOTH;
|
|
config.bluetooth.mode = meshtastic_Config_BluetoothConfig_PairingMode_FIXED_PIN;
|
|
#else
|
|
config.bluetooth.mode = hasScreen ? meshtastic_Config_BluetoothConfig_PairingMode_RANDOM_PIN
|
|
: meshtastic_Config_BluetoothConfig_PairingMode_FIXED_PIN;
|
|
#endif
|
|
|
|
// for backward compat, default position flags are ALT+MSL
|
|
config.position.position_flags =
|
|
(meshtastic_Config_PositionConfig_PositionFlags_ALTITUDE | meshtastic_Config_PositionConfig_PositionFlags_ALTITUDE_MSL |
|
|
meshtastic_Config_PositionConfig_PositionFlags_SPEED | meshtastic_Config_PositionConfig_PositionFlags_HEADING |
|
|
meshtastic_Config_PositionConfig_PositionFlags_DOP | meshtastic_Config_PositionConfig_PositionFlags_SATINVIEW);
|
|
|
|
// Set default value for 'Mesh via UDP'
|
|
#if HAS_UDP_MULTICAST
|
|
#ifdef USERPREFS_NETWORK_ENABLED_PROTOCOLS
|
|
config.network.enabled_protocols = USERPREFS_NETWORK_ENABLED_PROTOCOLS;
|
|
#else
|
|
config.network.enabled_protocols = 0;
|
|
#endif // Network enabled protocols
|
|
#endif // UDP Multicast
|
|
|
|
#ifdef USERPREFS_NETWORK_WIFI_ENABLED
|
|
config.network.wifi_enabled = USERPREFS_NETWORK_WIFI_ENABLED;
|
|
#endif
|
|
|
|
#if USE_ETHERNET_DEFAULT
|
|
config.network.eth_enabled = true;
|
|
#endif
|
|
|
|
#ifdef USERPREFS_NETWORK_WIFI_SSID
|
|
strncpy(config.network.wifi_ssid, USERPREFS_NETWORK_WIFI_SSID, sizeof(config.network.wifi_ssid));
|
|
#endif
|
|
|
|
#ifdef USERPREFS_NETWORK_WIFI_PSK
|
|
strncpy(config.network.wifi_psk, USERPREFS_NETWORK_WIFI_PSK, sizeof(config.network.wifi_psk));
|
|
#endif
|
|
|
|
#if defined(USERPREFS_NETWORK_IPV6_ENABLED)
|
|
config.network.ipv6_enabled = USERPREFS_NETWORK_IPV6_ENABLED;
|
|
#else
|
|
config.network.ipv6_enabled = default_network_ipv6_enabled;
|
|
#endif
|
|
|
|
#ifdef DISPLAY_FLIP_SCREEN
|
|
config.display.flip_screen = true;
|
|
#endif
|
|
|
|
#ifdef RAK4630
|
|
config.display.wake_on_tap_or_motion = true;
|
|
#endif
|
|
|
|
#if defined(T_WATCH_S3) || defined(SENSECAP_INDICATOR)
|
|
config.display.screen_on_secs = 30;
|
|
config.display.wake_on_tap_or_motion = true;
|
|
#endif
|
|
|
|
#ifdef COMPASS_ORIENTATION
|
|
config.display.compass_orientation = COMPASS_ORIENTATION;
|
|
#endif
|
|
|
|
#if defined(ARCH_ESP32) && !MESHTASTIC_EXCLUDE_WIFI
|
|
if (MeshtasticOTA::isUpdated()) {
|
|
MeshtasticOTA::recoverConfig(&config.network);
|
|
}
|
|
#endif
|
|
|
|
#ifdef USERPREFS_CONFIG_DEVICE_ROLE
|
|
// Apply role-specific defaults when role is set via user preferences
|
|
installRoleDefaults(config.device.role);
|
|
#endif
|
|
|
|
initConfigIntervals();
|
|
variantDefaultConfig();
|
|
variantDefaultModuleConfig();
|
|
}
|
|
|
|
void NodeDB::initConfigIntervals()
|
|
{
|
|
#ifdef USERPREFS_CONFIG_GPS_UPDATE_INTERVAL
|
|
config.position.gps_update_interval = USERPREFS_CONFIG_GPS_UPDATE_INTERVAL;
|
|
#else
|
|
config.position.gps_update_interval = default_gps_update_interval;
|
|
#endif
|
|
|
|
#ifdef USERPREFS_CONFIG_POSITION_BROADCAST_INTERVAL
|
|
config.position.position_broadcast_secs = USERPREFS_CONFIG_POSITION_BROADCAST_INTERVAL;
|
|
#else
|
|
config.position.position_broadcast_secs = default_broadcast_interval_secs;
|
|
#endif
|
|
|
|
config.power.ls_secs = default_ls_secs;
|
|
config.power.min_wake_secs = default_min_wake_secs;
|
|
config.power.sds_secs = default_sds_secs;
|
|
config.power.wait_bluetooth_secs = default_wait_bluetooth_secs;
|
|
|
|
config.display.screen_on_secs = default_screen_on_secs;
|
|
|
|
#if defined(USE_POWERSAVE)
|
|
config.power.is_power_saving = true;
|
|
config.display.screen_on_secs = 30;
|
|
config.power.wait_bluetooth_secs = 30;
|
|
#endif
|
|
}
|
|
|
|
// Always-on traffic management defaults. Only booleans are written; every
|
|
// numeric field stays 0 and resolves to its default_traffic_mgmt_* macro at
|
|
// use (e.g. position dedup precision/interval), so fork-wide tuning changes
|
|
// take effect without another migration. Rate limiting and the features that
|
|
// exhaust or reshape relayed traffic (exhaust_hop_*, drop_unknown_enabled,
|
|
// nodeinfo_direct_response) stay opt-in.
|
|
static void installTrafficManagementDefaults(meshtastic_LocalModuleConfig &mc)
|
|
{
|
|
mc.has_traffic_management = true;
|
|
mc.traffic_management = meshtastic_ModuleConfig_TrafficManagementConfig_init_zero;
|
|
#if HAS_TRAFFIC_MANAGEMENT
|
|
// Position dedup ships enabled at the 11-hour default window on all supported targets.
|
|
// STM32WL is excluded at compile time (HAS_TRAFFIC_MANAGEMENT=0 in mesh-pb-constants.h).
|
|
// Set position_min_interval_secs=0 at runtime to disable dedup.
|
|
mc.traffic_management.position_min_interval_secs = default_traffic_mgmt_position_min_interval_secs;
|
|
#endif
|
|
}
|
|
|
|
// --- 2.8 position/telemetry opt-in migration helpers -------------------------------------------------
|
|
// Pure field mutators (no I/O), so the native test suite can exercise them directly. The version gate
|
|
// and saveToDisk live in loadFromDisk() below.
|
|
|
|
void optInDisablePositionSharing(meshtastic_ChannelFile &cf)
|
|
{
|
|
for (pb_size_t i = 0; i < cf.channels_count; i++) {
|
|
// Only flip PUBLIC / default-PSK channels. A channel with a real private key is a deliberate
|
|
// trusted-group setup where the "leak location to strangers" concern doesn't apply, so its
|
|
// configured precision (including full precision) is preserved.
|
|
if (!channelFileUsesPublicKey(cf, (ChannelIndex)i))
|
|
continue;
|
|
cf.channels[i].settings.has_module_settings = true;
|
|
cf.channels[i].settings.module_settings.position_precision = 0;
|
|
}
|
|
}
|
|
|
|
void optInDisableTelemetryBroadcast(meshtastic_LocalModuleConfig &mc)
|
|
{
|
|
// Every mesh-broadcast telemetry enable flag (each gates its module's sendTelemetry() to the mesh).
|
|
mc.telemetry.device_telemetry_enabled = false;
|
|
mc.telemetry.environment_measurement_enabled = false;
|
|
mc.telemetry.air_quality_enabled = false;
|
|
mc.telemetry.power_measurement_enabled = false;
|
|
mc.telemetry.health_measurement_enabled = false;
|
|
// Position leak via the public MQTT map. Leave map_reporting_enabled alone (anonymous presence is
|
|
// still allowed) and strip only the location component.
|
|
mc.mqtt.map_report_settings.should_report_location = false;
|
|
}
|
|
|
|
void NodeDB::installDefaultModuleConfig()
|
|
{
|
|
LOG_INFO("Install default ModuleConfig");
|
|
memset(&moduleConfig, 0, sizeof(meshtastic_ModuleConfig));
|
|
|
|
moduleConfig.version = DEVICESTATE_CUR_VER;
|
|
moduleConfig.has_mqtt = true;
|
|
moduleConfig.has_range_test = true;
|
|
moduleConfig.has_serial = true;
|
|
moduleConfig.has_store_forward = true;
|
|
moduleConfig.has_telemetry = true;
|
|
moduleConfig.has_external_notification = true;
|
|
#if defined(PIN_BUZZER) || defined(PIN_VIBRATION) || defined(LED_NOTIFICATION) || defined(PCA_LED_NOTIFICATION) || \
|
|
defined(NEOPIXEL_STATUS_NOTIFICATION_PIN)
|
|
moduleConfig.external_notification.enabled = true;
|
|
#endif
|
|
|
|
#if defined(PIN_BUZZER)
|
|
moduleConfig.external_notification.output_buzzer = PIN_BUZZER;
|
|
moduleConfig.external_notification.use_pwm = true;
|
|
moduleConfig.external_notification.alert_message_buzzer = true;
|
|
#endif
|
|
|
|
#if defined(PIN_VIBRATION)
|
|
moduleConfig.external_notification.output_vibra = PIN_VIBRATION;
|
|
moduleConfig.external_notification.alert_message_vibra = true;
|
|
moduleConfig.external_notification.output_ms = 500;
|
|
#endif
|
|
|
|
#if defined(LED_NOTIFICATION)
|
|
moduleConfig.external_notification.output = LED_NOTIFICATION;
|
|
moduleConfig.external_notification.active = LED_STATE_ON;
|
|
moduleConfig.external_notification.alert_message = true;
|
|
moduleConfig.external_notification.output_ms = 1000;
|
|
#endif
|
|
|
|
#if defined(PIN_VIBRATION)
|
|
moduleConfig.external_notification.nag_timeout = 2;
|
|
#elif defined(PIN_BUZZER) || defined(LED_NOTIFICATION) || defined(NEOPIXEL_STATUS_NOTIFICATION_PIN)
|
|
moduleConfig.external_notification.nag_timeout = default_ringtone_nag_secs;
|
|
#endif
|
|
|
|
#ifdef HAS_I2S
|
|
// Don't worry about the other settings for T-Watch, we'll also use the DRV2056 behavior for notifications
|
|
moduleConfig.external_notification.enabled = true;
|
|
moduleConfig.external_notification.use_i2s_as_buzzer = true;
|
|
moduleConfig.external_notification.alert_message_buzzer = true;
|
|
#if HAS_TFT
|
|
if (moduleConfig.external_notification.nag_timeout == default_ringtone_nag_secs)
|
|
moduleConfig.external_notification.nag_timeout = 0;
|
|
#else
|
|
moduleConfig.external_notification.nag_timeout = default_ringtone_nag_secs;
|
|
#endif // HAS_TFT
|
|
#endif // HAS_I2S
|
|
|
|
#ifdef NANO_G2_ULTRA
|
|
moduleConfig.external_notification.enabled = true;
|
|
moduleConfig.external_notification.alert_message = true;
|
|
moduleConfig.external_notification.output_ms = 100;
|
|
moduleConfig.external_notification.active = true;
|
|
#endif // NANO_G2_ULTRA
|
|
|
|
#ifdef T_LORA_PAGER
|
|
moduleConfig.canned_message.updown1_enabled = true;
|
|
moduleConfig.canned_message.inputbroker_pin_a = ROTARY_A;
|
|
moduleConfig.canned_message.inputbroker_pin_b = ROTARY_B;
|
|
moduleConfig.canned_message.inputbroker_pin_press = ROTARY_PRESS;
|
|
moduleConfig.canned_message.inputbroker_event_cw = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar(28);
|
|
moduleConfig.canned_message.inputbroker_event_ccw = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar(29);
|
|
moduleConfig.canned_message.inputbroker_event_press = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_SELECT;
|
|
#endif
|
|
|
|
moduleConfig.has_canned_message = true;
|
|
|
|
#if USERPREFS_MQTT_ENABLED && !MESHTASTIC_EXCLUDE_MQTT
|
|
moduleConfig.mqtt.enabled = true;
|
|
#endif
|
|
|
|
#ifdef USERPREFS_MQTT_ADDRESS
|
|
strncpy(moduleConfig.mqtt.address, USERPREFS_MQTT_ADDRESS, sizeof(moduleConfig.mqtt.address));
|
|
#else
|
|
strncpy(moduleConfig.mqtt.address, default_mqtt_address, sizeof(moduleConfig.mqtt.address));
|
|
#endif
|
|
|
|
#ifdef USERPREFS_MQTT_USERNAME
|
|
strncpy(moduleConfig.mqtt.username, USERPREFS_MQTT_USERNAME, sizeof(moduleConfig.mqtt.username));
|
|
#else
|
|
strncpy(moduleConfig.mqtt.username, default_mqtt_username, sizeof(moduleConfig.mqtt.username));
|
|
#endif
|
|
|
|
#ifdef USERPREFS_MQTT_PASSWORD
|
|
strncpy(moduleConfig.mqtt.password, USERPREFS_MQTT_PASSWORD, sizeof(moduleConfig.mqtt.password));
|
|
#else
|
|
strncpy(moduleConfig.mqtt.password, default_mqtt_password, sizeof(moduleConfig.mqtt.password));
|
|
#endif
|
|
|
|
#ifdef USERPREFS_MQTT_ROOT_TOPIC
|
|
strncpy(moduleConfig.mqtt.root, USERPREFS_MQTT_ROOT_TOPIC, sizeof(moduleConfig.mqtt.root));
|
|
#else
|
|
strncpy(moduleConfig.mqtt.root, default_mqtt_root, sizeof(moduleConfig.mqtt.root));
|
|
#endif
|
|
|
|
#ifdef USERPREFS_MQTT_ENCRYPTION_ENABLED
|
|
moduleConfig.mqtt.encryption_enabled = USERPREFS_MQTT_ENCRYPTION_ENABLED;
|
|
#else
|
|
moduleConfig.mqtt.encryption_enabled = default_mqtt_encryption_enabled;
|
|
#endif
|
|
|
|
#ifdef USERPREFS_MQTT_TLS_ENABLED
|
|
moduleConfig.mqtt.tls_enabled = USERPREFS_MQTT_TLS_ENABLED;
|
|
#else
|
|
moduleConfig.mqtt.tls_enabled = default_mqtt_tls_enabled;
|
|
#endif
|
|
|
|
moduleConfig.has_neighbor_info = true;
|
|
moduleConfig.neighbor_info.enabled = false;
|
|
|
|
installTrafficManagementDefaults(moduleConfig);
|
|
|
|
moduleConfig.has_detection_sensor = true;
|
|
moduleConfig.detection_sensor.enabled = false;
|
|
moduleConfig.detection_sensor.detection_trigger_type = meshtastic_ModuleConfig_DetectionSensorConfig_TriggerType_LOGIC_HIGH;
|
|
moduleConfig.detection_sensor.minimum_broadcast_secs = 45;
|
|
|
|
moduleConfig.has_ambient_lighting = true;
|
|
moduleConfig.ambient_lighting.current = 10;
|
|
// Default to a color based on our node number
|
|
moduleConfig.ambient_lighting.red = (myNodeInfo.my_node_num & 0xFF0000) >> 16;
|
|
moduleConfig.ambient_lighting.green = (myNodeInfo.my_node_num & 0x00FF00) >> 8;
|
|
moduleConfig.ambient_lighting.blue = myNodeInfo.my_node_num & 0x0000FF;
|
|
|
|
#if !MESHTASTIC_EXCLUDE_BEACON
|
|
moduleConfig.has_mesh_beacon = true;
|
|
// Default flags: listen on, broadcast off, legacy split on.
|
|
moduleConfig.mesh_beacon.flags = meshtastic_ModuleConfig_MeshBeaconConfig_Flags_FLAG_LISTEN_ENABLED |
|
|
meshtastic_ModuleConfig_MeshBeaconConfig_Flags_FLAG_LEGACY_SPLIT;
|
|
// Set or clear a single beacon flag bit from a USERPREFS boolean.
|
|
#define BEACON_APPLY_FLAG(enabled, flag) \
|
|
do { \
|
|
if (enabled) \
|
|
moduleConfig.mesh_beacon.flags |= (uint32_t)(flag); \
|
|
else \
|
|
moduleConfig.mesh_beacon.flags &= ~(uint32_t)(flag); \
|
|
} while (0)
|
|
#ifdef USERPREFS_MESH_BEACON_LISTEN_ENABLED
|
|
BEACON_APPLY_FLAG(USERPREFS_MESH_BEACON_LISTEN_ENABLED, meshtastic_ModuleConfig_MeshBeaconConfig_Flags_FLAG_LISTEN_ENABLED);
|
|
#endif
|
|
#ifdef USERPREFS_MESH_BEACON_BROADCAST_ENABLED
|
|
BEACON_APPLY_FLAG(USERPREFS_MESH_BEACON_BROADCAST_ENABLED,
|
|
meshtastic_ModuleConfig_MeshBeaconConfig_Flags_FLAG_BROADCAST_ENABLED);
|
|
#endif
|
|
#ifdef USERPREFS_MESH_BEACON_MESSAGE
|
|
strncpy(moduleConfig.mesh_beacon.broadcast_message, USERPREFS_MESH_BEACON_MESSAGE,
|
|
sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1);
|
|
moduleConfig.mesh_beacon.broadcast_message[sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1] = '\0';
|
|
#endif
|
|
#ifdef USERPREFS_MESH_BEACON_INTERVAL_SECS
|
|
moduleConfig.mesh_beacon.broadcast_interval_secs =
|
|
(USERPREFS_MESH_BEACON_INTERVAL_SECS != 0 &&
|
|
USERPREFS_MESH_BEACON_INTERVAL_SECS < default_mesh_beacon_min_broadcast_interval_secs)
|
|
? default_mesh_beacon_min_broadcast_interval_secs
|
|
: USERPREFS_MESH_BEACON_INTERVAL_SECS;
|
|
#endif
|
|
#ifdef USERPREFS_MESH_BEACON_OFFER_PRESET
|
|
moduleConfig.mesh_beacon.has_broadcast_offer_preset = true;
|
|
moduleConfig.mesh_beacon.broadcast_offer_preset = USERPREFS_MESH_BEACON_OFFER_PRESET;
|
|
#endif
|
|
#ifdef USERPREFS_MESH_BEACON_OFFER_REGION
|
|
moduleConfig.mesh_beacon.broadcast_offer_region = USERPREFS_MESH_BEACON_OFFER_REGION;
|
|
#endif
|
|
#ifdef USERPREFS_MESH_BEACON_OFFER_CHANNEL_NAME
|
|
moduleConfig.mesh_beacon.has_broadcast_offer_channel = true;
|
|
strncpy(moduleConfig.mesh_beacon.broadcast_offer_channel.name, USERPREFS_MESH_BEACON_OFFER_CHANNEL_NAME,
|
|
sizeof(moduleConfig.mesh_beacon.broadcast_offer_channel.name) - 1);
|
|
moduleConfig.mesh_beacon.broadcast_offer_channel.name[sizeof(moduleConfig.mesh_beacon.broadcast_offer_channel.name) - 1] =
|
|
'\0';
|
|
#endif
|
|
#ifdef USERPREFS_MESH_BEACON_OFFER_CHANNEL_PSK
|
|
moduleConfig.mesh_beacon.has_broadcast_offer_channel = true;
|
|
static const uint8_t beaconOfferPsk[] = USERPREFS_MESH_BEACON_OFFER_CHANNEL_PSK;
|
|
static_assert(sizeof(beaconOfferPsk) <= sizeof(moduleConfig.mesh_beacon.broadcast_offer_channel.psk.bytes),
|
|
"USERPREFS_MESH_BEACON_OFFER_CHANNEL_PSK exceeds the 32-byte channel PSK buffer");
|
|
memcpy(moduleConfig.mesh_beacon.broadcast_offer_channel.psk.bytes, beaconOfferPsk, sizeof(beaconOfferPsk));
|
|
moduleConfig.mesh_beacon.broadcast_offer_channel.psk.size = sizeof(beaconOfferPsk);
|
|
#endif
|
|
#ifdef USERPREFS_MESH_BEACON_ON_PRESET
|
|
moduleConfig.mesh_beacon.has_broadcast_on_preset = true;
|
|
moduleConfig.mesh_beacon.broadcast_on_preset = USERPREFS_MESH_BEACON_ON_PRESET;
|
|
#endif
|
|
#ifdef USERPREFS_MESH_BEACON_ON_REGION
|
|
moduleConfig.mesh_beacon.broadcast_on_region = USERPREFS_MESH_BEACON_ON_REGION;
|
|
#endif
|
|
#ifdef USERPREFS_MESH_BEACON_ON_CHANNEL_NAME
|
|
moduleConfig.mesh_beacon.has_broadcast_on_channel = true;
|
|
strncpy(moduleConfig.mesh_beacon.broadcast_on_channel.name, USERPREFS_MESH_BEACON_ON_CHANNEL_NAME,
|
|
sizeof(moduleConfig.mesh_beacon.broadcast_on_channel.name) - 1);
|
|
moduleConfig.mesh_beacon.broadcast_on_channel.name[sizeof(moduleConfig.mesh_beacon.broadcast_on_channel.name) - 1] = '\0';
|
|
#endif
|
|
#ifdef USERPREFS_MESH_BEACON_ON_CHANNEL_PSK
|
|
moduleConfig.mesh_beacon.has_broadcast_on_channel = true;
|
|
static const uint8_t beaconOnPsk[] = USERPREFS_MESH_BEACON_ON_CHANNEL_PSK;
|
|
static_assert(sizeof(beaconOnPsk) <= sizeof(moduleConfig.mesh_beacon.broadcast_on_channel.psk.bytes),
|
|
"USERPREFS_MESH_BEACON_ON_CHANNEL_PSK exceeds the 32-byte channel PSK buffer");
|
|
memcpy(moduleConfig.mesh_beacon.broadcast_on_channel.psk.bytes, beaconOnPsk, sizeof(beaconOnPsk));
|
|
moduleConfig.mesh_beacon.broadcast_on_channel.psk.size = sizeof(beaconOnPsk);
|
|
#endif
|
|
#ifdef USERPREFS_MESH_BEACON_ON_CHANNEL_NUM
|
|
moduleConfig.mesh_beacon.has_broadcast_on_channel = true;
|
|
moduleConfig.mesh_beacon.broadcast_on_channel.channel_num = USERPREFS_MESH_BEACON_ON_CHANNEL_NUM;
|
|
#endif
|
|
#ifdef USERPREFS_MESH_BEACON_LEGACY_SPLIT
|
|
BEACON_APPLY_FLAG(USERPREFS_MESH_BEACON_LEGACY_SPLIT, meshtastic_ModuleConfig_MeshBeaconConfig_Flags_FLAG_LEGACY_SPLIT);
|
|
#endif
|
|
#undef BEACON_APPLY_FLAG
|
|
// Per-preset broadcast targets (up to 4). Each TARGET_<N>_* key bumps broadcast_targets_count as needed.
|
|
#define BEACON_TARGET_PRESET(N, VAL) \
|
|
do { \
|
|
if (moduleConfig.mesh_beacon.broadcast_targets_count < (N) + 1) \
|
|
moduleConfig.mesh_beacon.broadcast_targets_count = (N) + 1; \
|
|
moduleConfig.mesh_beacon.broadcast_targets[(N)].has_preset = true; \
|
|
moduleConfig.mesh_beacon.broadcast_targets[(N)].preset = (VAL); \
|
|
} while (0)
|
|
#define BEACON_TARGET_REGION(N, VAL) \
|
|
do { \
|
|
if (moduleConfig.mesh_beacon.broadcast_targets_count < (N) + 1) \
|
|
moduleConfig.mesh_beacon.broadcast_targets_count = (N) + 1; \
|
|
moduleConfig.mesh_beacon.broadcast_targets[(N)].region = (VAL); \
|
|
} while (0)
|
|
// Target channel is referenced by index into the device's channel table (0..MAX_NUM_CHANNELS-1).
|
|
#define BEACON_TARGET_CH_INDEX(N, VAL) \
|
|
do { \
|
|
if (moduleConfig.mesh_beacon.broadcast_targets_count < (N) + 1) \
|
|
moduleConfig.mesh_beacon.broadcast_targets_count = (N) + 1; \
|
|
moduleConfig.mesh_beacon.broadcast_targets[(N)].has_channel_index = true; \
|
|
moduleConfig.mesh_beacon.broadcast_targets[(N)].channel_index = (VAL); \
|
|
} while (0)
|
|
#ifdef USERPREFS_MESH_BEACON_TARGET_0_PRESET
|
|
BEACON_TARGET_PRESET(0, USERPREFS_MESH_BEACON_TARGET_0_PRESET);
|
|
#endif
|
|
#ifdef USERPREFS_MESH_BEACON_TARGET_0_REGION
|
|
BEACON_TARGET_REGION(0, USERPREFS_MESH_BEACON_TARGET_0_REGION);
|
|
#endif
|
|
#ifdef USERPREFS_MESH_BEACON_TARGET_0_CHANNEL_INDEX
|
|
BEACON_TARGET_CH_INDEX(0, USERPREFS_MESH_BEACON_TARGET_0_CHANNEL_INDEX);
|
|
#endif
|
|
#ifdef USERPREFS_MESH_BEACON_TARGET_1_PRESET
|
|
BEACON_TARGET_PRESET(1, USERPREFS_MESH_BEACON_TARGET_1_PRESET);
|
|
#endif
|
|
#ifdef USERPREFS_MESH_BEACON_TARGET_1_REGION
|
|
BEACON_TARGET_REGION(1, USERPREFS_MESH_BEACON_TARGET_1_REGION);
|
|
#endif
|
|
#ifdef USERPREFS_MESH_BEACON_TARGET_1_CHANNEL_INDEX
|
|
BEACON_TARGET_CH_INDEX(1, USERPREFS_MESH_BEACON_TARGET_1_CHANNEL_INDEX);
|
|
#endif
|
|
#ifdef USERPREFS_MESH_BEACON_TARGET_2_PRESET
|
|
BEACON_TARGET_PRESET(2, USERPREFS_MESH_BEACON_TARGET_2_PRESET);
|
|
#endif
|
|
#ifdef USERPREFS_MESH_BEACON_TARGET_2_REGION
|
|
BEACON_TARGET_REGION(2, USERPREFS_MESH_BEACON_TARGET_2_REGION);
|
|
#endif
|
|
#ifdef USERPREFS_MESH_BEACON_TARGET_2_CHANNEL_INDEX
|
|
BEACON_TARGET_CH_INDEX(2, USERPREFS_MESH_BEACON_TARGET_2_CHANNEL_INDEX);
|
|
#endif
|
|
#ifdef USERPREFS_MESH_BEACON_TARGET_3_PRESET
|
|
BEACON_TARGET_PRESET(3, USERPREFS_MESH_BEACON_TARGET_3_PRESET);
|
|
#endif
|
|
#ifdef USERPREFS_MESH_BEACON_TARGET_3_REGION
|
|
BEACON_TARGET_REGION(3, USERPREFS_MESH_BEACON_TARGET_3_REGION);
|
|
#endif
|
|
#ifdef USERPREFS_MESH_BEACON_TARGET_3_CHANNEL_INDEX
|
|
BEACON_TARGET_CH_INDEX(3, USERPREFS_MESH_BEACON_TARGET_3_CHANNEL_INDEX);
|
|
#endif
|
|
#undef BEACON_TARGET_PRESET
|
|
#undef BEACON_TARGET_REGION
|
|
#undef BEACON_TARGET_CH_INDEX
|
|
#endif // !MESHTASTIC_EXCLUDE_BEACON
|
|
|
|
initModuleConfigIntervals();
|
|
}
|
|
|
|
void NodeDB::installRoleDefaults(meshtastic_Config_DeviceConfig_Role role)
|
|
{
|
|
if (role == meshtastic_Config_DeviceConfig_Role_ROUTER) {
|
|
initConfigIntervals();
|
|
initModuleConfigIntervals();
|
|
moduleConfig.telemetry.device_update_interval = default_telemetry_broadcast_interval_secs;
|
|
config.device.rebroadcast_mode = meshtastic_Config_DeviceConfig_RebroadcastMode_CORE_PORTNUMS_ONLY;
|
|
owner.has_is_unmessagable = true;
|
|
owner.is_unmessagable = true;
|
|
} else if (role == meshtastic_Config_DeviceConfig_Role_ROUTER_LATE) {
|
|
moduleConfig.telemetry.device_update_interval = ONE_DAY;
|
|
owner.has_is_unmessagable = true;
|
|
owner.is_unmessagable = true;
|
|
} else if (role == meshtastic_Config_DeviceConfig_Role_SENSOR) {
|
|
owner.has_is_unmessagable = true;
|
|
owner.is_unmessagable = true;
|
|
moduleConfig.telemetry.device_update_interval = default_telemetry_broadcast_interval_secs;
|
|
moduleConfig.telemetry.environment_measurement_enabled = true;
|
|
moduleConfig.telemetry.environment_update_interval = 300;
|
|
} else if (role == meshtastic_Config_DeviceConfig_Role_LOST_AND_FOUND) {
|
|
config.position.position_broadcast_smart_enabled = false;
|
|
config.position.position_broadcast_secs = 300; // Every 5 minutes
|
|
} else if (role == meshtastic_Config_DeviceConfig_Role_TAK) {
|
|
config.device.node_info_broadcast_secs = ONE_DAY;
|
|
config.position.position_broadcast_smart_enabled = false;
|
|
config.position.position_broadcast_secs = ONE_DAY;
|
|
// Remove Altitude MSL from flags since CoTs use HAE (height above ellipsoid)
|
|
config.position.position_flags =
|
|
(meshtastic_Config_PositionConfig_PositionFlags_ALTITUDE | meshtastic_Config_PositionConfig_PositionFlags_SPEED |
|
|
meshtastic_Config_PositionConfig_PositionFlags_HEADING | meshtastic_Config_PositionConfig_PositionFlags_DOP);
|
|
moduleConfig.telemetry.device_update_interval = ONE_DAY;
|
|
} else if (role == meshtastic_Config_DeviceConfig_Role_TRACKER) {
|
|
owner.has_is_unmessagable = true;
|
|
owner.is_unmessagable = true;
|
|
moduleConfig.telemetry.device_update_interval = default_telemetry_broadcast_interval_secs;
|
|
} else if (role == meshtastic_Config_DeviceConfig_Role_TAK_TRACKER) {
|
|
owner.has_is_unmessagable = true;
|
|
owner.is_unmessagable = true;
|
|
config.device.node_info_broadcast_secs = ONE_DAY;
|
|
config.position.position_broadcast_smart_enabled = true;
|
|
config.position.position_broadcast_secs = 3 * 60; // Every 3 minutes
|
|
config.position.broadcast_smart_minimum_distance = 20;
|
|
config.position.broadcast_smart_minimum_interval_secs = 15;
|
|
// Remove Altitude MSL from flags since CoTs use HAE (height above ellipsoid)
|
|
config.position.position_flags =
|
|
(meshtastic_Config_PositionConfig_PositionFlags_ALTITUDE | meshtastic_Config_PositionConfig_PositionFlags_SPEED |
|
|
meshtastic_Config_PositionConfig_PositionFlags_HEADING | meshtastic_Config_PositionConfig_PositionFlags_DOP);
|
|
moduleConfig.telemetry.device_update_interval = ONE_DAY;
|
|
} else if (role == meshtastic_Config_DeviceConfig_Role_CLIENT_HIDDEN) {
|
|
config.device.rebroadcast_mode = meshtastic_Config_DeviceConfig_RebroadcastMode_LOCAL_ONLY;
|
|
config.device.node_info_broadcast_secs = MAX_INTERVAL;
|
|
config.position.position_broadcast_smart_enabled = false;
|
|
config.position.position_broadcast_secs = MAX_INTERVAL;
|
|
moduleConfig.neighbor_info.update_interval = MAX_INTERVAL;
|
|
moduleConfig.telemetry.device_update_interval = MAX_INTERVAL;
|
|
moduleConfig.telemetry.environment_update_interval = MAX_INTERVAL;
|
|
moduleConfig.telemetry.air_quality_interval = MAX_INTERVAL;
|
|
moduleConfig.telemetry.health_update_interval = MAX_INTERVAL;
|
|
}
|
|
}
|
|
|
|
void NodeDB::initModuleConfigIntervals()
|
|
{
|
|
// Zero out telemetry intervals so that they coalesce to defaults in Default.h
|
|
#ifdef USERPREFS_CONFIG_DEVICE_TELEM_UPDATE_INTERVAL
|
|
moduleConfig.telemetry.device_update_interval = USERPREFS_CONFIG_DEVICE_TELEM_UPDATE_INTERVAL;
|
|
#else
|
|
moduleConfig.telemetry.device_update_interval = MAX_INTERVAL;
|
|
#endif
|
|
|
|
#ifdef USERPREFS_CONFIG_ENVIRONMENT_MEASUREMENT_ENABLED
|
|
moduleConfig.telemetry.environment_measurement_enabled = USERPREFS_CONFIG_ENVIRONMENT_MEASUREMENT_ENABLED;
|
|
#endif
|
|
|
|
#ifdef USERPREFS_CONFIG_ENV_TELEM_UPDATE_INTERVAL
|
|
moduleConfig.telemetry.environment_update_interval = USERPREFS_CONFIG_ENV_TELEM_UPDATE_INTERVAL;
|
|
#else
|
|
moduleConfig.telemetry.environment_update_interval = 0;
|
|
#endif
|
|
|
|
#ifdef USERPREFS_CONFIG_ENV_SCREEN_SCREEN_ENABLED
|
|
moduleConfig.telemetry.environment_screen_enabled = USERPREFS_CONFIG_ENV_SCREEN_SCREEN_ENABLED;
|
|
#endif
|
|
|
|
#ifdef USERPREFS_CONFIG_AQ_TELEM_UPDATE_INTERVAL
|
|
moduleConfig.telemetry.air_quality_interval = USERPREFS_CONFIG_AQ_TELEM_UPDATE_INTERVAL;
|
|
#else
|
|
moduleConfig.telemetry.air_quality_interval = 0;
|
|
#endif
|
|
|
|
#ifdef USERPREFS_CONFIG_AQ_MEASUREMENT_ENABLED
|
|
moduleConfig.telemetry.air_quality_enabled = USERPREFS_CONFIG_AQ_MEASUREMENT_ENABLED;
|
|
#endif
|
|
|
|
#ifdef USERPREFS_CONFIG_AQ_SCREEN_ENABLED
|
|
moduleConfig.telemetry.air_quality_screen_enabled = USERPREFS_CONFIG_AQ_SCREEN_ENABLED;
|
|
#endif
|
|
|
|
moduleConfig.telemetry.power_update_interval = 0;
|
|
moduleConfig.telemetry.health_update_interval = 0;
|
|
moduleConfig.neighbor_info.update_interval = 0;
|
|
moduleConfig.paxcounter.paxcounter_update_interval = 0;
|
|
}
|
|
|
|
void NodeDB::installDefaultChannels()
|
|
{
|
|
LOG_INFO("Install default ChannelFile");
|
|
memset(&channelFile, 0, sizeof(meshtastic_ChannelFile));
|
|
channelFile.version = DEVICESTATE_CUR_VER;
|
|
}
|
|
|
|
void NodeDB::resetNodes(bool keepFavorites)
|
|
{
|
|
if (!config.position.fixed_position)
|
|
clearLocalPosition();
|
|
NodeNum ourNum = getNodeNum();
|
|
numMeshNodes = 1;
|
|
if (keepFavorites) {
|
|
LOG_INFO("Clearing node database - preserving favorites");
|
|
for (size_t i = 0; i < meshNodes->size(); i++) {
|
|
meshtastic_NodeInfoLite &node = meshNodes->at(i);
|
|
if (i > 0 && !nodeInfoLiteIsFavorite(&node)) {
|
|
eraseNodeSatellites(node.num);
|
|
node = meshtastic_NodeInfoLite();
|
|
} else {
|
|
numMeshNodes += 1;
|
|
}
|
|
};
|
|
} else {
|
|
LOG_INFO("Clearing node database - removing favorites");
|
|
for (size_t i = 1; i < meshNodes->size(); i++) {
|
|
const NodeNum gone = meshNodes->at(i).num;
|
|
if (gone)
|
|
eraseNodeSatellites(gone);
|
|
}
|
|
std::fill(nodeDatabase.nodes.begin() + 1, nodeDatabase.nodes.end(), meshtastic_NodeInfoLite());
|
|
}
|
|
(void)ourNum;
|
|
#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();
|
|
saveDeviceStateToDisk();
|
|
if (neighborInfoModule && moduleConfig.neighbor_info.enabled)
|
|
neighborInfoModule->resetNeighbors();
|
|
}
|
|
|
|
void NodeDB::removeNodeByNum(NodeNum nodeNum)
|
|
{
|
|
int newPos = 0, removed = 0;
|
|
for (int i = 0; i < numMeshNodes; i++) {
|
|
if (meshNodes->at(i).num != nodeNum)
|
|
meshNodes->at(newPos++) = meshNodes->at(i);
|
|
else
|
|
removed++;
|
|
}
|
|
numMeshNodes -= removed;
|
|
if (removed) {
|
|
// Clear exactly the slots compaction vacated. Sizing this from `removed` (rather than a
|
|
// fixed one) keeps it inside the vector when nothing matched and the store is full.
|
|
const size_t first = numMeshNodes;
|
|
const size_t last = std::min(first + removed, nodeDatabase.nodes.size());
|
|
std::fill(nodeDatabase.nodes.begin() + first, nodeDatabase.nodes.begin() + last, meshtastic_NodeInfoLite());
|
|
}
|
|
// Drop the node's satellite stores and warm-tier copy regardless of which tier it lived in, so
|
|
// an explicit removal fully forgets it.
|
|
eraseNodeSatellites(nodeNum);
|
|
#if WARM_NODE_COUNT > 0
|
|
// 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();
|
|
}
|
|
|
|
void NodeDB::clearLocalPosition()
|
|
{
|
|
#if !MESHTASTIC_EXCLUDE_POSITIONDB
|
|
concurrency::LockGuard guard(&satelliteMutex);
|
|
nodePositions.erase(getNodeNum());
|
|
#endif
|
|
setLocalPosition(meshtastic_Position_init_default);
|
|
localPositionUpdatedSinceBoot = false;
|
|
}
|
|
|
|
bool NodeDB::copyNodePosition(NodeNum n, meshtastic_PositionLite &out) const
|
|
{
|
|
#if MESHTASTIC_EXCLUDE_POSITIONDB
|
|
(void)n;
|
|
(void)out;
|
|
return false;
|
|
#else
|
|
concurrency::LockGuard guard(&satelliteMutex);
|
|
return copySatelliteEntry(nodePositions, n, out);
|
|
#endif
|
|
}
|
|
|
|
bool NodeDB::copyNodeTelemetry(NodeNum n, meshtastic_DeviceMetrics &out) const
|
|
{
|
|
#if MESHTASTIC_EXCLUDE_TELEMETRYDB
|
|
(void)n;
|
|
(void)out;
|
|
return false;
|
|
#else
|
|
concurrency::LockGuard guard(&satelliteMutex);
|
|
return copySatelliteEntry(nodeTelemetry, n, out);
|
|
#endif
|
|
}
|
|
|
|
bool NodeDB::copyNodeEnvironment(NodeNum n, meshtastic_EnvironmentMetrics &out) const
|
|
{
|
|
#if MESHTASTIC_EXCLUDE_ENVIRONMENTDB
|
|
(void)n;
|
|
(void)out;
|
|
return false;
|
|
#else
|
|
concurrency::LockGuard guard(&satelliteMutex);
|
|
return copySatelliteEntry(nodeEnvironment, n, out);
|
|
#endif
|
|
}
|
|
|
|
bool NodeDB::copyNodeStatus(NodeNum n, meshtastic_StatusMessage &out) const
|
|
{
|
|
#if MESHTASTIC_EXCLUDE_STATUSDB
|
|
(void)n;
|
|
(void)out;
|
|
return false;
|
|
#else
|
|
concurrency::LockGuard guard(&satelliteMutex);
|
|
return copySatelliteEntry(nodeStatus, n, out);
|
|
#endif
|
|
}
|
|
|
|
std::vector<NodeNum> NodeDB::snapshotPositionNodeNums(NodeNum exclude) const
|
|
{
|
|
#if MESHTASTIC_EXCLUDE_POSITIONDB
|
|
(void)exclude;
|
|
return {};
|
|
#else
|
|
concurrency::LockGuard guard(&satelliteMutex);
|
|
return snapshotSatelliteNodeNums(nodePositions, exclude);
|
|
#endif
|
|
}
|
|
|
|
std::vector<NodeNum> NodeDB::snapshotTelemetryNodeNums(NodeNum exclude) const
|
|
{
|
|
#if MESHTASTIC_EXCLUDE_TELEMETRYDB
|
|
(void)exclude;
|
|
return {};
|
|
#else
|
|
concurrency::LockGuard guard(&satelliteMutex);
|
|
return snapshotSatelliteNodeNums(nodeTelemetry, exclude);
|
|
#endif
|
|
}
|
|
|
|
std::vector<NodeNum> NodeDB::snapshotEnvironmentNodeNums(NodeNum exclude) const
|
|
{
|
|
#if MESHTASTIC_EXCLUDE_ENVIRONMENTDB
|
|
(void)exclude;
|
|
return {};
|
|
#else
|
|
concurrency::LockGuard guard(&satelliteMutex);
|
|
return snapshotSatelliteNodeNums(nodeEnvironment, exclude);
|
|
#endif
|
|
}
|
|
|
|
std::vector<NodeNum> NodeDB::snapshotStatusNodeNums(NodeNum exclude) const
|
|
{
|
|
#if MESHTASTIC_EXCLUDE_STATUSDB
|
|
(void)exclude;
|
|
return {};
|
|
#else
|
|
concurrency::LockGuard guard(&satelliteMutex);
|
|
return snapshotSatelliteNodeNums(nodeStatus, exclude);
|
|
#endif
|
|
}
|
|
|
|
void NodeDB::setNodeStatus(NodeNum n, const meshtastic_StatusMessage &status)
|
|
{
|
|
#if MESHTASTIC_EXCLUDE_STATUSDB
|
|
(void)n;
|
|
(void)status;
|
|
#else
|
|
concurrency::LockGuard guard(&satelliteMutex);
|
|
evictSatelliteOverCap(*this, nodeStatus, n);
|
|
nodeStatus[n] = status;
|
|
#endif
|
|
}
|
|
|
|
void NodeDB::touchNodePositionTime(NodeNum n, uint32_t time)
|
|
{
|
|
#if MESHTASTIC_EXCLUDE_POSITIONDB
|
|
(void)n;
|
|
(void)time;
|
|
#else
|
|
concurrency::LockGuard guard(&satelliteMutex);
|
|
evictSatelliteOverCap(*this, nodePositions, n);
|
|
nodePositions[n].time = time;
|
|
#endif
|
|
}
|
|
|
|
void NodeDB::eraseNodeSatellites(NodeNum n)
|
|
{
|
|
concurrency::LockGuard guard(&satelliteMutex);
|
|
#if !MESHTASTIC_EXCLUDE_POSITIONDB
|
|
nodePositions.erase(n);
|
|
#endif
|
|
|
|
#if !MESHTASTIC_EXCLUDE_TELEMETRYDB
|
|
nodeTelemetry.erase(n);
|
|
#endif
|
|
|
|
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB
|
|
nodeEnvironment.erase(n);
|
|
#endif
|
|
|
|
#if !MESHTASTIC_EXCLUDE_STATUSDB
|
|
nodeStatus.erase(n);
|
|
#endif
|
|
}
|
|
|
|
bool NodeDB::enforceSatelliteCaps()
|
|
{
|
|
concurrency::LockGuard guard(&satelliteMutex);
|
|
bool trimmedAny = false;
|
|
auto trim = [this, &trimmedAny](auto &map, const char *name) {
|
|
const size_t before = map.size();
|
|
while (map.size() > MAX_SATELLITE_NODES) {
|
|
if (!evictStalestSatellite(*this, map))
|
|
break;
|
|
}
|
|
if (map.size() != before) {
|
|
trimmedAny = true;
|
|
LOG_MIGRATION("Trimmed %s satellites %u -> %u (cap %d)", name, (unsigned)before, (unsigned)map.size(),
|
|
MAX_SATELLITE_NODES);
|
|
}
|
|
};
|
|
#if !MESHTASTIC_EXCLUDE_POSITIONDB
|
|
trim(nodePositions, "position");
|
|
#endif
|
|
|
|
#if !MESHTASTIC_EXCLUDE_TELEMETRYDB
|
|
trim(nodeTelemetry, "telemetry");
|
|
#endif
|
|
|
|
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB
|
|
trim(nodeEnvironment, "environment");
|
|
#endif
|
|
|
|
#if !MESHTASTIC_EXCLUDE_STATUSDB
|
|
trim(nodeStatus, "status");
|
|
#endif
|
|
|
|
(void)trim; // all four maps may be compiled out
|
|
|
|
// Approximate satellite heap usage: each std::map entry is one rb-tree node,
|
|
// value_type plus ~44 B of node overhead (parent/left/right pointers, color,
|
|
// allocator rounding on 32-bit targets - an estimate, not exact bookkeeping).
|
|
size_t satBytes = 0;
|
|
#if !MESHTASTIC_EXCLUDE_POSITIONDB
|
|
satBytes += nodePositions.size() * (sizeof(decltype(nodePositions)::value_type) + 44);
|
|
#endif
|
|
#if !MESHTASTIC_EXCLUDE_TELEMETRYDB
|
|
satBytes += nodeTelemetry.size() * (sizeof(decltype(nodeTelemetry)::value_type) + 44);
|
|
#endif
|
|
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB
|
|
satBytes += nodeEnvironment.size() * (sizeof(decltype(nodeEnvironment)::value_type) + 44);
|
|
#endif
|
|
#if !MESHTASTIC_EXCLUDE_STATUSDB
|
|
satBytes += nodeStatus.size() * (sizeof(decltype(nodeStatus)::value_type) + 44);
|
|
#endif
|
|
memaudit::set("satmaps", satBytes);
|
|
|
|
return trimmedAny;
|
|
}
|
|
|
|
#if WARM_NODE_COUNT > 0
|
|
// Classify an evicted node's hop-protected category for the warm tier. Favorite/ignored/
|
|
// verified are local flags (rarely reach warm - they're eviction-protected - but classify
|
|
// them if they do); otherwise tracker/sensor/tak_tracker are role-protected.
|
|
static uint8_t warmProtectedCategory(const meshtastic_NodeInfoLite &n)
|
|
{
|
|
if (n.bitfield & (NODEINFO_BITFIELD_IS_FAVORITE_MASK | NODEINFO_BITFIELD_IS_IGNORED_MASK |
|
|
NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK))
|
|
return static_cast<uint8_t>(WarmProtected::Flag);
|
|
if (IS_ONE_OF(n.role, meshtastic_Config_DeviceConfig_Role_TRACKER, meshtastic_Config_DeviceConfig_Role_SENSOR,
|
|
meshtastic_Config_DeviceConfig_Role_TAK_TRACKER))
|
|
return static_cast<uint8_t>(WarmProtected::Role);
|
|
return static_cast<uint8_t>(WarmProtected::None);
|
|
}
|
|
|
|
// The warm tier packs the device role into a 4-bit field (WARM_ROLE_MASK). Fail the build
|
|
// loudly if a new role outgrows it, rather than silently truncating role on eviction.
|
|
static_assert(_meshtastic_Config_DeviceConfig_Role_MAX <= WARM_ROLE_MASK,
|
|
"device role no longer fits the 4-bit warm metadata field");
|
|
#endif // WARM_NODE_COUNT > 0
|
|
|
|
void NodeDB::cleanupMeshDB()
|
|
{
|
|
int newPos = 0, removed = 0;
|
|
for (int i = 0; i < numMeshNodes; i++) {
|
|
meshtastic_NodeInfoLite &n = meshNodes->at(i);
|
|
// Keep ignored (blocked) nodes even without user info: a block set by
|
|
// bare node ID has no NodeInfo and would otherwise be purged here,
|
|
// silently dropping the block.
|
|
if (nodeInfoLiteHasUser(&n) || nodeInfoLiteIsIgnored(&n)) {
|
|
if (n.public_key.size > 0) {
|
|
if (memfll(n.public_key.bytes, 0, n.public_key.size)) {
|
|
n.public_key.size = 0;
|
|
}
|
|
}
|
|
if (newPos != i)
|
|
meshNodes->at(newPos++) = n;
|
|
else
|
|
newPos++;
|
|
} else {
|
|
// No user info - drop this node and its satellites
|
|
const NodeNum gone = n.num;
|
|
if (gone) {
|
|
#if WARM_NODE_COUNT > 0
|
|
// Keep any key we learned (e.g. via a DM before the NodeInfo
|
|
// exchange completed) rather than losing it with the purge.
|
|
if (n.public_key.size == 32)
|
|
warmStore.absorb(gone, n.last_heard, n.public_key.bytes, n.role, warmProtectedCategory(n),
|
|
nodeInfoLiteHasXeddsaSigned(&n));
|
|
#endif
|
|
|
|
eraseNodeSatellites(gone);
|
|
}
|
|
removed++;
|
|
}
|
|
}
|
|
numMeshNodes -= removed;
|
|
std::fill(nodeDatabase.nodes.begin() + numMeshNodes, nodeDatabase.nodes.begin() + numMeshNodes + removed,
|
|
meshtastic_NodeInfoLite());
|
|
LOG_DEBUG("cleanupMeshDB purged %d entries", removed);
|
|
}
|
|
|
|
void NodeDB::installDefaultDeviceState()
|
|
{
|
|
LOG_INFO("Install default DeviceState");
|
|
// memset(&devicestate, 0, sizeof(meshtastic_DeviceState));
|
|
|
|
// init our devicestate with valid flags so protobuf writing/reading will work
|
|
devicestate.has_my_node = true;
|
|
devicestate.has_owner = true;
|
|
devicestate.version = DEVICESTATE_CUR_VER;
|
|
devicestate.receive_queue_count = 0; // Not yet implemented FIXME
|
|
devicestate.has_rx_waypoint = false;
|
|
|
|
generatePacketId(); // FIXME - ugly way to init current_packet_id;
|
|
|
|
// Set default owner name
|
|
pickNewNodeNum(); // based on macaddr now
|
|
#ifdef USERPREFS_CONFIG_OWNER_LONG_NAME
|
|
snprintf(owner.long_name, sizeof(owner.long_name), (const char *)USERPREFS_CONFIG_OWNER_LONG_NAME);
|
|
#else
|
|
snprintf(owner.long_name, sizeof(owner.long_name), "Meshtastic %04x", getNodeNum() & 0x0ffff);
|
|
#endif
|
|
|
|
clampLongName(owner.long_name); // vendor userprefs may exceed the local cap
|
|
|
|
#ifdef USERPREFS_CONFIG_OWNER_SHORT_NAME
|
|
snprintf(owner.short_name, sizeof(owner.short_name), (const char *)USERPREFS_CONFIG_OWNER_SHORT_NAME);
|
|
#else
|
|
snprintf(owner.short_name, sizeof(owner.short_name), "%04x", getNodeNum() & 0x0ffff);
|
|
#endif
|
|
|
|
snprintf(owner.id, sizeof(owner.id), "!%08x", getNodeNum()); // Default node ID now based on nodenum
|
|
memcpy(owner.macaddr, ourMacAddr, sizeof(owner.macaddr));
|
|
owner.has_is_unmessagable = true;
|
|
owner.is_unmessagable = false;
|
|
}
|
|
|
|
// We reserve a few nodenums for future use
|
|
#define NUM_RESERVED 4
|
|
|
|
/**
|
|
* get our starting (provisional) nodenum from flash.
|
|
*/
|
|
void NodeDB::pickNewNodeNum()
|
|
{
|
|
NodeNum nodeNum = myNodeInfo.my_node_num;
|
|
getMacAddr(ourMacAddr); // Make sure ourMacAddr is set
|
|
if (nodeNum == 0) {
|
|
// Pick an initial nodenum based on the macaddr
|
|
nodeNum = (ourMacAddr[2] << 24) | (ourMacAddr[3] << 16) | (ourMacAddr[4] << 8) | ourMacAddr[5];
|
|
}
|
|
|
|
// Identity check via public key (or "empty slot?" when no keys yet);
|
|
// macaddr no longer lives on the slim header.
|
|
auto isOurOwnEntry = [&](const meshtastic_NodeInfoLite *n) -> bool {
|
|
if (!n)
|
|
return false;
|
|
if (owner.public_key.size == 32 && n->public_key.size == 32)
|
|
return memcmp(n->public_key.bytes, owner.public_key.bytes, 32) == 0;
|
|
return !nodeInfoLiteHasUser(n);
|
|
};
|
|
|
|
meshtastic_NodeInfoLite *found;
|
|
while (((found = getMeshNode(nodeNum)) && !isOurOwnEntry(found)) ||
|
|
(nodeNum == NODENUM_BROADCAST || nodeNum < NUM_RESERVED)) {
|
|
NodeNum candidate = random(NUM_RESERVED, LONG_MAX); // try a new random choice
|
|
if (found)
|
|
LOG_WARN("NOTE! Our desired nodenum 0x%08x is invalid or in use, picking 0x%08x", nodeNum, candidate);
|
|
nodeNum = candidate;
|
|
}
|
|
LOG_DEBUG("Use nodenum 0x%08x ", nodeNum);
|
|
|
|
myNodeInfo.my_node_num = nodeNum;
|
|
}
|
|
|
|
/** Load a protobuf from a file, return LoadFileResult */
|
|
LoadFileResult NodeDB::loadProto(const char *filename, size_t protoSize, size_t objSize, const pb_msgdesc_t *fields,
|
|
void *dest_struct)
|
|
{
|
|
LoadFileResult state = LoadFileResult::OTHER_FAILURE;
|
|
|
|
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
|
// check if the file is encrypted and decrypt before protobuf decode
|
|
if (EncryptedStorage::isEncrypted(filename)) {
|
|
// ZeroizingArrayPtr wipes the decrypted plaintext (which contains config
|
|
// secrets - channel PSKs, security private_key, etc.) before delete[],
|
|
// so it isn't recoverable from the heap after this function returns.
|
|
auto decBuf = meshtastic_security::make_zeroizing_array(protoSize);
|
|
if (!decBuf) {
|
|
LOG_ERROR("OOM decrypting %s", filename);
|
|
return LoadFileResult::OTHER_FAILURE;
|
|
}
|
|
size_t decLen = 0;
|
|
if (EncryptedStorage::readAndDecrypt(filename, decBuf.get(), protoSize, decLen)) {
|
|
LOG_INFO("Load encrypted %s", filename);
|
|
pb_istream_t stream = pb_istream_from_buffer(decBuf.get(), decLen);
|
|
if (fields != &meshtastic_NodeDatabase_msg)
|
|
memset(dest_struct, 0, objSize);
|
|
if (!pb_decode(&stream, fields, dest_struct)) {
|
|
LOG_ERROR("Error: can't decode protobuf %s", PB_GET_ERROR(&stream));
|
|
state = LoadFileResult::DECODE_FAILED;
|
|
storageCorruptThisLoad = true;
|
|
} else {
|
|
LOG_INFO("Loaded encrypted %s successfully", filename);
|
|
state = LoadFileResult::LOAD_SUCCESS;
|
|
}
|
|
} else {
|
|
LOG_ERROR("Decrypt failed for %s, treating as corrupt", filename);
|
|
state = LoadFileResult::DECODE_FAILED;
|
|
storageCorruptThisLoad = true;
|
|
}
|
|
return state;
|
|
}
|
|
#endif
|
|
|
|
#ifdef FSCom
|
|
concurrency::LockGuard g(spiLock);
|
|
|
|
auto f = FSCom.open(filename, FILE_O_READ);
|
|
|
|
if (f) {
|
|
LOG_INFO("Load %s", filename);
|
|
pb_istream_t stream = {&readcb, &f, protoSize};
|
|
if (fields != &meshtastic_NodeDatabase_msg &&
|
|
fields != &meshtastic_NodeDatabase_Legacy_msg) // both NodeDatabase descriptors contain std::vector members
|
|
memset(dest_struct, 0, objSize);
|
|
if (!pb_decode(&stream, fields, dest_struct)) {
|
|
LOG_ERROR("Error: can't decode protobuf %s", PB_GET_ERROR(&stream));
|
|
state = LoadFileResult::DECODE_FAILED;
|
|
} else {
|
|
LOG_INFO("Loaded %s successfully", filename);
|
|
state = LoadFileResult::LOAD_SUCCESS;
|
|
}
|
|
f.close();
|
|
} else {
|
|
LOG_ERROR("Could not open / read %s", filename);
|
|
}
|
|
#else
|
|
LOG_ERROR("ERROR: Filesystem not implemented");
|
|
state = LoadFileResult::NO_FILESYSTEM;
|
|
#endif
|
|
return state;
|
|
}
|
|
|
|
#if WARM_NODE_COUNT > 0
|
|
void NodeDB::demoteOldestHotNodesToWarm()
|
|
{
|
|
const int keep = MAX_NUM_NODES;
|
|
if (numMeshNodes <= keep)
|
|
return;
|
|
|
|
// Protected nodes (favorite/ignored/verified) outrank recency and are demoted
|
|
// only when the store is full of them; within a class, most-recently-heard
|
|
// wins. Index 0 is self and stays put (sort from +1), as in runtime eviction.
|
|
std::sort(meshNodes->begin() + 1, meshNodes->begin() + numMeshNodes,
|
|
[](const meshtastic_NodeInfoLite &a, const meshtastic_NodeInfoLite &b) {
|
|
const bool ka = nodeInfoLiteIsProtected(&a);
|
|
const bool kb = nodeInfoLiteIsProtected(&b);
|
|
if (ka != kb)
|
|
return ka;
|
|
return a.last_heard > b.last_heard;
|
|
});
|
|
|
|
int demoted = 0;
|
|
for (int i = keep; i < numMeshNodes; i++) {
|
|
const meshtastic_NodeInfoLite &n = (*meshNodes)[i];
|
|
if (n.num == 0)
|
|
continue;
|
|
// Keep the public key if we have one (40 B warm record); keyless nodes
|
|
// still get a placeholder so re-admission restores last_heard.
|
|
warmStore.absorb(n.num, n.last_heard, n.public_key.size > 0 ? n.public_key.bytes : nullptr, n.role,
|
|
warmProtectedCategory(n), nodeInfoLiteHasXeddsaSigned(&n));
|
|
// Demotion drops the node from the header table, so drop its satellites
|
|
// too (the eviction chokepoint) - they'd otherwise orphan until the next
|
|
// enforceSatelliteCaps pass.
|
|
eraseNodeSatellites(n.num);
|
|
demoted++;
|
|
}
|
|
numMeshNodes = keep; // the resize() in loadFromDisk reclaims the demoted tail
|
|
LOG_MIGRATION("NodeDB migration: demoted %d node(s) over %d into the warm tier (keepers preferred)", demoted, keep);
|
|
}
|
|
#endif
|
|
|
|
void NodeDB::nodeDBSelfCare()
|
|
{
|
|
if (!meshNodes)
|
|
return;
|
|
|
|
const NodeNum self = getNodeNum();
|
|
const bool nodesOverCap = numMeshNodes > MAX_NUM_NODES;
|
|
|
|
// Confirm self is present and its key matches what we just (re)derived. A
|
|
// non-empty DB that doesn't contain us means a foreign/over-cap or corrupt
|
|
// nodes.proto was loaded; an empty DB is just a fresh device (no warning).
|
|
meshtastic_NodeInfoLite *selfNode = getMeshNode(self);
|
|
if (!selfNode && numMeshNodes > 0) {
|
|
LOG_WARN("NodeDB self-care: self 0x%08x absent from DB, re-adding", (unsigned)self);
|
|
} else if (selfNode && owner.public_key.size == 32 && selfNode->public_key.size == 32 &&
|
|
memcmp(selfNode->public_key.bytes, owner.public_key.bytes, 32) != 0) {
|
|
LOG_WARN("NodeDB self-care: self 0x%08x key mismatch, refreshing", (unsigned)self);
|
|
}
|
|
|
|
// Maintenance that must never touch self. Pin self to index 0 first so
|
|
// the positional demote/eviction scans (which skip index 0) provably exclude
|
|
// us, wherever the loaded file happened to place our row.
|
|
if (selfNode && numMeshNodes > 0 && selfNode != &meshNodes->at(0)) {
|
|
std::swap(meshNodes->at(0), *selfNode);
|
|
}
|
|
|
|
#if WARM_NODE_COUNT > 0
|
|
if (nodesOverCap)
|
|
demoteOldestHotNodesToWarm(); // demotes oldest NON-self overflow; index 0 (us) left in place
|
|
#endif
|
|
|
|
if (numMeshNodes > MAX_NUM_NODES) {
|
|
LOG_WARN("NodeDB self-care: %d over cap %d, truncating", numMeshNodes, MAX_NUM_NODES);
|
|
numMeshNodes = MAX_NUM_NODES;
|
|
}
|
|
// Normalise the backing store to the hot cap so getOrCreateMeshNode always
|
|
// has spare slots to append into (it indexes meshNodes->at(numMeshNodes++)).
|
|
meshNodes->resize(MAX_NUM_NODES);
|
|
memaudit::set("nodedb", MAX_NUM_NODES * sizeof(meshtastic_NodeInfoLite));
|
|
|
|
const bool satsTrimmed = enforceSatelliteCaps();
|
|
|
|
// Ensure self exists, sits at index 0, and carries current owner info - after
|
|
// any demotion has freed a slot. Covers the foreign/fixture case where the
|
|
// loaded file did not contain us at all.
|
|
meshtastic_NodeInfoLite *info = getOrCreateMeshNode(self);
|
|
if (info) {
|
|
TypeConversions::CopyUserToNodeInfoLite(info, owner);
|
|
if (info != &meshNodes->at(0))
|
|
std::swap(meshNodes->at(0), *info);
|
|
}
|
|
|
|
// One-shot rewrite: only when we healed something, and never while storage
|
|
// is locked - a locked boot loads placeholder defaults that must not be written
|
|
// over the encrypted store; reloadFromDisk() re-runs self-care once unlocked.
|
|
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
|
const bool storageLocked = EncryptedStorage::isLockdownActive() && !EncryptedStorage::isUnlocked();
|
|
#else
|
|
const bool storageLocked = false;
|
|
#endif
|
|
|
|
if ((nodesOverCap || satsTrimmed) && !storageLocked) {
|
|
LOG_MIGRATION("NodeDB self-care: healed store (nodes-over-cap:%s sats-trimmed:%s); rewriting nodes.proto once",
|
|
nodesOverCap ? "yes" : "no", satsTrimmed ? "yes" : "no");
|
|
saveNodeDatabaseToDisk();
|
|
#if WARM_NODE_COUNT > 0
|
|
warmStore.saveIfDirty();
|
|
#endif
|
|
}
|
|
}
|
|
|
|
void NodeDB::loadFromDisk()
|
|
{
|
|
// Mark the current device state as completely unusable, so that if we fail reading the entire file from
|
|
// disk we will still factoryReset to restore things.
|
|
devicestate.version = 0;
|
|
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
|
// Reset the per-load decrypt-failure tracker. Set by loadProto on any
|
|
// encrypted file that fails to decrypt or proto-decode; consumed by
|
|
// reloadFromDisk to surface storage corruption to the operator instead
|
|
// of silently falling back to defaults.
|
|
storageCorruptThisLoad = false;
|
|
#endif
|
|
|
|
migrationSavePending = false;
|
|
configDecodeFailed = false;
|
|
|
|
meshtastic_Config_SecurityConfig backupSecurity = meshtastic_Config_SecurityConfig_init_zero;
|
|
|
|
#ifdef ARCH_ESP32
|
|
spiLock->lock();
|
|
// If the legacy deviceState exists, start over with a factory reset
|
|
if (FSCom.exists("/static/static"))
|
|
rmDir("/static/static"); // Remove bad static web files bundle from initial 2.5.13 release
|
|
spiLock->unlock();
|
|
#endif
|
|
|
|
#ifdef FSCom
|
|
#if defined(FACTORY_INSTALL) && !defined(ARCH_PORTDUINO)
|
|
spiLock->lock();
|
|
if (!FSCom.exists("/prefs/" xstr(BUILD_EPOCH))) {
|
|
LOG_WARN("Factory Install Reset!");
|
|
rmDir("/prefs");
|
|
FSCom.mkdir("/prefs");
|
|
File f2 = FSCom.open("/prefs/" xstr(BUILD_EPOCH), FILE_O_WRITE);
|
|
if (f2) {
|
|
f2.flush();
|
|
f2.close();
|
|
}
|
|
}
|
|
spiLock->unlock();
|
|
#endif // FACTORY_INSTALL, not PORTDUINO
|
|
spiLock->lock();
|
|
if (FSCom.exists(legacyPrefFileName)) {
|
|
spiLock->unlock();
|
|
LOG_WARN("Legacy prefs version found, factory resetting");
|
|
if (loadProto(configFileName, meshtastic_LocalConfig_size, sizeof(meshtastic_LocalConfig), &meshtastic_LocalConfig_msg,
|
|
&config) == LoadFileResult::LOAD_SUCCESS &&
|
|
config.has_security && config.security.private_key.size > 0) {
|
|
LOG_DEBUG("Saving backup of security config and keys");
|
|
backupSecurity = config.security;
|
|
}
|
|
spiLock->lock();
|
|
rmDir("/prefs");
|
|
spiLock->unlock();
|
|
} else {
|
|
spiLock->unlock();
|
|
}
|
|
|
|
#endif // FSCom
|
|
|
|
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
|
// Only take the locked-boot defaults path when lockdown is ACTIVE (the
|
|
// device is provisioned) AND storage is still locked. A lockdown-capable
|
|
// build that has never been provisioned - or that was disabled - falls
|
|
// through to the normal plaintext load below and behaves like stock.
|
|
if (EncryptedStorage::isLockdownActive() && !EncryptedStorage::isUnlocked()) {
|
|
// Encrypted storage is locked. Install defaults and wait for the
|
|
// passphrase over BLE/serial; PhoneAPI::handleLockdownAuthInline
|
|
// calls reloadFromDisk() once the storage is unlocked.
|
|
LOG_WARN("NodeDB: Encrypted storage locked, using default config until unlocked");
|
|
installDefaultNodeDatabase();
|
|
installDefaultDeviceState();
|
|
installDefaultConfig();
|
|
installDefaultModuleConfig();
|
|
installDefaultChannels();
|
|
|
|
// Hold the radio silent until the operator unlocks. installDefaultConfig
|
|
// would otherwise honour USERPREFS_CONFIG_LORA_REGION (the common shape
|
|
// for managed deployments) and the LongFast default channel synthesised
|
|
// by installDefaultChannels, so the device would beacon nodeinfo /
|
|
// telemetry on the public default PSK before any unlock - and process
|
|
// incoming default-channel packets the same way. Forcing region=UNSET
|
|
// gates both TX and RX in RadioLibInterface (see the region==UNSET
|
|
// checks in startSend and readData); tx_enabled=false is belt-and-
|
|
// suspenders for any code path that does not consult region directly.
|
|
// reloadFromDisk() restores the persisted lora config when the
|
|
// operator unlocks.
|
|
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
|
|
config.lora.tx_enabled = false;
|
|
return;
|
|
}
|
|
#endif
|
|
|
|
// Arm the direct-into-map decode so satellite entries skip the temp vectors.
|
|
{
|
|
concurrency::LockGuard guard(&satelliteMutex);
|
|
armNodeDatabaseDecodeTargets();
|
|
}
|
|
struct Disarm {
|
|
NodeDB &self;
|
|
~Disarm() { self.disarmNodeDatabaseDecodeTargets(); }
|
|
} disarm{*this};
|
|
|
|
// Avoid push_back's power-of-2 capacity growth wasting RAM at small N.
|
|
nodeDatabase.nodes.reserve(MAX_NUM_NODES);
|
|
|
|
auto state = loadProto(nodeDatabaseFileName, getMaxNodesAllocatedSize(), sizeof(meshtastic_NodeDatabase),
|
|
&meshtastic_NodeDatabase_msg, &nodeDatabase);
|
|
if (nodeDatabase.version < DEVICESTATE_MIN_VER) {
|
|
LOG_WARN("NodeDatabase %d is old, discard", nodeDatabase.version);
|
|
installDefaultNodeDatabase();
|
|
} else if (nodeDatabase.version < DEVICESTATE_CUR_VER) {
|
|
if (migrateLegacyNodeDatabase())
|
|
migrationSavePending = true;
|
|
else
|
|
installDefaultNodeDatabase();
|
|
} else {
|
|
meshNodes = &nodeDatabase.nodes;
|
|
numMeshNodes = nodeDatabase.nodes.size();
|
|
// Counts computed outside LOG_INFO() so cppcheck doesn't choke on #if in macro args.
|
|
const unsigned posCount =
|
|
#if !MESHTASTIC_EXCLUDE_POSITIONDB
|
|
(unsigned)nodePositions.size();
|
|
#else
|
|
0u;
|
|
#endif
|
|
|
|
const unsigned telCount =
|
|
#if !MESHTASTIC_EXCLUDE_TELEMETRYDB
|
|
(unsigned)nodeTelemetry.size();
|
|
#else
|
|
0u;
|
|
#endif
|
|
|
|
const unsigned envCount =
|
|
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB
|
|
(unsigned)nodeEnvironment.size();
|
|
#else
|
|
0u;
|
|
#endif
|
|
|
|
const unsigned statusCount =
|
|
#if !MESHTASTIC_EXCLUDE_STATUSDB
|
|
(unsigned)nodeStatus.size();
|
|
#else
|
|
0u;
|
|
#endif
|
|
|
|
LOG_INFO("Loaded saved nodedatabase v%d: %d nodes, %u pos, %u tel, %u env, %u status", nodeDatabase.version,
|
|
nodeDatabase.nodes.size(), posCount, telCount, envCount, statusCount);
|
|
}
|
|
|
|
// Left UNTRIMMED on purpose: trim/demote/satellite-cap/self-pin/rewrite all
|
|
// run in nodeDBSelfCare() once getNodeNum() is valid (still 0 here on a cold
|
|
// boot, so we could only assume index 0 == self - the very bug being fixed).
|
|
#if WARM_NODE_COUNT > 0
|
|
// Load the warm tier so its on-disk snapshot is available before the node DB
|
|
// is exercised (and before nodeDBSelfCare() demotes any overflow into it).
|
|
warmStore.load();
|
|
#endif
|
|
|
|
// static DeviceState scratch; We no longer read into a tempbuf because this structure is 15KB of valuable RAM
|
|
state = loadProto(deviceStateFileName, meshtastic_DeviceState_size, sizeof(meshtastic_DeviceState),
|
|
&meshtastic_DeviceState_msg, &devicestate);
|
|
|
|
// See https://github.com/meshtastic/firmware/issues/4184#issuecomment-2269390786
|
|
// It is very important to try and use the saved prefs even if we fail to read meshtastic_DeviceState. Because most of our
|
|
// critical config may still be valid (in the other files - loaded next).
|
|
// Also, if we did fail on reading we probably failed on the enormous (and non critical) nodeDB. So DO NOT install default
|
|
// device state.
|
|
// if (state != LoadFileResult::LOAD_SUCCESS) {
|
|
// installDefaultDeviceState(); // Our in RAM copy might now be corrupt
|
|
//} else {
|
|
if ((state != LoadFileResult::LOAD_SUCCESS) || (devicestate.version < DEVICESTATE_MIN_VER)) {
|
|
LOG_WARN("Devicestate %d is old or invalid, discard", devicestate.version);
|
|
installDefaultDeviceState();
|
|
|
|
// Attempt recovery of owner fields from our own NodeDB entry if available.
|
|
meshtastic_NodeInfoLite *us = getMeshNode(getNodeNum());
|
|
if (nodeInfoLiteHasUser(us)) {
|
|
LOG_WARN("Restoring owner fields (long_name/short_name/is_licensed/is_unmessagable) from NodeDB for our node 0x%08x",
|
|
us->num);
|
|
// owner.long_name (40) is wider than the lite source (25); bound by the source
|
|
memcpy(owner.long_name, us->long_name, sizeof(us->long_name));
|
|
owner.long_name[sizeof(us->long_name) - 1] = '\0';
|
|
memcpy(owner.short_name, us->short_name, sizeof(owner.short_name));
|
|
owner.short_name[sizeof(owner.short_name) - 1] = '\0';
|
|
owner.is_licensed = nodeInfoLiteIsLicensed(us);
|
|
owner.has_is_unmessagable = nodeInfoLiteHasIsUnmessagable(us);
|
|
owner.is_unmessagable = nodeInfoLiteIsUnmessagable(us);
|
|
|
|
// Save the recovered owner to device state on disk
|
|
saveToDisk(SEGMENT_DEVICESTATE);
|
|
}
|
|
} else {
|
|
LOG_INFO("Loaded saved devicestate version %d", devicestate.version);
|
|
}
|
|
|
|
// Devicestate saved by firmware that allowed 39-byte names gets clamped on
|
|
// first load; from here on owner never carries more than the local cap.
|
|
clampLongName(owner.long_name);
|
|
|
|
state = loadProto(configFileName, meshtastic_LocalConfig_size, sizeof(meshtastic_LocalConfig), &meshtastic_LocalConfig_msg,
|
|
&config);
|
|
if (state == LoadFileResult::DECODE_FAILED) {
|
|
// Config file present but undecodable this boot (corruption / torn write / transient decrypt fail).
|
|
// loadProto() already zeroed `config`, so the keypair is gone from RAM; minting a new one would change
|
|
// our NodeNum (== crc32(public_key)) and orphan us on the mesh. configDecodeFailed freezes identity and
|
|
// skips persisting (see ctor), so a transient failure self-heals on the next clean boot. A genuinely
|
|
// absent config returns OTHER_FAILURE, so this never fires on first boot. Boot degraded + radio-silent.
|
|
LOG_ERROR("Config decode failed - freezing identity, booting degraded (radio silent until restored)");
|
|
configDecodeFailed = true;
|
|
installDefaultConfig(true);
|
|
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
|
|
config.lora.tx_enabled = false;
|
|
} else if (state != LoadFileResult::LOAD_SUCCESS) {
|
|
// No decodable config to work with: the file is absent (first boot) or could not be opened (OTHER_FAILURE
|
|
// / NO_FILESYSTEM). Unlike DECODE_FAILED there are no usable contents to protect, so install defaults.
|
|
installDefaultConfig();
|
|
} else if (config.version < DEVICESTATE_MIN_VER) {
|
|
LOG_WARN("config %d is old, discard", config.version);
|
|
installDefaultConfig(true);
|
|
} else {
|
|
LOG_INFO("Loaded saved config version %d", config.version);
|
|
}
|
|
|
|
// Coerce LoRa config fields derived from presets while bootstrapping.
|
|
// Some clients/UI components display bandwidth/spread_factor directly from config even in preset mode.
|
|
if (config.has_lora && config.lora.use_preset) {
|
|
RadioInterface::clampConfigLora(config.lora);
|
|
}
|
|
|
|
#if defined(USERPREFS_LORA_TX_DISABLED) && USERPREFS_LORA_TX_DISABLED
|
|
config.lora.tx_enabled = false;
|
|
#endif
|
|
|
|
// Always-apply LoRa overrides: applied after loading saved config so they
|
|
// take effect even when NVS already has a valid config (e.g. region-locked
|
|
// dev boards with no BLE/serial to set the region at runtime).
|
|
#ifdef USERPREFS_CONFIG_LORA_REGION
|
|
// Skip on a degraded boot to keep the radio silent (identity is already protected by the keygen gate).
|
|
if (!configDecodeFailed)
|
|
config.lora.region = USERPREFS_CONFIG_LORA_REGION;
|
|
#endif
|
|
|
|
#ifdef USERPREFS_LORACONFIG_USE_PRESET
|
|
config.lora.use_preset = USERPREFS_LORACONFIG_USE_PRESET;
|
|
#endif
|
|
|
|
#ifdef USERPREFS_LORACONFIG_BANDWIDTH
|
|
config.lora.bandwidth = USERPREFS_LORACONFIG_BANDWIDTH;
|
|
#endif
|
|
|
|
#ifdef USERPREFS_LORACONFIG_SPREAD_FACTOR
|
|
config.lora.spread_factor = USERPREFS_LORACONFIG_SPREAD_FACTOR;
|
|
#endif
|
|
|
|
#ifdef USERPREFS_LORACONFIG_CODING_RATE
|
|
config.lora.coding_rate = USERPREFS_LORACONFIG_CODING_RATE;
|
|
#endif
|
|
|
|
#ifdef USERPREFS_LORACONFIG_OVERRIDE_FREQUENCY
|
|
config.lora.override_frequency = USERPREFS_LORACONFIG_OVERRIDE_FREQUENCY;
|
|
#endif
|
|
|
|
if (backupSecurity.private_key.size > 0) {
|
|
LOG_DEBUG("Restoring backup of security config");
|
|
config.security = backupSecurity;
|
|
saveToDisk(SEGMENT_CONFIG);
|
|
}
|
|
|
|
// Make sure we load hard coded admin keys even when the configuration file has none.
|
|
// Initialize admin_key_count to zero
|
|
byte numAdminKeys = 0;
|
|
#if defined(USERPREFS_USE_ADMIN_KEY_0) || defined(USERPREFS_USE_ADMIN_KEY_1) || defined(USERPREFS_USE_ADMIN_KEY_2)
|
|
uint16_t sum = 0;
|
|
#endif
|
|
|
|
#ifdef USERPREFS_USE_ADMIN_KEY_0
|
|
|
|
for (uint8_t b = 0; b < 32; b++) {
|
|
sum += config.security.admin_key[0].bytes[b];
|
|
}
|
|
if (sum == 0) {
|
|
numAdminKeys += 1;
|
|
LOG_INFO("Admin 0 key zero. Loading hard coded key from user preferences.");
|
|
memcpy(config.security.admin_key[0].bytes, userprefs_admin_key_0, 32);
|
|
config.security.admin_key[0].size = 32;
|
|
}
|
|
#endif
|
|
|
|
#ifdef USERPREFS_USE_ADMIN_KEY_1
|
|
sum = 0;
|
|
for (uint8_t b = 0; b < 32; b++) {
|
|
sum += config.security.admin_key[1].bytes[b];
|
|
}
|
|
if (sum == 0) {
|
|
numAdminKeys += 1;
|
|
LOG_INFO("Admin 1 key zero. Loading hard coded key from user preferences.");
|
|
memcpy(config.security.admin_key[1].bytes, userprefs_admin_key_1, 32);
|
|
config.security.admin_key[1].size = 32;
|
|
}
|
|
#endif
|
|
|
|
#ifdef USERPREFS_USE_ADMIN_KEY_2
|
|
sum = 0;
|
|
for (uint8_t b = 0; b < 32; b++) {
|
|
sum += config.security.admin_key[2].bytes[b];
|
|
}
|
|
if (sum == 0) {
|
|
numAdminKeys += 1;
|
|
LOG_INFO("Admin 2 key zero. Loading hard coded key from user preferences.");
|
|
memcpy(config.security.admin_key[2].bytes, userprefs_admin_key_2, 32);
|
|
config.security.admin_key[2].size = 32;
|
|
}
|
|
#endif
|
|
|
|
if (numAdminKeys > 0) {
|
|
LOG_INFO("Saving %d hard coded admin keys.", numAdminKeys);
|
|
config.security.admin_key_count = numAdminKeys;
|
|
saveToDisk(SEGMENT_CONFIG);
|
|
}
|
|
|
|
state = loadProto(moduleConfigFileName, meshtastic_LocalModuleConfig_size, sizeof(meshtastic_LocalModuleConfig),
|
|
&meshtastic_LocalModuleConfig_msg, &moduleConfig);
|
|
if (state != LoadFileResult::LOAD_SUCCESS) {
|
|
installDefaultModuleConfig(); // Our in RAM copy might now be corrupt
|
|
} else {
|
|
if (moduleConfig.version < DEVICESTATE_MIN_VER) {
|
|
LOG_WARN("moduleConfig %d is old, discard", moduleConfig.version);
|
|
installDefaultModuleConfig();
|
|
} else {
|
|
LOG_INFO("Loaded saved moduleConfig version %d", moduleConfig.version);
|
|
}
|
|
}
|
|
|
|
// Always-on traffic management: a device that has NEVER configured TMM
|
|
// (has_traffic_management false - AdminModule always sets the has_ flag on
|
|
// write, even when disabling) gets the fork defaults. Explicitly configured
|
|
// devices keep their exact settings.
|
|
if (!moduleConfig.has_traffic_management) {
|
|
LOG_INFO("Traffic management never configured, installing always-on defaults");
|
|
installTrafficManagementDefaults(moduleConfig);
|
|
saveToDisk(SEGMENT_MODULECONFIG);
|
|
}
|
|
|
|
state = loadProto(channelFileName, meshtastic_ChannelFile_size, sizeof(meshtastic_ChannelFile), &meshtastic_ChannelFile_msg,
|
|
&channelFile);
|
|
if (state != LoadFileResult::LOAD_SUCCESS) {
|
|
installDefaultChannels(); // Our in RAM copy might now be corrupt
|
|
} else {
|
|
if (channelFile.version < DEVICESTATE_MIN_VER) {
|
|
LOG_WARN("channelFile %d is old, discard", channelFile.version);
|
|
installDefaultChannels();
|
|
} else {
|
|
LOG_INFO("Loaded saved channelFile version %d", channelFile.version);
|
|
}
|
|
}
|
|
|
|
state = loadProto(uiconfigFileName, meshtastic_DeviceUIConfig_size, sizeof(meshtastic_DeviceUIConfig),
|
|
&meshtastic_DeviceUIConfig_msg, &uiconfig);
|
|
if (state == LoadFileResult::LOAD_SUCCESS) {
|
|
LOG_INFO("Loaded UIConfig");
|
|
}
|
|
|
|
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
|
// Ensure all config segments are persisted to encrypted storage.
|
|
// installDefaultConfig/installDefaultModuleConfig only set in-memory structs
|
|
// without saving to disk, so we force a save here to ensure encrypted files exist.
|
|
//
|
|
// Only when lockdown is ACTIVE. A capable-but-off device must leave its
|
|
// files as plaintext - encryptAndWrite would fail anyway (no DEK), but
|
|
// skipping the whole block avoids the wasted attempts and error logs.
|
|
if (EncryptedStorage::isLockdownActive()) {
|
|
const char *filesToCheck[] = {configFileName, moduleConfigFileName, channelFileName, deviceStateFileName,
|
|
nodeDatabaseFileName};
|
|
const int segments[] = {SEGMENT_CONFIG, SEGMENT_MODULECONFIG, SEGMENT_CHANNELS, SEGMENT_DEVICESTATE,
|
|
SEGMENT_NODEDATABASE};
|
|
int toSave = 0;
|
|
for (int i = 0; i < 5; i++) {
|
|
if (!EncryptedStorage::isEncrypted(filesToCheck[i])) {
|
|
toSave |= segments[i];
|
|
}
|
|
}
|
|
if (toSave) {
|
|
LOG_INFO("Lockdown: Saving unencrypted segments to encrypted storage (mask=0x%x)", toSave);
|
|
saveToDisk(toSave);
|
|
}
|
|
|
|
// Migrate any remaining plaintext proto files (from standard firmware upgrade)
|
|
for (const char *fn : filesToCheck) {
|
|
if (!EncryptedStorage::isEncrypted(fn)) {
|
|
LOG_INFO("Migrating %s to encrypted storage", fn);
|
|
EncryptedStorage::migrateFile(fn);
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
|
|
// 2.4.X - configuration migration to update new default intervals
|
|
if (moduleConfig.version < 23) {
|
|
LOG_DEBUG("ModuleConfig version %d is stale, upgrading to new default intervals", moduleConfig.version);
|
|
moduleConfig.version = DEVICESTATE_CUR_VER;
|
|
if (moduleConfig.telemetry.device_update_interval == 900)
|
|
moduleConfig.telemetry.device_update_interval = 0;
|
|
if (moduleConfig.telemetry.environment_update_interval == 900)
|
|
moduleConfig.telemetry.environment_update_interval = 0;
|
|
if (moduleConfig.telemetry.air_quality_interval == 900)
|
|
moduleConfig.telemetry.air_quality_interval = 0;
|
|
if (moduleConfig.telemetry.power_update_interval == 900)
|
|
moduleConfig.telemetry.power_update_interval = 0;
|
|
if (moduleConfig.neighbor_info.update_interval == 900)
|
|
moduleConfig.neighbor_info.update_interval = 0;
|
|
if (moduleConfig.paxcounter.paxcounter_update_interval == 900)
|
|
moduleConfig.paxcounter.paxcounter_update_interval = 0;
|
|
|
|
saveToDisk(SEGMENT_MODULECONFIG);
|
|
}
|
|
|
|
// 2.8 - privacy: one-time flip of position sharing and device telemetry to OPT-IN for nodes upgrading
|
|
// from a build that shipped them on-by-default. Gated on a dedicated watermark (POSITION_TELEMETRY_OPTIN_VER)
|
|
// so it runs exactly once and does NOT re-clobber a user who later re-enables sharing (ordinary saves never
|
|
// re-stamp .version, so a re-enabled node stays at the watermark and skips this block on the next boot).
|
|
// Position is disabled only on public/default-PSK channels; private-PSK channels are preserved.
|
|
if (channelFile.version < POSITION_TELEMETRY_OPTIN_VER) {
|
|
LOG_INFO("Opt-in migration: disabling position broadcast on public channels");
|
|
optInDisablePositionSharing(channelFile);
|
|
channelFile.version = POSITION_TELEMETRY_OPTIN_VER;
|
|
saveToDisk(SEGMENT_CHANNELS);
|
|
}
|
|
if (moduleConfig.version < POSITION_TELEMETRY_OPTIN_VER) {
|
|
LOG_INFO("Opt-in migration: forcing device telemetry broadcast to opt-in");
|
|
optInDisableTelemetryBroadcast(moduleConfig);
|
|
moduleConfig.version = POSITION_TELEMETRY_OPTIN_VER;
|
|
saveToDisk(SEGMENT_MODULECONFIG);
|
|
}
|
|
#if ARCH_PORTDUINO
|
|
// set any config overrides
|
|
if (portduino_config.has_configDisplayMode) {
|
|
config.display.displaymode = (_meshtastic_Config_DisplayConfig_DisplayMode)portduino_config.configDisplayMode;
|
|
}
|
|
if (portduino_config.has_statusMessage) {
|
|
moduleConfig.has_statusmessage = true;
|
|
strncpy(moduleConfig.statusmessage.node_status, portduino_config.statusMessage.c_str(),
|
|
sizeof(moduleConfig.statusmessage.node_status));
|
|
moduleConfig.statusmessage.node_status[sizeof(moduleConfig.statusmessage.node_status) - 1] = '\0';
|
|
}
|
|
if (portduino_config.enable_UDP) {
|
|
config.network.enabled_protocols = meshtastic_Config_NetworkConfig_ProtocolFlags_UDP_BROADCAST;
|
|
}
|
|
|
|
#endif
|
|
}
|
|
|
|
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
|
// Serializes reloadFromDisk against itself. Other readers of config /
|
|
// channelFile / nodeDatabase don't take this lock today, so this only
|
|
// prevents reload-vs-reload races (e.g. fast successive unlocks). It is
|
|
// not a full data-race fix for those structs - that would require
|
|
// thread-shared locking discipline across the whole codebase, beyond
|
|
// the audit's M7 scope. The radio standby+reconfigure below keeps the
|
|
// radio out of the window where SX12xx registers are mid-swap.
|
|
static concurrency::Lock g_reloadFromDiskMutex;
|
|
|
|
/**
|
|
* Re-run loadFromDisk() after encrypted storage is unlocked at runtime.
|
|
* Holds the radio in standby across the file IO + proto decode so the
|
|
* SX12xx is not mid-RX/TX when config.lora is overwritten, then calls
|
|
* reconfigure() to push the now-real settings to the chip.
|
|
*
|
|
* Returns true iff every encrypted file decrypted and decoded cleanly.
|
|
* On false the caller MUST treat storage as corrupt - see header.
|
|
*/
|
|
bool NodeDB::reloadFromDisk()
|
|
{
|
|
concurrency::LockGuard guard(&g_reloadFromDiskMutex);
|
|
LOG_INFO("NodeDB: Reloading config from encrypted storage after unlock");
|
|
|
|
RadioInterface *rIface = router ? router->getRadioIface() : nullptr;
|
|
|
|
// Park the radio while config.lora / channelFile swap. Without this,
|
|
// a concurrent send or receive can read half-old / half-new state
|
|
// (channel keys, region, modem preset) and the SX12xx ends up in
|
|
// an inconsistent register set that only a reboot recovers from.
|
|
if (rIface)
|
|
rIface->sleep();
|
|
|
|
loadFromDisk();
|
|
|
|
if (storageCorruptThisLoad) {
|
|
LOG_ERROR("NodeDB: storage decrypt/decode failed during reload - surfacing as corrupt");
|
|
// Leave the radio sleeping. Caller will lock storage and emit
|
|
// a LOCKED(storage_corrupt) status; we must not reconfigure
|
|
// the chip with the locked-default placeholder values still
|
|
// sitting in config.lora.
|
|
return false;
|
|
}
|
|
|
|
// loadFromDisk() leaves the store untrimmed; run self-care now (getNodeNum()
|
|
// is valid at runtime) to trim/demote non-self overflow, pin self to index 0
|
|
// and normalise the backing store before the node DB is exercised again.
|
|
nodeDBSelfCare();
|
|
|
|
// Preserve constructor ordering: persist any migration only after self-care.
|
|
if (migrationSavePending) {
|
|
saveNodeDatabaseToDisk();
|
|
migrationSavePending = false;
|
|
}
|
|
|
|
// Push the now-real config to the radio.
|
|
if (rIface) {
|
|
channels.onConfigChanged();
|
|
rIface->reconfigure();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool NodeDB::disableLockdownToPlaintext()
|
|
{
|
|
concurrency::LockGuard guard(&g_reloadFromDiskMutex);
|
|
if (!EncryptedStorage::isUnlocked()) {
|
|
LOG_ERROR("NodeDB: disable requested but storage not unlocked");
|
|
return false;
|
|
}
|
|
LOG_INFO("NodeDB: reverting encrypted prefs to plaintext for lockdown disable");
|
|
|
|
// Decrypt each encrypted pref back to plaintext IN PLACE. Mirror of the
|
|
// plaintext->encrypted migrate loop above. Order does not matter here;
|
|
// EncryptedStorage::removeLockdownArtifacts() (which deletes the DEK,
|
|
// the commit point) only runs after every file is confirmed plaintext.
|
|
const char *filesToCheck[] = {configFileName, moduleConfigFileName, channelFileName, deviceStateFileName,
|
|
nodeDatabaseFileName};
|
|
for (const char *fn : filesToCheck) {
|
|
if (!EncryptedStorage::migrateFileToPlaintext(fn)) {
|
|
LOG_ERROR("NodeDB: failed to revert %s to plaintext; aborting disable (device stays in lockdown)", fn);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// All files are plaintext now - remove the lockdown artifacts. Deleting
|
|
// /prefs/.dek is the atomic commit: after it, isLockdownActive() is false.
|
|
EncryptedStorage::removeLockdownArtifacts();
|
|
return true;
|
|
}
|
|
#endif
|
|
|
|
/** Save a protobuf from a file, return true for success */
|
|
bool NodeDB::saveProto(const char *filename, size_t protoSize, const pb_msgdesc_t *fields, const void *dest_struct,
|
|
bool fullAtomic)
|
|
{
|
|
|
|
// do not try to save anything if power level is not safe. In many cases flash will be lock-protected
|
|
// and all writes will fail anyway. Device should be sleeping at this point anyway.
|
|
if (!powerHAL_isPowerLevelSafe()) {
|
|
LOG_ERROR("Error: trying to saveProto() on unsafe device power level.");
|
|
return false;
|
|
}
|
|
|
|
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
|
// Encrypt all files except uiconfig (no secrets) and the DEK file (self-encrypted).
|
|
// Only when lockdown is ACTIVE (provisioned). A lockdown-capable but DISABLED
|
|
// device has no DEK, so encryptAndWrite would fail and config would never
|
|
// persist - it must save plaintext exactly like stock firmware. Once enabled,
|
|
// the reloadFromDisk migrate pass re-saves these plaintext files encrypted.
|
|
if (EncryptedStorage::isLockdownActive() && strcmp(filename, uiconfigFileName) != 0) {
|
|
// ZeroizingArrayPtr wipes the unencrypted protobuf encoding (which contains
|
|
// config secrets - channel PSKs, security private_key, etc.) before delete[],
|
|
// so plaintext copies aren't left in heap memory after encryption completes.
|
|
auto pbBuf = meshtastic_security::make_zeroizing_array(protoSize);
|
|
if (!pbBuf) {
|
|
LOG_ERROR("OOM encoding %s for encryption", filename);
|
|
return false;
|
|
}
|
|
|
|
pb_ostream_t stream = pb_ostream_from_buffer(pbBuf.get(), protoSize);
|
|
if (!pb_encode(&stream, fields, dest_struct)) {
|
|
LOG_ERROR("Error: can't encode protobuf %s", PB_GET_ERROR(&stream));
|
|
return false;
|
|
}
|
|
|
|
size_t encodedSize = stream.bytes_written;
|
|
bool ok = EncryptedStorage::encryptAndWrite(filename, pbBuf.get(), encodedSize, fullAtomic);
|
|
|
|
if (!ok) {
|
|
LOG_ERROR("EncryptedStorage: Failed to encrypt and write %s", filename);
|
|
}
|
|
return ok;
|
|
}
|
|
#endif
|
|
|
|
bool okay = false;
|
|
#ifdef FSCom
|
|
auto f = SafeFile(filename, fullAtomic);
|
|
|
|
LOG_INFO("Save %s", filename);
|
|
pb_ostream_t stream = {&writecb, static_cast<Print *>(&f), protoSize};
|
|
|
|
if (!pb_encode(&stream, fields, dest_struct)) {
|
|
LOG_ERROR("Error: can't encode protobuf %s", PB_GET_ERROR(&stream));
|
|
} else {
|
|
okay = true;
|
|
}
|
|
|
|
bool writeSucceeded = f.close();
|
|
|
|
if (!okay || !writeSucceeded) {
|
|
LOG_ERROR("Can't write prefs!");
|
|
}
|
|
#else
|
|
LOG_ERROR("ERROR: Filesystem not implemented");
|
|
#endif
|
|
return okay;
|
|
}
|
|
|
|
bool NodeDB::saveChannelsToDisk()
|
|
{
|
|
|
|
// do not try to save anything if power level is not safe. In many cases flash will be lock-protected
|
|
// and all writes will fail anyway.
|
|
if (!powerHAL_isPowerLevelSafe()) {
|
|
LOG_ERROR("Error: trying to saveChannelsToDisk() on unsafe device power level.");
|
|
return false;
|
|
}
|
|
|
|
#ifdef FSCom
|
|
spiLock->lock();
|
|
FSCom.mkdir("/prefs");
|
|
spiLock->unlock();
|
|
#endif
|
|
|
|
return saveProto(channelFileName, meshtastic_ChannelFile_size, &meshtastic_ChannelFile_msg, &channelFile);
|
|
}
|
|
|
|
bool NodeDB::saveDeviceStateToDisk()
|
|
{
|
|
|
|
// do not try to save anything if power level is not safe. In many cases flash will be lock-protected
|
|
// and all writes will fail anyway. Device should be sleeping at this point anyway.
|
|
if (!powerHAL_isPowerLevelSafe()) {
|
|
LOG_ERROR("Error: trying to saveDeviceStateToDisk() on unsafe device power level.");
|
|
return false;
|
|
}
|
|
|
|
#ifdef FSCom
|
|
spiLock->lock();
|
|
FSCom.mkdir("/prefs");
|
|
spiLock->unlock();
|
|
#endif
|
|
// Note: if MAX_NUM_NODES=100 and meshtastic_NodeInfoLite_size=166, so will be approximately 17KB
|
|
// Because so huge we _must_ not use fullAtomic, because the filesystem is probably too small to hold two copies of this
|
|
return saveProto(deviceStateFileName, meshtastic_DeviceState_size, &meshtastic_DeviceState_msg, &devicestate, true);
|
|
}
|
|
|
|
bool NodeDB::saveNodeDatabaseToDisk()
|
|
{
|
|
// Don't persist the node DB until this device has a PKI keypair
|
|
// TODO: revisit when https://github.com/meshtastic/firmware/pull/10478 lands
|
|
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
|
|
if (owner.public_key.size != 32 && !owner.is_licensed) {
|
|
LOG_DEBUG("Skip NodeDB without key");
|
|
return true;
|
|
}
|
|
#endif
|
|
|
|
// do not try to save anything if power level is not safe. In many cases flash will be lock-protected
|
|
// and all writes will fail anyway. Device should be sleeping at this point anyway.
|
|
if (!powerHAL_isPowerLevelSafe()) {
|
|
LOG_ERROR("Error: trying to saveNodeDatabaseToDisk() on unsafe device power level.");
|
|
return false;
|
|
}
|
|
|
|
// Defer (don't fail) while xmodem holds the prefs file handle. Returning false
|
|
// would propagate through saveToDisk() and trigger fsFormat() mid-transfer.
|
|
#ifdef FSCom
|
|
if (xModem.isBusy()) {
|
|
LOG_DEBUG("Deferring NodeDB save: xmodem transfer in progress");
|
|
return true;
|
|
}
|
|
#endif
|
|
|
|
#ifdef FSCom
|
|
spiLock->lock();
|
|
FSCom.mkdir("/prefs");
|
|
spiLock->unlock();
|
|
#endif
|
|
|
|
// Project the maps into the on-disk vectors just before encoding; cleared
|
|
// again on the way out so we don't carry duplicate state.
|
|
concurrency::LockGuard guard(&satelliteMutex);
|
|
#if !MESHTASTIC_EXCLUDE_POSITIONDB
|
|
nodeDatabase.positions.clear();
|
|
nodeDatabase.positions.reserve(nodePositions.size());
|
|
for (const auto &kv : nodePositions) {
|
|
meshtastic_NodePositionEntry entry = meshtastic_NodePositionEntry_init_default;
|
|
entry.num = kv.first;
|
|
entry.has_position = true;
|
|
entry.position = kv.second;
|
|
nodeDatabase.positions.push_back(entry);
|
|
}
|
|
#else
|
|
nodeDatabase.positions.clear();
|
|
#endif
|
|
|
|
#if !MESHTASTIC_EXCLUDE_TELEMETRYDB
|
|
nodeDatabase.telemetry.clear();
|
|
nodeDatabase.telemetry.reserve(nodeTelemetry.size());
|
|
for (const auto &kv : nodeTelemetry) {
|
|
meshtastic_NodeTelemetryEntry entry = meshtastic_NodeTelemetryEntry_init_default;
|
|
entry.num = kv.first;
|
|
entry.has_device_metrics = true;
|
|
entry.device_metrics = kv.second;
|
|
nodeDatabase.telemetry.push_back(entry);
|
|
}
|
|
#else
|
|
nodeDatabase.telemetry.clear();
|
|
#endif
|
|
|
|
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB
|
|
nodeDatabase.environment.clear();
|
|
nodeDatabase.environment.reserve(nodeEnvironment.size());
|
|
for (const auto &kv : nodeEnvironment) {
|
|
meshtastic_NodeEnvironmentEntry entry = meshtastic_NodeEnvironmentEntry_init_default;
|
|
entry.num = kv.first;
|
|
entry.has_environment_metrics = true;
|
|
entry.environment_metrics = kv.second;
|
|
nodeDatabase.environment.push_back(entry);
|
|
}
|
|
#else
|
|
nodeDatabase.environment.clear();
|
|
#endif
|
|
|
|
#if !MESHTASTIC_EXCLUDE_STATUSDB
|
|
nodeDatabase.status.clear();
|
|
nodeDatabase.status.reserve(nodeStatus.size());
|
|
for (const auto &kv : nodeStatus) {
|
|
meshtastic_NodeStatusEntry entry = meshtastic_NodeStatusEntry_init_default;
|
|
entry.num = kv.first;
|
|
entry.has_status = true;
|
|
entry.status = kv.second;
|
|
nodeDatabase.status.push_back(entry);
|
|
}
|
|
#else
|
|
nodeDatabase.status.clear();
|
|
#endif
|
|
|
|
size_t nodeDatabaseSize;
|
|
pb_get_encoded_size(&nodeDatabaseSize, meshtastic_NodeDatabase_fields, &nodeDatabase);
|
|
bool ok = saveProto(nodeDatabaseFileName, nodeDatabaseSize, &meshtastic_NodeDatabase_msg, &nodeDatabase, false);
|
|
|
|
nodeDatabase.positions.clear();
|
|
nodeDatabase.positions.shrink_to_fit();
|
|
nodeDatabase.telemetry.clear();
|
|
nodeDatabase.telemetry.shrink_to_fit();
|
|
nodeDatabase.environment.clear();
|
|
nodeDatabase.environment.shrink_to_fit();
|
|
nodeDatabase.status.clear();
|
|
nodeDatabase.status.shrink_to_fit();
|
|
#if WARM_NODE_COUNT > 0
|
|
#ifdef ARCH_RP2040
|
|
// nodes.proto + warm.dat are written back-to-back without the loop running between them;
|
|
// reset the 8s HW watchdog so the second write gets a full budget (issue #10746).
|
|
watchdog_update();
|
|
#endif
|
|
// Same cadence as the node DB; failure is logged but must not propagate -
|
|
// a false return from here would trigger saveToDisk()'s fsFormat() path.
|
|
warmStore.saveIfDirty();
|
|
#endif
|
|
return ok;
|
|
}
|
|
|
|
bool NodeDB::saveToDiskNoRetry(int saveWhat)
|
|
{
|
|
|
|
// do not try to save anything if power level is not safe. In many cases flash will be lock-protected
|
|
// and all writes will fail anyway. Device should be sleeping at this point anyway.
|
|
if (!powerHAL_isPowerLevelSafe()) {
|
|
LOG_ERROR("Error: trying to saveToDiskNoRetry() on unsafe device power level.");
|
|
return false;
|
|
}
|
|
|
|
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
|
// When lockdown is ACTIVE but storage is still locked, encryptAndWrite()
|
|
// returns false for every file. That would cause saveToDisk()'s nRF52 retry
|
|
// path to call FSCom.format(), wiping all encrypted proto files from flash.
|
|
// Return true here - "nothing to save, not an error."
|
|
//
|
|
// Gate on isLockdownActive(): a lockdown-capable but DISABLED device (never
|
|
// provisioned) also has isUnlocked()==false, but it must persist plaintext
|
|
// normally - skipping here would silently drop every config write (e.g. the
|
|
// LoRa region) until the device is provisioned.
|
|
if (EncryptedStorage::isLockdownActive() && !EncryptedStorage::isUnlocked()) {
|
|
LOG_WARN("NodeDB: saveToDisk skipped - encrypted storage locked");
|
|
return true;
|
|
}
|
|
#endif
|
|
|
|
bool success = true;
|
|
#ifdef FSCom
|
|
spiLock->lock();
|
|
FSCom.mkdir("/prefs");
|
|
spiLock->unlock();
|
|
#endif
|
|
|
|
if (saveWhat & SEGMENT_CONFIG) {
|
|
config.has_device = true;
|
|
config.has_display = true;
|
|
config.has_lora = true;
|
|
config.has_position = true;
|
|
config.has_power = true;
|
|
config.has_network = true;
|
|
config.has_bluetooth = true;
|
|
config.has_security = true;
|
|
|
|
success &= saveProto(configFileName, meshtastic_LocalConfig_size, &meshtastic_LocalConfig_msg, &config);
|
|
}
|
|
|
|
if (saveWhat & SEGMENT_MODULECONFIG) {
|
|
moduleConfig.has_canned_message = true;
|
|
moduleConfig.has_external_notification = true;
|
|
moduleConfig.has_mqtt = true;
|
|
moduleConfig.has_range_test = true;
|
|
moduleConfig.has_serial = true;
|
|
moduleConfig.has_store_forward = true;
|
|
moduleConfig.has_telemetry = true;
|
|
moduleConfig.has_neighbor_info = true;
|
|
moduleConfig.has_detection_sensor = true;
|
|
moduleConfig.has_ambient_lighting = true;
|
|
moduleConfig.has_audio = true;
|
|
moduleConfig.has_paxcounter = true;
|
|
moduleConfig.has_statusmessage = true;
|
|
#if !MESHTASTIC_EXCLUDE_BEACON
|
|
moduleConfig.has_mesh_beacon = true;
|
|
#endif
|
|
|
|
success &=
|
|
saveProto(moduleConfigFileName, meshtastic_LocalModuleConfig_size, &meshtastic_LocalModuleConfig_msg, &moduleConfig);
|
|
}
|
|
|
|
if (saveWhat & SEGMENT_CHANNELS) {
|
|
success &= saveChannelsToDisk();
|
|
}
|
|
|
|
if (saveWhat & SEGMENT_DEVICESTATE) {
|
|
success &= saveDeviceStateToDisk();
|
|
}
|
|
|
|
if (saveWhat & SEGMENT_NODEDATABASE) {
|
|
success &= saveNodeDatabaseToDisk();
|
|
}
|
|
|
|
return success;
|
|
}
|
|
|
|
bool NodeDB::saveToDisk(int saveWhat)
|
|
{
|
|
LOG_DEBUG("Save to disk %d", saveWhat);
|
|
|
|
// do not try to save anything if power level is not safe. In many cases flash will be lock-protected
|
|
// and all writes will fail anyway. Device should be sleeping at this point anyway.
|
|
if (!powerHAL_isPowerLevelSafe()) {
|
|
LOG_ERROR("Error: trying to saveToDisk() on unsafe device power level.");
|
|
return false;
|
|
}
|
|
|
|
bool success = saveToDiskNoRetry(saveWhat);
|
|
|
|
if (!success) {
|
|
LOG_ERROR("Failed to save to disk, retrying");
|
|
spiLock->lock();
|
|
fsFormat();
|
|
spiLock->unlock();
|
|
|
|
success = saveToDiskNoRetry(saveWhat);
|
|
|
|
RECORD_CRITICALERROR(success ? meshtastic_CriticalErrorCode_FLASH_CORRUPTION_RECOVERABLE
|
|
: meshtastic_CriticalErrorCode_FLASH_CORRUPTION_UNRECOVERABLE);
|
|
}
|
|
|
|
return success;
|
|
}
|
|
|
|
const meshtastic_NodeInfoLite *NodeDB::readNextMeshNode(uint32_t &readIndex)
|
|
{
|
|
if (readIndex < numMeshNodes)
|
|
return &meshNodes->at(readIndex++);
|
|
else
|
|
return NULL;
|
|
}
|
|
|
|
/// Given a node, return how many seconds in the past (vs now) that we last heard from it
|
|
uint32_t sinceLastSeen(const meshtastic_NodeInfoLite *n)
|
|
{
|
|
uint32_t now = getTime();
|
|
|
|
int delta = (int)(now - n->last_heard);
|
|
if (delta < 0) // our clock must be slightly off still - not set from GPS yet
|
|
delta = 0;
|
|
|
|
return delta;
|
|
}
|
|
|
|
uint32_t sinceReceived(const meshtastic_MeshPacket *p)
|
|
{
|
|
uint32_t now = getTime();
|
|
|
|
int delta = (int)(now - p->rx_time);
|
|
if (delta < 0) // our clock must be slightly off still - not set from GPS yet
|
|
delta = 0;
|
|
|
|
return delta;
|
|
}
|
|
|
|
HopStartStatus classifyHopStart(const meshtastic_MeshPacket &p)
|
|
{
|
|
// Guard against invalid values.
|
|
if (p.hop_start < p.hop_limit)
|
|
return HopStartStatus::INVALID;
|
|
|
|
if (p.hop_start == 0) {
|
|
// hop_start == 0 is either a modern zero-hop broadcast (e.g. beacon) or pre-2.3.0 firmware (585805c)
|
|
// that never populated hop_start. Firmware 2.5.0 (bf34329) introduced a bitfield that is always
|
|
// present; use it to tell the two apart. The bitfield is encrypted under the channel key, so this can
|
|
// only be resolved for decoded packets - until then the status stays MISSING_OR_UNKNOWN. Callers
|
|
// acting before decode must therefore not treat UNKNOWN as a drop (the bitfield isn't readable yet);
|
|
// the verdict is re-checked post-decode in Router::handleReceived.
|
|
if (p.which_payload_variant == meshtastic_MeshPacket_decoded_tag && p.decoded.has_bitfield)
|
|
return HopStartStatus::VALID;
|
|
return HopStartStatus::MISSING_OR_UNKNOWN;
|
|
}
|
|
|
|
return HopStartStatus::VALID;
|
|
}
|
|
|
|
int8_t getHopsAway(const meshtastic_MeshPacket &p, int8_t defaultIfUnknown)
|
|
{
|
|
// Firmware prior to 2.3.0 (585805c) lacked a hop_start field. Firmware version 2.5.0 (bf34329) introduced a
|
|
// bitfield that is always present. Use the presence of the bitfield to determine if the origin's firmware
|
|
// version is guaranteed to have hop_start populated. Note that this can only be done for decoded packets as
|
|
// the bitfield is encrypted under the channel encryption key. For encrypted packets, this returns
|
|
// defaultIfUnknown when hop_start is 0.
|
|
if (p.hop_start == 0 && !(p.which_payload_variant == meshtastic_MeshPacket_decoded_tag && p.decoded.has_bitfield))
|
|
return defaultIfUnknown; // Cannot reliably determine the number of hops.
|
|
|
|
// Guard against invalid values.
|
|
if (p.hop_start < p.hop_limit)
|
|
return defaultIfUnknown;
|
|
|
|
return p.hop_start - p.hop_limit;
|
|
}
|
|
|
|
#define NUM_ONLINE_SECS (60 * 60 * 2) // 2 hrs to consider someone offline
|
|
|
|
size_t NodeDB::getNumOnlineMeshNodes(bool localOnly)
|
|
{
|
|
size_t numseen = 0;
|
|
|
|
// FIXME this implementation is kinda expensive
|
|
for (int i = 0; i < numMeshNodes; i++) {
|
|
if (localOnly && nodeInfoLiteViaMqtt(&meshNodes->at(i)))
|
|
continue;
|
|
if (sinceLastSeen(&meshNodes->at(i)) < NUM_ONLINE_SECS)
|
|
numseen++;
|
|
}
|
|
|
|
return numseen;
|
|
}
|
|
|
|
#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)
|
|
{
|
|
static uint32_t lastLogMs = 0;
|
|
if (Throttle::isWithinTimespanMs(lastLogMs, HOPSTART_DROP_LOG_INTERVAL_MS)) {
|
|
return;
|
|
}
|
|
lastLogMs = millis();
|
|
const bool decoded = (p.which_payload_variant == meshtastic_MeshPacket_decoded_tag);
|
|
const bool hasBitfield = decoded && p.decoded.has_bitfield;
|
|
LOG_DEBUG(
|
|
"Drop packet (%s): hop_start invalid/missing (from=0x%08x id=%u hop_start=%u hop_limit=%u decoded=%d has_bitfield=%d)",
|
|
context ? context : "unknown", p.from, p.id, p.hop_start, p.hop_limit, decoded, hasBitfield);
|
|
}
|
|
|
|
/** Update position info for this node based on received position data
|
|
*/
|
|
void NodeDB::updatePosition(uint32_t nodeId, const meshtastic_Position &p, RxSource src)
|
|
{
|
|
meshtastic_NodeInfoLite *info = getOrCreateMeshNode(nodeId);
|
|
if (!info) {
|
|
return;
|
|
}
|
|
|
|
#if MESHTASTIC_EXCLUDE_POSITIONDB
|
|
// Build flag opted out: header still tracks last_heard via updateFrom; we
|
|
// simply don't cache the position payload anywhere on this device.
|
|
if (src == RX_SRC_LOCAL) {
|
|
LOG_INFO("updatePosition LOCAL (PositionDB excluded) time=%u lat=%d lon=%d alt=%d", p.time, p.latitude_i, p.longitude_i,
|
|
p.altitude);
|
|
setLocalPosition(p);
|
|
}
|
|
(void)nodeId;
|
|
updateGUIforNode = info;
|
|
notifyObservers(true);
|
|
#else
|
|
{
|
|
concurrency::LockGuard guard(&satelliteMutex);
|
|
evictSatelliteOverCap(*this, nodePositions, nodeId);
|
|
meshtastic_PositionLite &slot = nodePositions[nodeId]; // creates default-zero entry if missing
|
|
|
|
if (src == RX_SRC_LOCAL) {
|
|
// Local packet, fully authoritative
|
|
LOG_INFO("updatePosition LOCAL pos@%x time=%u lat=%d lon=%d alt=%d", p.timestamp, p.time, p.latitude_i, p.longitude_i,
|
|
p.altitude);
|
|
|
|
setLocalPosition(p);
|
|
slot = TypeConversions::ConvertToPositionLite(p);
|
|
} else if ((p.time > 0) && !p.latitude_i && !p.longitude_i && !p.timestamp && !p.location_source) {
|
|
// FIXME SPECIAL TIME SETTING PACKET FROM EUD TO RADIO
|
|
// (stop-gap fix for issue #900)
|
|
LOG_DEBUG("updatePosition SPECIAL time setting time=%u", p.time);
|
|
slot.time = p.time;
|
|
} else {
|
|
// Be careful to only update fields that have been set by the REMOTE sender
|
|
// A lot of position reports don't have time populated. In that case, be careful to not blow away the time we
|
|
// recorded based on the packet rxTime
|
|
//
|
|
// FIXME perhaps handle RX_SRC_USER separately?
|
|
LOG_INFO("updatePosition REMOTE node=0x%08x time=%u lat=%d lon=%d", nodeId, p.time, p.latitude_i, p.longitude_i);
|
|
|
|
// First, back up fields that we want to protect from overwrite
|
|
uint32_t tmp_time = slot.time;
|
|
|
|
// Next, update atomically
|
|
slot = TypeConversions::ConvertToPositionLite(p);
|
|
|
|
// Last, restore any fields that may have been overwritten
|
|
if (!slot.time)
|
|
slot.time = tmp_time;
|
|
}
|
|
}
|
|
updateGUIforNode = info;
|
|
notifyObservers(true); // Force an update whether or not our node counts have changed
|
|
#endif
|
|
}
|
|
|
|
/** Update telemetry info for this node based on received metrics. Stores
|
|
* device_metrics and environment_metrics into their respective satellite
|
|
* maps; other variants (air_quality, power, local_stats, health) are
|
|
* intentionally not retained per-node.
|
|
*/
|
|
void NodeDB::updateTelemetry(uint32_t nodeId, const meshtastic_Telemetry &t, RxSource src)
|
|
{
|
|
meshtastic_NodeInfoLite *info = getOrCreateMeshNode(nodeId);
|
|
if (!info)
|
|
return;
|
|
|
|
if (t.which_variant == meshtastic_Telemetry_device_metrics_tag) {
|
|
if (src == RX_SRC_LOCAL) {
|
|
LOG_DEBUG("updateTelemetry LOCAL device");
|
|
} else {
|
|
LOG_DEBUG("updateTelemetry REMOTE device node=0x%08x", nodeId);
|
|
}
|
|
#if !MESHTASTIC_EXCLUDE_TELEMETRYDB
|
|
concurrency::LockGuard guard(&satelliteMutex);
|
|
evictSatelliteOverCap(*this, nodeTelemetry, nodeId);
|
|
nodeTelemetry[nodeId] = t.variant.device_metrics;
|
|
#endif
|
|
|
|
} else if (t.which_variant == meshtastic_Telemetry_environment_metrics_tag) {
|
|
if (src == RX_SRC_LOCAL) {
|
|
LOG_DEBUG("updateTelemetry LOCAL env");
|
|
} else {
|
|
LOG_DEBUG("updateTelemetry REMOTE env node=0x%08x", nodeId);
|
|
}
|
|
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB
|
|
concurrency::LockGuard guard(&satelliteMutex);
|
|
evictSatelliteOverCap(*this, nodeEnvironment, nodeId);
|
|
nodeEnvironment[nodeId] = t.variant.environment_metrics;
|
|
#endif
|
|
|
|
} else {
|
|
return; // air_quality / power / local_stats / health: not stored per-node
|
|
}
|
|
updateGUIforNode = info;
|
|
notifyObservers(true);
|
|
}
|
|
|
|
/**
|
|
* Update the node database with a new contact
|
|
*/
|
|
void NodeDB::addFromContact(meshtastic_SharedContact contact)
|
|
{
|
|
meshtastic_NodeInfoLite *info = getOrCreateMeshNode(contact.node_num);
|
|
if (!info || !contact.has_user) {
|
|
return;
|
|
}
|
|
// If the local node has this node marked as manually verified
|
|
// and the client does not, do not allow the client to update the
|
|
// saved public key.
|
|
if (nodeInfoLiteIsKeyManuallyVerified(info) && !contact.manually_verified) {
|
|
if (contact.user.public_key.size != info->public_key.size ||
|
|
memcmp(contact.user.public_key.bytes, info->public_key.bytes, info->public_key.size) != 0) {
|
|
return;
|
|
}
|
|
}
|
|
info->num = contact.node_num;
|
|
TypeConversions::CopyUserToNodeInfoLite(info, contact.user);
|
|
if (contact.should_ignore) {
|
|
// Block the contact and drop its rich satellite data, but keep the
|
|
// public key copied above - an ignored peer keeps a usable identity
|
|
// (a verifiable target) rather than a bare node number.
|
|
if (!setProtectedFlag(info, NODEINFO_BITFIELD_IS_IGNORED_MASK, true))
|
|
LOG_WARN(PROTECTED_CAP_WARN_FMT, "ignore", contact.node_num, MAX_NUM_NODES - 2);
|
|
nodeInfoLiteSetBit(info, NODEINFO_BITFIELD_IS_FAVORITE_MASK, false);
|
|
eraseNodeSatellites(contact.node_num);
|
|
#if HAS_SCREEN || defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS)
|
|
messageStore.deleteAllMessagesFromNode(contact.node_num);
|
|
#endif
|
|
} else {
|
|
/* Clients are sending add_contact before every text message DM (because clients may hold a larger node database with
|
|
* public keys than the radio holds). However, we don't want to update last_heard just because we sent someone a DM!
|
|
*/
|
|
|
|
/* "Boring old nodes" are the first to be evicted out of the node database when full. This includes a newly-zeroed
|
|
* nodeinfo because it has: !is_favorite && last_heard==0. To keep this from happening when we addFromContact, we set the
|
|
* new node as a favorite, and we leave last_heard alone (even if it's zero).
|
|
*/
|
|
if (config.device.role == meshtastic_Config_DeviceConfig_Role_CLIENT_BASE) {
|
|
// Special case for CLIENT_BASE: is_favorite has special meaning, and we don't want to automatically set it
|
|
// without the user doing so deliberately. We don't normally expect users to use a CLIENT_BASE to send DMs or to add
|
|
// contacts, but we should make sure it doesn't auto-favorite in case they do. Instead, as a workaround, we'll set
|
|
// last_heard to now, so that the add_contact node doesn't immediately get evicted.
|
|
info->last_heard = getTime();
|
|
} else {
|
|
// Normal case: set is_favorite to prevent expiration.
|
|
// last_heard will remain as-is (or remain 0 if this entry wasn't in the nodeDB).
|
|
// If the protected cap refuses the favorite, fall back to stamping last_heard so the
|
|
// contact still isn't the first eviction victim.
|
|
if (!setProtectedFlag(info, NODEINFO_BITFIELD_IS_FAVORITE_MASK, true))
|
|
info->last_heard = getTime();
|
|
}
|
|
|
|
// As the clients will begin sending the contact with DMs, we want to strictly check if the node is manually verified
|
|
if (contact.manually_verified) {
|
|
if (!setProtectedFlag(info, NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK, true))
|
|
LOG_WARN(PROTECTED_CAP_WARN_FMT, "verify", contact.node_num, MAX_NUM_NODES - 2);
|
|
}
|
|
// Mark the node's key as manually verified to indicate trustworthiness.
|
|
updateGUIforNode = info;
|
|
sortMeshDB();
|
|
notifyObservers(true); // Force an update whether or not our node counts have changed
|
|
}
|
|
saveNodeDatabaseToDisk();
|
|
}
|
|
|
|
/** Update user info and channel for this node based on received user data
|
|
*/
|
|
bool NodeDB::updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelIndex, bool xeddsaSigned)
|
|
{
|
|
// 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;
|
|
}
|
|
|
|
meshtastic_NodeInfoLite *info = getOrCreateMeshNode(nodeId);
|
|
if (!info) {
|
|
return false;
|
|
}
|
|
|
|
#if !(MESHTASTIC_EXCLUDE_PKI)
|
|
if (p.public_key.size == 32 && nodeId != nodeDB->getNodeNum()) {
|
|
printBytes("Incoming Pubkey: ", p.public_key.bytes, 32);
|
|
|
|
// Alert the user if a remote node is advertising public key that matches our own
|
|
if (owner.public_key.size == 32 && memcmp(p.public_key.bytes, owner.public_key.bytes, 32) == 0) {
|
|
if (!duplicateWarned) {
|
|
duplicateWarned = true;
|
|
// Sanitize before embedding long_name in the phone-facing ClientNotification string
|
|
// (defense-in-depth vs PB_VALIDATE_UTF8).
|
|
char safeName[sizeof(p.long_name)];
|
|
strncpy(safeName, p.long_name, sizeof(safeName));
|
|
safeName[sizeof(safeName) - 1] = '\0';
|
|
sanitizeUtf8(safeName, sizeof(safeName));
|
|
char warning[] =
|
|
"Remote device %s has advertised your public key. This may indicate a compromised key. You may need "
|
|
"to regenerate your public keys.";
|
|
LOG_WARN(warning, safeName);
|
|
meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed();
|
|
if (cn) {
|
|
cn->level = meshtastic_LogRecord_Level_WARNING;
|
|
cn->time = getValidTime(RTCQualityFromNet);
|
|
snprintf(cn->message, sizeof(cn->message), warning, safeName);
|
|
service->sendClientNotification(cn);
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
if (info->public_key.size == 32) { // if we have a key for this user already, don't overwrite with a new one
|
|
// if the key doesn't match, don't update nodeDB at all.
|
|
if (p.public_key.size != 32 || (memcmp(p.public_key.bytes, info->public_key.bytes, 32) != 0)) {
|
|
LOG_WARN("Public Key mismatch, dropping NodeInfo");
|
|
return false;
|
|
}
|
|
LOG_INFO("Public Key set for node, not updating!");
|
|
} else if (p.public_key.size == 32) {
|
|
LOG_INFO("Update Node Pubkey!");
|
|
}
|
|
#endif
|
|
|
|
// Always ensure user.id is derived from nodeId, regardless of what was received
|
|
snprintf(p.id, sizeof(p.id), "!%08x", nodeId);
|
|
|
|
meshtastic_NodeInfoLite before = *info;
|
|
TypeConversions::CopyUserToNodeInfoLite(info, p);
|
|
bool changed =
|
|
(memcmp(before.long_name, info->long_name, sizeof(info->long_name)) != 0) ||
|
|
(memcmp(before.short_name, info->short_name, sizeof(info->short_name)) != 0) || (before.hw_model != info->hw_model) ||
|
|
(before.role != info->role) || (before.public_key.size != info->public_key.size) ||
|
|
(info->public_key.size > 0 && memcmp(before.public_key.bytes, info->public_key.bytes, info->public_key.size) != 0) ||
|
|
(before.bitfield != info->bitfield) || (info->channel != channelIndex);
|
|
|
|
if (info->public_key.size == 32) {
|
|
printBytes("Saved Pubkey: ", info->public_key.bytes, 32);
|
|
}
|
|
if (nodeId != getNodeNum())
|
|
info->channel = channelIndex; // Set channel we need to use to reach this node (but don't set our own channel)
|
|
LOG_DEBUG("Update changed=%d user %s/%s, id=0x%08x, channel=%d", changed, info->long_name, info->short_name, nodeId,
|
|
info->channel);
|
|
|
|
if (changed) {
|
|
updateGUIforNode = info;
|
|
notifyObservers(true); // Force an update whether or not our node counts have changed
|
|
|
|
// We just changed something about a User,
|
|
// store our DB unless we just did so less than a minute ago
|
|
|
|
if (!Throttle::isWithinTimespanMs(lastNodeDbSave, ONE_MINUTE_MS)) {
|
|
saveToDisk(SEGMENT_NODEDATABASE);
|
|
lastNodeDbSave = millis();
|
|
} else {
|
|
LOG_DEBUG("Defer NodeDB saveToDisk for now");
|
|
}
|
|
}
|
|
|
|
#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;
|
|
}
|
|
|
|
/// given a subpacket sniffed from the network, update our DB state
|
|
/// we updateGUI and updateGUIforNode if we think our this change is big enough for a redraw
|
|
void NodeDB::updateFrom(const meshtastic_MeshPacket &mp)
|
|
{
|
|
if (mp.from == getNodeNum()) {
|
|
LOG_DEBUG("Ignore update from self");
|
|
return;
|
|
}
|
|
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);
|
|
|
|
// 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;
|
|
}
|
|
|
|
if (mp.rx_time) // if the packet has a valid timestamp use it to update our last_heard
|
|
info->last_heard = mp.rx_time;
|
|
|
|
if (mp.rx_snr)
|
|
info->snr = mp.rx_snr; // keep the most recent SNR we received for this node.
|
|
|
|
nodeInfoLiteSetBit(info, NODEINFO_BITFIELD_VIA_MQTT_MASK,
|
|
mp.via_mqtt); // Store if we received this packet via MQTT
|
|
|
|
#if HAS_VARIABLE_HOPS
|
|
// Only sample genuine RF-origin packets. The transport check excludes packets received
|
|
// directly from the broker (TRANSPORT_MQTT), but an MQTT-origin packet rebroadcast onto
|
|
// LoRa by a gateway arrives as TRANSPORT_LORA with via_mqtt set - count those would
|
|
// inflate the local mesh-size estimate with non-RF nodes (and they usually carry
|
|
// hop_start==0, landing in the hop-0 bucket that pulls the recommendation lowest), so
|
|
// exclude via_mqtt too.
|
|
if (mp.transport_mechanism == meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA && !mp.via_mqtt &&
|
|
hopScalingModule) {
|
|
uint8_t hopCount = std::max(int8_t(0), getHopsAway(mp));
|
|
hopScalingModule->samplePacketForHistogram(mp.from, hopCount);
|
|
}
|
|
#endif
|
|
|
|
// If hopStart was set and there wasn't someone messing with the limit in the middle, add hopsAway
|
|
const int8_t hopsAway = getHopsAway(mp);
|
|
if (hopsAway >= 0) {
|
|
info->has_hops_away = true;
|
|
info->hops_away = hopsAway;
|
|
}
|
|
sortMeshDB();
|
|
}
|
|
}
|
|
|
|
int NodeDB::numProtectedNodes() const
|
|
{
|
|
int count = 0;
|
|
for (int i = 0; i < numMeshNodes; i++)
|
|
if (nodeInfoLiteIsProtected(&meshNodes->at(i)))
|
|
count++;
|
|
return count;
|
|
}
|
|
|
|
bool NodeDB::setProtectedFlag(meshtastic_NodeInfoLite *node, uint32_t mask, bool on)
|
|
{
|
|
if (!node)
|
|
return false;
|
|
if (!on) {
|
|
nodeInfoLiteSetBit(node, mask, false);
|
|
return true;
|
|
}
|
|
// Adding a flag to a node that is already protected doesn't grow the
|
|
// protected set, so it's always allowed. A newly-protected node is refused
|
|
// once the protected set has reached MAX_NUM_NODES-2, leaving two evictable
|
|
// slots so getOrCreateMeshNode can always make room.
|
|
if (nodeInfoLiteIsProtected(node) || numProtectedNodes() < MAX_NUM_NODES - 2) {
|
|
nodeInfoLiteSetBit(node, mask, true);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool NodeDB::set_favorite(bool is_favorite, uint32_t nodeId)
|
|
{
|
|
meshtastic_NodeInfoLite *lite = getMeshNode(nodeId);
|
|
if (!lite)
|
|
return false;
|
|
if (nodeInfoLiteIsFavorite(lite) == is_favorite)
|
|
return true; // already in the requested state
|
|
if (setProtectedFlag(lite, NODEINFO_BITFIELD_IS_FAVORITE_MASK, is_favorite)) {
|
|
sortMeshDB();
|
|
saveNodeDatabaseToDisk();
|
|
return true;
|
|
}
|
|
LOG_WARN(PROTECTED_CAP_WARN_FMT, "favorite", nodeId, MAX_NUM_NODES - 2);
|
|
return false;
|
|
}
|
|
|
|
bool NodeDB::isFavorite(uint32_t nodeId)
|
|
{
|
|
// returns true if nodeId is_favorite; false if not or not found
|
|
|
|
// NODENUM_BROADCAST will never be in the DB
|
|
if (nodeId == NODENUM_BROADCAST)
|
|
return false;
|
|
|
|
const meshtastic_NodeInfoLite *lite = getMeshNode(nodeId);
|
|
|
|
if (lite) {
|
|
return nodeInfoLiteIsFavorite(lite);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool NodeDB::isFromOrToFavoritedNode(const meshtastic_MeshPacket &p)
|
|
{
|
|
// This method is logically equivalent to:
|
|
// return isFavorite(p.from) || isFavorite(p.to);
|
|
// but is more efficient by:
|
|
// 1. doing only one pass through the database, instead of two
|
|
// 2. exiting early when a favorite is found, or if both from and to have been seen
|
|
|
|
if (p.to == NODENUM_BROADCAST)
|
|
return isFavorite(p.from); // we never store NODENUM_BROADCAST in the DB, so we only need to check p.from
|
|
|
|
meshtastic_NodeInfoLite *lite = NULL;
|
|
|
|
bool seenFrom = false;
|
|
bool seenTo = false;
|
|
|
|
for (int i = 0; i < numMeshNodes; i++) {
|
|
lite = &meshNodes->at(i);
|
|
|
|
if (lite->num == p.from) {
|
|
if (nodeInfoLiteIsFavorite(lite))
|
|
return true;
|
|
|
|
seenFrom = true;
|
|
}
|
|
|
|
if (lite->num == p.to) {
|
|
if (nodeInfoLiteIsFavorite(lite))
|
|
return true;
|
|
|
|
seenTo = true;
|
|
}
|
|
|
|
if (seenFrom && seenTo)
|
|
return false; // we've seen both, and neither is a favorite, so we can stop searching early
|
|
|
|
// Note: if we knew that sortMeshDB was always called after any change to is_favorite, we could exit early after searching
|
|
// all favorited nodes first.
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
void NodeDB::pause_sort(bool paused)
|
|
{
|
|
sortingIsPaused = paused;
|
|
}
|
|
|
|
void NodeDB::sortMeshDB()
|
|
{
|
|
if (!sortingIsPaused && (lastSort == 0 || !Throttle::isWithinTimespanMs(lastSort, 1000 * 5))) {
|
|
lastSort = millis();
|
|
bool changed = true;
|
|
while (changed) { // dumb reverse bubble sort, but probably not bad for what we're doing
|
|
changed = false;
|
|
for (int i = numMeshNodes - 1; i > 0; i--) { // lowest case this should examine is i == 1
|
|
if (meshNodes->at(i - 1).num == getNodeNum()) {
|
|
// noop
|
|
} else if (meshNodes->at(i).num ==
|
|
getNodeNum()) { // in the oddball case our own node num is not at location 0, put it there
|
|
// TODO: Look for at(i-1) also matching own node num, and throw the DB in the trash
|
|
std::swap(meshNodes->at(i), meshNodes->at(i - 1));
|
|
changed = true;
|
|
} else if (nodeInfoLiteIsFavorite(&meshNodes->at(i)) && !nodeInfoLiteIsFavorite(&meshNodes->at(i - 1))) {
|
|
std::swap(meshNodes->at(i), meshNodes->at(i - 1));
|
|
changed = true;
|
|
} else if (!nodeInfoLiteIsFavorite(&meshNodes->at(i)) && nodeInfoLiteIsFavorite(&meshNodes->at(i - 1))) {
|
|
// noop
|
|
} else if (meshNodes->at(i).last_heard > meshNodes->at(i - 1).last_heard) {
|
|
std::swap(meshNodes->at(i), meshNodes->at(i - 1));
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
LOG_INFO("Sort took %u milliseconds", millis() - lastSort);
|
|
}
|
|
}
|
|
|
|
uint8_t NodeDB::getMeshNodeChannel(NodeNum n)
|
|
{
|
|
const meshtastic_NodeInfoLite *info = getMeshNode(n);
|
|
if (!info) {
|
|
return 0; // defaults to PRIMARY
|
|
}
|
|
return info->channel;
|
|
}
|
|
|
|
std::string NodeDB::getNodeId() const
|
|
{
|
|
char nodeId[16];
|
|
snprintf(nodeId, sizeof(nodeId), "!%08x", myNodeInfo.my_node_num);
|
|
return std::string(nodeId);
|
|
}
|
|
|
|
/// Find a node in our DB, return null for missing
|
|
/// NOTE: This function might be called from an ISR
|
|
meshtastic_NodeInfoLite *NodeDB::getMeshNode(NodeNum n)
|
|
{
|
|
for (int i = 0; i < numMeshNodes; i++)
|
|
if (meshNodes->at(i).num == n)
|
|
return &meshNodes->at(i);
|
|
|
|
return NULL;
|
|
}
|
|
|
|
ResolvedNode NodeDB::resolveLastByte(uint8_t lastByte, bool requireDirectNeighbor)
|
|
{
|
|
ResolvedNode result; // defaults to {None, 0}
|
|
|
|
// 0 is the NO_RELAY_NODE / NO_NEXT_HOP_PREFERENCE sentinel (also what MQTT-sourced packets carry
|
|
// when hop_start==0). getLastByteOfNodeNum() never yields 0, so nothing can legitimately match.
|
|
if (lastByte == 0)
|
|
return result;
|
|
|
|
const NodeNum self = getNodeNum();
|
|
NodeNum firstMatch = 0;
|
|
uint8_t matches = 0;
|
|
|
|
for (size_t i = 0; i < numMeshNodes; i++) {
|
|
const meshtastic_NodeInfoLite *node = &meshNodes->at(i);
|
|
|
|
// Candidate gate: never resolve to ourselves, the sentinels, or an ignored node.
|
|
if (node->num == self || node->num == 0 || node->num == NODENUM_BROADCAST)
|
|
continue;
|
|
if (nodeInfoLiteIsIgnored(node))
|
|
continue;
|
|
if (getLastByteOfNodeNum(node->num) != lastByte) // cheapest discriminator last
|
|
continue;
|
|
|
|
// Relevance gate: is this node a plausible relay for the requested scope?
|
|
bool relevant;
|
|
if (requireDirectNeighbor) {
|
|
relevant = node->has_hops_away && node->hops_away == 0 && sinceLastSeen(node) < NEXTHOP_NEIGHBOR_FRESH_SECS;
|
|
} else {
|
|
const bool directNeighbor = node->has_hops_away && node->hops_away == 0;
|
|
const bool routerRole =
|
|
IS_ONE_OF(node->role, meshtastic_Config_DeviceConfig_Role_ROUTER, meshtastic_Config_DeviceConfig_Role_ROUTER_LATE,
|
|
meshtastic_Config_DeviceConfig_Role_CLIENT_BASE);
|
|
relevant = directNeighbor || nodeInfoLiteIsFavorite(node) || routerRole;
|
|
}
|
|
if (!relevant)
|
|
continue;
|
|
|
|
if (++matches == 1) {
|
|
firstMatch = node->num;
|
|
} else {
|
|
// A second relevant candidate shares this byte: ambiguous. No further scanning can
|
|
// change that, so stop early and report the collision.
|
|
result.status = LastByteResolution::Ambiguous;
|
|
result.num = 0;
|
|
return result;
|
|
}
|
|
}
|
|
|
|
if (matches == 1) {
|
|
result.status = LastByteResolution::Unique;
|
|
result.num = firstMatch;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
bool NodeDB::resolveUniqueLastByte(uint8_t lastByte, bool requireDirectNeighbor, NodeNum *outNum)
|
|
{
|
|
ResolvedNode r = resolveLastByte(lastByte, requireDirectNeighbor);
|
|
if (r.status == LastByteResolution::Unique) {
|
|
if (outNum)
|
|
*outNum = r.num;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// returns true if the maximum number of nodes is reached or we are running low on memory
|
|
bool NodeDB::isFull()
|
|
{
|
|
return (numMeshNodes >= MAX_NUM_NODES) || (memGet.getFreeHeap() < MINIMUM_SAFE_FREE_HEAP);
|
|
}
|
|
|
|
uint32_t NodeDB::hotNodeLastHeard(NodeNum n) const
|
|
{
|
|
for (int i = 0; i < numMeshNodes; i++)
|
|
if (meshNodes->at(i).num == n)
|
|
return meshNodes->at(i).last_heard;
|
|
return 0;
|
|
}
|
|
|
|
bool NodeDB::copyPublicKeyAuthoritative(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out)
|
|
{
|
|
const meshtastic_NodeInfoLite *info = getMeshNode(n);
|
|
if (info && info->public_key.size == 32) {
|
|
out = info->public_key;
|
|
return true;
|
|
}
|
|
#if WARM_NODE_COUNT > 0
|
|
if (warmStore.copyKey(n, out.bytes)) {
|
|
out.size = 32;
|
|
return true;
|
|
}
|
|
#endif
|
|
return false;
|
|
}
|
|
|
|
bool NodeDB::copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out)
|
|
{
|
|
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 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);
|
|
if (nodeInfoLiteHasUser(info))
|
|
return info->role;
|
|
#if WARM_NODE_COUNT > 0
|
|
// Hot-store miss: fall back to the role the warm tier cached at eviction.
|
|
uint8_t role = 0, prot = 0;
|
|
if (warmStore.lookupMeta(n, role, prot))
|
|
return static_cast<meshtastic_Config_DeviceConfig_Role>(role);
|
|
#endif
|
|
return meshtastic_Config_DeviceConfig_Role_CLIENT;
|
|
}
|
|
|
|
/// Find a node in our DB, create an empty NodeInfo if missing
|
|
meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n)
|
|
{
|
|
meshtastic_NodeInfoLite *lite = getMeshNode(n);
|
|
|
|
if (!lite) {
|
|
if (isFull()) {
|
|
LOG_INFO("Node database full with %i nodes and %u bytes free. Erasing oldest entry", numMeshNodes,
|
|
memGet.getFreeHeap());
|
|
// look for oldest node and erase it
|
|
uint32_t oldest = UINT32_MAX;
|
|
uint32_t oldestBoring = UINT32_MAX;
|
|
int oldestIndex = -1;
|
|
int oldestBoringIndex = -1;
|
|
for (int i = 1; i < numMeshNodes; i++) {
|
|
const meshtastic_NodeInfoLite *cand = &meshNodes->at(i);
|
|
const bool isFavoriteNode = nodeInfoLiteIsFavorite(cand);
|
|
const bool isIgnored = nodeInfoLiteIsIgnored(cand);
|
|
const bool isVerified = nodeInfoLiteIsKeyManuallyVerified(cand);
|
|
// Simply the oldest non-favorite, non-ignored, non-verified node
|
|
if (!isFavoriteNode && !isIgnored && !isVerified && cand->last_heard < oldest) {
|
|
oldest = cand->last_heard;
|
|
oldestIndex = i;
|
|
}
|
|
// The oldest "boring" node
|
|
if (!isFavoriteNode && !isIgnored && cand->public_key.size == 0 && cand->last_heard < oldestBoring) {
|
|
oldestBoring = cand->last_heard;
|
|
oldestBoringIndex = i;
|
|
}
|
|
}
|
|
// if we found a "boring" node, evict it
|
|
if (oldestBoringIndex != -1) {
|
|
oldestIndex = oldestBoringIndex;
|
|
}
|
|
|
|
if (oldestIndex != -1) {
|
|
const meshtastic_NodeInfoLite &evicted = meshNodes->at(oldestIndex);
|
|
#if WARM_NODE_COUNT > 0
|
|
// Demote to the warm tier so the identity (and crucially the
|
|
// PKI key) outlives the hot-store slot.
|
|
warmStore.absorb(evicted.num, evicted.last_heard, evicted.public_key.size == 32 ? evicted.public_key.bytes : NULL,
|
|
evicted.role, warmProtectedCategory(evicted), nodeInfoLiteHasXeddsaSigned(&evicted));
|
|
#endif
|
|
eraseNodeSatellites(evicted.num);
|
|
// Shove the remaining nodes down the chain
|
|
for (int i = oldestIndex; i < numMeshNodes - 1; i++) {
|
|
meshNodes->at(i) = meshNodes->at(i + 1);
|
|
}
|
|
(numMeshNodes)--;
|
|
}
|
|
}
|
|
// Don't append past the end of the vector. The protected-node cap
|
|
// (numProtectedNodes() <= MAX_NUM_NODES-2) means the eviction above frees
|
|
// a slot in normal operation; this guards the legacy case of a pre-cap
|
|
// database that is full of protected nodes - refuse rather than overrun.
|
|
if (numMeshNodes >= MAX_NUM_NODES)
|
|
return NULL;
|
|
// Pre-size before append when run before nodeDBSelfCare() (boot keygen); else at() aborts on nRF52.
|
|
if (static_cast<size_t>(numMeshNodes) >= meshNodes->size())
|
|
meshNodes->resize(numMeshNodes + 1);
|
|
// add the node at the end
|
|
lite = &meshNodes->at((numMeshNodes)++);
|
|
|
|
// everything is missing except the nodenum
|
|
memset(lite, 0, sizeof(*lite));
|
|
lite->num = n;
|
|
#if WARM_NODE_COUNT > 0
|
|
// Re-admission: restore what the warm tier kept for this node
|
|
WarmNodeEntry warm;
|
|
if (warmStore.take(n, warm)) {
|
|
lite->last_heard = warmTimeOf(warm); // mask off the stolen metadata bits
|
|
// Restore the role the warm tier cached, so re-admission isn't stuck at CLIENT
|
|
// until the next NodeInfo arrives.
|
|
lite->role = static_cast<meshtastic_Config_DeviceConfig_Role>(warmRoleOf(warm));
|
|
// Restore the signer bit too: it is learned from verified traffic, not from
|
|
// NodeInfo, so a round trip through the warm tier must not relearn it from zero.
|
|
nodeInfoLiteSetBit(lite, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, warmSignerOf(warm));
|
|
if (!memfll(warm.public_key, 0, sizeof(warm.public_key))) {
|
|
lite->public_key.size = 32;
|
|
memcpy(lite->public_key.bytes, warm.public_key, 32);
|
|
}
|
|
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());
|
|
}
|
|
|
|
return lite;
|
|
}
|
|
|
|
/// Sometimes we will have Position objects that only have a time, so check for
|
|
/// valid lat/lon
|
|
bool NodeDB::hasValidPosition(const meshtastic_NodeInfoLite *n)
|
|
{
|
|
if (!n)
|
|
return false;
|
|
if (n->num == getNodeNum()) {
|
|
return localPosition.latitude_i != 0 || localPosition.longitude_i != 0;
|
|
}
|
|
meshtastic_PositionLite pos;
|
|
return copyNodePosition(n->num, pos) && (pos.latitude_i != 0 || pos.longitude_i != 0);
|
|
}
|
|
|
|
/// If we have a node / user and they report is_licensed = true
|
|
/// we consider them licensed
|
|
UserLicenseStatus NodeDB::getLicenseStatus(uint32_t nodeNum)
|
|
{
|
|
const meshtastic_NodeInfoLite *info = getMeshNode(nodeNum);
|
|
if (!nodeInfoLiteHasUser(info))
|
|
return UserLicenseStatus::NotKnown;
|
|
return nodeInfoLiteIsLicensed(info) ? UserLicenseStatus::Licensed : UserLicenseStatus::NotLicensed;
|
|
}
|
|
|
|
#if !defined(MESHTASTIC_EXCLUDE_PKI)
|
|
bool NodeDB::checkLowEntropyPublicKey(const meshtastic_Config_SecurityConfig_public_key_t &keyToTest)
|
|
{
|
|
if (keyToTest.size == 32) {
|
|
uint8_t keyHash[32] = {0};
|
|
memcpy(keyHash, keyToTest.bytes, keyToTest.size);
|
|
crypto->hash(keyHash, 32);
|
|
for (uint16_t i = 0; i < sizeof(LOW_ENTROPY_HASHES) / sizeof(LOW_ENTROPY_HASHES[0]); i++) {
|
|
if (memcmp(keyHash, LOW_ENTROPY_HASHES[i], sizeof(LOW_ENTROPY_HASHES[0])) == 0) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
#endif
|
|
|
|
bool NodeDB::generateCryptoKeyPair(const uint8_t *privateKey)
|
|
{
|
|
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
|
|
// Only generate keys for non-licensed users and if the LoRa region is set. The native simulator
|
|
// boots region-UNSET but still needs a keypair so PKI-encrypted DMs work between sim nodes, so
|
|
// allow keygen there regardless of region.
|
|
bool regionBlocksKeygen = config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET;
|
|
#if ARCH_PORTDUINO
|
|
if (portduino_config.lora_module == use_simradio)
|
|
regionBlocksKeygen = false;
|
|
#endif
|
|
if (owner.is_licensed || regionBlocksKeygen) {
|
|
return false;
|
|
}
|
|
|
|
bool keygenSuccess = false;
|
|
// Record whether the stored key is a known compromised/low-entropy key so main.cpp can warn the
|
|
// user. A detected low-entropy key is regenerated below, but the flag stays set so the
|
|
// "Compromised keys were detected and regenerated" notification still fires.
|
|
keyIsLowEntropy = checkLowEntropyPublicKey(config.security.public_key);
|
|
|
|
// If a specific private key was provided, use it
|
|
if (privateKey != nullptr) {
|
|
LOG_INFO("Using provided private key for PKI");
|
|
memcpy(config.security.private_key.bytes, privateKey, 32);
|
|
config.security.private_key.size = 32;
|
|
config.security.public_key.size = 32;
|
|
|
|
// Generate public key from the provided private key
|
|
if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) {
|
|
keygenSuccess = true;
|
|
} else {
|
|
LOG_ERROR("Failed to generate public key from provided private key");
|
|
return false;
|
|
}
|
|
}
|
|
// Try to regenerate public key from existing private key if it's valid and not low entropy
|
|
else if (config.security.private_key.size == 32 && !keyIsLowEntropy) {
|
|
config.security.public_key.size = 32;
|
|
LOG_DEBUG("Regenerate PKI public key from existing private key");
|
|
if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) {
|
|
keygenSuccess = true;
|
|
}
|
|
} else {
|
|
// Generate a new key pair
|
|
LOG_INFO("Generate new PKI keys");
|
|
config.security.public_key.size = 32;
|
|
config.security.private_key.size = 32;
|
|
crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes);
|
|
keygenSuccess = true;
|
|
}
|
|
|
|
// Update sizes and copy to owner if successful
|
|
if (keygenSuccess) {
|
|
owner.public_key.size = 32;
|
|
memcpy(owner.public_key.bytes, config.security.public_key.bytes, 32);
|
|
|
|
// Set the DH private key for crypto operations
|
|
LOG_DEBUG("Set DH private key for crypto operations");
|
|
crypto->setDHPrivateKey(config.security.private_key.bytes);
|
|
|
|
// Conditionally create new identity based on parameter
|
|
createNewIdentity();
|
|
}
|
|
return keygenSuccess;
|
|
#else
|
|
return false;
|
|
#endif
|
|
}
|
|
|
|
bool NodeDB::createNewIdentity()
|
|
{
|
|
uint32_t oldNodeNum = getNodeNum();
|
|
uint32_t newNodeNum = crc32Buffer(config.security.public_key.bytes, config.security.public_key.size);
|
|
|
|
// If the key hasn't changed, nothing to do
|
|
if (newNodeNum == oldNodeNum)
|
|
return false;
|
|
|
|
// Remove the old node entry entirely rather than retiring it in place. Flagging the old identity as
|
|
// ignored (the previous behavior) left a keyless ghost of ourselves that survived cleanup/eviction, was
|
|
// still streamed to clients, and made any DM/admin aimed at it fail forever with PKI_SEND_FAIL_PUBLIC_KEY.
|
|
// removeNodeByNum() drops the lite entry, its satellite stores, and the warm-tier copy.
|
|
if (getMeshNode(oldNodeNum) != NULL) {
|
|
LOG_DEBUG("Old node num %u is now %u, removing stale identity", oldNodeNum, newNodeNum);
|
|
removeNodeByNum(oldNodeNum);
|
|
} else {
|
|
// Lite entry already absent: drop any orphaned satellite-store entries directly.
|
|
eraseNodeSatellites(oldNodeNum);
|
|
}
|
|
|
|
myNodeInfo.my_node_num = newNodeNum;
|
|
|
|
meshtastic_NodeInfoLite *info = getOrCreateMeshNode(getNodeNum());
|
|
if (!info)
|
|
return false;
|
|
TypeConversions::CopyUserToNodeInfoLite(info, owner);
|
|
|
|
return true;
|
|
}
|
|
|
|
bool NodeDB::backupPreferences(meshtastic_AdminMessage_BackupLocation location)
|
|
{
|
|
bool success = false;
|
|
lastBackupAttempt = millis();
|
|
#ifdef FSCom
|
|
if (location == meshtastic_AdminMessage_BackupLocation_FLASH) {
|
|
meshtastic_BackupPreferences backup = meshtastic_BackupPreferences_init_zero;
|
|
backup.version = DEVICESTATE_CUR_VER;
|
|
backup.timestamp = getValidTime(RTCQuality::RTCQualityDevice, false);
|
|
backup.has_config = true;
|
|
backup.config = config;
|
|
backup.has_module_config = true;
|
|
backup.module_config = moduleConfig;
|
|
backup.has_channels = true;
|
|
backup.channels = channelFile;
|
|
backup.has_owner = true;
|
|
backup.owner = owner;
|
|
|
|
size_t backupSize;
|
|
pb_get_encoded_size(&backupSize, meshtastic_BackupPreferences_fields, &backup);
|
|
|
|
spiLock->lock();
|
|
FSCom.mkdir("/backups");
|
|
spiLock->unlock();
|
|
success = saveProto(backupFileName, backupSize, &meshtastic_BackupPreferences_msg, &backup);
|
|
|
|
if (success) {
|
|
LOG_INFO("Saved backup preferences");
|
|
} else {
|
|
LOG_ERROR("Failed to save backup preferences to file");
|
|
}
|
|
} else if (location == meshtastic_AdminMessage_BackupLocation_SD) {
|
|
// TODO: After more mainline SD card support
|
|
}
|
|
#endif
|
|
return success;
|
|
}
|
|
|
|
bool NodeDB::restorePreferences(meshtastic_AdminMessage_BackupLocation location, int restoreWhat)
|
|
{
|
|
bool success = false;
|
|
#ifdef FSCom
|
|
if (location == meshtastic_AdminMessage_BackupLocation_FLASH) {
|
|
spiLock->lock();
|
|
if (!FSCom.exists(backupFileName)) {
|
|
spiLock->unlock();
|
|
LOG_WARN("Could not restore. No backup file found");
|
|
return false;
|
|
} else {
|
|
spiLock->unlock();
|
|
}
|
|
meshtastic_BackupPreferences backup = meshtastic_BackupPreferences_init_zero;
|
|
success = loadProto(backupFileName, meshtastic_BackupPreferences_size, sizeof(meshtastic_BackupPreferences),
|
|
&meshtastic_BackupPreferences_msg, &backup);
|
|
if (success) {
|
|
if (restoreWhat & SEGMENT_CONFIG) {
|
|
config = backup.config;
|
|
LOG_DEBUG("Restored config");
|
|
}
|
|
if (restoreWhat & SEGMENT_MODULECONFIG) {
|
|
moduleConfig = backup.module_config;
|
|
LOG_DEBUG("Restored module config");
|
|
}
|
|
if (restoreWhat & SEGMENT_DEVICESTATE) {
|
|
devicestate.owner = backup.owner;
|
|
LOG_DEBUG("Restored device state");
|
|
}
|
|
if (restoreWhat & SEGMENT_CHANNELS) {
|
|
channelFile = backup.channels;
|
|
LOG_DEBUG("Restored channels");
|
|
}
|
|
|
|
success = saveToDisk(restoreWhat);
|
|
if (success) {
|
|
LOG_INFO("Restored preferences from backup");
|
|
} else {
|
|
LOG_ERROR("Failed to save restored preferences to flash");
|
|
}
|
|
} else {
|
|
LOG_ERROR("Failed to restore preferences from backup file");
|
|
}
|
|
} else if (location == meshtastic_AdminMessage_BackupLocation_SD) {
|
|
// TODO: After more mainline SD card support
|
|
}
|
|
#endif
|
|
return success;
|
|
}
|
|
|
|
/// Record an error that should be reported via analytics
|
|
void recordCriticalError(meshtastic_CriticalErrorCode code, uint32_t address, const char *filename)
|
|
{
|
|
if (filename) {
|
|
LOG_ERROR("NOTE! Record critical error %d at %s:%lu", code, filename, address);
|
|
} else {
|
|
LOG_ERROR("NOTE! Record critical error %d, address=0x%lx", code, address);
|
|
}
|
|
|
|
// Record error to DB
|
|
error_code = code;
|
|
error_address = address;
|
|
|
|
// Currently portuino is mostly used for simulation. Make sure the user notices something really bad happened
|
|
#ifdef ARCH_PORTDUINO
|
|
LOG_ERROR("A critical failure occurred");
|
|
// TODO: Determine if other critical errors should also cause an immediate exit
|
|
if (code == meshtastic_CriticalErrorCode_FLASH_CORRUPTION_RECOVERABLE ||
|
|
code == meshtastic_CriticalErrorCode_FLASH_CORRUPTION_UNRECOVERABLE)
|
|
exit(2);
|
|
#endif
|
|
}
|