mirror of
https://github.com/alexhopeoconnor/firmware.git
synced 2026-10-04 03:18:10 +10:00
Merge branch 'meshtastic:develop' into nailed-test-number
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
# NodeInfo stores: the base and extended databases
|
||||
|
||||
This document is an overview of the node-identity and traffic-state databases that the
|
||||
TrafficManagementModule (TMM) either owns or leans on. There are four stores in play, but
|
||||
only three form the identity lookup chain:
|
||||
|
||||
1. **NodeDB hot store** - the authoritative `NodeInfoLite` array (identity tier 1).
|
||||
2. **Warm tier** (`WarmNodeStore`) - minimal persisted records for hot-store evictees
|
||||
(identity tier 2).
|
||||
3. **TMM NodeInfo payload cache** (extended) - the ephemeral **third identity tier**: full
|
||||
`User` payloads plus direct-response metadata; PSRAM-backed on hardware, plain heap in
|
||||
native tests.
|
||||
|
||||
The fourth store, the **TMM unified cache** (base - flat 10-byte-per-node traffic-shaping
|
||||
state), is not part of that chain: it sits beside it, keyed by the same NodeNum, and only
|
||||
its 4-bit cached role acts as a final fallback when all three identity tiers miss.
|
||||
|
||||
Sources of truth: `src/mesh/NodeDB.{h,cpp}`, `src/mesh/WarmNodeStore.h`,
|
||||
`src/modules/TrafficManagementModule.{h,cpp}`, sizing in `src/mesh/mesh-pb-constants.h`.
|
||||
|
||||
---
|
||||
|
||||
## 1. NodeDB hot store (authoritative)
|
||||
|
||||
- **What:** the classic `meshNodes` array of `meshtastic_NodeInfoLite` - full identity as
|
||||
flattened fields (names, role, public key, bitfield flags such as `HAS_XEDDSA_SIGNED`;
|
||||
position/telemetry live in satellite stores reached via copy-out accessors, not nested
|
||||
members). Everything else in this document is a cache or a fallback for it.
|
||||
- **Capacity:** `MAX_NUM_NODES`, per platform - 250 on native, 120 on nRF52840/generic
|
||||
ESP32, 10 on STM32WL (see `mesh-pb-constants.h`).
|
||||
- **Eviction:** oldest non-protected node when full (`getOrCreateMeshNode`). On eviction
|
||||
the node's essentials are **absorbed into the warm tier** (see §2); on re-admission the
|
||||
warm record is rehydrated back (`take()`), including the signer bit.
|
||||
- **Persistence:** the node database file, saved on the usual NodeDB cadence.
|
||||
- **Authority:** key pinning (`updateUser`'s "Public Key mismatch" drop), signer
|
||||
provenance, and identity content all originate here. The lookup helpers that other
|
||||
stores mirror:
|
||||
- `copyPublicKeyAuthoritative(n, out)` - hot store, then warm tier. The pin reference
|
||||
for caches; never consults opportunistic caches.
|
||||
- `copyPublicKey(n, out)` - the above, then **TMM's NodeInfo cache as last resort**
|
||||
(extends the encrypt-to pool for nodes both tiers have forgotten).
|
||||
- `isVerifiedSignerForKey(n, key32)` - key-matched signer verdict across hot + warm.
|
||||
- `isKnownXeddsaSigner(n)` - key-agnostic "should this node's signable traffic arrive
|
||||
signed", across hot + warm. Gates that check only the hot store would let a
|
||||
warm-evicted signer be impersonated with unsigned frames.
|
||||
- `getNodeRole(n)` - hot store, then the role cached in the warm tier, else `CLIENT`.
|
||||
|
||||
## 2. Warm tier - `WarmNodeStore` (NodeDB-owned)
|
||||
|
||||
- **What:** the "long-tail" second tier. When a node ages out of the hot store, a minimal
|
||||
record survives so DMs keep encrypting: the key is expensive to re-learn; everything
|
||||
else rebuilds from traffic in seconds.
|
||||
- **Entry:** exactly 40 bytes - `num(4) | last_heard(4) | public_key(32)`. The low 7 bits
|
||||
of `last_heard` are omitted, and replaced with metadata (role: 4 bits, protected
|
||||
category: 2, signer bit: 1), leaving ~128 s recency resolution - plenty for LRU ranking.
|
||||
- **Capacity:** `WARM_NODE_COUNT` (100 on constrained parts; platform-tiered).
|
||||
- **Eviction:** LRU by `last_heard`, with keyed entries outranking keyless; keyless
|
||||
candidates never displace keyed entries.
|
||||
- **Persistence:** nRF52840 uses a 12 KB raw-flash record-ring below LittleFS
|
||||
(append/replay/compact); everywhere else `/prefs/warm.dat`.
|
||||
- **Membership invariant:** a node lives in the hot **XOR** warm tier. `take()` removes
|
||||
the warm record when the node is re-admitted hot, restoring role/protected/signer bits.
|
||||
|
||||
## 3. TMM unified cache (base, traffic state)
|
||||
|
||||
- **What:** TMM's own flat array of packed 10-byte `UnifiedCacheEntry` records - the
|
||||
per-node state behind position dedup, rate limiting, unknown-packet filtering, plus two
|
||||
piggybacked caches:
|
||||
- `next_hop` - last-byte relay hint, written only from ACK-confirmed NextHopRouter
|
||||
decisions (no TTL; keeps the slot alive across sweeps).
|
||||
- a **4-bit device role** (split across the top bits of two count bytes) - the _third_
|
||||
fallback for role-aware policy after the hot store and warm tier, surviving even total
|
||||
NodeDB eviction. Read through `resolveSenderRole()`, refreshed by
|
||||
`updateCachedRoleFromNodeInfo()` on observed NodeInfo.
|
||||
- **Entry layout:**
|
||||
`node(4) | pos_fingerprint(1) | rate_count(1) | unknown_count(1) | pos_time(1) | rate_unknown_time(1) | next_hop(1)`
|
||||
= 10 bytes, all platforms. Timestamps are free-running modular ticks (uint8 / nibbles)
|
||||
with presence carried by non-zero sentinels - no epochs, no absolute time.
|
||||
- **Capacity:** `TRAFFIC_MANAGEMENT_CACHE_SIZE`, per memory class: 2048 (PSRAM S3 /
|
||||
native), 500 (medium), 400 (small), 250 (nRF52840 - deliberately class-deviant for heap
|
||||
headroom), 0 when `HAS_TRAFFIC_MANAGEMENT=0`. Variant-overridable.
|
||||
- **Eviction:** linear scan; insertion on a full cache evicts the stalest entry,
|
||||
preferring to keep entries with a `next_hop` hint **or** a cached special (non-`CLIENT`)
|
||||
role - the long-tail state this cache exists to retain (`findOrCreateEntry`'s `preferred`
|
||||
test covers both, not just `next_hop`).
|
||||
- **Persistence:** none - RAM/PSRAM only, rebuilt from traffic.
|
||||
|
||||
## 4. TMM NodeInfo payload cache (extended, the ephemeral third tier)
|
||||
|
||||
- **What:** a flat array of `NodeInfoPayloadEntry` (PSRAM-backed on hardware; see
|
||||
Availability) - the full cached `User` payload (names, role, key) plus the metadata that
|
||||
backs TMM's **spoofed direct NodeInfo replies** on a target's behalf, independent of
|
||||
NodeDB (the serve/throttle behaviour is documented in
|
||||
[traffic_management_module.md](traffic_management_module.md)). Also the last-resort key
|
||||
source for `NodeDB::copyPublicKey()`.
|
||||
- **Availability:** `TMM_HAS_NODEINFO_CACHE` - ESP32 with PSRAM (production home; 2000
|
||||
entries is too large for MCU internal RAM), plus native unit-test builds on the plain
|
||||
heap so the trust/retention paths run in CI.
|
||||
- **Entry:** `node`, `user` (full nanopb `User`), the `obsTick` recency stamp (3 min/tick),
|
||||
`sourceChannel`, `decodedBitfield`, and packed 1-bit flags: `hasDecodedBitfield`,
|
||||
`keySignerProven`, `hasObserved`, `hasFullUser`, `isMember`. (The direct-response throttle
|
||||
no longer keeps per-entry state here - it is a pair of separate RAM tables; see the module
|
||||
doc.)
|
||||
- **Capacity:** `kNodeInfoCacheEntries = 2000`, linear scan (NodeInfo traffic is
|
||||
low-rate).
|
||||
- **Persistence:** none - this tier is deliberately ephemeral; it reconstructs from NodeDB
|
||||
seeding plus observed traffic after every boot.
|
||||
|
||||
### Trust & provenance model
|
||||
|
||||
- **Key pin, three layers deep:** an incoming NodeInfo key is checked against
|
||||
`copyPublicKeyAuthoritative()` (hot then warm - the same coverage as `updateUser`'s own
|
||||
pin), and, failing NodeDB knowledge, against the cache's **own previously cached key**
|
||||
(TOFU pin). Mismatches are dropped, never overwritten. A frame advertising _our own_ key
|
||||
is dropped outright (impersonation).
|
||||
- **`keySignerProven`:** set when a frame's XEdDSA signature was router-verified
|
||||
(`mp.xeddsa_signed`) or when NodeDB already knew the node as a signer **for the same
|
||||
key** (`isVerifiedSignerForKey`). Monotonic per slot; a changed key resets it.
|
||||
- **Unsigned-identity gate:** a NodeInfo arriving _unsigned_ from a node we have ever
|
||||
verified as a signer - per `NodeDB::isKnownXeddsaSigner()`, which covers hot **and
|
||||
warm** tiers - drives no cache, role, or `updateUser()` write. (Warm coverage matters: a
|
||||
signer evicted to the warm tier would otherwise be forgeable with its own public key
|
||||
until re-heard. The same rule guards `Router::checkXeddsaReceivePolicy`'s
|
||||
unsigned-broadcast drop.)
|
||||
- **Serve gate honesty:** only a genuinely _heard_ NODEINFO frame stamps
|
||||
`obsTick`/`hasObserved`. Seeding and write-through are knowledge, not observation - they
|
||||
can never make a silent node look alive to the replay path. The 6 h serve window is
|
||||
enforced by the sweep-cleared `hasObserved` bit; the spoofed-reply throttle that gate
|
||||
feeds lives in the module (see [traffic_management_module.md](traffic_management_module.md)).
|
||||
|
||||
### Consistency with NodeDB (anti-entropy)
|
||||
|
||||
Four mechanisms keep this tier a superset of NodeDB's identities. All **merge rather than
|
||||
overwrite**, so a keyless commit never costs the cache a learned TOFU key.
|
||||
|
||||
| Mechanism | When | Role |
|
||||
| --------------------------------------------------------------------- | --------------------------- | -------------------------------- |
|
||||
| Write-through hooks (`onNodeIdentityCommitted`, `onNodeKeyCommitted`) | every identity/key commit | immediate upsert |
|
||||
| Reconcile sweep (`reconcileNodeInfoFromNodeDBLocked`) | boot seed, then hourly | re-seed from hot + warm tiers |
|
||||
| Membership refresh | inside the hourly reconcile | re-mark which nodes NodeDB holds |
|
||||
| Purge hooks (`purgeNode`, `purgeAll`) | node removal / reset | drop the node from both caches |
|
||||
|
||||
Two details that bite: the reconcile sweep transfers signer verdicts only when **key-matched**;
|
||||
and membership refresh clears-then-re-marks from both tiers rather than a per-entry NodeDB lookup
|
||||
each sweep (which would be O(entries x members) under the lock). A keyless warm-tier record still
|
||||
marks membership (`isMember`) even though it has no `User` to seed - `isMember` is a keep-alive,
|
||||
independent of `hasFullUser`. Because the re-mark is only hourly, hook-driven additions and
|
||||
`purgeNode()` removals are immediate, but a **passive** NodeDB eviction may lag membership by up to
|
||||
an hour.
|
||||
|
||||
**Retention:** no timed eviction. Slots die only by LRU displacement on insert, ranked by
|
||||
trust tiers - members and signer-proven keys are stickiest; the seeding pass additionally
|
||||
refuses to churn one member out for another (`spareMembers`).
|
||||
|
||||
**Key-commit funnel:** every path that writes a remote key into the hot store must route
|
||||
the write-through. Full-identity commits funnel through `NodeDB::updateUser()`; bare-key
|
||||
commits (admin-channel learn in `Router::perhapsDecode`, manual verification in
|
||||
`KeyVerificationModule`) funnel through `NodeDB::commitRemoteKey()`, which carries an
|
||||
explicit `KeyCommitTrust` provenance (`ManuallyVerified` maps to `proven=true` in this
|
||||
cache). Never assign `info->public_key` directly when **learning or rotating a remote
|
||||
key** - the cache would silently diverge until the next reconcile. (The lone direct write
|
||||
in `getOrCreateMeshNode()`'s warm-tier re-admission is exempt: it restores a key the warm
|
||||
tier already holds, which this cache already tracks as a member, so nothing new is learned
|
||||
and the hourly reconcile re-seeds it even if the packet path had LRU-evicted that slot.)
|
||||
|
||||
**Enable gate:** the write-through hooks, the sweep, the packet path, **and the
|
||||
`copyPublicKey()`/`copyUser()` accessors** all no-op while `moduleConfig.has_traffic_management`
|
||||
is off, so cache content, maintenance, and reads are keyed to the same condition. This enforces
|
||||
(not just documents) the corollary that the pubkey-pool superset property holds only while the
|
||||
module is enabled: a disabled module's frozen cache never feeds PKI resolution or name
|
||||
rehydration.
|
||||
|
||||
### Tick clocks and wrap safety
|
||||
|
||||
All TMM timestamps are free-running modular ticks (uint8 or nibble) from `clockMs()`; modular
|
||||
subtraction is correct only while the true age stays below the counter period, so every clock
|
||||
needs something to clear expired state before it aliases.
|
||||
|
||||
| Clock | Tick / period | Window | Kept honest by |
|
||||
| ------------------ | -------------- | --------------- | -------------------------------------------------- |
|
||||
| pos | 6 min / 25.6 h | <=255 ticks | 60 s sweep (margin as low as 1 tick at the clamp) |
|
||||
| rate | 5 min / 80 min | <=15 ticks | sweep + read-time window reset (`isRateLimited()`) |
|
||||
| unknown | 1 min / 16 min | 12 ticks | sweep + read-time window reset |
|
||||
| NodeInfo `obsTick` | 3 min / 12.8 h | 120 ticks (6 h) | sweep only |
|
||||
|
||||
`obsTick` is the sharp case: `maintainNodeInfoCacheLocked()` clearing `hasObserved` is the
|
||||
_sole_ guarantee the 6 h serve gate never reads an aliased stamp. That makes the sweep a
|
||||
compile-time invariant - guarded by `TMM_HAS_NODEINFO_CACHE` **alone** (never
|
||||
`TRAFFIC_MANAGEMENT_CACHE_SIZE`, which a variant may zero independently), mirroring `purgeAll()`:
|
||||
a build that has the cache always has its sweep.
|
||||
|
||||
The warm tier is different by design: `WarmNodeStore.last_heard` is an **absolute** unix-seconds
|
||||
timestamp (128 s quantised), so it cannot wrap until 2106 and needs no sweep - the TMM caches
|
||||
chose 1-byte ticks instead to stay at 10 B/entry across up to 2048 entries.
|
||||
|
||||
### Direct-response behavior
|
||||
|
||||
How this cache's identities are served as spoofed direct NodeInfo replies - the serve gates,
|
||||
the per-requester/per-target/global throttle, and the "throttled forwards, not dropped"
|
||||
behaviour - is documented with the module in
|
||||
[traffic_management_module.md](traffic_management_module.md).
|
||||
|
||||
---
|
||||
|
||||
## Property matrix
|
||||
|
||||
Side-by-side view of what each store actually holds ("-" = not held). Details and
|
||||
rationale live in the per-store sections above.
|
||||
|
||||
| Property | 1. Hot store (`NodeInfoLite`) | 2. Warm tier (`WarmNodeEntry`) | 3. NodeInfo cache (`NodeInfoPayloadEntry`) | 4. Unified cache (`UnifiedCacheEntry`) |
|
||||
| ------------------------- | -------------------------------- | ---------------------------------------------- | ------------------------------------------------------------- | ---------------------------------------------------- |
|
||||
| Node number | yes | yes | yes (0 = free slot) | yes (0 = free slot) |
|
||||
| Names + user id | yes (flattened fields) | - | yes (full `User`, when `hasFullUser`) | - |
|
||||
| Public key (32 B) | yes (authoritative) | yes (keyed entries) | yes (TOFU or proven; pinned against tiers 1-2) | - |
|
||||
| Signer provenance | `HAS_XEDDSA_SIGNED` bitfield bit | 1 signer bit (shared with `last_heard`) | `keySignerProven` (monotonic per key) | - |
|
||||
| Device role | `role` field | 4-bit role (metadata steal) | inside the cached `User` | 4-bit role in count-byte top bits (final fallback) |
|
||||
| Recency | `last_heard` (unix secs) | `last_heard` (unix secs, 128 s quantised) | `obsTick` (3 min modular tick) + `hasObserved` | pos/rate/unknown modular ticks |
|
||||
| Position / telemetry | via satellite copy-out accessors | - | - | 8-bit position _fingerprint_ only (dedup) |
|
||||
| Protected / favorite | bitfield flags | 2-bit protected category | - (`isMember` keep-alive instead) | - |
|
||||
| Routing hint (`next_hop`) | yes (persisted field) | - | - | ACK-confirmed relay byte (preloaded from tier 1) |
|
||||
| Direct-reply metadata | - | - | `sourceChannel`, `decodedBitfield` (+ `hasDecodedBitfield`) | - |
|
||||
| Traffic-shaping counters | - | - | - | rate + unknown counts, pos fingerprint |
|
||||
| Entry size | largest (full struct) | 40 B exact | ~`sizeof(User)`+8, platform-padded (no size assert by design) | 10 B exact |
|
||||
| Capacity | `MAX_NUM_NODES` (250/120/10) | `WARM_NODE_COUNT` (~100) | `kNodeInfoCacheEntries` (2000) | `TRAFFIC_MANAGEMENT_CACHE_SIZE` (2048/500/400/250/0) |
|
||||
| Persistence | node DB file | raw-flash ring (nRF52840) or `/prefs/warm.dat` | none (rebuilt from seed + traffic) | none |
|
||||
| Storage | RAM | RAM + flash | PSRAM on hardware; plain heap in native tests | PSRAM when available, else heap |
|
||||
|
||||
## How a lookup falls through the tiers
|
||||
|
||||
```text
|
||||
identity/role/key consumer
|
||||
│
|
||||
▼
|
||||
1. hot store (NodeInfoLite) full identity, authoritative
|
||||
│ miss
|
||||
▼
|
||||
2. warm tier (WarmNodeStore) key + role/protected/signer bits, persisted
|
||||
│ miss
|
||||
▼
|
||||
3. TMM NodeInfo cache (extended) full User payloads + TOFU/proven keys, ephemeral
|
||||
│ miss (role-only: 4-bit role in the unified cache)
|
||||
▼
|
||||
defaults (no key; role = CLIENT)
|
||||
```
|
||||
|
||||
The unified cache (§3) sits beside this chain rather than in it: it is traffic-shaping
|
||||
state keyed by the same NodeNum, whose role bits act as the final role fallback when all
|
||||
three identity tiers miss.
|
||||
@@ -0,0 +1,193 @@
|
||||
# The Traffic Management Module (TMM)
|
||||
|
||||
TMM is an optional module that shapes **transit** traffic on busy meshes. Large networks get
|
||||
noisy fast - repeated position packets, bursty senders, and unknown/undecryptable frames all
|
||||
burn limited airtime and power - and TMM filters or answers that traffic before it is
|
||||
rebroadcast. On supported targets it **ships enabled** (`has_traffic_management` defaults to
|
||||
true) with position dedup running at its 11 h default; the other features each default off, so
|
||||
the module is on out of the box but opt-in per feature. It was introduced in
|
||||
[meshtastic/firmware#9358](https://github.com/meshtastic/firmware/pull/9358).
|
||||
|
||||
This document covers the module's behaviour, with a deep dive on the two TMM-specific
|
||||
NodeInfo features - **direct-serve** (answering NodeInfo requests on another node's behalf)
|
||||
and the **throttling** that bounds it. The identity/traffic-state stores those features read
|
||||
from are documented separately in [node_info_stores.md](node_info_stores.md); this file owns
|
||||
the direct-serve and throttle behaviour, that file owns the stores.
|
||||
|
||||
Sources of truth: `src/modules/TrafficManagementModule.{h,cpp}`, defaults in
|
||||
`src/mesh/Default.h`.
|
||||
|
||||
---
|
||||
|
||||
## How it runs
|
||||
|
||||
- **Enablement is three-gated.** Compile-time `HAS_TRAFFIC_MANAGEMENT` (with the
|
||||
`MESHTASTIC_EXCLUDE_TRAFFIC_MANAGEMENT` build exclusion), then the runtime
|
||||
`moduleConfig.has_traffic_management` presence flag. While the runtime gate is off, the
|
||||
packet path, the maintenance sweep, the NodeDB write-through hooks, and the cache accessors
|
||||
all no-op - content, maintenance, and reads are keyed to the same condition.
|
||||
- **It runs before `RoutingModule`** in `callModules()`. Returning `STOP` from
|
||||
`handleReceived()` fully consumes a packet, so it is never rebroadcast; `CONTINUE` lets it
|
||||
proceed through normal relay handling.
|
||||
- **State is cheap.** Per-node traffic-shaping counters live in a flat 10-byte
|
||||
`UnifiedCacheEntry` array (position fingerprint, rate/unknown counters, modular tick
|
||||
stamps, a next-hop hint, and a 4-bit role fallback) - see
|
||||
[node_info_stores.md §3](node_info_stores.md). Direct-serve additionally reads the PSRAM
|
||||
NodeInfo payload cache (or the NodeDB fallback when that cache is absent).
|
||||
|
||||
## What it does
|
||||
|
||||
| Feature | Default | In one line |
|
||||
| ------------------------ | -------------- | -------------------------------------------------------------- |
|
||||
| Position dedup | on, 11 h | Suppresses a stationary sender's repeated position broadcasts. |
|
||||
| Per-sender rate limit | off | Caps how many transit packets one sender may spend per window. |
|
||||
| Unknown-packet filter | off | Drops a sender's undecryptable traffic past a threshold. |
|
||||
| NodeInfo direct response | off | Answers a NodeInfo request on the target's behalf (see below). |
|
||||
| Position precision clamp | channel-driven | Truncates relayed position to the channel's precision. |
|
||||
|
||||
Config lives under `moduleConfig.traffic_management`; the per-feature sections below give the
|
||||
exact fields, defaults, and behaviour. NodeInfo direct response has its own deep-dive sections
|
||||
after these.
|
||||
|
||||
### Position dedup
|
||||
|
||||
`position_min_interval_secs` (default 11 h; `0` disables). Drops a duplicate position from the
|
||||
same sender inside the interval, where "duplicate" means the same fingerprint on the channel's
|
||||
`position_precision` grid (firmware default 19-bit, ~90 m cells). Role caps only ever _shorten_
|
||||
the interval: **tracker / TAK tracker → 1 h**, **lost-and-found → 15 min**.
|
||||
|
||||
### Per-sender rate limit
|
||||
|
||||
`rate_limit_window_secs` + `rate_limit_max_packets` (default off; either `0` disables). Drops a
|
||||
sender's transit packets once it exceeds the budget within the window.
|
||||
|
||||
### Unknown-packet filter
|
||||
|
||||
`unknown_packet_threshold` (default `0` = off). Drops undecryptable traffic from a sender once it
|
||||
passes the threshold within a ~5 min window.
|
||||
|
||||
### NodeInfo direct response
|
||||
|
||||
`nodeinfo_direct_response_max_hops` (default `0` = off). When set, a neighbour that already
|
||||
holds the target's identity answers a unicast NodeInfo request on its behalf, saving the full
|
||||
round trip. This is TMM's most security-sensitive feature; the serve gates and the throttle
|
||||
that bounds it are covered in the two dedicated sections below.
|
||||
|
||||
### Position precision clamp
|
||||
|
||||
Driven by the channel's `position_precision` ceiling (else the 19-bit firmware default).
|
||||
`alterReceived()` truncates relayed position coordinates to that precision.
|
||||
|
||||
### Shelved
|
||||
|
||||
Present in the config surface but currently no-ops in the module, deferred until the right
|
||||
heuristics are settled: hop exhaustion for position/telemetry (`exhaust_hop_position` /
|
||||
`exhaust_hop_telemetry`) and `router_preserve_hops`. `alterReceived()` leaves rebroadcast hop
|
||||
handling untouched.
|
||||
|
||||
---
|
||||
|
||||
## NodeInfo direct response (direct-serve)
|
||||
|
||||
Normally a unicast NodeInfo request travels all the way to the target and the reply travels
|
||||
all the way back. On a large mesh that is several hops of airtime per lookup. When
|
||||
`nodeinfo_direct_response_max_hops > 0`, a neighbour that already holds the target's identity
|
||||
answers **on the target's behalf** with a spoofed reply, cutting the round trip to one hop.
|
||||
|
||||
**Data source.** The reply payload comes from the TMM NodeInfo payload cache (PSRAM-backed;
|
||||
full cached `User` plus provenance metadata) or, on builds without that cache, from the
|
||||
NodeDB fallback. Both are described in [node_info_stores.md §4](node_info_stores.md); this
|
||||
feature is a _consumer_ of them.
|
||||
|
||||
**Decision pipeline** (`shouldRespondToNodeInfo()`), in order - any failure returns `false`
|
||||
and the request is left to propagate normally:
|
||||
|
||||
1. **Eligibility** (checked by the caller): `nodeinfo_direct_response_max_hops > 0`,
|
||||
`NODEINFO_APP` portnum, `want_response`, and the packet is unicast, not to us, not from us.
|
||||
2. **Hop clamp** (`isMinHopsFromRequestor()`): respond only when the requester is within the
|
||||
role-clamped hop ceiling - **routers up to 3 hops** (`kRouterDefaultMaxHops`, may be
|
||||
lowered by config), **clients direct-only, 0 hops** (`kClientDefaultMaxHops`).
|
||||
3. **Identity lookup**: NodeInfo cache hit (cache path) or NodeDB fallback (fallback path).
|
||||
4. **Staleness gate (6 h)**: never vouch for a node not genuinely _heard_ within the serve
|
||||
window. Only a real observed frame stamps the recency bit - seeding and write-through are
|
||||
knowledge, not observation, so a silent node can never look alive to this path.
|
||||
5. **Signer-provenance gate** (`TMM_NODEINFO_REPLAY_SIGNED_GATE`, default on): vouch only for
|
||||
an identity whose key is signer-proven (XEdDSA-verified, directly or inherited from
|
||||
NodeDB). A trust-on-first-use identity is left for the genuine node - or another
|
||||
cache-holder that _has_ proof - to answer. Bypassed when PKI is compiled out.
|
||||
6. **Throttle** (`directResponseAllowed()`): see the next section.
|
||||
|
||||
**The spoofed reply.** On success TMM emits a NodeInfo reply with `from` set to the _target_
|
||||
(so the requester sees a valid answer), `to` the requester, `hop_limit = 0` (one hop only),
|
||||
`request_id` the original packet id, and the OK_TO_MQTT bit set from local
|
||||
`config.lora.config_ok_to_mqtt` policy. The requester's own identity claim in the request is
|
||||
**not** written back to NodeDB - a unicast NodeInfo is unsigned, so treating it as an
|
||||
identity update would be unauthenticated. `nodeinfo_cache_hits` counts only replies actually
|
||||
sent.
|
||||
|
||||
---
|
||||
|
||||
## Throttling direct responses
|
||||
|
||||
A direct reply is addressed to the requesting packet's `from` and spoofs the requested
|
||||
target - and **both fields are unauthenticated header data**. Without a bound, an attacker
|
||||
crafts requests carrying a victim's address as `from`, and every neighbour holding the target
|
||||
transmits at the victim: a reflector-amplification primitive. The throttle is the security
|
||||
core of this feature, checked immediately before a reply would go out so requests declined for
|
||||
other reasons never consume the budget.
|
||||
|
||||
**Three bounds**, all keyed off `clockMs()` and evaluated under `cacheLock`:
|
||||
|
||||
| Bound | Window | Bounds |
|
||||
| ------------------------------------------------ | ------ | ------------------------------------------------ |
|
||||
| Per requester (`kDirectResponsePerRequesterMs`) | 60 s | how much any single node can be made to receive |
|
||||
| Per target (`kDirectResponsePerTargetMs`) | 60 s | how often we vouch for the same identity |
|
||||
| Global airtime floor (`kDirectResponseGlobalMs`) | 1 s | total spoofed TX, regardless of key distribution |
|
||||
|
||||
**Mechanism.** The two per-key bounds are fixed **8-slot LRU tables in internal RAM**
|
||||
(`directRequesterSeen`, `directTargetSeen`) - _not_ the PSRAM NodeInfo cache - so they behave
|
||||
identically with and without PSRAM, on the cache path and the NodeDB-fallback path alike.
|
||||
Timestamps are full `uint32` milliseconds compared by wrap-safe subtraction, so there is no
|
||||
tick clock and no maintenance sweep to keep them honest. `directResponseAllowed(requester,
|
||||
target, now)` resolves a slot in _both_ tables before stamping either - so a reply one axis
|
||||
throttles never consumes the other axis's budget - then records the send on all three bounds.
|
||||
The global floor is a single stamp, checked first as the cheap common case.
|
||||
|
||||
**When a table fills.** For an unseen key with no free slot, `directResponseSlot()` evicts the
|
||||
**least-recently-used** entry (smallest last-reply time) and admits the new key. The LRU
|
||||
victim is by construction the entry closest to expiring anyway, so eviction is the
|
||||
lowest-cost choice. An attacker who cycles more than 8 distinct requesters or targets - easy,
|
||||
since both are unauthenticated - evicts entries and defeats _per-key_ throttling for the
|
||||
cycled keys; that is expected, and why the **global 1 s floor is the hard backstop**. It is a
|
||||
single stamp, cannot fill, and caps total spoofed replies at ~1/s no matter what. Per-key
|
||||
throttling degrades gracefully to the floor under pressure.
|
||||
|
||||
**Throttled is not dropped.** A throttled request returns `false`, which lets
|
||||
`handleReceived()` `CONTINUE`: the request forwards toward the genuine target (which can
|
||||
answer itself) rather than being black-holed. A requester whose first reply was lost on a
|
||||
noisy link would otherwise get silence for the whole window; repeats of the same packet id
|
||||
are already absorbed by the router's duplicate detection.
|
||||
|
||||
**Evolution.** The original design split throttling by path: a per-entry `respTick` stamp in
|
||||
each NodeInfo cache slot (cache path, 30 s, swept for wrap-safety) plus a single module-global
|
||||
stamp for the NodeDB fallback (30 s, neither per-requester nor per-target). Those two routes
|
||||
were unified into the symmetric per-requester + per-target RAM tables above, aligned to a
|
||||
single 60 s window, so both axes hold with and without PSRAM and the cache entry no longer
|
||||
carries throttle state.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
All tunables live under `moduleConfig.traffic_management`; the whole module is gated by the
|
||||
`has_traffic_management` presence flag, and each per-feature section above lists its own
|
||||
field(s) and default. Two related sets of knobs are **firmware constants, not config**: the
|
||||
role-based position caps `default_traffic_mgmt_tracker_position_min_interval_secs` (1 h) and
|
||||
`default_traffic_mgmt_lost_and_found_position_min_interval_secs` (15 min), and the direct-serve
|
||||
throttle windows (the `kDirectResponse*Ms` constants).
|
||||
|
||||
## See also
|
||||
|
||||
- [node_info_stores.md](node_info_stores.md) - the NodeDB hot store, warm tier, TMM NodeInfo
|
||||
payload cache, and unified cache that the direct-serve path reads from, plus their trust,
|
||||
provenance, and anti-entropy model.
|
||||
+15
-4
@@ -18,7 +18,7 @@ uint8_t MeshModule::numPeriodicModules = 0;
|
||||
*/
|
||||
meshtastic_MeshPacket *MeshModule::currentReply;
|
||||
|
||||
MeshModule::MeshModule(const char *_name) : name(_name)
|
||||
MeshModule::MeshModule(const char *_name, meshtastic_PortNum _ourPortNum) : name(_name), ourPortNum(_ourPortNum)
|
||||
{
|
||||
// Can't trust static initializer order, so we check each time
|
||||
if (!modules)
|
||||
@@ -27,6 +27,12 @@ MeshModule::MeshModule(const char *_name) : name(_name)
|
||||
modules->push_back(this);
|
||||
}
|
||||
|
||||
bool MeshModule::replyPortMatches(meshtastic_PortNum modulePort, const meshtastic_MeshPacket &mp)
|
||||
{
|
||||
return modulePort != meshtastic_PortNum_UNKNOWN_APP && mp.which_payload_variant == meshtastic_MeshPacket_decoded_tag &&
|
||||
mp.decoded.portnum == modulePort;
|
||||
}
|
||||
|
||||
void MeshModule::setup() {}
|
||||
|
||||
MeshModule::~MeshModule()
|
||||
@@ -107,6 +113,7 @@ void MeshModule::callModules(meshtastic_MeshPacket &mp, RxSource src)
|
||||
auto &pi = **i;
|
||||
|
||||
pi.currentRequest = ∓
|
||||
pi.ignoreRequest = false;
|
||||
|
||||
/// We only call modules that are interested in the packet (and the message is destined to us or we are promiscious)
|
||||
bool wantsPacket = (isDecoded || pi.encryptedOk) && (pi.isPromiscuous || toUs) && pi.wantPacket(&mp);
|
||||
@@ -157,9 +164,13 @@ void MeshModule::callModules(meshtastic_MeshPacket &mp, RxSource src)
|
||||
// better solution (FIXME) would be to let phones have their own distinct addresses and we 'route' to them like
|
||||
// any other node.
|
||||
if (isDecoded && mp.decoded.want_response && toUs && (!isFromUs(&mp) || isToUs(&mp)) && !currentReply) {
|
||||
pi.sendResponse(mp);
|
||||
if (replyPortMatches(pi.ourPortNum, mp)) {
|
||||
pi.sendResponse(mp);
|
||||
LOG_INFO("Asked module '%s' to send a response", pi.name);
|
||||
} else {
|
||||
LOG_DEBUG("Module '%s' cannot respond on portnum=%d", pi.name, mp.decoded.portnum);
|
||||
}
|
||||
ignoreRequest = ignoreRequest || pi.ignoreRequest; // If at least one module asks it, we may ignore a request
|
||||
LOG_INFO("Asked module '%s' to send a response", pi.name);
|
||||
} else {
|
||||
LOG_DEBUG("Module '%s' considered", pi.name);
|
||||
}
|
||||
@@ -313,4 +324,4 @@ bool MeshModule::isRequestingFocus()
|
||||
} else
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -68,10 +68,12 @@ class MeshModule
|
||||
/** Constructor
|
||||
* name is for debugging output
|
||||
*/
|
||||
MeshModule(const char *_name);
|
||||
MeshModule(const char *_name, meshtastic_PortNum _ourPortNum = meshtastic_PortNum_UNKNOWN_APP);
|
||||
|
||||
virtual ~MeshModule();
|
||||
|
||||
static bool replyPortMatches(meshtastic_PortNum modulePort, const meshtastic_MeshPacket &mp);
|
||||
|
||||
/** For use only by MeshService
|
||||
*/
|
||||
static void callModules(meshtastic_MeshPacket &mp, RxSource src = RX_SRC_RADIO);
|
||||
@@ -88,6 +90,7 @@ class MeshModule
|
||||
#endif
|
||||
protected:
|
||||
const char *name;
|
||||
meshtastic_PortNum ourPortNum;
|
||||
|
||||
/** Most modules only care about packets that are destined for their node (i.e. broadcasts or has their node as the specific
|
||||
recipient) But some plugs might want to 'sniff' packets that are merely being routed (passing through the current node). Those
|
||||
@@ -103,7 +106,7 @@ class MeshModule
|
||||
* flag */
|
||||
bool encryptedOk = false;
|
||||
|
||||
/* We allow modules to ignore a request without sending an error if they have a specific reason for it. */
|
||||
/* Per-packet flag cleared by callModules(); modules can suppress an error response for a specific request. */
|
||||
bool ignoreRequest = false;
|
||||
|
||||
/**
|
||||
|
||||
+127
-1
@@ -31,6 +31,9 @@
|
||||
#if HAS_VARIABLE_HOPS
|
||||
#include "modules/HopScalingModule.h"
|
||||
#endif
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
#include "modules/TrafficManagementModule.h"
|
||||
#endif
|
||||
#include "xmodem.h"
|
||||
#include <ErriezCRC32.h>
|
||||
#include <algorithm>
|
||||
@@ -767,6 +770,12 @@ bool NodeDB::factoryReset(bool eraseBleBonds)
|
||||
warmStore.clear();
|
||||
warmStore.saveIfDirty();
|
||||
#endif
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
// Factory reset forgets everything; TMM's RAM caches must not survive to resurrect
|
||||
// identities (the device usually reboots after this, but don't rely on it).
|
||||
if (trafficManagementModule)
|
||||
trafficManagementModule->purgeAll();
|
||||
#endif
|
||||
|
||||
// second, install default state (this will deal with the duplicate mac address issue)
|
||||
installDefaultNodeDatabase();
|
||||
@@ -1597,6 +1606,11 @@ void NodeDB::resetNodes(bool keepFavorites)
|
||||
#if WARM_NODE_COUNT > 0
|
||||
warmStore.clear(); // warm entries are never favorites; a DB reset clears them too
|
||||
#endif
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
// A user-initiated DB reset forgets everything; TMM's caches must not resurrect it.
|
||||
if (trafficManagementModule)
|
||||
trafficManagementModule->purgeAll();
|
||||
#endif
|
||||
|
||||
devicestate.has_rx_waypoint = false;
|
||||
saveNodeDatabaseToDisk();
|
||||
@@ -1629,6 +1643,12 @@ void NodeDB::removeNodeByNum(NodeNum nodeNum)
|
||||
// Explicit user removal: don't let the warm tier resurrect the node
|
||||
warmStore.remove(nodeNum);
|
||||
#endif
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
// Explicit removal is full removal: the TrafficManagement caches (unified slot +
|
||||
// NodeInfo identity cache) must not keep serving or resurrect the node either.
|
||||
if (trafficManagementModule)
|
||||
trafficManagementModule->purgeNode(nodeNum);
|
||||
#endif
|
||||
|
||||
LOG_DEBUG("NodeDB::removeNodeByNum purged %d entries. Save changes", removed);
|
||||
saveNodeDatabaseToDisk();
|
||||
@@ -3404,6 +3424,20 @@ bool NodeDB::updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelInde
|
||||
}
|
||||
}
|
||||
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
// Write-through: every accepted remote-identity commit lands here (NodeInfoModule,
|
||||
// MeshService, and TMM's requester learning all funnel through updateUser; the two
|
||||
// key-write sites that bypass it call onNodeKeyCommitted instead), so TMM's NodeInfo
|
||||
// cache reflects the commit immediately rather than at the next reconcile pass. Runs on
|
||||
// acceptance, not on `changed`: an identical update still proves the identity is
|
||||
// current. `p` is the post-hygiene payload; signerKnown transfers only key-matched
|
||||
// verified-signer status (isVerifiedSignerForKey semantics), never a bare node flag.
|
||||
if (nodeId != getNodeNum() && trafficManagementModule) {
|
||||
const bool signerKnown = p.public_key.size == 32 && isVerifiedSignerForKey(nodeId, p.public_key.bytes);
|
||||
trafficManagementModule->onNodeIdentityCommitted(nodeId, p, signerKnown);
|
||||
}
|
||||
#endif
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
@@ -3714,7 +3748,7 @@ uint32_t NodeDB::hotNodeLastHeard(NodeNum n) const
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool NodeDB::copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out)
|
||||
bool NodeDB::copyPublicKeyAuthoritative(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out)
|
||||
{
|
||||
const meshtastic_NodeInfoLite *info = getMeshNode(n);
|
||||
if (info && info->public_key.size == 32) {
|
||||
@@ -3730,6 +3764,81 @@ bool NodeDB::copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out)
|
||||
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);
|
||||
@@ -3827,6 +3936,23 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n)
|
||||
}
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -360,6 +360,33 @@ class NodeDB
|
||||
/// tier. Returns false if we don't know a key for n.
|
||||
bool copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out);
|
||||
|
||||
/// Copy the 32-byte key for n from the AUTHORITATIVE tiers only (hot, then warm; never
|
||||
/// opportunistic caches) - the pin reference for caches that mirror NodeDB's key hygiene.
|
||||
bool copyPublicKeyAuthoritative(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out);
|
||||
|
||||
/// True if n is a known XEdDSA signer for exactly `key32` (hot signed bitfield or warm
|
||||
/// signer bit); the key match stops a rotated key inheriting a stale signer verdict.
|
||||
bool isVerifiedSignerForKey(NodeNum n, const uint8_t *key32);
|
||||
|
||||
/// Key-agnostic "should n's signable traffic arrive signed", per hot bitfield or warm signer
|
||||
/// bit - hot-only gates would let a warm-evicted signer be impersonated with unsigned frames.
|
||||
bool isKnownXeddsaSigner(NodeNum n);
|
||||
|
||||
/// Provenance of a bare-key commit that deliberately bypasses updateUser()'s
|
||||
/// User-payload / TOFU-pin path. Maps to the TrafficManagement cache's `proven` flag:
|
||||
/// only ManuallyVerified vouches for possession of exactly this key.
|
||||
enum class KeyCommitTrust : uint8_t {
|
||||
AdminChannelProven, // possession shown to the admin channel (AEAD) - TOFU-grade for signing
|
||||
ManuallyVerified, // the user confirmed possession of exactly this key
|
||||
};
|
||||
|
||||
/// THE primitive for key writes that bypass updateUser() (no User payload; provenance
|
||||
/// differs from a received NodeInfo): writes the 32-byte key to the hot store and
|
||||
/// write-through to the TrafficManagement NodeInfo cache. Any future direct key-write
|
||||
/// site must call this rather than assigning info->public_key, or the TrafficManagement
|
||||
/// cache silently diverges until the next hourly reconcile.
|
||||
void commitRemoteKey(NodeNum n, const uint8_t key32[32], KeyCommitTrust trust);
|
||||
|
||||
/// Resolve a node's device role - hot store (with user) first, then the role
|
||||
/// cached in the warm tier, else CLIENT. Lets role-aware policy keep firing for
|
||||
/// nodes that have aged out of the hot store.
|
||||
|
||||
+25
-22
@@ -14,7 +14,6 @@
|
||||
#include "modules/RoutingModule.h"
|
||||
#include <pb_encode.h>
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
#include "modules/TrafficManagementModule.h"
|
||||
#endif
|
||||
#if HAS_VARIABLE_HOPS
|
||||
#include "modules/HopScalingModule.h"
|
||||
@@ -535,11 +534,11 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p)
|
||||
// non-PKI broadcast whose signed encoding would still fit the LoRa frame. Size p->decoded
|
||||
// canonically so this counts the same fields the sender's signedDataFits() gate counted;
|
||||
// adding XEDDSA_SIGNATURE_FIELD_BYTES to that unsigned base mirrors it exactly, whatever
|
||||
// fields the Data carried, with padding hidden inside Data.payload stripped. Unicast/PKI
|
||||
// packets and broadcasts too big to carry a signature are never signed, so they must not be
|
||||
// hard-failed here even for a known signer.
|
||||
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->from);
|
||||
if (node && nodeInfoLiteHasXeddsaSigned(node) && !p->pki_encrypted && isBroadcast(p->to)) {
|
||||
// fields the Data carried. Unicast/PKI packets and broadcasts too big to carry a signature
|
||||
// are never signed, so they must not be hard-failed here even for a known signer.
|
||||
// isKnownXeddsaSigner consults the warm tier too: a signer evicted from the hot store
|
||||
// must not become impersonatable via unsigned broadcasts until it is re-heard.
|
||||
if (nodeDB->isKnownXeddsaSigner(p->from) && !p->pki_encrypted && isBroadcast(p->to)) {
|
||||
size_t canonicalSize;
|
||||
if (!canonicalSignableSize(&p->decoded, &canonicalSize))
|
||||
return true; // can't size it; never drop on a sizing failure
|
||||
@@ -622,21 +621,23 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
|
||||
bool decrypted = false;
|
||||
ChannelIndex chIndex = 0;
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI)
|
||||
// Resolve the sender's public key: prefer the one stored in NodeDB (hot store or warm tier), else
|
||||
// fall back to a not-yet-committed key held during an in-progress key-verification handshake.
|
||||
meshtastic_NodeInfoLite_public_key_t remotePublic = {0, {0}};
|
||||
bool haveRemoteKey = nodeDB->copyPublicKey(p->from, remotePublic);
|
||||
// A pending key is an unverified identity claim supplied by whoever opened the handshake, so it is
|
||||
// accepted only for the exchange itself (checked after decode). perhapsEncode applies the same rule.
|
||||
bool havePendingKey = false;
|
||||
if (!haveRemoteKey) {
|
||||
havePendingKey = crypto->getPendingPublicKey(p->from, remotePublic);
|
||||
haveRemoteKey = havePendingKey;
|
||||
}
|
||||
|
||||
meshtastic_NodeInfoLite *ourNode = nullptr;
|
||||
if (p->channel == 0 && isToUs(p) && p->to > 0 && !isBroadcast(p->to) && rawSize > MESHTASTIC_PKC_OVERHEAD &&
|
||||
(ourNode = nodeDB->getMeshNode(p->to)) != nullptr && ourNode->public_key.size > 0) {
|
||||
// Resolve the sender's public key only for actual PKI-decrypt candidates: prefer NodeDB
|
||||
// (hot store or warm tier), else a not-yet-committed key held during an in-progress
|
||||
// key-verification handshake. On a full NodeDB miss, copyPublicKey() falls through to a
|
||||
// linear scan of TrafficManagement's large NodeInfo cache, so it must not run for every
|
||||
// encrypted channel packet from an unknown sender - only for packets we might decrypt.
|
||||
meshtastic_NodeInfoLite_public_key_t remotePublic = {0, {0}};
|
||||
bool haveRemoteKey = nodeDB->copyPublicKey(p->from, remotePublic);
|
||||
// A pending key is an unverified identity claim supplied by whoever opened the handshake, so it is
|
||||
// accepted only for the exchange itself (checked after decode). perhapsEncode applies the same rule.
|
||||
bool havePendingKey = false;
|
||||
if (!haveRemoteKey) {
|
||||
havePendingKey = crypto->getPendingPublicKey(p->from, remotePublic);
|
||||
haveRemoteKey = havePendingKey;
|
||||
}
|
||||
// Try the sender's known key first, then each configured admin key so an authorized admin can
|
||||
// reach a node that has not yet learned their key. AES-CCM AEAD rejects wrong candidates.
|
||||
bool viaAdminKey = false;
|
||||
@@ -684,10 +685,12 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
|
||||
p->which_payload_variant = meshtastic_MeshPacket_decoded_tag; // change type to decoded
|
||||
if (viaAdminKey) {
|
||||
// Persist the admin key for the sender so future packets take the fast path and we can
|
||||
// PKI-reply; p->from is bound into the AEAD nonce, so the trusted admin authenticated it.
|
||||
meshtastic_NodeInfoLite *fromNode = nodeDB->getOrCreateMeshNode(p->from);
|
||||
if (fromNode != nullptr)
|
||||
fromNode->public_key = remotePublic;
|
||||
// PKI-reply; p->from is bound into the AEAD nonce, so the trusted admin authenticated
|
||||
// it. commitRemoteKey is the bare-key commit primitive: it bypasses updateUser's
|
||||
// User-payload path deliberately and handles the TrafficManagement write-through.
|
||||
// AdminChannelProven = possession shown to the admin channel, not via an XEdDSA
|
||||
// NodeInfo signature, so the key stays TOFU-grade for signing purposes.
|
||||
nodeDB->commitRemoteKey(p->from, remotePublic.bytes, NodeDB::KeyCommitTrust::AdminChannelProven);
|
||||
}
|
||||
} else {
|
||||
// AEAD already authenticated this ciphertext, so no other candidate could decode it -
|
||||
|
||||
@@ -8,14 +8,11 @@
|
||||
*/
|
||||
class SinglePortModule : public MeshModule
|
||||
{
|
||||
protected:
|
||||
meshtastic_PortNum ourPortNum;
|
||||
|
||||
public:
|
||||
/** Constructor
|
||||
* name is for debugging output
|
||||
*/
|
||||
SinglePortModule(const char *_name, meshtastic_PortNum _ourPortNum) : MeshModule(_name), ourPortNum(_ourPortNum) {}
|
||||
SinglePortModule(const char *_name, meshtastic_PortNum _ourPortNum) : MeshModule(_name, _ourPortNum) {}
|
||||
|
||||
protected:
|
||||
/**
|
||||
@@ -38,4 +35,4 @@ class SinglePortModule : public MeshModule
|
||||
|
||||
return p;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -161,6 +161,12 @@ bool WarmNodeStore::lookupMeta(NodeNum num, uint8_t &role, uint8_t &protectedCat
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WarmNodeStore::isVerifiedSigner(NodeNum num) const
|
||||
{
|
||||
const WarmNodeEntry *e = find(num);
|
||||
return e && warmSignerOf(*e);
|
||||
}
|
||||
|
||||
bool WarmNodeStore::take(NodeNum num, WarmNodeEntry &out)
|
||||
{
|
||||
WarmNodeEntry *e = find(num);
|
||||
|
||||
@@ -119,6 +119,10 @@ class WarmNodeStore
|
||||
/// @return false if the node is not in the warm tier.
|
||||
bool lookupMeta(NodeNum num, uint8_t &role, uint8_t &protectedCat) const;
|
||||
|
||||
/// True if the warm tier holds this node with its signer bit set (an XEdDSA signature
|
||||
/// was verified from it before eviction).
|
||||
bool isVerifiedSigner(NodeNum num) const;
|
||||
|
||||
/// Find and remove an entry (used when the node is re-admitted to the hot store).
|
||||
bool take(NodeNum num, WarmNodeEntry &out);
|
||||
|
||||
@@ -131,6 +135,15 @@ class WarmNodeStore
|
||||
size_t count() const;
|
||||
size_t capacity() const { return entries ? WARM_NODE_COUNT : 0; }
|
||||
|
||||
/// Slot-indexed read for whole-tier reconciliation: the entry in slot i, or nullptr
|
||||
/// when the slot is empty or i >= capacity().
|
||||
const WarmNodeEntry *entryAt(size_t i) const
|
||||
{
|
||||
if (!entries || i >= WARM_NODE_COUNT || entries[i].num == 0)
|
||||
return nullptr;
|
||||
return &entries[i];
|
||||
}
|
||||
|
||||
#if MESHTASTIC_NODEDB_MIGRATION_VERBOSE
|
||||
/// Debug: dump every live warm entry (num / last_heard / has-key) to the
|
||||
/// console. Compiled out unless MESHTASTIC_NODEDB_MIGRATION_VERBOSE.
|
||||
|
||||
@@ -421,6 +421,11 @@ void KeyVerificationModule::commitVerifiedRemoteNode()
|
||||
if (node->public_key.size != 32 && crypto->getPendingPublicKey(currentRemoteNode, pending))
|
||||
node->public_key = pending;
|
||||
node->bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK;
|
||||
// Re-commit via the bare-key primitive: writing the same bytes back is a no-op for the hot
|
||||
// store, but it routes the TrafficManagement write-through. ManuallyVerified: the user just
|
||||
// confirmed possession of exactly this key - the strongest provenance that cache can carry.
|
||||
if (node->public_key.size == 32)
|
||||
nodeDB->commitRemoteKey(currentRemoteNode, node->public_key.bytes, NodeDB::KeyCommitTrust::ManuallyVerified);
|
||||
LOG_INFO("Node 0x%08x manually verified with security number %u", currentRemoteNode, currentSecurityNumber);
|
||||
if (nodeInfoModule)
|
||||
nodeInfoModule->sendOurNodeInfo(currentRemoteNode, false, node->channel, true);
|
||||
|
||||
@@ -109,7 +109,7 @@ bool SerialModule::isValidConfig(const meshtastic_ModuleConfig_SerialConfig &con
|
||||
return true;
|
||||
}
|
||||
|
||||
SerialModuleRadio::SerialModuleRadio() : MeshModule("SerialModuleRadio")
|
||||
SerialModuleRadio::SerialModuleRadio() : SinglePortModule("SerialModuleRadio", meshtastic_PortNum_SERIAL_APP)
|
||||
{
|
||||
switch (moduleConfig.serial.mode) {
|
||||
case meshtastic_ModuleConfig_SerialConfig_Serial_Mode_TEXTMSG:
|
||||
@@ -330,18 +330,6 @@ void SerialModule::sendTelemetry(meshtastic_Telemetry m)
|
||||
service->sendToMesh(p, RX_SRC_LOCAL, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocates a new mesh packet for use as a reply to a received packet.
|
||||
*
|
||||
* @return A pointer to the newly allocated mesh packet.
|
||||
*/
|
||||
meshtastic_MeshPacket *SerialModuleRadio::allocReply()
|
||||
{
|
||||
auto reply = allocDataPacket(); // Allocate a packet for sending
|
||||
|
||||
return reply;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a payload to a specified destination node.
|
||||
*
|
||||
@@ -351,7 +339,7 @@ meshtastic_MeshPacket *SerialModuleRadio::allocReply()
|
||||
void SerialModuleRadio::sendPayload(NodeNum dest, bool wantReplies)
|
||||
{
|
||||
const meshtastic_Channel *ch = (boundChannel != NULL) ? &channels.getByName(boundChannel) : NULL;
|
||||
meshtastic_MeshPacket *p = allocReply();
|
||||
meshtastic_MeshPacket *p = allocDataPacket();
|
||||
if (!p)
|
||||
return;
|
||||
p->to = dest;
|
||||
|
||||
@@ -40,7 +40,7 @@ extern SerialModule *serialModule;
|
||||
* Radio interface for SerialModule
|
||||
*
|
||||
*/
|
||||
class SerialModuleRadio : public MeshModule
|
||||
class SerialModuleRadio : public SinglePortModule
|
||||
{
|
||||
uint32_t lastRxID = 0;
|
||||
char outbuf[90] = "";
|
||||
@@ -54,31 +54,14 @@ class SerialModuleRadio : public MeshModule
|
||||
void sendPayload(NodeNum dest = NODENUM_BROADCAST, bool wantReplies = false);
|
||||
|
||||
protected:
|
||||
virtual meshtastic_MeshPacket *allocReply() override;
|
||||
|
||||
/** Called to handle a particular incoming message
|
||||
|
||||
@return ProcessMessage::STOP if you've guaranteed you've handled this message and no other handlers should be considered for
|
||||
it
|
||||
*/
|
||||
virtual ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override;
|
||||
|
||||
meshtastic_PortNum ourPortNum;
|
||||
|
||||
virtual bool wantPacket(const meshtastic_MeshPacket *p) override { return p->decoded.portnum == ourPortNum; }
|
||||
|
||||
meshtastic_MeshPacket *allocDataPacket()
|
||||
{
|
||||
// Update our local node info with our position (even if we don't decide to update anyone else)
|
||||
meshtastic_MeshPacket *p = router->allocForSending();
|
||||
if (!p)
|
||||
return nullptr;
|
||||
p->decoded.portnum = ourPortNum;
|
||||
|
||||
return p;
|
||||
}
|
||||
};
|
||||
|
||||
extern SerialModuleRadio *serialModuleRadio;
|
||||
|
||||
#endif
|
||||
#endif
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,25 +8,32 @@
|
||||
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
|
||||
/**
|
||||
* TrafficManagementModule - Packet inspection and traffic shaping for mesh networks.
|
||||
*
|
||||
* This module provides:
|
||||
* - Position deduplication (drop redundant position broadcasts)
|
||||
* - Per-node rate limiting (throttle chatty nodes)
|
||||
* - Unknown packet filtering (drop undecoded packets from repeat offenders)
|
||||
* - NodeInfo direct response (answer queries from cache to reduce mesh chatter)
|
||||
* - Local-only telemetry/position (exhaust hop_limit for local broadcasts)
|
||||
* - Router hop preservation (maintain hop_limit for router-to-router traffic)
|
||||
*
|
||||
* Memory Optimization:
|
||||
* Uses one flat unified cache (plain array, linear scan) shared by all
|
||||
* per-node features instead of separate per-feature caches. Timestamps are
|
||||
* stored as free-running modular tick counters (pos: 8-bit 360 s/tick;
|
||||
* rate+unknown: paired 4-bit nibbles in one byte) for a 10-byte entry.
|
||||
* LoRa packet rates are low enough that an O(n) scan of ~1000 entries is
|
||||
* negligible next to packet processing.
|
||||
*/
|
||||
// Replay provenance gate: when 1 (default), direct responses are spoofed only for nodes whose
|
||||
// cached key is signer-proven (XEdDSA-verified), not for trust-on-first-use identities.
|
||||
// Define as 0 to also serve fresh TOFU-only nodes; bypassed entirely when PKI is excluded.
|
||||
#ifndef TMM_NODEINFO_REPLAY_REQUIRE_SIGNED
|
||||
#define TMM_NODEINFO_REPLAY_REQUIRE_SIGNED 1
|
||||
#endif
|
||||
|
||||
// Effective gate: only meaningful when PKI is compiled in.
|
||||
#if TMM_NODEINFO_REPLAY_REQUIRE_SIGNED && !(MESHTASTIC_EXCLUDE_PKI)
|
||||
#define TMM_NODEINFO_REPLAY_SIGNED_GATE 1
|
||||
#else
|
||||
#define TMM_NODEINFO_REPLAY_SIGNED_GATE 0
|
||||
#endif
|
||||
|
||||
// NodeInfo cache availability. Production home is ESP32+PSRAM (the 2000-entry array is too big
|
||||
// for MCU internal RAM); native unit-test builds enable it on the plain heap so the cache paths
|
||||
// run in CI (tests needing the NodeDB fallback call dropNodeInfoCacheForTest()).
|
||||
#if (defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM)) || (defined(ARCH_PORTDUINO) && defined(PIO_UNIT_TESTING))
|
||||
#define TMM_HAS_NODEINFO_CACHE 1
|
||||
#else
|
||||
#define TMM_HAS_NODEINFO_CACHE 0
|
||||
#endif
|
||||
|
||||
/// Packet inspection and traffic shaping: position dedup, per-node rate limiting, unknown-packet
|
||||
/// filtering, NodeInfo direct response, and the next-hop/role overflow caches. One flat 10-byte
|
||||
/// unified cache backs all per-node features; see docs/node_info_stores.md for the store overview.
|
||||
class TrafficManagementModule : public MeshModule, private concurrency::OSThread
|
||||
{
|
||||
public:
|
||||
@@ -37,41 +44,66 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
|
||||
TrafficManagementModule(const TrafficManagementModule &) = delete;
|
||||
TrafficManagementModule &operator=(const TrafficManagementModule &) = delete;
|
||||
|
||||
/// Snapshot of the module's counters (thread-safe).
|
||||
meshtastic_TrafficManagementStats getStats() const;
|
||||
/// Zero all counters (thread-safe).
|
||||
void resetStats();
|
||||
/// Placeholder for the removed router_preserve_hops stat.
|
||||
void recordRouterHopPreserved();
|
||||
|
||||
// Next-hop overflow cache (routing hint).
|
||||
// setNextHop: store a confirmed last-byte next hop for `dest`. Called by
|
||||
// NextHopRouter from its ACK-confirmed decision (see sniffReceived). The
|
||||
// byte must come from a bidirectionally-verified relay, not one-way inference.
|
||||
// getNextHopHint: return the cached next-hop byte for `dest`, 0 if unknown.
|
||||
// clearNextHop: forget any cached next hop for `dest` (setNextHop refuses to store
|
||||
// 0, so this is the way NextHopRouter decays a stale/failing overflow route).
|
||||
/// Store a confirmed last-byte next hop for `dest`. Called only from NextHopRouter's
|
||||
/// ACK-confirmed decision - the byte must come from a bidirectionally-verified relay.
|
||||
void setNextHop(NodeNum dest, uint8_t nextHopByte);
|
||||
/// Cached next-hop byte for `dest`, 0 if unknown.
|
||||
uint8_t getNextHopHint(NodeNum dest);
|
||||
/// Forget the cached next hop for `dest` (how NextHopRouter decays a failing route).
|
||||
void clearNextHop(NodeNum dest);
|
||||
|
||||
// Warm-start the next-hop cache from persisted NodeInfoLite hints so confirmed
|
||||
// hops survive later hot-store (NodeDB) eviction. Idempotent; runs once after
|
||||
// nodeDB is populated (lazily on first maintenance pass).
|
||||
// @return true if it actually ran (prereqs met / nothing to do); false if
|
||||
// prerequisites (cache, nodeDB) weren't ready yet, so the caller should retry.
|
||||
/// Warm-start the next-hop cache from persisted NodeInfoLite hints so confirmed hops survive
|
||||
/// hot-store eviction. @return true if it ran; false if prerequisites (cache, nodeDB) weren't
|
||||
/// ready and the caller should retry on a later pass.
|
||||
bool preloadNextHopsFromNodeDB();
|
||||
|
||||
/**
|
||||
* Check if this packet should have its hops exhausted.
|
||||
* Called from perhapsRebroadcast() to force hop_limit = 0 regardless of
|
||||
* router_preserve_hops or favorite node logic.
|
||||
*/
|
||||
/// Last-resort key source for NodeDB::copyPublicKey() after the hot and warm tiers miss.
|
||||
/// Copies the 32-byte key for `node` into out[32]; `signerProven` (optional) reports whether
|
||||
/// the key was XEdDSA-verified vs trust-on-first-use. Thread-safe.
|
||||
bool copyPublicKey(NodeNum node, uint8_t out[32], bool *signerProven = nullptr) const;
|
||||
|
||||
/// Copy the full cached User for `node` (used by NodeDB to rehydrate a re-admitted node's
|
||||
/// name - the warm tier keeps keys but not names). False on miss or key-only records.
|
||||
/// `signerProven` (optional) reports the cached key's provenance. Thread-safe.
|
||||
bool copyUser(NodeNum node, meshtastic_User &out, bool *signerProven = nullptr) const;
|
||||
|
||||
/// Write-through hook from NodeDB::updateUser(): upsert the committed identity immediately
|
||||
/// (the reconcile sweep remains the backstop). NodeDB's key is authoritative, but a keyless
|
||||
/// commit keeps a TOFU key this cache already holds; never touches the observation stamp.
|
||||
/// No-op while the module is disabled in moduleConfig (maintenance is gated the same way).
|
||||
void onNodeIdentityCommitted(NodeNum node, const meshtastic_User &user, bool signerKnown);
|
||||
|
||||
/// Key-only commit hook for key writes that bypass updateUser (admin-key learn, manual key
|
||||
/// verification). A changed key resets provenance; pass proven=true only when the commit
|
||||
/// itself established possession. Never touches the observation stamp. Thread-safe.
|
||||
/// No-op while the module is disabled in moduleConfig (maintenance is gated the same way).
|
||||
void onNodeKeyCommitted(NodeNum node, const uint8_t key32[32], bool proven);
|
||||
|
||||
/// Zero one node's slots in both caches (identity, key, provenance, role, next-hop, dedup
|
||||
/// state). Called by NodeDB removal so no TMM tier resurrects a deliberately deleted node;
|
||||
/// passive eviction is unaffected. Thread-safe.
|
||||
void purgeNode(NodeNum node);
|
||||
/// Clear both cache tables outright (resetNodes / factory reset). Thread-safe.
|
||||
void purgeAll();
|
||||
|
||||
/// True when perhapsRebroadcast() must force hop_limit=0 for this packet, regardless of
|
||||
/// router_preserve_hops or favorite-node logic (set by alterReceived()).
|
||||
bool shouldExhaustHops(const meshtastic_MeshPacket &mp) const
|
||||
{
|
||||
return exhaustRequested && exhaustRequestedFrom == getFrom(&mp) && exhaustRequestedId == mp.id;
|
||||
}
|
||||
|
||||
// Injectable monotonic clock (ms). All TMM time reads go through clockMs() so unit tests can
|
||||
// advance a virtual timebase instead of sleeping real seconds across the 6 min/360 s tick.
|
||||
// Mirrors HopScalingModule::s_testNowMs. Writable from tests as TrafficManagementModule::s_testNowMs;
|
||||
// ignored in production (clockMs() returns millis()).
|
||||
// Injectable monotonic clock (ms): tests advance s_testNowMs instead of sleeping across
|
||||
// ticks (mirrors HopScalingModule); production reads millis().
|
||||
inline static uint32_t s_testNowMs = 0;
|
||||
/// Monotonic module clock in ms (virtual under PIO_UNIT_TESTING).
|
||||
#ifdef PIO_UNIT_TESTING
|
||||
static uint32_t clockMs() { return s_testNowMs; }
|
||||
#else
|
||||
@@ -79,43 +111,40 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
|
||||
#endif
|
||||
|
||||
protected:
|
||||
/// Inspect a received packet; may consume it (STOP) for dedup/rate/unknown/direct-response.
|
||||
ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override;
|
||||
/// Promiscuous: this module inspects every packet.
|
||||
bool wantPacket(const meshtastic_MeshPacket *p) override { return true; }
|
||||
/// Mutate relayed packets in place (position precision clamp).
|
||||
void alterReceived(meshtastic_MeshPacket &mp) override;
|
||||
/// 60 s maintenance sweep: expire timed state, saturate tick stamps, reconcile with NodeDB.
|
||||
int32_t runOnce() override;
|
||||
// Protected so test shims can flush per-node traffic state.
|
||||
/// Clear all per-node traffic state (protected for test shims).
|
||||
void flushCache();
|
||||
// Introspection for tests: the cached device role for a node, or -1 if the node has
|
||||
// no cache entry (distinguishes "not tracked / evicted" from CLIENT == 0).
|
||||
/// Test introspection: the cached role for `node`, or -1 when it has no entry
|
||||
/// (distinguishes "not tracked" from CLIENT == 0).
|
||||
int peekCachedRole(NodeNum node);
|
||||
|
||||
/// Test hook: force a cached NodeInfo entry's key to signer-proven so replay-gate tests
|
||||
/// can skip a full XEdDSA verification. No-op if absent.
|
||||
void markKeySignerProvenForTest(NodeNum node);
|
||||
|
||||
/// Test hook: free the NodeInfo cache so the NodeDB fallback path can be exercised in
|
||||
/// builds where the cache is compiled in. No-op when already absent.
|
||||
void dropNodeInfoCacheForTest();
|
||||
|
||||
/// Test introspection: NodeInfo flag bits for `node` (-1 if absent): bit0 hasObserved,
|
||||
/// bit1 isMember, bit2 hasFullUser, bit3 keySignerProven.
|
||||
int peekNodeInfoFlagsForTest(NodeNum node);
|
||||
|
||||
/// Test introspection: NodeInfo cache capacity (kNodeInfoCacheEntries), so tests can
|
||||
/// fill the cache exactly and force the tiered-LRU eviction paths.
|
||||
static constexpr uint16_t nodeInfoCacheCapacityForTest() { return kNodeInfoCacheEntries; }
|
||||
|
||||
private:
|
||||
// =========================================================================
|
||||
// Unified Cache Entry (10 bytes) - Same for ALL platforms
|
||||
// =========================================================================
|
||||
//
|
||||
// Layout:
|
||||
// [0-3] node - NodeNum (4 bytes, 0 = empty slot)
|
||||
// [4] pos_fingerprint - 4 bits lat + 4 bits lon (0 = no position seen)
|
||||
// [5] rate_count - [7:6] role[3:2] | [5:0] packets in rate window (0 = no window active)
|
||||
// [6] unknown_count - [7:6] role[1:0] | [5:0] unknown packets in window (0 = no window active)
|
||||
// [7] pos_time - Position tick (uint8, free-running 360 s/tick)
|
||||
// [8] rate_unknown_time - [7:4] rate nibble (300 s/tick) | [3:0] unknown nibble (60 s/tick)
|
||||
// [9] next_hop - Last-byte relay to reach `node` (0 = none)
|
||||
//
|
||||
// The 4-bit device role (bits [7:6] of rate_count paired with [7:6] of unknown_count)
|
||||
// caches the sender's meshtastic_Config_DeviceConfig_Role as a third fallback after the
|
||||
// hot store and warm store, for nodes evicted from both. Read/written via
|
||||
// resolveSenderRole(). Max encodable value is 15.
|
||||
//
|
||||
// Presence sentinels (no epoch, no +1 offset needed):
|
||||
// pos active: pos_fingerprint != 0
|
||||
// rate active: getRateCount() != 0 (low 6 bits only)
|
||||
// unknown active: getUnknownCount() != 0 (low 6 bits only)
|
||||
//
|
||||
// next_hop: routing hint written only from ACK-confirmed NextHopRouter decisions.
|
||||
// No TTL - keeps the slot alive across maintenance sweeps.
|
||||
//
|
||||
// 10-byte packed entry, all platforms. Tick stamps are free-running modular counters with
|
||||
// non-zero presence sentinels; the 4-bit cached role rides the top bits of the two count
|
||||
// bytes (tier-3 role fallback). Full layout and rationale: docs/node_info_stores.md.
|
||||
#if _meshtastic_Config_DeviceConfig_Role_MAX > 15
|
||||
#warning "Device role enum max exceeds 15 - TMM 4-bit role cache (rate_count[7:6]/unknown_count[7:6]) will truncate new values"
|
||||
#endif
|
||||
@@ -128,80 +157,72 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
|
||||
uint8_t rate_unknown_time;
|
||||
uint8_t next_hop;
|
||||
|
||||
/// Packets seen in the current rate window (low 6 bits).
|
||||
uint8_t getRateCount() const { return rate_count & 0x3F; }
|
||||
/// Set the rate-window count, preserving the role bits.
|
||||
void setRateCount(uint8_t c) { rate_count = static_cast<uint8_t>((rate_count & 0xC0) | (c & 0x3F)); }
|
||||
/// Unknown packets seen in the current window (low 6 bits).
|
||||
uint8_t getUnknownCount() const { return unknown_count & 0x3F; }
|
||||
/// Set the unknown-window count, preserving the role bits.
|
||||
void setUnknownCount(uint8_t c) { unknown_count = static_cast<uint8_t>((unknown_count & 0xC0) | (c & 0x3F)); }
|
||||
/// Cached 4-bit device role, reassembled from the two count bytes' top bits.
|
||||
uint8_t getCachedRole() const { return static_cast<uint8_t>(((rate_count >> 6) << 2) | (unknown_count >> 6)); }
|
||||
/// Store a 4-bit device role across the two count bytes' top bits.
|
||||
void setCachedRole(uint8_t role)
|
||||
{
|
||||
rate_count = static_cast<uint8_t>((rate_count & 0x3F) | ((role >> 2) << 6));
|
||||
unknown_count = static_cast<uint8_t>((unknown_count & 0x3F) | ((role & 0x03) << 6));
|
||||
}
|
||||
/// Rate-window tick nibble.
|
||||
uint8_t getRateTime() const { return (rate_unknown_time >> 4) & 0x0F; }
|
||||
/// Unknown-window tick nibble.
|
||||
uint8_t getUnknownTime() const { return rate_unknown_time & 0x0F; }
|
||||
/// Set the rate-window tick nibble.
|
||||
void setRateTime(uint8_t t) { rate_unknown_time = static_cast<uint8_t>((rate_unknown_time & 0x0F) | ((t & 0x0F) << 4)); }
|
||||
/// Set the unknown-window tick nibble.
|
||||
void setUnknownTime(uint8_t t) { rate_unknown_time = static_cast<uint8_t>((rate_unknown_time & 0xF0) | (t & 0x0F)); }
|
||||
};
|
||||
static_assert(sizeof(UnifiedCacheEntry) == 10, "UnifiedCacheEntry should be 10 bytes");
|
||||
|
||||
// =========================================================================
|
||||
// Flat unified cache
|
||||
// =========================================================================
|
||||
//
|
||||
// Plain array, linear scan (same idiom as WarmNodeStore). A lookup walks at
|
||||
// most cacheSize() × 10 B - microseconds at LoRa packet rates, not worth a
|
||||
// hash table. Insertion on a full cache evicts the stalest entry,
|
||||
// preferring entries without a next_hop hint (those are the long-tail
|
||||
// routing state this cache exists to keep).
|
||||
//
|
||||
/// Unified cache capacity. Plain array, linear scan (same idiom as WarmNodeStore); insertion
|
||||
/// on a full cache evicts the stalest entry, preferring ones without a next_hop hint.
|
||||
static constexpr uint16_t cacheSize() { return TRAFFIC_MANAGEMENT_CACHE_SIZE; }
|
||||
|
||||
// NodeInfo cache configuration (PSRAM path): a flat PSRAM array of payload
|
||||
// entries, linear scan keyed by `node`, LRU eviction by lastObservedMs.
|
||||
// NodeInfo traffic is low-rate, so a full scan per lookup/insert is fine.
|
||||
// NodeInfo cache (PSRAM-backed on hardware, heap in native tests): flat payload array,
|
||||
// linear scan, trust/membership-tiered LRU eviction on insert. NodeInfo traffic is
|
||||
// low-rate, so full scans are fine.
|
||||
static constexpr uint16_t kNodeInfoCacheEntries = 2000;
|
||||
/// NodeInfo cache capacity.
|
||||
static constexpr uint16_t nodeInfoTargetEntries() { return kNodeInfoCacheEntries; }
|
||||
|
||||
// =========================================================================
|
||||
// Free-Running Tick Counters
|
||||
// =========================================================================
|
||||
//
|
||||
// Timestamps are stored as free-running modular tick counters derived from
|
||||
// millis(). No epoch anchor needed: modular subtraction gives correct age
|
||||
// as long as the true age stays below the counter period.
|
||||
//
|
||||
// pos_time : uint8 (256 ticks × 360 s = 25.6 h period; max window 12 h = 120 ticks)
|
||||
// rate_time : nibble (16 ticks × 300 s = 80 min period; max window 1 h = 12 ticks)
|
||||
// unknown_time: nibble (16 ticks × 60 s = 16 min period; max window 12 min = 12 ticks)
|
||||
//
|
||||
// Presence sentinels (no +1 offset needed; count fields serve as guards):
|
||||
// pos active: pos_fingerprint != 0 (0 is reserved sentinel; computePositionFingerprint() remaps computed-0 → 0xFF)
|
||||
// rate active: getRateCount() != 0 (low 6 bits; high 2 bits are cached role)
|
||||
// unknown active: getUnknownCount() != 0
|
||||
//
|
||||
static constexpr uint32_t kPosTimeTickMs = 360'000UL; // 6 min/tick
|
||||
static constexpr uint32_t kRateTimeTickMs = 300'000UL; // 5 min/tick
|
||||
static constexpr uint32_t kUnknownTimeTickMs = 60'000UL; // 1 min/tick
|
||||
// Free-running modular tick clocks derived from clockMs(); modular subtraction gives correct
|
||||
// age while true age stays below the counter period. Presence is carried by non-zero
|
||||
// sentinels (unified cache) or explicit validity bits (NodeInfo cache).
|
||||
static constexpr uint32_t kPosTimeTickMs = 360'000UL; // 6 min/tick (uint8: 25.6 h period)
|
||||
static constexpr uint32_t kRateTimeTickMs = 300'000UL; // 5 min/tick (nibble: 80 min period)
|
||||
static constexpr uint32_t kUnknownTimeTickMs = 60'000UL; // 1 min/tick (nibble: 16 min period)
|
||||
|
||||
/// Current position-clock tick (6 min/tick).
|
||||
static uint8_t currentPosTick() { return static_cast<uint8_t>(clockMs() / kPosTimeTickMs); }
|
||||
/// Current rate-clock tick nibble (5 min/tick).
|
||||
static uint8_t currentRateTick() { return static_cast<uint8_t>((clockMs() / kRateTimeTickMs) & 0x0F); }
|
||||
/// Current unknown-clock tick nibble (1 min/tick).
|
||||
static uint8_t currentUnknownTick() { return static_cast<uint8_t>((clockMs() / kUnknownTimeTickMs) & 0x0F); }
|
||||
// =========================================================================
|
||||
// Position Fingerprint
|
||||
// =========================================================================
|
||||
//
|
||||
// Computes 8-bit fingerprint from truncated lat/lon coordinates.
|
||||
// Extracts lower 4 significant bits from each coordinate.
|
||||
//
|
||||
// fingerprint = (lat_low4 << 4) | lon_low4
|
||||
//
|
||||
// Unlike a hash, adjacent grid cells have sequential fingerprints,
|
||||
// so nearby positions never collide. Collisions only occur for
|
||||
// positions 16+ grid cells apart in both dimensions.
|
||||
//
|
||||
// Guards: If precision < 4 bits, uses min(precision, 4) bits.
|
||||
//
|
||||
|
||||
// NodeInfo observation tick (same idiom). The 60 s sweep clears the presence bit once the serve
|
||||
// window passes, so the stamp is never read near its uint8 aliasing horizon. (Response throttling
|
||||
// no longer lives here - it is the fixed per-requester/per-target RAM tables below, which use
|
||||
// wrap-safe uint32 ms compares and so need no tick clock or sweep.)
|
||||
static constexpr uint32_t kNodeInfoObsTickMs = 180000UL; // 3 min/tick (12.8 h period)
|
||||
static constexpr uint8_t kNodeInfoMaxServeAgeTicks = 120; // 6 h serve window
|
||||
|
||||
/// Current NodeInfo observation tick (3 min/tick).
|
||||
static uint8_t currentObsTick() { return static_cast<uint8_t>(clockMs() / kNodeInfoObsTickMs); }
|
||||
static_assert(kNodeInfoMaxServeAgeTicks * kNodeInfoObsTickMs == 6UL * 60UL * 60UL * 1000UL,
|
||||
"cache serve window must equal the fallback path's 6 h");
|
||||
|
||||
/// 8-bit position fingerprint from truncated lat/lon: low 4 significant bits of each, so
|
||||
/// adjacent grid cells never collide (collisions need 16+ cells apart in both dimensions).
|
||||
static uint8_t computePositionFingerprint(int32_t lat_truncated, int32_t lon_truncated, uint8_t precision);
|
||||
|
||||
// =========================================================================
|
||||
@@ -213,42 +234,60 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
|
||||
bool cacheFromPsram = false; // Tracks allocator for correct deallocation
|
||||
|
||||
struct NodeInfoPayloadEntry {
|
||||
// Node identifier associated with this payload slot.
|
||||
// 0 means the slot is currently unused.
|
||||
// Node identifier for this slot; 0 means unused.
|
||||
NodeNum node;
|
||||
|
||||
// Cached NODEINFO_APP payload body. This is separate from NodeDB and is only
|
||||
// used by the PSRAM-backed direct-response path in this module.
|
||||
// Cached NODEINFO_APP payload, independent of NodeDB; serves the PSRAM-backed
|
||||
// direct-response path and the last-resort pubkey pool.
|
||||
meshtastic_User user;
|
||||
|
||||
// Extra response metadata captured from the latest observed NODEINFO_APP
|
||||
// packet for this node. shouldRespondToNodeInfo() uses this metadata when
|
||||
// building spoofed replies for requesting clients.
|
||||
|
||||
// Last local uptime tick (millis) when this entry was refreshed.
|
||||
uint32_t lastObservedMs;
|
||||
|
||||
// Last RTC/packet timestamp (seconds) observed for this NodeInfo frame.
|
||||
// If unavailable in packet, remains 0.
|
||||
uint32_t lastObservedRxTime;
|
||||
// Tick of the last genuinely HEARD NODEINFO frame (kNodeInfoObsTickMs clock). Drives the
|
||||
// replay staleness gate and LRU age; seeding/write-through never touch it, so a spoofed
|
||||
// reply is only ever backed by genuine recent observation. Validity: hasObserved.
|
||||
uint8_t obsTick;
|
||||
|
||||
// Channel where we most recently heard this node's NodeInfo.
|
||||
uint8_t sourceChannel;
|
||||
|
||||
// Cached decoded bitfield metadata from the source packet.
|
||||
// We preserve non-OK_TO_MQTT bits in direct replies when available.
|
||||
bool hasDecodedBitfield;
|
||||
// Cached decoded bitfield from the source packet (non-OK_TO_MQTT bits are preserved
|
||||
// in direct replies). Validity: hasDecodedBitfield.
|
||||
uint8_t decodedBitfield;
|
||||
};
|
||||
|
||||
NodeInfoPayloadEntry *nodeInfoPayload = nullptr; // NodeInfo payloads in PSRAM (flat array, linear scan)
|
||||
// 1-bit flags, packed into one byte (6 spare bits; add future booleans here rather
|
||||
// than new bytes - the array is 2000 entries).
|
||||
|
||||
// The source packet carried a decoded bitfield (so decodedBitfield is meaningful).
|
||||
uint8_t hasDecodedBitfield : 1;
|
||||
|
||||
// Key provenance: set once an XEdDSA signature was verified for user.public_key
|
||||
// (directly, or inherited from NodeDB via isVerifiedSignerForKey). Monotonic per slot;
|
||||
// the key-pin checks forbid the key changing underneath it. TOFU keys start at 0.
|
||||
uint8_t keySignerProven : 1;
|
||||
|
||||
// obsTick is valid: a NODEINFO frame was actually heard within the observation clock's
|
||||
// horizon. Cleared by the sweep once the serve window passes (saturation).
|
||||
uint8_t hasObserved : 1;
|
||||
|
||||
// `user` carries a real User payload (from an observed frame or hot-store seed) rather
|
||||
// than a key-only warm-tier record. copyUser()/name-rehydration require it.
|
||||
uint8_t hasFullUser : 1;
|
||||
|
||||
// Node currently exists in NodeDB (hot or warm), per the last hourly reconcile pass
|
||||
// (write-through hooks set it immediately on commit; purgeNode clears immediately on
|
||||
// removal; a passive NodeDB eviction may lag up to an hour). Member entries are
|
||||
// stickiest under LRU; the bit is the keep-alive (no TTL).
|
||||
uint8_t isMember : 1;
|
||||
};
|
||||
// No exact-size static_assert: sizeof(meshtastic_User) and its padding vary by platform, so
|
||||
// any fixed byte count would fail the build on some boards.
|
||||
|
||||
NodeInfoPayloadEntry *nodeInfoPayload = nullptr; // NodeInfo payloads (flat array; PSRAM on hardware, heap in tests)
|
||||
bool nodeInfoPayloadFromPsram = false; // Tracks allocator for correct deallocation
|
||||
|
||||
meshtastic_TrafficManagementStats stats;
|
||||
|
||||
// Flag set during alterReceived() when packet should be exhausted.
|
||||
// Checked by perhapsRebroadcast() to force hop_limit = 0 only for the
|
||||
// matching packet key (from + id). Reset at start of handleReceived().
|
||||
// Set during alterReceived() when the packet's hops should be exhausted; checked by
|
||||
// perhapsRebroadcast() for the matching packet key. Reset at start of handleReceived().
|
||||
bool exhaustRequested = false;
|
||||
NodeNum exhaustRequestedFrom = 0;
|
||||
PacketId exhaustRequestedId = 0;
|
||||
@@ -256,61 +295,97 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
|
||||
// One-shot guard: warm-start next-hop cache from NodeDB on first maintenance pass.
|
||||
bool nextHopPreloaded = false;
|
||||
|
||||
// Reconcile cadence: full boot seed on the first maintenance pass, then hourly. The
|
||||
// write-through hooks give immediacy; this periodic repair self-heals anything they miss.
|
||||
static constexpr uint8_t kNodeInfoReconcileSweeps = 60; // sweeps between reconciliations (60 x 60 s = 1 h)
|
||||
bool nodeInfoSeeded = false;
|
||||
uint8_t sweepsSinceNodeInfoReconcile = 0;
|
||||
|
||||
// =========================================================================
|
||||
// Cache Operations
|
||||
// =========================================================================
|
||||
|
||||
// Find or create entry for node (linear scan; stalest-first eviction when full)
|
||||
/// Find or create the unified-cache entry for `node` (stalest-first eviction when full).
|
||||
UnifiedCacheEntry *findOrCreateEntry(NodeNum node, bool *isNew);
|
||||
|
||||
// Find existing entry (no creation)
|
||||
/// Find an existing unified-cache entry (no creation).
|
||||
UnifiedCacheEntry *findEntry(NodeNum node);
|
||||
|
||||
// Resolve a sender's advertised device role for the position hot path. The tier-3
|
||||
// cache (this entry's getCachedRole) is authoritative and is kept fresh by
|
||||
// updateCachedRoleFromNodeInfo() - updated when NodeDB learns a role, not re-derived
|
||||
// per packet. Only on first tracking (isNew) do we scan NodeDB (hot store → warm
|
||||
// store, via getNodeRole) to seed the cache, so a resident special-role node is
|
||||
// correct from its first position; after that the read is O(1) and survives the node
|
||||
// aging out of both NodeDB stores. Caller must hold cacheLock; entry may be null
|
||||
// (→ NodeDB scan only).
|
||||
/// Resolve a sender's device role for the position hot path. The tier-3 cache is
|
||||
/// authoritative once seeded (NodeDB is scanned only on first tracking), so the read is O(1)
|
||||
/// and survives the node aging out of both NodeDB stores. Caller must hold cacheLock.
|
||||
meshtastic_Config_DeviceConfig_Role resolveSenderRole(NodeNum from, UnifiedCacheEntry *entry, bool isNew);
|
||||
|
||||
// Refresh the tier-3 role cache from an observed NodeInfo (the same event that updates
|
||||
// NodeDB's role). Reads role from the packet's User payload; updates only nodes already
|
||||
// tracked (no entry creation). Takes cacheLock.
|
||||
/// Refresh the tier-3 role cache from an observed NodeInfo (the same event that updates
|
||||
/// NodeDB's role). Updates only nodes already tracked. Takes cacheLock.
|
||||
void updateCachedRoleFromNodeInfo(const meshtastic_MeshPacket &mp);
|
||||
|
||||
// NodeInfo cache operations (flat PSRAM payload array, linear scan)
|
||||
/// Find an existing NodeInfo cache entry (no creation).
|
||||
const NodeInfoPayloadEntry *findNodeInfoEntry(NodeNum node) const;
|
||||
NodeInfoPayloadEntry *findOrCreateNodeInfoEntry(NodeNum node, bool *usedEmptySlot);
|
||||
/// Mutable variant of findNodeInfoEntry().
|
||||
NodeInfoPayloadEntry *findNodeInfoEntryMutable(NodeNum node)
|
||||
{
|
||||
return const_cast<NodeInfoPayloadEntry *>(findNodeInfoEntry(node));
|
||||
}
|
||||
/// Find or create a NodeInfo cache entry, evicting by trust/membership tier when full.
|
||||
/// With spareMembers, returns nullptr instead of evicting an isMember entry (the seeding
|
||||
/// pass never churns one NodeDB-tier node out for another; the packet path may).
|
||||
NodeInfoPayloadEntry *findOrCreateNodeInfoEntry(NodeNum node, bool *usedEmptySlot, bool spareMembers = false);
|
||||
/// Number of occupied NodeInfo cache slots. Caller must hold cacheLock.
|
||||
uint16_t countNodeInfoEntriesLocked() const;
|
||||
|
||||
/// 60 s NodeInfo-cache maintenance under cacheLock: saturate the expired obsTick stamp (wrap-safety
|
||||
/// for the modular clock) and run the boot/hourly reconcile. Guarded by TMM_HAS_NODEINFO_CACHE alone
|
||||
/// (never the unified cache size); see docs/node_info_stores.md "Tick clocks and wrap safety".
|
||||
void maintainNodeInfoCacheLocked();
|
||||
|
||||
/// Anti-entropy under cacheLock: upsert hot-store + warm-tier records this cache lacks (never sets
|
||||
/// hasObserved - seeding is knowledge, not observation), and refresh isMember from both NodeDB
|
||||
/// tiers. Cost/lag: docs/node_info_stores.md "Consistency with NodeDB (anti-entropy)".
|
||||
void reconcileNodeInfoFromNodeDBLocked();
|
||||
/// Learn an observed NODEINFO frame into the cache (key hygiene + provenance rules apply).
|
||||
void cacheNodeInfoPacket(const meshtastic_MeshPacket &mp);
|
||||
|
||||
// =========================================================================
|
||||
// Traffic Management Logic
|
||||
// =========================================================================
|
||||
|
||||
/// True when this position broadcast duplicates the sender's last one within the dedup window.
|
||||
bool shouldDropPosition(const meshtastic_MeshPacket *p, const meshtastic_Position *pos, uint32_t nowMs);
|
||||
/// Decide (and with sendResponse, emit) a spoofed direct NodeInfo reply for a unicast request.
|
||||
bool shouldRespondToNodeInfo(const meshtastic_MeshPacket *p, bool sendResponse);
|
||||
|
||||
// Replies go to the requesting packet's unauthenticated `from`, so space them per requester to bound
|
||||
// what any one node can be made to receive, plus a global floor on airtime.
|
||||
static constexpr uint32_t kDirectResponsePerRequestorMs = 60'000UL;
|
||||
// Direct-response throttles bounding the reflector risk of spoofed replies: three fixed bounds
|
||||
// (per requester, per target, 1 s global airtime floor) via 8-slot LRU RAM tables, wrap-safe and
|
||||
// PSRAM-agnostic. Design & rationale: docs/traffic_management_module.md "Throttling direct responses".
|
||||
static constexpr uint32_t kDirectResponsePerRequesterMs = 60'000UL;
|
||||
static constexpr uint32_t kDirectResponsePerTargetMs = 60'000UL;
|
||||
static constexpr uint32_t kDirectResponseGlobalMs = 1'000UL;
|
||||
static constexpr size_t kDirectResponseTrackedRequestors = 8;
|
||||
static constexpr size_t kDirectResponseTrackedNodes = 8;
|
||||
struct DirectResponseThrottleEntry {
|
||||
NodeNum requestor;
|
||||
uint32_t lastReplyMs;
|
||||
NodeNum key; // requester or target node; 0 = unused slot
|
||||
uint32_t lastReplyMs; // clockMs() of our last reply keyed on this node
|
||||
};
|
||||
DirectResponseThrottleEntry directResponseSeen[kDirectResponseTrackedRequestors] = {};
|
||||
DirectResponseThrottleEntry directRequesterSeen[kDirectResponseTrackedNodes] = {};
|
||||
DirectResponseThrottleEntry directTargetSeen[kDirectResponseTrackedNodes] = {};
|
||||
uint32_t lastDirectResponseMs = 0;
|
||||
bool directResponseAllowed(NodeNum requestor, uint32_t nowMs);
|
||||
/// True (and records the send) when a spoofed direct reply to `requester` for `target` is within
|
||||
/// every throttle window; false throttles it. Caller must NOT hold cacheLock (this takes it).
|
||||
bool directResponseAllowed(NodeNum requester, NodeNum target, uint32_t nowMs);
|
||||
/// Slot in `table` to stamp for `key` at `nowMs`, or nullptr if `key` is still within `windowMs`
|
||||
/// of its last reply (throttled). Does not stamp - the caller stamps only once all windows pass.
|
||||
static DirectResponseThrottleEntry *directResponseSlot(DirectResponseThrottleEntry *table, NodeNum key, uint32_t nowMs,
|
||||
uint32_t windowMs);
|
||||
/// True when the requestor is within the role-clamped hop limit for direct responses.
|
||||
bool isMinHopsFromRequestor(const meshtastic_MeshPacket *p) const;
|
||||
/// True when `from` exceeded the configured packet budget for the current rate window.
|
||||
bool isRateLimited(NodeNum from, uint32_t nowMs);
|
||||
/// True when `p`'s sender exceeded the undecodable-packet threshold for the current window.
|
||||
bool shouldDropUnknown(const meshtastic_MeshPacket *p, uint32_t nowMs);
|
||||
|
||||
/// Log a traffic action (drop/respond/clamp) with port name and packet routing context.
|
||||
void logAction(const char *action, const meshtastic_MeshPacket *p, const char *reason) const;
|
||||
/// Increment a stats counter under cacheLock.
|
||||
void incrementStat(uint32_t *field);
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,23 @@
|
||||
#include "TestUtil.h"
|
||||
#include <unity.h>
|
||||
|
||||
#include "configuration.h"
|
||||
#include "mesh/CryptoEngine.h"
|
||||
#include "mesh/MeshService.h"
|
||||
#include "mesh/NodeDB.h"
|
||||
#include "mesh/RadioInterface.h"
|
||||
#include "mesh/Router.h"
|
||||
#include "modules/NeighborInfoModule.h"
|
||||
#include "modules/RoutingModule.h"
|
||||
#include "support/MockMeshService.h"
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr NodeNum LOCAL_NODE = 0x11111111;
|
||||
constexpr NodeNum REMOTE_NODE = 0x22222222;
|
||||
|
||||
// Minimal concrete subclass for testing the base class helper
|
||||
class TestModule : public MeshModule
|
||||
{
|
||||
@@ -13,11 +30,207 @@ class TestModule : public MeshModule
|
||||
using MeshModule::isMultiHopBroadcastRequest;
|
||||
};
|
||||
|
||||
class MockNodeDB : public NodeDB
|
||||
{
|
||||
};
|
||||
|
||||
class MockRadioInterface : public RadioInterface
|
||||
{
|
||||
public:
|
||||
ErrorCode send(meshtastic_MeshPacket *p) override
|
||||
{
|
||||
packetPool.release(p);
|
||||
return ERRNO_OK;
|
||||
}
|
||||
|
||||
uint32_t getPacketTime(uint32_t totalPacketLen, bool received = false) override
|
||||
{
|
||||
(void)totalPacketLen;
|
||||
(void)received;
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
class MockRouter : public Router
|
||||
{
|
||||
public:
|
||||
~MockRouter()
|
||||
{
|
||||
delete cryptLock;
|
||||
cryptLock = nullptr;
|
||||
}
|
||||
|
||||
ErrorCode send(meshtastic_MeshPacket *p) override
|
||||
{
|
||||
sentPackets.push_back(*p);
|
||||
packetPool.release(p);
|
||||
return ERRNO_OK;
|
||||
}
|
||||
|
||||
std::vector<meshtastic_MeshPacket> sentPackets;
|
||||
};
|
||||
|
||||
struct AckNak {
|
||||
meshtastic_Routing_Error error;
|
||||
NodeNum to;
|
||||
PacketId requestId;
|
||||
ChannelIndex channel;
|
||||
};
|
||||
|
||||
class MockRoutingModule : public RoutingModule
|
||||
{
|
||||
public:
|
||||
void sendAckNak(meshtastic_Routing_Error err, NodeNum to, PacketId idFrom, ChannelIndex chIndex, uint8_t hopLimit = 0,
|
||||
bool ackWantsAck = false) override
|
||||
{
|
||||
(void)hopLimit;
|
||||
(void)ackWantsAck;
|
||||
ackNaks.push_back({err, to, idFrom, chIndex});
|
||||
}
|
||||
|
||||
std::vector<AckNak> ackNaks;
|
||||
|
||||
protected:
|
||||
bool wantPacket(const meshtastic_MeshPacket *p) override
|
||||
{
|
||||
(void)p;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
class SyntheticReplyModule : public MeshModule
|
||||
{
|
||||
public:
|
||||
SyntheticReplyModule(const char *name, meshtastic_PortNum modulePort, meshtastic_PortNum replyPort,
|
||||
bool acceptsEveryPort = false)
|
||||
: MeshModule(name, modulePort), replyPort(replyPort), acceptsEveryPort(acceptsEveryPort)
|
||||
{
|
||||
isPromiscuous = acceptsEveryPort;
|
||||
}
|
||||
|
||||
uint32_t allocReplyCalls = 0;
|
||||
|
||||
protected:
|
||||
bool wantPacket(const meshtastic_MeshPacket *p) override { return acceptsEveryPort || p->decoded.portnum == ourPortNum; }
|
||||
|
||||
meshtastic_MeshPacket *allocReply() override
|
||||
{
|
||||
allocReplyCalls++;
|
||||
meshtastic_MeshPacket *reply = router->allocForSending();
|
||||
reply->decoded.portnum = replyPort;
|
||||
return reply;
|
||||
}
|
||||
|
||||
private:
|
||||
meshtastic_PortNum replyPort;
|
||||
bool acceptsEveryPort;
|
||||
};
|
||||
|
||||
class ObservingIgnoreModule : public MeshModule
|
||||
{
|
||||
public:
|
||||
ObservingIgnoreModule() : MeshModule("storeforward-shaped", meshtastic_PortNum_STORE_FORWARD_APP) {}
|
||||
|
||||
uint32_t allocReplyCalls = 0;
|
||||
|
||||
protected:
|
||||
bool wantPacket(const meshtastic_MeshPacket *p) override { return p->decoded.portnum == meshtastic_PortNum_TEXT_MESSAGE_APP; }
|
||||
|
||||
ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override
|
||||
{
|
||||
(void)mp;
|
||||
ignoreRequest = true;
|
||||
return ProcessMessage::CONTINUE;
|
||||
}
|
||||
|
||||
meshtastic_MeshPacket *allocReply() override
|
||||
{
|
||||
allocReplyCalls++;
|
||||
return nullptr;
|
||||
}
|
||||
};
|
||||
|
||||
class ReplyIgnoreModule : public MeshModule
|
||||
{
|
||||
public:
|
||||
ReplyIgnoreModule() : MeshModule("reply-ignore", meshtastic_PortNum_NEIGHBORINFO_APP) {}
|
||||
|
||||
uint32_t allocReplyCalls = 0;
|
||||
|
||||
protected:
|
||||
bool wantPacket(const meshtastic_MeshPacket *p) override { return p->decoded.portnum == ourPortNum; }
|
||||
|
||||
meshtastic_MeshPacket *allocReply() override
|
||||
{
|
||||
allocReplyCalls++;
|
||||
ignoreRequest = true;
|
||||
return nullptr;
|
||||
}
|
||||
};
|
||||
|
||||
static TestModule *testModule;
|
||||
static meshtastic_MeshPacket testPacket;
|
||||
static MockNodeDB *mockNodeDB;
|
||||
static MockMeshService *mockService;
|
||||
static MockRouter *mockRouter;
|
||||
static MockRoutingModule *mockRoutingModule;
|
||||
static NeighborInfoModule *realNeighborInfoModule;
|
||||
static std::vector<MeshModule *> dispatchModules;
|
||||
|
||||
template <typename T> static T *registerDispatchModule(T *module)
|
||||
{
|
||||
dispatchModules.push_back(module);
|
||||
return module;
|
||||
}
|
||||
|
||||
static meshtastic_MeshPacket makeRequest(meshtastic_PortNum port)
|
||||
{
|
||||
meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_zero;
|
||||
packet.from = REMOTE_NODE;
|
||||
packet.to = LOCAL_NODE;
|
||||
packet.id = 0x12345678;
|
||||
packet.channel = 0;
|
||||
packet.hop_start = 3;
|
||||
packet.hop_limit = 3;
|
||||
packet.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
|
||||
packet.decoded.portnum = port;
|
||||
packet.decoded.want_response = true;
|
||||
return packet;
|
||||
}
|
||||
|
||||
static void dispatch(meshtastic_PortNum port)
|
||||
{
|
||||
meshtastic_MeshPacket request = makeRequest(port);
|
||||
MeshModule::callModules(request);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void setUp(void)
|
||||
{
|
||||
config = meshtastic_LocalConfig_init_zero;
|
||||
moduleConfig = meshtastic_LocalModuleConfig_init_zero;
|
||||
channelFile = meshtastic_ChannelFile_init_zero;
|
||||
owner = meshtastic_User_init_zero;
|
||||
myNodeInfo.my_node_num = LOCAL_NODE;
|
||||
|
||||
mockNodeDB = new MockNodeDB();
|
||||
nodeDB = mockNodeDB;
|
||||
myNodeInfo.my_node_num = LOCAL_NODE;
|
||||
|
||||
mockService = new MockMeshService();
|
||||
service = mockService;
|
||||
|
||||
channels.initDefaults();
|
||||
channels.onConfigChanged();
|
||||
|
||||
mockRouter = new MockRouter();
|
||||
mockRouter->addInterface(std::unique_ptr<RadioInterface>(new MockRadioInterface()));
|
||||
router = mockRouter;
|
||||
|
||||
mockRoutingModule = new MockRoutingModule();
|
||||
routingModule = mockRoutingModule;
|
||||
|
||||
testModule = new TestModule();
|
||||
memset(&testPacket, 0, sizeof(testPacket));
|
||||
TestModule::currentRequest = &testPacket;
|
||||
@@ -26,7 +239,34 @@ void setUp(void)
|
||||
void tearDown(void)
|
||||
{
|
||||
TestModule::currentRequest = NULL;
|
||||
|
||||
for (auto it = dispatchModules.rbegin(); it != dispatchModules.rend(); ++it)
|
||||
delete *it;
|
||||
dispatchModules.clear();
|
||||
|
||||
delete realNeighborInfoModule;
|
||||
realNeighborInfoModule = nullptr;
|
||||
|
||||
delete testModule;
|
||||
testModule = nullptr;
|
||||
|
||||
delete mockRoutingModule;
|
||||
mockRoutingModule = nullptr;
|
||||
routingModule = nullptr;
|
||||
|
||||
while (auto *status = mockService->getQueueStatusForPhone())
|
||||
mockService->releaseQueueStatusToPool(status);
|
||||
delete mockService;
|
||||
mockService = nullptr;
|
||||
service = nullptr;
|
||||
|
||||
delete mockRouter;
|
||||
mockRouter = nullptr;
|
||||
router = nullptr;
|
||||
|
||||
delete mockNodeDB;
|
||||
mockNodeDB = nullptr;
|
||||
nodeDB = nullptr;
|
||||
}
|
||||
|
||||
// Zero-hop broadcast (hop_limit == hop_start): should be allowed
|
||||
@@ -98,6 +338,139 @@ static void test_singleHopRelayedBroadcast_isBlocked()
|
||||
TEST_ASSERT_TRUE(testModule->isMultiHopBroadcastRequest());
|
||||
}
|
||||
|
||||
static void test_replyPortMatches_ownPort()
|
||||
{
|
||||
meshtastic_MeshPacket request = makeRequest(meshtastic_PortNum_TELEMETRY_APP);
|
||||
|
||||
TEST_ASSERT_TRUE(MeshModule::replyPortMatches(meshtastic_PortNum_TELEMETRY_APP, request));
|
||||
}
|
||||
|
||||
static void test_replyPortMatches_neighborInfoVsTelemetry()
|
||||
{
|
||||
meshtastic_MeshPacket request = makeRequest(meshtastic_PortNum_TELEMETRY_APP);
|
||||
|
||||
TEST_ASSERT_FALSE(MeshModule::replyPortMatches(meshtastic_PortNum_NEIGHBORINFO_APP, request));
|
||||
}
|
||||
|
||||
static void test_replyPortMatches_positionRequest()
|
||||
{
|
||||
meshtastic_MeshPacket request = makeRequest(meshtastic_PortNum_POSITION_APP);
|
||||
|
||||
TEST_ASSERT_TRUE(MeshModule::replyPortMatches(meshtastic_PortNum_POSITION_APP, request));
|
||||
}
|
||||
|
||||
static void test_replyPortMatches_unknownModulePort()
|
||||
{
|
||||
meshtastic_MeshPacket request = makeRequest(meshtastic_PortNum_TELEMETRY_APP);
|
||||
|
||||
TEST_ASSERT_FALSE(MeshModule::replyPortMatches(meshtastic_PortNum_UNKNOWN_APP, request));
|
||||
}
|
||||
|
||||
static void test_replyPortMatches_unknownRequestPort()
|
||||
{
|
||||
meshtastic_MeshPacket request = makeRequest(meshtastic_PortNum_UNKNOWN_APP);
|
||||
|
||||
TEST_ASSERT_FALSE(MeshModule::replyPortMatches(meshtastic_PortNum_TELEMETRY_APP, request));
|
||||
}
|
||||
|
||||
static void test_dispatch_foreignPortOffenderCannotShadowOwner()
|
||||
{
|
||||
auto *offender = registerDispatchModule(new SyntheticReplyModule("neighbor-shaped", meshtastic_PortNum_NEIGHBORINFO_APP,
|
||||
meshtastic_PortNum_NEIGHBORINFO_APP, true));
|
||||
auto *owner = registerDispatchModule(
|
||||
new SyntheticReplyModule("telemetry-owner", meshtastic_PortNum_TELEMETRY_APP, meshtastic_PortNum_TELEMETRY_APP));
|
||||
|
||||
dispatch(meshtastic_PortNum_TELEMETRY_APP);
|
||||
|
||||
TEST_ASSERT_EQUAL_UINT32(0, offender->allocReplyCalls);
|
||||
TEST_ASSERT_EQUAL_UINT32(1, owner->allocReplyCalls);
|
||||
TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size());
|
||||
TEST_ASSERT_EQUAL(meshtastic_PortNum_TELEMETRY_APP, mockRouter->sentPackets[0].decoded.portnum);
|
||||
TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size());
|
||||
}
|
||||
|
||||
static void test_dispatch_ownerPortStillReplies()
|
||||
{
|
||||
auto *offender = registerDispatchModule(new SyntheticReplyModule("neighbor-shaped", meshtastic_PortNum_NEIGHBORINFO_APP,
|
||||
meshtastic_PortNum_NEIGHBORINFO_APP, true));
|
||||
auto *owner = registerDispatchModule(
|
||||
new SyntheticReplyModule("telemetry-owner", meshtastic_PortNum_TELEMETRY_APP, meshtastic_PortNum_TELEMETRY_APP));
|
||||
|
||||
dispatch(meshtastic_PortNum_NEIGHBORINFO_APP);
|
||||
|
||||
TEST_ASSERT_EQUAL_UINT32(1, offender->allocReplyCalls);
|
||||
TEST_ASSERT_EQUAL_UINT32(0, owner->allocReplyCalls);
|
||||
TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size());
|
||||
TEST_ASSERT_EQUAL(meshtastic_PortNum_NEIGHBORINFO_APP, mockRouter->sentPackets[0].decoded.portnum);
|
||||
TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size());
|
||||
}
|
||||
|
||||
static void test_dispatch_crossPortReplyUsesRequestOwner()
|
||||
{
|
||||
auto *position = registerDispatchModule(
|
||||
new SyntheticReplyModule("position-owner", meshtastic_PortNum_POSITION_APP, meshtastic_PortNum_ATAK_PLUGIN_V2));
|
||||
|
||||
dispatch(meshtastic_PortNum_POSITION_APP);
|
||||
|
||||
TEST_ASSERT_EQUAL_UINT32(1, position->allocReplyCalls);
|
||||
TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size());
|
||||
TEST_ASSERT_EQUAL(meshtastic_PortNum_ATAK_PLUGIN_V2, mockRouter->sentPackets[0].decoded.portnum);
|
||||
TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size());
|
||||
}
|
||||
|
||||
static void test_dispatch_foreignPortObserverCanSuppressNak()
|
||||
{
|
||||
auto *observer = registerDispatchModule(new ObservingIgnoreModule());
|
||||
|
||||
dispatch(meshtastic_PortNum_TEXT_MESSAGE_APP);
|
||||
|
||||
TEST_ASSERT_EQUAL_UINT32(0, observer->allocReplyCalls);
|
||||
TEST_ASSERT_EQUAL_UINT32(0, mockRouter->sentPackets.size());
|
||||
TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size());
|
||||
}
|
||||
|
||||
static void test_dispatch_noResponderSendsNak()
|
||||
{
|
||||
dispatch(meshtastic_PortNum_TELEMETRY_APP);
|
||||
|
||||
TEST_ASSERT_EQUAL_UINT32(0, mockRouter->sentPackets.size());
|
||||
TEST_ASSERT_EQUAL_UINT32(1, mockRoutingModule->ackNaks.size());
|
||||
TEST_ASSERT_EQUAL(meshtastic_Routing_Error_NO_RESPONSE, mockRoutingModule->ackNaks[0].error);
|
||||
TEST_ASSERT_EQUAL_HEX32(REMOTE_NODE, mockRoutingModule->ackNaks[0].to);
|
||||
}
|
||||
|
||||
static void test_dispatch_ignoreRequestIsClearedPerPacket()
|
||||
{
|
||||
auto *ignoring = registerDispatchModule(new ReplyIgnoreModule());
|
||||
|
||||
dispatch(meshtastic_PortNum_NEIGHBORINFO_APP);
|
||||
|
||||
TEST_ASSERT_EQUAL_UINT32(1, ignoring->allocReplyCalls);
|
||||
TEST_ASSERT_EQUAL_UINT32(0, mockRouter->sentPackets.size());
|
||||
TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size());
|
||||
|
||||
dispatch(meshtastic_PortNum_TELEMETRY_APP);
|
||||
|
||||
TEST_ASSERT_EQUAL_UINT32(1, ignoring->allocReplyCalls);
|
||||
TEST_ASSERT_EQUAL_UINT32(0, mockRouter->sentPackets.size());
|
||||
TEST_ASSERT_EQUAL_UINT32(1, mockRoutingModule->ackNaks.size());
|
||||
TEST_ASSERT_EQUAL(meshtastic_Routing_Error_NO_RESPONSE, mockRoutingModule->ackNaks[0].error);
|
||||
}
|
||||
|
||||
static void test_dispatch_realNeighborInfoCannotShadowTelemetryOwner()
|
||||
{
|
||||
moduleConfig.neighbor_info.enabled = true;
|
||||
realNeighborInfoModule = new NeighborInfoModule();
|
||||
registerDispatchModule(
|
||||
new SyntheticReplyModule("telemetry-owner", meshtastic_PortNum_TELEMETRY_APP, meshtastic_PortNum_TELEMETRY_APP));
|
||||
|
||||
dispatch(meshtastic_PortNum_TELEMETRY_APP);
|
||||
|
||||
TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size());
|
||||
TEST_ASSERT_EQUAL(meshtastic_PortNum_TELEMETRY_APP, mockRouter->sentPackets[0].decoded.portnum);
|
||||
TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size());
|
||||
}
|
||||
|
||||
void setup()
|
||||
{
|
||||
initializeTestEnvironment();
|
||||
@@ -110,6 +483,18 @@ void setup()
|
||||
RUN_TEST(test_noCurrentRequest_isAllowed);
|
||||
RUN_TEST(test_legacyPacket_zeroHopStart_isAllowed);
|
||||
RUN_TEST(test_singleHopRelayedBroadcast_isBlocked);
|
||||
RUN_TEST(test_replyPortMatches_ownPort);
|
||||
RUN_TEST(test_replyPortMatches_neighborInfoVsTelemetry);
|
||||
RUN_TEST(test_replyPortMatches_positionRequest);
|
||||
RUN_TEST(test_replyPortMatches_unknownModulePort);
|
||||
RUN_TEST(test_replyPortMatches_unknownRequestPort);
|
||||
RUN_TEST(test_dispatch_foreignPortOffenderCannotShadowOwner);
|
||||
RUN_TEST(test_dispatch_ownerPortStillReplies);
|
||||
RUN_TEST(test_dispatch_crossPortReplyUsesRequestOwner);
|
||||
RUN_TEST(test_dispatch_foreignPortObserverCanSuppressNak);
|
||||
RUN_TEST(test_dispatch_noResponderSendsNak);
|
||||
RUN_TEST(test_dispatch_ignoreRequestIsClearedPerPacket);
|
||||
RUN_TEST(test_dispatch_realNeighborInfoCannotShadowTelemetryOwner);
|
||||
exit(UNITY_END());
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user