Files
meshtastic_firmware/docs/traffic_management_module.md

13 KiB

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.

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; 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. 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; 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.


Tick clocks and wrap safety

Every per-node timestamp in TMM's caches is a free-running modular tick (uint8 or nibble) taken from clockMs() - never an absolute time. That is what keeps UnifiedCacheEntry at 10 bytes across up to 2048 entries. The cost is that modular subtraction is only correct while the true age stays below the counter's period, so every clock needs something to clear expired state before it aliases. (The direct-serve throttle above is the deliberate exception: full uint32 milliseconds compared by wrap-safe subtraction, hence no tick and no sweep.)

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 stores these clocks stamp, and the warm tier's contrasting absolute timestamps, are described in node_info_stores.md.


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 - 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.