# 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. --- ## 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](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](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.