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