mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-22 07:48:26 +00:00
Compare commits
144 Commits
v0.4.0-rc2
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80c956a6fd | ||
|
|
75d7077880 | ||
|
|
a47ddbd5a5 | ||
|
|
15db6471db | ||
|
|
146d19a8d8 | ||
|
|
bda327b5f5 | ||
|
|
ea74cd7e58 | ||
|
|
78377208af | ||
|
|
37adb13d5b | ||
|
|
26a579b1c9 | ||
|
|
93a5b71728 | ||
|
|
3ebb14eda4 | ||
|
|
4ff7de4d81 | ||
|
|
e4a854f6b0 | ||
|
|
0c3d9a0b73 | ||
|
|
281ed132f1 | ||
|
|
c5492f4572 | ||
|
|
7a97599921 | ||
|
|
7fe1d75637 | ||
|
|
e21e09d7e6 | ||
|
|
e7537929ba | ||
|
|
347cbe60bd | ||
|
|
11ec16777c | ||
|
|
5b09e22956 | ||
|
|
a382b17931 | ||
|
|
a90049d3a1 | ||
|
|
791b35c221 | ||
|
|
6a80790742 | ||
|
|
60b8acf716 | ||
|
|
4fc295d90a | ||
|
|
b3f2018fce | ||
|
|
56bbc81a40 | ||
|
|
b38f8c6ffb | ||
|
|
cf62cff5f4 | ||
|
|
7fe3388f2f | ||
|
|
252d16fab9 | ||
|
|
94d7b91244 | ||
|
|
7790eb86bd | ||
|
|
cf1c957336 | ||
|
|
5ccd95cf3f | ||
|
|
119b85d28e | ||
|
|
74245e80ac | ||
|
|
e42598a86e | ||
|
|
054d17aac5 | ||
|
|
fbb4fb8879 | ||
|
|
87399795f8 | ||
|
|
765819f52b | ||
|
|
5021197f5c | ||
|
|
31f5a8c1b7 | ||
|
|
3e7ca90212 | ||
|
|
9588c50063 | ||
|
|
f698da50b6 | ||
|
|
1f765cfd8f | ||
|
|
3b99a416ad | ||
|
|
e064c96df3 | ||
|
|
a70c725e48 | ||
|
|
c8077967cd | ||
|
|
5dfa571908 | ||
|
|
6bebca88ac | ||
|
|
5d5da69a5b | ||
|
|
e05b868cf8 | ||
|
|
0ebd1b44c0 | ||
|
|
800cfb23e3 | ||
|
|
e9112cc1bb | ||
|
|
c80a7fdea5 | ||
|
|
0bf031dd32 | ||
|
|
4a0584a5e9 | ||
|
|
59155df4e3 | ||
|
|
fcaee74ec0 | ||
|
|
56e3d56c25 | ||
|
|
7b0590f70e | ||
|
|
b93a127623 | ||
|
|
85a4983dbe | ||
|
|
5090ab7851 | ||
|
|
03ced618ce | ||
|
|
bf81f422ea | ||
|
|
a0cf593580 | ||
|
|
5d08d27d3c | ||
|
|
b676c9d83a | ||
|
|
a45eefb58a | ||
|
|
d61d189572 | ||
|
|
d6ca632251 | ||
|
|
6c5fd3f4b0 | ||
|
|
434b9726aa | ||
|
|
26d70ebb59 | ||
|
|
567e6a535e | ||
|
|
6011d233c1 | ||
|
|
cb5a32693e | ||
|
|
9b46b6fa85 | ||
|
|
cbc089b820 | ||
|
|
4c95be0000 | ||
|
|
e362ab67a6 | ||
|
|
6c9f55ea80 | ||
|
|
89a31fd555 | ||
|
|
ab0a46f2c0 | ||
|
|
e839aead7a | ||
|
|
6d6889d0f6 | ||
|
|
196d9492da | ||
|
|
0f2e91b479 | ||
|
|
32475d859e | ||
|
|
f2e6b8befb | ||
|
|
1aacdfa086 | ||
|
|
a7dfe47663 | ||
|
|
8aab71af86 | ||
|
|
2cffc10520 | ||
|
|
81e4207631 | ||
|
|
1c1ed0d939 | ||
|
|
1c41f73931 | ||
|
|
39ad4d2e67 | ||
|
|
4d2504f59d | ||
|
|
1208f6a5c2 | ||
|
|
3f80530cc5 | ||
|
|
3b401a0cbd | ||
|
|
0b2212e1e8 | ||
|
|
b2ce7cd3c8 | ||
|
|
e3e03f6a5d | ||
|
|
2b009196b5 | ||
|
|
5d13090d8f | ||
|
|
6538731176 | ||
|
|
9697026c81 | ||
|
|
a2400d823f | ||
|
|
dc9334e725 | ||
|
|
309a91d293 | ||
|
|
c1ddbf053c | ||
|
|
b53db662c3 | ||
|
|
4ad5940114 | ||
|
|
4ed674ea8b | ||
|
|
a67801099d | ||
|
|
50a595a0ed | ||
|
|
4802792e38 | ||
|
|
9ea57b483a | ||
|
|
e03b206f62 | ||
|
|
1dbfefc9d0 | ||
|
|
1d277e67c7 | ||
|
|
793f844448 | ||
|
|
2491091868 | ||
|
|
243bd7985a | ||
|
|
965de26239 | ||
|
|
ab915d0479 | ||
|
|
30c5808e09 | ||
|
|
8f30924fc7 | ||
|
|
3c9a629ad4 | ||
|
|
d5ee526f0e | ||
|
|
22a5b3e5c6 |
42
.github/workflows/ci.yml
vendored
42
.github/workflows/ci.yml
vendored
@@ -88,6 +88,48 @@ jobs:
|
||||
${{ runner.os }}-cargo-
|
||||
- run: cargo clippy --all-targets --all-features -- -D warnings
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
# Android cross-check
|
||||
#
|
||||
# FIPS runs on Android as an embedded library — the host app owns the TUN
|
||||
# (an Android VpnService), so there are no daemon binaries to package, unlike
|
||||
# the desktop targets. This job only cross-compiles the library for the
|
||||
# android target to guard the android-only cfg paths (and the `not(android)`
|
||||
# exclusions) from silently bit-rotting; nothing else in CI compiles them.
|
||||
# cargo-ndk wires the NDK toolchain, which is required even for a check
|
||||
# because `ring` compiles C at build time.
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
android-check:
|
||||
name: Android cross-check (aarch64)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Install Rust toolchain (+ Android target)
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
target: aarch64-linux-android
|
||||
components: clippy
|
||||
cache: false
|
||||
rustflags: ''
|
||||
- name: Cache Cargo registry + build
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-android-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-
|
||||
- name: Install cargo-ndk
|
||||
uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: cargo-ndk
|
||||
- name: Clippy the library for Android
|
||||
run: |
|
||||
export ANDROID_NDK_HOME="${ANDROID_NDK_HOME:-$ANDROID_NDK_LATEST_HOME}"
|
||||
cargo ndk -t arm64-v8a clippy --lib -- -D warnings
|
||||
|
||||
build:
|
||||
name: Build (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
3
.github/workflows/package-openwrt.yml
vendored
3
.github/workflows/package-openwrt.yml
vendored
@@ -285,6 +285,7 @@ jobs:
|
||||
"$FILES_DIR/etc/fips/firewall.sh"
|
||||
"$FILES_DIR/etc/hotplug.d/net/99-fips"
|
||||
"$FILES_DIR/etc/uci-defaults/90-fips-setup"
|
||||
"$FILES_DIR/usr/bin/fips-mesh-setup"
|
||||
)
|
||||
fail=0
|
||||
for f in "${TARGETS[@]}"; do
|
||||
@@ -404,6 +405,7 @@ jobs:
|
||||
./usr/bin/fipsctl
|
||||
./usr/bin/fipstop
|
||||
./usr/bin/fips-gateway
|
||||
./usr/bin/fips-mesh-setup
|
||||
./etc/init.d/fips
|
||||
./etc/init.d/fips-gateway
|
||||
./etc/fips/fips.yaml
|
||||
@@ -717,6 +719,7 @@ jobs:
|
||||
|
||||
for path in \
|
||||
usr/bin/fips usr/bin/fipsctl usr/bin/fipstop usr/bin/fips-gateway \
|
||||
usr/bin/fips-mesh-setup \
|
||||
etc/init.d/fips etc/init.d/fips-gateway \
|
||||
etc/fips/fips.yaml etc/fips/firewall.sh etc/dnsmasq.d/fips.conf \
|
||||
etc/sysctl.d/fips-gateway.conf etc/sysctl.d/fips-bridge.conf \
|
||||
|
||||
91
CHANGELOG.md
91
CHANGELOG.md
@@ -11,9 +11,95 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- The Ethernet transport's per-interface `discovery` flag was renamed to
|
||||
`listen` (`transports.ethernet.*`) to match the symmetric `announce`
|
||||
(transmit) / `listen` (receive) neighbor-beacon vocabulary. The old
|
||||
`discovery:` key is still accepted via a serde alias, so deployed configs
|
||||
continue to load unchanged; `Config::to_yaml()` re-emits it under the
|
||||
canonical `listen:` name. Update your `fips.yaml` to `listen:`.
|
||||
|
||||
- The mesh-lookup control-metrics family is now emitted under the key
|
||||
`lookup` in `fipsctl stats metrics` and `show routing`. The former key
|
||||
`discovery` is still emitted as a deprecated alias carrying identical
|
||||
counters; update dashboards and alerts to read `lookup`.
|
||||
- The overloaded `node.discovery.*` config table was split into
|
||||
`node.lookup.*` (mesh-lookup scalars: `ttl`, `attempt_timeouts_secs`,
|
||||
`recent_expiry_secs`, `backoff_base_secs`, `backoff_max_secs`,
|
||||
`forward_min_interval_secs`) and `node.rendezvous.*` (peer rendezvous:
|
||||
`nostr.*`, `lan.*`). A deployed `node.discovery:` block still loads and is
|
||||
folded into the new tables with a one-time deprecation warning; migrate your
|
||||
`fips.yaml` to the new keys.
|
||||
|
||||
### Deprecated
|
||||
|
||||
- The `discovery` metric-family key (control-socket JSON). It is dual-emitted
|
||||
alongside the new `lookup` key during a migration window and will be removed.
|
||||
Migrate dashboards/alerts from `discovery.*` to `lookup.*`.
|
||||
- The `node.discovery.*` config table. Its keys were split into `node.lookup.*`
|
||||
(mesh-lookup) and `node.rendezvous.*` (peer rendezvous). A legacy
|
||||
`node.discovery:` block still applies for now with a deprecation warning and
|
||||
will be removed; migrate to `node.lookup.*` / `node.rendezvous.*`.
|
||||
- The Ethernet `transports.ethernet.discovery` flag, renamed to
|
||||
`transports.ethernet.listen`. The old key is still accepted via a serde
|
||||
alias and will be removed at the v2 cutover; migrate to `listen`.
|
||||
|
||||
### Fixed
|
||||
|
||||
## [0.4.0] - 2026-06-21
|
||||
## [0.4.1] - 2026-07-19
|
||||
|
||||
### Changed
|
||||
|
||||
- `node.bloom.max_inbound_fpr` default raised from `0.10` to `0.20`. The
|
||||
cap rejects inbound `FilterAnnounce` whose FPR (`fill^k`) exceeds it. On
|
||||
the fixed 1 KB / k=5 filter, `0.10` corresponds to fill 0.631 (~1,630
|
||||
reachable entries), and the busiest nodes' aggregates had again begun to
|
||||
reach it as the mesh grew. `0.20` (fill 0.7248, ~2,114 entries) restores
|
||||
headroom without materially weakening the antipoison gate: a saturated or
|
||||
poisoned filter is ~100% FPR and still rejected. This is the second raise
|
||||
of this cap in two releases; the fixed 1 KB filter is the underlying
|
||||
constraint, and the structural remedy is the v2 filter work rather than a
|
||||
further raise. A node running this default accepts announcements that a
|
||||
v0.4.0 node drops, so during a rolling upgrade the two versions can
|
||||
disagree about mesh size.
|
||||
- Bloom filter probing computes its SHA-256 digest once per operation
|
||||
rather than once per hash function. All k indices were already derived
|
||||
from a single digest, but the digest was recomputed inside the
|
||||
per-function loop, so every insert and membership test hashed the same
|
||||
bytes `hash_count` times (5x at the default). Output is bit-for-bit
|
||||
identical; this is the hottest path in packet forwarding and mesh-size
|
||||
estimation.
|
||||
- Identity operations reuse one shared `secp256k1` context instead of
|
||||
constructing a fresh one at every sign, verify, and key-derive site.
|
||||
Each construction allocated a context and ran randomization and blinding
|
||||
table setup. Behavior is unchanged: the same API calls are made, only the
|
||||
context lifetime differs, and the shared context still performs the
|
||||
standard construction-time blinding.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Spanning tree: the coordinate cache is now invalidated when the parent
|
||||
link is lost through peer removal. That path reparents or self-roots the
|
||||
node but omitted the invalidation every other position-change path
|
||||
performs, so cached entries for downstream destinations kept the node's
|
||||
now-stale coordinate prefix. Because routing access refreshes an entry's
|
||||
TTL, an actively routed stale entry never self-expired and was corrected
|
||||
only by a fresh insert.
|
||||
- Discovery: applying a `LookupResponse` now keeps the tighter of the
|
||||
cached and received `path_mtu` rather than overwriting unconditionally.
|
||||
A looser estimate arriving in a later response could clobber a tighter
|
||||
value already learned from a reactive `MtuExceeded` or
|
||||
`PathMtuNotification`, loosening a clamp that had been correctly
|
||||
tightened.
|
||||
|
||||
### Removed
|
||||
|
||||
- The `parent_switched` spanning-tree metric counter. It was incremented on
|
||||
the line immediately before `parent_switches` at every site and never
|
||||
independently, so the two were always identical. `parent_switches`
|
||||
remains as the sole counter. Consumers reading `parent_switched` from the
|
||||
control socket or `fipstop` should use `parent_switches`.
|
||||
|
||||
## [0.4.0] - 2026-06-27
|
||||
|
||||
### Added
|
||||
|
||||
@@ -327,6 +413,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
example is placed under `/usr/share/fips`, deliberately outside
|
||||
`/usr/share/doc`, which minimal and container installs path-exclude
|
||||
(so the install-time seed source is never dropped).
|
||||
- openwrt: the `.apk` package now defaults `ethernet.wan` to the
|
||||
OpenWrt 25 DSA port name `wan`; the `.ipk` package keeps `eth0` for
|
||||
OpenWrt 24 and earlier.
|
||||
|
||||
#### CI & test-harness reliability
|
||||
|
||||
|
||||
3
Cargo.lock
generated
3
Cargo.lock
generated
@@ -1074,7 +1074,7 @@ checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5"
|
||||
|
||||
[[package]]
|
||||
name = "fips"
|
||||
version = "0.4.0-dev"
|
||||
version = "0.5.0-dev"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"bech32",
|
||||
@@ -1087,6 +1087,7 @@ dependencies = [
|
||||
"hex",
|
||||
"hkdf",
|
||||
"libc",
|
||||
"libm",
|
||||
"mdns-sd",
|
||||
"nostr",
|
||||
"nostr-sdk",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "fips"
|
||||
version = "0.4.0-dev"
|
||||
version = "0.5.0-dev"
|
||||
edition = "2024"
|
||||
description = "A distributed, decentralized network routing protocol for mesh nodes connecting over arbitrary transports"
|
||||
license = "MIT"
|
||||
@@ -17,6 +17,7 @@ secp256k1 = { version = "0.30", features = ["rand", "global-context"] }
|
||||
sha2 = "0.10"
|
||||
hkdf = "0.12"
|
||||
ring = "0.17"
|
||||
libm = "0.2"
|
||||
rand = "0.10.1"
|
||||
crossbeam-channel = "0.5"
|
||||
thiserror = "2.0"
|
||||
@@ -108,3 +109,8 @@ path = "src/bin/fips-gateway.rs"
|
||||
[[bin]]
|
||||
name = "fipstop"
|
||||
path = "src/bin/fipstop/main.rs"
|
||||
|
||||
[[bench]]
|
||||
name = "routing_next_hop"
|
||||
path = "benches/routing_next_hop.rs"
|
||||
harness = false
|
||||
|
||||
36
README.md
36
README.md
@@ -3,7 +3,7 @@
|
||||

|
||||
[](LICENSE)
|
||||
[](https://www.rust-lang.org/)
|
||||
[](#status--roadmap)
|
||||
[](#status--roadmap)
|
||||
|
||||
A self-organizing encrypted mesh network built on Nostr identities,
|
||||
capable of operating over arbitrary transports without central
|
||||
@@ -112,17 +112,19 @@ tutorial progression starting at
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
Requires Rust 1.94.1+ (edition 2024). Linux, macOS, and Windows are
|
||||
supported; transport availability varies by platform.
|
||||
Requires Rust 1.94.1+ (edition 2024). Linux, macOS, and Windows run as
|
||||
standalone daemons; Android is supported as an embedded library (the host
|
||||
app owns the TUN, e.g. a `VpnService`). Transport availability varies by
|
||||
platform.
|
||||
|
||||
| Transport | Linux | macOS | Windows | OpenWrt |
|
||||
|-----------|:-----:|:-----:|:-------:|:-------:|
|
||||
| UDP | ✅ | ✅ | ✅ | ✅ |
|
||||
| TCP | ✅ | ✅ | ✅ | ✅ |
|
||||
| Ethernet | ✅ | ✅ | ❌ | ✅ |
|
||||
| Tor | ✅ | ✅ | ✅ | ✅ |
|
||||
| Nym | ✅ | ✅ | ✅ | ❌ |
|
||||
| BLE | ✅ | ❌ | ❌ | ❌ |
|
||||
| Transport | Linux | macOS | Windows | Android | OpenWrt |
|
||||
|-----------|:-----:|:-----:|:-------:|:-------:|:-------:|
|
||||
| UDP | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| TCP | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Ethernet | ✅ | ✅ | ❌ | ❌ | ✅ |
|
||||
| Tor | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
| Nym | ✅ | ✅ | ✅ | ❌ | ❌ |
|
||||
| BLE | ✅ | ❌ | ❌ | ❌ | ❌ |
|
||||
|
||||
On Linux, a source build requires `libclang` — the LAN gateway's
|
||||
nftables bindings are generated by `bindgen` at build time, which
|
||||
@@ -210,12 +212,14 @@ testing/ Docker-based integration test harnesses + chaos simulation
|
||||
|
||||
## Status & roadmap
|
||||
|
||||
FIPS is at **v0.4.0**. The core protocol works end-to-end over
|
||||
FIPS is at **v0.5.0-dev** on the `master` branch.
|
||||
[v0.4.1](https://github.com/jmcorgan/fips/releases/tag/v0.4.1) has
|
||||
shipped; this development line continues the testing-and-polishing
|
||||
track toward v0.5.0. The core protocol works end-to-end over
|
||||
UDP, TCP, Ethernet, Tor, Nym, and Bluetooth on a global, public test
|
||||
mesh of thousands of nodes. v0.4.0 builds on the v0.3.0 testing-and-polishing
|
||||
track, adding the Nym mixnet transport and mDNS LAN discovery
|
||||
alongside the existing Nostr-mediated peer discovery, UDP NAT
|
||||
traversal, peer ACL, and packaging hardening. New wire-format work
|
||||
mesh of thousands of nodes. v0.4.0 added the Nym mixnet transport and
|
||||
mDNS LAN discovery alongside the existing Nostr-mediated peer discovery,
|
||||
UDP NAT traversal, peer ACL, and packaging hardening. New wire-format work
|
||||
continues to be staged on the `next` branch for the subsequent
|
||||
release line.
|
||||
|
||||
|
||||
393
RELEASE-NOTES.md
393
RELEASE-NOTES.md
@@ -1,302 +1,134 @@
|
||||
# FIPS v0.4.0
|
||||
# FIPS v0.4.1
|
||||
|
||||
**Released**: 2026-06-21 (provisional)
|
||||
**Released**: 2026-07-19
|
||||
|
||||
v0.4.0 is the throughput-and-observability release on the v0.3.x wire
|
||||
format. It adds two new ways for nodes to find and reach each other (the
|
||||
Nym mixnet transport and opt-in mDNS LAN discovery), overhauls the data
|
||||
plane for higher single-node throughput and lower per-packet CPU, moves
|
||||
the entire operator read surface off the data-plane hot path so
|
||||
observability stays responsive under load, ships a reworked `fipstop`
|
||||
TUI, and hardens FMP and FSP rekey to be hitless under packet loss in
|
||||
both directions. It also folds in the accumulated mesh-convergence,
|
||||
admission-control, and packaging fixes from the maintenance line.
|
||||
v0.4.1 is a maintenance release on the v0.4.x line. It raises the default
|
||||
antipoison cap on inbound bloom filter announcements, removes a redundant
|
||||
spanning-tree metric counter, fixes two convergence and path-MTU bugs, and
|
||||
cuts per-packet CPU in the bloom and identity paths. There is no wire
|
||||
format change and no new feature surface.
|
||||
|
||||
v0.4.0 is wire-compatible with v0.3.0. Mixed meshes interoperate; there
|
||||
is no flag-day upgrade. A deployed v0.3.0 node and an upgraded v0.4.0
|
||||
node peer, rekey, and route normally, so you can roll the upgrade out
|
||||
across a mesh in any order.
|
||||
v0.4.1 is wire-compatible with v0.4.0. Nodes can be upgraded one at a time
|
||||
with no coordinated restart, though one behavior change below is worth
|
||||
reading before you start a rolling upgrade.
|
||||
|
||||
## At a glance
|
||||
|
||||
- New outbound Nym mixnet transport with a single-container demo and a
|
||||
new mixnet-relay example.
|
||||
- Opt-in mDNS / DNS-SD discovery on the local link.
|
||||
- Data-plane overhaul: off-task encrypt and decrypt worker pools, GSO,
|
||||
connected-UDP send path, copy-avoidance on receive, batched macOS
|
||||
receive.
|
||||
- The full `show_*` read surface now serves off the receive loop, so
|
||||
`fipsctl` and `fipstop` stay responsive on loaded nodes; a new
|
||||
counter-only `show_metrics` query enables a Prometheus scraper at no
|
||||
hot-path cost.
|
||||
- Reworked `fipstop` TUI on a machine-verified render-snapshot base.
|
||||
- Rekey is now hitless under loss and reordering in both directions.
|
||||
- New packaging targets: an OpenWrt `.apk` for OpenWrt 25+ and a Nix
|
||||
flake for reproducible from-source builds on Nix/NixOS.
|
||||
- Six route-class transit counters partition forwarded traffic by its
|
||||
tree relationship to the next hop, visible via `show_routing` and
|
||||
`show_status`.
|
||||
|
||||
## What's new
|
||||
|
||||
### Nym mixnet transport
|
||||
|
||||
FIPS can now peer over the [Nym](https://nymtech.net/) mixnet for
|
||||
metadata-resistant connectivity. The new `transports.nym` transport
|
||||
makes outbound connections through a `nym-socks5-client` SOCKS5 proxy
|
||||
that you run alongside the daemon (for example as a service running
|
||||
alongside the fips daemon, or as a sidecar container). The transport
|
||||
waits at startup for the nym-socks5-client to become ready before giving
|
||||
up.
|
||||
|
||||
This is a privacy and anonymity deployment mode chosen for its own
|
||||
properties. It mixes your FIPS traffic into the Nym cover-traffic
|
||||
network so that link-level observers cannot correlate which mesh peers
|
||||
are talking. A new `examples/sidecar-nostr-mixnet-relay/` demonstrates a
|
||||
FIPS-reachable Nostr relay peered across the mixnet end to end, and a
|
||||
single-container demo ships with the transport.
|
||||
|
||||
Enable it by adding a `transports.nym` instance and pointing it at your
|
||||
running nym-socks5-client. See the transports reference for the field
|
||||
set.
|
||||
|
||||
### mDNS LAN discovery
|
||||
|
||||
Nodes on a shared local link can now find each other with zero address
|
||||
configuration. The opt-in `node.discovery.lan` path runs an mDNS /
|
||||
DNS-SD responder and browser: each node advertises a FIPS service record
|
||||
on the link and adopts the peers it discovers. This complements the
|
||||
existing Nostr-mediated overlay discovery for the common case where the
|
||||
peers are simply on the same LAN.
|
||||
|
||||
Turn it on with `node.discovery.lan.enabled: true`. `service_type` and
|
||||
`scope` tune the advertised service record and which interfaces
|
||||
participate. Discovery on the local link needs no relay and no STUN.
|
||||
|
||||
### Data-plane throughput overhaul
|
||||
|
||||
The receive and send paths were reworked for higher single-node
|
||||
throughput and lower per-packet CPU, building on the v0.3.0
|
||||
crypto-backend swap:
|
||||
|
||||
- **Off-task encrypt and decrypt.** Per-peer encrypt and decrypt now run
|
||||
on dedicated worker tasks rather than inline on the receive loop, so a
|
||||
single busy peer no longer serializes the whole node's crypto.
|
||||
- **GSO and connected-UDP send.** The Linux send path uses generic
|
||||
segmentation offload and a connected-UDP socket where available,
|
||||
cutting syscall overhead on bulk flows.
|
||||
- **Copy-avoidance on receive.** The receive hot path avoids buffer
|
||||
copies it previously made per packet.
|
||||
- **Batched macOS receive.** macOS gains a `recvmsg_x` batched receive,
|
||||
mirroring the Linux `recvmmsg` batching from v0.3.0.
|
||||
- **Shared immutable-state context and an atomic metric registry.**
|
||||
Immutable per-node state moved into a single shared context, and
|
||||
counters live in an atomic metric registry that the new `show_metrics`
|
||||
query reads without touching the hot path.
|
||||
|
||||
These are all internal to the data plane and require no operator action.
|
||||
|
||||
### Observability off the hot path
|
||||
|
||||
Every read-only control query now renders from a snapshot published once
|
||||
per tick into a lock-free `ArcSwap`, served from the control accept task
|
||||
instead of round-tripping the data-plane receive loop. This covers
|
||||
`show_status`, `show_stats_*`, `show_peers`, `show_sessions`,
|
||||
`show_links`, `show_connections`, `show_transports`, `show_mmp`,
|
||||
`show_tree`, `show_bloom`, `show_cache`, `show_routing`,
|
||||
`show_identity_cache`, `show_acl`, `show_listening_sockets`, and the new
|
||||
`show_metrics`. Only the mutating `connect` and `disconnect` commands
|
||||
still reach the loop.
|
||||
|
||||
The practical effect: on a loaded node where the receive loop was busy,
|
||||
`fipsctl` and `fipstop` queries previously stalled or timed out (the
|
||||
five-second query pattern operators saw). They now answer promptly
|
||||
regardless of data-plane load. Per-entity snapshots reuse unchanged rows
|
||||
by pointer, so the per-tick publish cost stays bounded as peer and
|
||||
session counts grow.
|
||||
|
||||
A new **`show_metrics`** query (surfaced as `fipsctl stats metrics`)
|
||||
returns a counter-only snapshot of every metric family. It is the
|
||||
enabler for a Prometheus scraper that pulls node counters at no hot-path
|
||||
cost.
|
||||
|
||||
Six **route-class transit counters** partition transit-forwarded packets
|
||||
by their tree relationship to the chosen next hop — tree-up, tree-down,
|
||||
tree-down-cross, cross-link descend, cross-link ascend, and direct-peer
|
||||
— and the six classes sum to `forwarded_packets`. They surface through
|
||||
`show_routing` and `show_status`, and the `fipstop` routing tab is
|
||||
reorganized so its two columns separate own/endpoint traffic from
|
||||
forwarded/transit traffic with the tree-down-cross line visually flagged.
|
||||
|
||||
### Reworked fipstop TUI
|
||||
|
||||
`fipstop` gets a rendering, navigation, and read-surface overhaul on a
|
||||
machine-verified base: a render-snapshot harness asserts the exact text
|
||||
grid and per-cell style of every view against canned control-socket
|
||||
output. New daemon-resolved fields surface through the snapshots,
|
||||
including effective persistence, root and is-root state, a
|
||||
per-transport-type peer-count map, per-peer effective depth, the root
|
||||
npub, and the last-sent uptree filter fill ratio with the subtree size
|
||||
estimate.
|
||||
|
||||
A separate fix clears a garbled-screen problem on startup and stray
|
||||
bytes on quit, most visible over SSH and inside tmux: startup now forces
|
||||
a full repaint before the first draw, and quit stops and joins the
|
||||
stdin-poll thread before restoring the terminal, so post-raw-mode
|
||||
keystrokes no longer echo onto the restored screen.
|
||||
|
||||
### Rekey reliability
|
||||
|
||||
FMP and FSP session rekey are now hitless under packet loss and
|
||||
reordering in both directions:
|
||||
|
||||
- Inbound frames are authenticated against the pending session before
|
||||
the K-bit cutover promotes it, so a spoofed or stale frame cannot
|
||||
derail a rekey in progress.
|
||||
- Rekey message-1 retransmission is bounded, and the link-dead heartbeat
|
||||
is rekey-aware so an in-flight rekey is not mistaken for a dead link.
|
||||
- FSP session rekey holds connectivity across the rekey window under
|
||||
loss and reordering.
|
||||
- Dual-initiation races (both peers starting a rekey at once on a
|
||||
high-latency link) are desynchronized with symmetric jitter so the two
|
||||
sides converge on one session rather than fighting.
|
||||
- An exhausted retransmission-budget abort, an expected and self-limiting
|
||||
outcome on lossy or high-latency links, is logged at debug rather than
|
||||
warn.
|
||||
|
||||
The net operator takeaway: rekey completes cleanly without dropping
|
||||
traffic, even on lossy or high-latency links, and the log no longer
|
||||
cries wolf when a rekey gives up and retries.
|
||||
|
||||
### New packaging targets
|
||||
|
||||
- **OpenWrt `.apk`.** A new `.apk` package targets OpenWrt 25+, where
|
||||
apk-tools is the mandatory package manager; the existing `.ipk`
|
||||
continues to cover OpenWrt 24.x and earlier. It is built SDK-free,
|
||||
reusing the `.ipk` cross-compile and installed-filesystem payload, and
|
||||
releases publish `.apk` artifacts and checksums alongside `.ipk`. Like
|
||||
the `.ipk`, the package is unsigned and installed with
|
||||
`apk add --allow-untrusted`.
|
||||
- **Nix flake.** A `flake.nix` at the project root builds all four
|
||||
binaries (`fips`, `fipsctl`, `fips-gateway`, `fipstop`) from source on
|
||||
Nix/NixOS, pinning the exact toolchain and wiring the native build
|
||||
dependencies so no host setup is needed beyond Nix with flakes
|
||||
enabled. It exposes `nix build`, `nix run`, a `nix develop` dev shell,
|
||||
and `nix flake check`, with `flake.lock` committed for reproducibility.
|
||||
- `node.bloom.max_inbound_fpr` default moves from `0.10` to `0.20`.
|
||||
- The `parent_switched` metric counter is gone. Use `parent_switches`.
|
||||
- Spanning tree no longer serves stale coordinates after a parent link is
|
||||
lost through peer removal.
|
||||
- Discovery no longer loosens a path MTU clamp it had correctly tightened.
|
||||
- Bloom probing and identity operations do measurably less work per call,
|
||||
with identical results.
|
||||
|
||||
## Behavior changes worth flagging
|
||||
|
||||
These affect operators on upgrade.
|
||||
### The inbound filter FPR cap default doubles again
|
||||
|
||||
- **Bloom filter antipoison cap raised.** `node.bloom.max_inbound_fpr`
|
||||
moves from 0.05 to 0.10, accepting filters with a higher derived
|
||||
false-positive rate before rejecting them. This reduces spurious
|
||||
filter rejections on larger meshes while keeping the antipoison
|
||||
protection in place.
|
||||
- **TCP inbound cap honors `max_connections`.** The TCP inbound accept
|
||||
ceiling now resolves from explicit per-transport
|
||||
`max_inbound_connections`, then node-wide
|
||||
`node.limits.max_connections`, then the built-in default of 256.
|
||||
Previously the TCP inbound ceiling was hardwired to 256 and ignored
|
||||
`max_connections`, so raising it had no effect on inbound TCP.
|
||||
- **Static host aliases hot-reload.** `/etc/fips/hosts` now reloads on
|
||||
mtime change once per tick rather than only at startup, so display
|
||||
names in `fipsctl` and `fipstop` reflect edits without a daemon
|
||||
restart. The peer ACL reloads through the same lock-free snapshot
|
||||
mechanism.
|
||||
- **Quieter logs on busy public-mesh nodes.** Routine per-peer
|
||||
connection-lifecycle and capacity-cap events, no-route session-datagram
|
||||
drops, and exhausted rekey-budget aborts are demoted to debug, so
|
||||
genuinely notable info and warn lines are no longer drowned out.
|
||||
- **More visible drops.** Receive-path silent rejections now flow
|
||||
through typed reject-reason counters, and discovery counts requests
|
||||
dropped when the dedup cache is full (`req_dedup_cache_full`, visible
|
||||
via `show_routing`). Drops that were previously silent are now
|
||||
countable.
|
||||
- **Tor connect-refused accounting.** The Tor transport increments its
|
||||
`connect_refused` statistic (the "Refused" line in `fipstop`) on an
|
||||
actively-refused SOCKS5 connect, instead of recording every connect
|
||||
failure as a generic SOCKS5 error.
|
||||
`node.bloom.max_inbound_fpr` goes from `0.10` to `0.20`. The cap rejects
|
||||
inbound `FilterAnnounce` frames whose advertised false positive rate
|
||||
exceeds it. On the fixed 1 KB, k=5 filter, `0.10` corresponds to a fill of
|
||||
0.631 and roughly 1,630 reachable entries, and the busiest nodes'
|
||||
aggregates had started reaching that ceiling as the mesh grew. `0.20`
|
||||
corresponds to a fill of 0.7248 and roughly 2,114 entries.
|
||||
|
||||
Be aware that this is the second time in two releases that this default
|
||||
has doubled, for the same reason both times. That is worth stating plainly
|
||||
rather than repeating the previous release's framing: raising the cap buys
|
||||
headroom, it does not fix anything. The real constraint is the fixed 1 KB
|
||||
filter size, which is a protocol constant. The structural remedy is the v2
|
||||
filter work, where filter capacity scales with the mesh instead of being
|
||||
pinned. This release is an interim step to keep legitimate aggregates from
|
||||
being rejected until that lands. It is not the start of a pattern of
|
||||
raising the cap once per release, and if you are sizing capacity planning
|
||||
around this number, plan against the v2 work rather than against a third
|
||||
raise.
|
||||
|
||||
The antipoison property the cap exists for is preserved. A saturated or
|
||||
deliberately poisoned filter still presents an FPR near 100% and is still
|
||||
rejected.
|
||||
|
||||
**This matters during a rolling upgrade.** A v0.4.1 node accepts a
|
||||
`FilterAnnounce` with a derived FPR between 0.10 and 0.20; a v0.4.0 node
|
||||
drops the same frame, and the drop is silent on the wire with no NACK. The
|
||||
cap also gates the mesh size estimator, which declines to produce a value
|
||||
when any contributing filter is over the cap. So while a mesh is partly
|
||||
upgraded, upgraded and not-yet-upgraded nodes can legitimately report
|
||||
different mesh sizes, or one can report a size while the other reports
|
||||
unknown. This resolves once every node is on v0.4.1. If you want to avoid
|
||||
the window entirely, set `node.bloom.max_inbound_fpr: 0.10` explicitly in
|
||||
your config before upgrading and remove it after the last node is done.
|
||||
|
||||
### The `parent_switched` counter is removed
|
||||
|
||||
`parent_switched` was incremented on the line immediately before
|
||||
`parent_switches` at every site and never independently, so the two
|
||||
counters always held the same value. `parent_switched` is now gone from
|
||||
the tree metrics, the control socket snapshot, and the `fipstop` tree
|
||||
view. `parent_switches` remains and is unchanged.
|
||||
|
||||
If you scrape the control socket, or have dashboards or alerts referencing
|
||||
`parent_switched`, point them at `parent_switches`. Anything still asking
|
||||
for `parent_switched` will find nothing rather than a zero.
|
||||
|
||||
## Notable bug fixes
|
||||
|
||||
The CHANGELOG has the exhaustive list. This is the operator-relevant
|
||||
subset of fixes for behavior that shipped in v0.3.0.
|
||||
### Stale coordinates after losing a parent through peer removal
|
||||
|
||||
- **Symmetric peer teardown on manual disconnect.** A manual
|
||||
`fipsctl disconnect` now sends the peer a scoped Disconnect so both
|
||||
ends tear down and re-handshake cleanly. Previously a manual
|
||||
disconnect tore down only the local side, leaving the peer with a
|
||||
stale session that was never re-adopted as a child and whose bloom
|
||||
filter was never re-recorded.
|
||||
- **Gateway holds long-lived and DNS-cached mappings.** `fips-gateway`
|
||||
no longer drops a virtual-IP mapping while traffic is still flowing.
|
||||
The mapping TTL clock previously advanced only on DNS re-query, so a
|
||||
busy long-lived or DNS-cached client could have its mapping reclaimed
|
||||
mid-flow. The tick now refreshes the mapping whenever conntrack reports
|
||||
active sessions and recovers a draining mapping to active when traffic
|
||||
resumes; only genuinely idle mappings drain.
|
||||
- **Accurate mesh-size estimate under filter overlap.** The mesh-size
|
||||
estimator now estimates the cardinality of the OR-union of self plus
|
||||
every connected peer's inbound filter, instead of summing per-filter
|
||||
cardinalities of tree peers. Summing assumed the filters were disjoint,
|
||||
so a stale or oversized parent filter or a routing loop inflated the
|
||||
reported mesh size and a tree rebalance flapped the count. OR-union
|
||||
deduplicates overlap, equals the old result in the disjoint case, and
|
||||
removes the estimate's dependence on tree-declaration cache freshness.
|
||||
- **Single-uplink node reattaches within a round-trip.** A node with one
|
||||
tree peer, which has periodic parent re-evaluation disabled, was left
|
||||
self-rooted and unreachable if its one-shot attaching TreeAnnounce was
|
||||
lost, until the next periodic re-broadcast. Tree-position exchange is
|
||||
now self-healing on the receive path: a node that hears an announce
|
||||
advertising a strictly worse root echoes its own declaration back,
|
||||
provoking the better-rooted peer to re-push its real position
|
||||
immediately.
|
||||
- **macOS self-connections work end to end (#117).** Traffic a macOS
|
||||
node sends to its own `<npub>.fips` address is now delivered locally
|
||||
for full TCP/UDP, not just `ping6`. The point-to-point `utun` egresses
|
||||
self-addressed packets into the daemon with an unfinished transport
|
||||
checksum (macOS offloads it on the `lo0` loopback route), so
|
||||
re-injecting them verbatim made the local stack drop every segment the
|
||||
MSS-clamp rewrite did not happen to fix and self-connections
|
||||
half-opened and hung. The hairpin path now recomputes the TCP/UDP
|
||||
checksum before re-injection. Linux was unaffected.
|
||||
When a node's parent link dropped via peer removal, the node correctly
|
||||
reparented or self-rooted, but skipped the coordinate cache invalidation
|
||||
that every other position-change path performs. Cached entries for
|
||||
downstream destinations kept the node's old coordinate prefix. This did
|
||||
not self-correct the way a stale cache entry normally would: routing
|
||||
access refreshes an entry's TTL, so an entry that was actively being
|
||||
routed through never expired, and was only fixed by an unrelated fresh
|
||||
insert. Both invalidation classes now run on this path, matching the
|
||||
loop-detection branch.
|
||||
|
||||
### Discovery could loosen a tightened path MTU clamp
|
||||
|
||||
An originator handling a `LookupResponse` overwrote its cached path MTU
|
||||
unconditionally. If a reactive `MtuExceeded` or `PathMtuNotification` had
|
||||
already taught it a tighter value, a later, looser discovery estimate
|
||||
would clobber that and re-loosen the clamp, risking a return to dropped
|
||||
oversized packets. The cached and received values are now compared and the
|
||||
tighter one is kept.
|
||||
|
||||
## Upgrade notes
|
||||
|
||||
Operator-actionable items moving from v0.3.0 to v0.4.0:
|
||||
This is a drop-in upgrade from v0.4.0 with no wire format change, no
|
||||
config migration, and no coordinated restart. Upgrade nodes in whatever
|
||||
order you like.
|
||||
|
||||
- **Wire-compatible, no flag day.** v0.4.0 peers with v0.3.0. Upgrade
|
||||
nodes in any order. During a rolling upgrade you may see some log lines
|
||||
on the upgraded side as it interacts with not-yet-upgraded peers;
|
||||
behavior is correct, log noise only.
|
||||
- **Bloom antipoison cap default changed.** `node.bloom.max_inbound_fpr`
|
||||
now defaults to 0.10 (was 0.05). If you set this explicitly, review
|
||||
whether you still want the old value.
|
||||
- **New optional config surfaces.** `transports.nym` (outbound Nym
|
||||
mixnet) and `node.discovery.lan` (mDNS LAN discovery) are both opt-in
|
||||
and off by default. Adding them is the only way to turn the new paths
|
||||
on.
|
||||
- **TCP inbound cap.** If you relied on the old hardwired 256 inbound-TCP
|
||||
ceiling, note it now honors `max_inbound_connections` then
|
||||
`node.limits.max_connections` then 256.
|
||||
- **New observability query.** `fipsctl stats metrics` (the
|
||||
`show_metrics` control query) returns a counter-only snapshot suitable
|
||||
for a scraper.
|
||||
Two things to do rather than assume:
|
||||
|
||||
## Getting v0.4.0
|
||||
1. If you monitor `parent_switched`, move to `parent_switches` before
|
||||
upgrading, or your dashboards will go blank rather than error.
|
||||
2. During the rolling window, expect upgraded and not-yet-upgraded nodes
|
||||
to potentially disagree about mesh size, per the FPR cap section above.
|
||||
This is expected and self-resolves. Do not chase it as a bug unless it
|
||||
persists after every node reports `0.4.1`.
|
||||
|
||||
If you have pinned `node.bloom.max_inbound_fpr` explicitly in your config,
|
||||
your setting is honored and nothing changes for you. The change only
|
||||
affects nodes taking the default.
|
||||
|
||||
Downgrading to v0.4.0 is supported and needs no special handling.
|
||||
|
||||
## Getting v0.4.1
|
||||
|
||||
- **Linux x86_64 / aarch64**: `.deb` and tarball at the
|
||||
[v0.4.0 release page](https://github.com/jmcorgan/fips/releases/tag/v0.4.0).
|
||||
[v0.4.1 release page](https://github.com/jmcorgan/fips/releases/tag/v0.4.1).
|
||||
- **Arch Linux**: `fips` from the AUR.
|
||||
- **macOS**: `.pkg` at the v0.4.0 release page.
|
||||
- **Windows**: ZIP at the v0.4.0 release page.
|
||||
- **macOS**: `.pkg` at the v0.4.1 release page.
|
||||
- **Windows**: ZIP at the v0.4.1 release page.
|
||||
- **OpenWrt**: `.ipk` (OpenWrt 24.x and earlier) or `.apk` (OpenWrt 25+)
|
||||
at the v0.4.0 release page.
|
||||
- **From source**: `cargo build --release` from a checkout of the v0.4.0
|
||||
at the v0.4.1 release page.
|
||||
- **From source**: `cargo build --release` from a checkout of the v0.4.1
|
||||
tag (Rust 1.94.1 per `rust-toolchain.toml`; `libclang-dev` is a
|
||||
required Linux build prerequisite).
|
||||
- **Nix / NixOS**: `nix build .#fips` from a checkout of the v0.4.0 tag
|
||||
- **Nix / NixOS**: `nix build .#fips` from a checkout of the v0.4.1 tag
|
||||
builds the binaries from source with the pinned toolchain and no manual
|
||||
prerequisites (see the Nix section of `packaging/README.md`).
|
||||
|
||||
@@ -309,13 +141,6 @@ The full per-commit changelog lives in
|
||||
Thanks to everyone who contributed code, packaging work, bug reports, or
|
||||
reviews to this release.
|
||||
|
||||
- [@jcorgan](https://github.com/jmcorgan): release shepherd, high-level
|
||||
design, control read plane, rekey hardening, admission, bug fixes,
|
||||
testing, packaging, PR coordination, and issue resolution.
|
||||
- [@mmalmi](https://github.com/mmalmi): opt-in mDNS LAN discovery and
|
||||
data-plane performance work.
|
||||
- [@Origami74](https://github.com/Origami74): macOS packaging and
|
||||
website coordination.
|
||||
- [@dskvr](https://github.com/dskvr): AUR packaging.
|
||||
- [@oleksky](https://github.com/oleksky): Nym mixnet transport and the
|
||||
single-container mixnet demo.
|
||||
- [@jcorgan](https://github.com/jmcorgan): release shepherd, spanning-tree
|
||||
and discovery fixes, bloom and identity performance work, antipoison cap
|
||||
change, and testing.
|
||||
|
||||
365
benches/routing_next_hop.rs
Normal file
365
benches/routing_next_hop.rs
Normal file
@@ -0,0 +1,365 @@
|
||||
//! Micro-benchmark quantifying the per-forwarded-packet heap-allocation cost
|
||||
//! of the routing next-hop candidate-assembly path.
|
||||
//!
|
||||
//! `find_next_hop` runs once per forwarded data packet. Its sans-IO core
|
||||
//! assembles a `Vec<Candidate>` by enumerating every peer through the
|
||||
//! `RoutingView` seam: `peer_addrs()` materializes a `Vec<NodeAddr>` of all
|
||||
//! peers, the survivors are snapshotted (each cloning its `TreeCoordinate`),
|
||||
//! and the result is collected into a second `Vec`. This bench measures that
|
||||
//! per-call allocation against a fused zero-alloc reference that iterates the
|
||||
//! peer map directly and borrows coordinates instead of cloning.
|
||||
//!
|
||||
//! Visibility caveat: the production `routing_candidates` / `select_best_candidate`
|
||||
//! / `RoutingView` / `Candidate` are `pub(crate)` (src/proto/routing/core.rs)
|
||||
//! and are not re-exported at the crate root, so an external bench crate cannot
|
||||
//! name them. Rather than change production visibility, this file reproduces
|
||||
//! that path verbatim over the real public `NodeAddr` / `TreeCoordinate` /
|
||||
//! `CoordEntry` / `BloomFilter` types with the same iterator chain and the same
|
||||
//! `HashMap`-backed view the shell uses (src/node/mod.rs NodeRoutingView). The
|
||||
//! allocation behavior is therefore identical to production by construction;
|
||||
//! only the symbol identity differs.
|
||||
|
||||
use std::alloc::{GlobalAlloc, Layout, System};
|
||||
use std::collections::HashMap;
|
||||
use std::hint::black_box;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main};
|
||||
use fips::{BloomFilter, NodeAddr, TreeCoordinate};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Counting global allocator: bumps a process-global counter on every heap
|
||||
// allocation operation (alloc / alloc_zeroed / realloc). Sampled tightly and
|
||||
// single-threaded in `report_allocs` so no unrelated allocations are captured.
|
||||
// ---------------------------------------------------------------------------
|
||||
struct CountingAlloc;
|
||||
|
||||
static ALLOCS: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
unsafe impl GlobalAlloc for CountingAlloc {
|
||||
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
|
||||
ALLOCS.fetch_add(1, Ordering::Relaxed);
|
||||
unsafe { System.alloc(layout) }
|
||||
}
|
||||
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
|
||||
unsafe { System.dealloc(ptr, layout) }
|
||||
}
|
||||
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
|
||||
ALLOCS.fetch_add(1, Ordering::Relaxed);
|
||||
unsafe { System.alloc_zeroed(layout) }
|
||||
}
|
||||
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
|
||||
ALLOCS.fetch_add(1, Ordering::Relaxed);
|
||||
unsafe { System.realloc(ptr, layout, new_size) }
|
||||
}
|
||||
}
|
||||
|
||||
#[global_allocator]
|
||||
static GLOBAL: CountingAlloc = CountingAlloc;
|
||||
|
||||
const PEER_COUNTS: [usize; 4] = [8, 32, 128, 256];
|
||||
/// Fraction of peers whose bloom filter reports the destination reachable.
|
||||
const REACH_NUMERATOR: usize = 1;
|
||||
const REACH_DENOMINATOR: usize = 2;
|
||||
/// Tree depth for synthetic coordinates (self..root), a realistic mesh depth.
|
||||
const COORD_DEPTH: usize = 8;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reproduction of the pub(crate) routing seam (src/proto/routing/core.rs).
|
||||
// ---------------------------------------------------------------------------
|
||||
trait RoutingView {
|
||||
fn peer_addrs(&self) -> Vec<NodeAddr>;
|
||||
fn peer_may_reach(&self, peer: &NodeAddr, dest: &NodeAddr) -> bool;
|
||||
fn peer_can_send(&self, peer: &NodeAddr) -> bool;
|
||||
fn peer_link_cost(&self, peer: &NodeAddr) -> f64;
|
||||
fn peer_coords(&self, peer: &NodeAddr) -> Option<TreeCoordinate>;
|
||||
}
|
||||
|
||||
struct Candidate {
|
||||
addr: NodeAddr,
|
||||
can_send: bool,
|
||||
link_cost: f64,
|
||||
coords: Option<TreeCoordinate>,
|
||||
}
|
||||
|
||||
/// Verbatim from `routing::routing_candidates` (core.rs). Allocates the
|
||||
/// `peer_addrs` Vec, clones each survivor's coords, and collects into a Vec.
|
||||
fn routing_candidates(rv: &impl RoutingView, dest: &NodeAddr) -> Vec<Candidate> {
|
||||
rv.peer_addrs()
|
||||
.into_iter()
|
||||
.filter(|peer| rv.peer_may_reach(peer, dest))
|
||||
.map(|peer| Candidate {
|
||||
can_send: rv.peer_can_send(&peer),
|
||||
link_cost: rv.peer_link_cost(&peer),
|
||||
coords: rv.peer_coords(&peer),
|
||||
addr: peer,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Verbatim from `routing::select_best_candidate` (core.rs). Pure, no alloc.
|
||||
fn select_best_candidate(
|
||||
candidates: &[Candidate],
|
||||
dest_coords: &TreeCoordinate,
|
||||
my_coords: &TreeCoordinate,
|
||||
) -> Option<NodeAddr> {
|
||||
let my_distance = my_coords.distance_to(dest_coords);
|
||||
let mut best: Option<(&Candidate, f64, usize)> = None;
|
||||
for candidate in candidates {
|
||||
if !candidate.can_send {
|
||||
continue;
|
||||
}
|
||||
let cost = candidate.link_cost;
|
||||
let dist = candidate
|
||||
.coords
|
||||
.as_ref()
|
||||
.map(|pc| pc.distance_to(dest_coords))
|
||||
.unwrap_or(usize::MAX);
|
||||
if dist >= my_distance {
|
||||
continue;
|
||||
}
|
||||
let dominated = match &best {
|
||||
None => true,
|
||||
Some((_, best_cost, best_dist)) => {
|
||||
cost < *best_cost
|
||||
|| (cost == *best_cost && dist < *best_dist)
|
||||
|| (cost == *best_cost
|
||||
&& dist == *best_dist
|
||||
&& candidate.addr < best.as_ref().unwrap().0.addr)
|
||||
}
|
||||
};
|
||||
if dominated {
|
||||
best = Some((candidate, cost, dist));
|
||||
}
|
||||
}
|
||||
best.map(|(candidate, _, _)| candidate.addr)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bench-local view, HashMap-backed exactly like src/node/mod.rs NodeRoutingView.
|
||||
// ---------------------------------------------------------------------------
|
||||
struct BenchPeer {
|
||||
bloom: BloomFilter,
|
||||
can_send: bool,
|
||||
link_cost: f64,
|
||||
}
|
||||
|
||||
struct BenchView {
|
||||
peers: HashMap<NodeAddr, BenchPeer>,
|
||||
coords: HashMap<NodeAddr, TreeCoordinate>,
|
||||
}
|
||||
|
||||
impl RoutingView for BenchView {
|
||||
fn peer_addrs(&self) -> Vec<NodeAddr> {
|
||||
self.peers.keys().copied().collect()
|
||||
}
|
||||
fn peer_may_reach(&self, peer: &NodeAddr, dest: &NodeAddr) -> bool {
|
||||
self.peers.get(peer).is_some_and(|p| p.bloom.contains(dest))
|
||||
}
|
||||
fn peer_can_send(&self, peer: &NodeAddr) -> bool {
|
||||
self.peers.get(peer).is_some_and(|p| p.can_send)
|
||||
}
|
||||
fn peer_link_cost(&self, peer: &NodeAddr) -> f64 {
|
||||
self.peers.get(peer).map_or(f64::INFINITY, |p| p.link_cost)
|
||||
}
|
||||
fn peer_coords(&self, peer: &NodeAddr) -> Option<TreeCoordinate> {
|
||||
self.coords.get(peer).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
/// Zero-alloc reference: what an iterator/visitor seam would do. Iterates the
|
||||
/// peer map directly, fuses the may_reach + can_send filters, borrows coords
|
||||
/// instead of cloning, and tracks the best hop inline. No Vec, no coord clone.
|
||||
fn resolve_next_hop_zeroalloc(
|
||||
view: &BenchView,
|
||||
dest: &NodeAddr,
|
||||
dest_coords: &TreeCoordinate,
|
||||
my_coords: &TreeCoordinate,
|
||||
) -> Option<NodeAddr> {
|
||||
let my_distance = my_coords.distance_to(dest_coords);
|
||||
let mut best: Option<(NodeAddr, f64, usize)> = None;
|
||||
for (addr, peer) in &view.peers {
|
||||
if !peer.bloom.contains(dest) {
|
||||
continue;
|
||||
}
|
||||
if !peer.can_send {
|
||||
continue;
|
||||
}
|
||||
let cost = peer.link_cost;
|
||||
let dist = view
|
||||
.coords
|
||||
.get(addr)
|
||||
.map(|pc| pc.distance_to(dest_coords))
|
||||
.unwrap_or(usize::MAX);
|
||||
if dist >= my_distance {
|
||||
continue;
|
||||
}
|
||||
let dominated = match &best {
|
||||
None => true,
|
||||
Some((best_addr, best_cost, best_dist)) => {
|
||||
cost < *best_cost
|
||||
|| (cost == *best_cost && dist < *best_dist)
|
||||
|| (cost == *best_cost && dist == *best_dist && *addr < *best_addr)
|
||||
}
|
||||
};
|
||||
if dominated {
|
||||
best = Some((*addr, cost, dist));
|
||||
}
|
||||
}
|
||||
best.map(|(addr, _, _)| addr)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario construction.
|
||||
// ---------------------------------------------------------------------------
|
||||
fn addr(tag: u8, i: u16) -> NodeAddr {
|
||||
let mut b = [0u8; 16];
|
||||
b[0] = tag;
|
||||
b[1..3].copy_from_slice(&i.to_le_bytes());
|
||||
NodeAddr::from_bytes(b)
|
||||
}
|
||||
|
||||
/// A depth-`COORD_DEPTH` coordinate whose leaf is `leaf`, sharing a fixed
|
||||
/// interior path and root with `shared_tag`. Peers built with the dest's
|
||||
/// shared_tag sit close to the destination (distance 2); a distinct shared_tag
|
||||
/// sits far (near the root), modeling our own position.
|
||||
fn coord(leaf: NodeAddr, shared_tag: u8) -> TreeCoordinate {
|
||||
let mut path = Vec::with_capacity(COORD_DEPTH);
|
||||
path.push(leaf);
|
||||
for level in 1..(COORD_DEPTH - 1) {
|
||||
path.push(addr(shared_tag, level as u16));
|
||||
}
|
||||
path.push(addr(9, 0)); // common root
|
||||
TreeCoordinate::from_addrs(path).expect("valid coord path")
|
||||
}
|
||||
|
||||
struct Scenario {
|
||||
view: BenchView,
|
||||
dest: NodeAddr,
|
||||
dest_coords: TreeCoordinate,
|
||||
my_coords: TreeCoordinate,
|
||||
}
|
||||
|
||||
impl Scenario {
|
||||
fn new(n: usize) -> Self {
|
||||
let dest = addr(2, 0);
|
||||
// Destination path uses interior tag 4; peers reuse tag 4 so survivors
|
||||
// are close to the destination. Our own coords use tag 5 (far).
|
||||
let dest_coords = coord(dest, 4);
|
||||
let my_coords = coord(addr(6, 0), 5);
|
||||
|
||||
let mut peers = HashMap::new();
|
||||
let mut coords = HashMap::new();
|
||||
for i in 0..n {
|
||||
let paddr = addr(1, i as u16);
|
||||
let mut bloom = BloomFilter::new();
|
||||
// Realistic fill: a handful of unrelated reachable addrs.
|
||||
for f in 0..4u16 {
|
||||
bloom.insert(&addr(7, i as u16 * 4 + f));
|
||||
}
|
||||
// A controlled fraction advertise the destination as reachable.
|
||||
if (i % REACH_DENOMINATOR) < REACH_NUMERATOR {
|
||||
bloom.insert(&dest);
|
||||
}
|
||||
peers.insert(
|
||||
paddr,
|
||||
BenchPeer {
|
||||
bloom,
|
||||
can_send: true,
|
||||
link_cost: 1.0 + (i as f64) * 0.01,
|
||||
},
|
||||
);
|
||||
// Peers share the destination's interior path (tag 4) → close.
|
||||
coords.insert(paddr, coord(paddr, 4));
|
||||
}
|
||||
|
||||
Self {
|
||||
view: BenchView { peers, coords },
|
||||
dest,
|
||||
dest_coords,
|
||||
my_coords,
|
||||
}
|
||||
}
|
||||
|
||||
fn survivors(&self) -> usize {
|
||||
self.view
|
||||
.peers
|
||||
.values()
|
||||
.filter(|p| p.bloom.contains(&self.dest))
|
||||
.count()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Allocation-per-call report (printed once, before criterion timing).
|
||||
// ---------------------------------------------------------------------------
|
||||
fn count_allocs<T>(iters: usize, mut f: impl FnMut() -> T) -> f64 {
|
||||
for _ in 0..8 {
|
||||
black_box(f());
|
||||
}
|
||||
let start = ALLOCS.load(Ordering::Relaxed);
|
||||
for _ in 0..iters {
|
||||
black_box(f());
|
||||
}
|
||||
let end = ALLOCS.load(Ordering::Relaxed);
|
||||
(end - start) as f64 / iters as f64
|
||||
}
|
||||
|
||||
fn report_allocs() {
|
||||
const ITERS: usize = 2000;
|
||||
println!("\n=== allocations per call (heap alloc ops: alloc+alloc_zeroed+realloc) ===");
|
||||
println!(
|
||||
"{:>6} {:>10} {:>16} {:>16}",
|
||||
"peers", "survivors", "current/call", "zero-alloc/call"
|
||||
);
|
||||
for &n in &PEER_COUNTS {
|
||||
let s = Scenario::new(n);
|
||||
let survivors = s.survivors();
|
||||
let current = count_allocs(ITERS, || {
|
||||
let cands = routing_candidates(&s.view, &s.dest);
|
||||
select_best_candidate(&cands, &s.dest_coords, &s.my_coords)
|
||||
});
|
||||
let zero = count_allocs(ITERS, || {
|
||||
resolve_next_hop_zeroalloc(&s.view, &s.dest, &s.dest_coords, &s.my_coords)
|
||||
});
|
||||
println!("{n:>6} {survivors:>10} {current:>16.2} {zero:>16.2}");
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
fn bench_next_hop(c: &mut Criterion) {
|
||||
report_allocs();
|
||||
|
||||
let mut group = c.benchmark_group("find_next_hop");
|
||||
for &n in &PEER_COUNTS {
|
||||
let scenario = Scenario::new(n);
|
||||
group.bench_with_input(BenchmarkId::new("current_alloc", n), &n, |b, _| {
|
||||
b.iter(|| {
|
||||
let cands = routing_candidates(&scenario.view, &scenario.dest);
|
||||
black_box(select_best_candidate(
|
||||
&cands,
|
||||
&scenario.dest_coords,
|
||||
&scenario.my_coords,
|
||||
))
|
||||
});
|
||||
});
|
||||
group.bench_with_input(BenchmarkId::new("zero_alloc_ref", n), &n, |b, _| {
|
||||
b.iter(|| {
|
||||
black_box(resolve_next_hop_zeroalloc(
|
||||
&scenario.view,
|
||||
&scenario.dest,
|
||||
&scenario.dest_coords,
|
||||
&scenario.my_coords,
|
||||
))
|
||||
});
|
||||
});
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group! {
|
||||
name = benches;
|
||||
config = Criterion::default().sample_size(50);
|
||||
targets = bench_next_hop
|
||||
}
|
||||
criterion_main!(benches);
|
||||
@@ -360,14 +360,14 @@ control socket and `fipstop` dashboard. (See `compute_mesh_size()` in
|
||||
|
||||
The estimator refuses to produce a value when any contributing filter
|
||||
is above the antipoison FPR cap (`node.bloom.max_inbound_fpr`,
|
||||
default `0.10`); a partial aggregate would silently underestimate.
|
||||
default `0.20`); a partial aggregate would silently underestimate.
|
||||
Consumers handle the resulting `None` by displaying an "unknown"
|
||||
state rather than a misleading number.
|
||||
|
||||
## Antipoison: Inbound FPR Cap
|
||||
|
||||
Inbound `FilterAnnounce` payloads are checked against
|
||||
`node.bloom.max_inbound_fpr` (default `0.10`). Filters whose
|
||||
`node.bloom.max_inbound_fpr` (default `0.20`). Filters whose
|
||||
estimated false positive rate exceeds the cap are dropped silently
|
||||
(no NACK on the wire) — they would otherwise inflate downstream
|
||||
candidate evaluation cost without contributing useful discrimination.
|
||||
|
||||
@@ -302,6 +302,34 @@ alternative — running under a dedicated unprivileged service
|
||||
account with the capability granted on the binary — see
|
||||
[../how-to/run-as-unprivileged-user.md](../how-to/run-as-unprivileged-user.md).
|
||||
|
||||
### App-Owned TUN (embedded hosts)
|
||||
|
||||
On platforms where FIPS is embedded rather than run as a daemon — notably
|
||||
Android, where the `VpnService` owns the TUN fd and the app has no
|
||||
`CAP_NET_ADMIN` — FIPS does not create `fips0` itself. Instead the embedder owns
|
||||
the fd and exchanges IPv6 packet bytes with FIPS over channels.
|
||||
|
||||
`Node::enable_app_owned_tun()` sets this up. It is called after `Node::new` and
|
||||
before `start()` (and before the node is moved into a background task), mirroring
|
||||
`control_read_handle()`, and returns two app-side channel ends:
|
||||
|
||||
- **app → mesh** — the embedder pushes IPv6 packets read from its fd into
|
||||
`app_outbound_tx`. These are drained by `run_rx_loop` into `handle_tun_outbound`
|
||||
and routed exactly as the Reader Thread's output would be.
|
||||
- **mesh → app** — inbound mesh traffic on port 256 is reconstructed and written
|
||||
to the node's `tun_tx` (the same sink the Writer Thread reads); the embedder
|
||||
pulls from `app_inbound_rx` and writes to its fd.
|
||||
|
||||
With the channels installed, `start()` skips system-TUN creation (it gates on
|
||||
`tun_tx` being unset), so FIPS does no `CAP_NET_ADMIN` operations.
|
||||
|
||||
Because packets enter via `app_outbound_tx` rather than the Reader Thread, they
|
||||
**bypass `handle_tun_packet`** — the `fd00::/8` destination filter, the ICMPv6
|
||||
Destination Unreachable for off-mesh dests (see [Reader Thread](#reader-thread)),
|
||||
and the [TUN-Side TCP MSS Clamping](#tun-side-tcp-mss-clamping). The embedder is
|
||||
therefore responsible for routing only `fd00::/8` to its TUN (so only mesh-bound
|
||||
packets arrive) and for clamping TCP MSS on outbound SYNs.
|
||||
|
||||
## Implementation Status
|
||||
|
||||
| Feature | Status |
|
||||
|
||||
@@ -477,7 +477,7 @@ The TXT record carries three keys (`src/discovery/lan/mod.rs:47-55`):
|
||||
|
||||
Once per node tick, the node drains browser events and acts on them in
|
||||
`poll_lan_discovery()` (`src/node/lifecycle.rs:907`, called from
|
||||
`src/node/handlers/rx_loop.rs:266`). For each discovered peer it finds
|
||||
`src/node/dataplane/rx_loop.rs:266`). For each discovered peer it finds
|
||||
a UDP transport whose family matches the peer address, parses the
|
||||
`npub` into a `PeerIdentity`, skips peers it is already connected to or
|
||||
currently connecting to, and otherwise initiates a connection.
|
||||
|
||||
@@ -262,7 +262,7 @@ UDP (1500 vs 1472 MTU).
|
||||
- **No IP dependency**: Operates below the IP layer. Nodes on the same
|
||||
Ethernet segment can communicate without IP addresses or routing
|
||||
infrastructure
|
||||
- **Broadcast discovery**: Nodes discover each other via periodic beacon
|
||||
- **Broadcast neighbor detection**: Nodes discover each other via periodic beacon
|
||||
broadcasts on the shared medium, with no static peer configuration required
|
||||
- **Higher MTU**: Standard Ethernet frames carry 1500 bytes of payload,
|
||||
yielding an effective FIPS MTU of 1499 after the frame type prefix
|
||||
@@ -293,7 +293,7 @@ socket.
|
||||
| Addressing | 6-byte MAC address |
|
||||
| Platform | Linux only (`CAP_NET_RAW` required) |
|
||||
|
||||
### Beacon Discovery
|
||||
### Neighbor Beacons
|
||||
|
||||
Ethernet nodes discover peers via broadcast beacons sent to
|
||||
ff:ff:ff:ff:ff:ff. Each beacon is a 34-byte frame containing the sender's
|
||||
@@ -301,7 +301,7 @@ x-only public key. Receiving nodes extract the MAC source address from the
|
||||
frame and the public key from the payload, then report the discovered peer
|
||||
to FMP.
|
||||
|
||||
Four configuration flags control discovery behavior — `discovery`
|
||||
Four configuration flags control neighbor behavior — `listen`
|
||||
(listen for beacons), `announce` (broadcast beacons), `auto_connect`
|
||||
(initiate handshakes to discovered peers), and `accept_connections`
|
||||
(accept inbound handshakes). The flag table and per-flag defaults
|
||||
@@ -310,13 +310,13 @@ under `transports.ethernet.*`.
|
||||
|
||||
A typical discoverable node sets `announce`, `auto_connect`, and
|
||||
`accept_connections` all true. A passive listener uses just
|
||||
`discovery: true` to observe the network without announcing itself.
|
||||
`listen: true` to observe the network without announcing itself.
|
||||
|
||||
### WiFi Compatibility
|
||||
|
||||
WiFi interfaces in infrastructure (managed) mode work transparently for
|
||||
unicast — the mac80211 subsystem handles frame translation between 802.11
|
||||
and 802.3. Broadcast beacon discovery is unreliable in managed mode because
|
||||
and 802.3. Broadcast neighbor detection is unreliable in managed mode because
|
||||
access points commonly isolate clients from each other's broadcast traffic.
|
||||
|
||||
Startup logging:
|
||||
@@ -895,7 +895,7 @@ transitions through `Starting` to `Up` (operational). `stop()` moves to
|
||||
| --------- | ------ | ----- |
|
||||
| UDP/IP | **Implemented** | Primary transport, AsyncFd/recvmsg, SO_RXQ_OVFL kernel drop detection |
|
||||
| TCP/IP | **Implemented** | FMP header-based framing, non-blocking connect, per-connection MSS MTU |
|
||||
| Ethernet | **Implemented** | AF_PACKET SOCK_DGRAM, EtherType 0x2121, beacon discovery, Linux only |
|
||||
| Ethernet | **Implemented** | AF_PACKET SOCK_DGRAM, EtherType 0x2121, neighbor beacons, Linux only |
|
||||
| WiFi | **Implemented** (via Ethernet transport, infrastructure mode) | mac80211 translates 802.11↔802.3; broadcast beacons unreliable through APs |
|
||||
| Tor | **Implemented** | Outbound SOCKS5, inbound via onion service, .onion and clearnet addressing |
|
||||
| Nym | **Implemented** | Outbound-only SOCKS5 through nym-socks5-client, mixnet anonymity, IP/hostname addressing |
|
||||
|
||||
@@ -25,4 +25,5 @@ X" to "X is done".
|
||||
| [persistent-identity.md](persistent-identity.md) | Provision a stable Nostr keypair so the node keeps the same npub across restarts |
|
||||
| [host-aliases.md](host-aliases.md) | Use shortnames (`test-us01.fips`, `my-laptop.fips`) instead of full npubs by editing `/etc/fips/hosts` or setting peer aliases |
|
||||
| [set-up-bluetooth-peer.md](set-up-bluetooth-peer.md) | Configure a Bluetooth Low Energy peer link |
|
||||
| [set-up-80211s-mesh-backhaul.md](set-up-80211s-mesh-backhaul.md) | Link OpenWrt FIPS routers over an open 802.11s radio backhaul (FIPS provides encryption, authentication, and routing) |
|
||||
| [diagnose-mtu-issues.md](diagnose-mtu-issues.md) | Triage MTU-shaped failures and rule out their imposters (bufferbloat, transport saturation) |
|
||||
|
||||
247
docs/how-to/set-up-80211s-mesh-backhaul.md
Normal file
247
docs/how-to/set-up-80211s-mesh-backhaul.md
Normal file
@@ -0,0 +1,247 @@
|
||||
# Set Up an 802.11s Mesh Backhaul (OpenWrt)
|
||||
|
||||
Link FIPS routers over radio — no cables, no APs, no shared
|
||||
infrastructure — by running the Ethernet transport on an open 802.11s
|
||||
mesh interface. The radio layer provides nothing but L2 frames to
|
||||
direct neighbors; FIPS provides everything else: encryption and
|
||||
authentication (Noise IK), peer discovery (Ethernet beacons), and
|
||||
routing (the spanning tree).
|
||||
|
||||
For the transport design, see
|
||||
[../design/fips-transport-layer.md](../design/fips-transport-layer.md).
|
||||
For all `transports.ethernet.*` configuration keys, see
|
||||
[../reference/configuration.md](../reference/configuration.md).
|
||||
|
||||
## Why open, why forwarding off
|
||||
|
||||
Two deliberate choices distinguish this from a stock 802.11s setup:
|
||||
|
||||
- **`encryption none`** — the mesh is open on purpose. Every FIPS peer
|
||||
link is already authenticated and encrypted by the Noise IK
|
||||
handshake, so SAE at L2 would duplicate that work, add a shared
|
||||
credential to provision across routers, and (on ath10k) force the
|
||||
firmware into its slower raw Tx/Rx mode. A stranger can form an
|
||||
802.11s peering with your router, but their frames die at the FIPS
|
||||
handshake — the same security model as mDNS and BLE discovery, where
|
||||
the advert is only a hint and the handshake is the authentication.
|
||||
What you concede: L2 metadata (MAC addresses, frame sizes) is
|
||||
visible in the air, and a hostile radio can burn airtime — both true
|
||||
of any radio link regardless of L2 encryption.
|
||||
- **`mesh_fwding 0`** — disables 802.11s's own HWMP routing so each
|
||||
mesh link is a plain neighbor link. FIPS is the routing layer; two
|
||||
routing layers would fight, and broadcast discovery beacons would
|
||||
flood the whole mesh instead of reaching direct neighbors only.
|
||||
|
||||
The interface is **not** bridged into `br-lan` — the FIPS Ethernet
|
||||
transport binds it directly.
|
||||
|
||||
## When to use
|
||||
|
||||
- Two or more OpenWrt FIPS routers within radio range of each other,
|
||||
where running cable is impractical.
|
||||
- You want the mesh segment to keep working with zero shared
|
||||
credentials or per-site configuration ("flash and drop in").
|
||||
|
||||
It is **not** for connecting phones or laptops — client devices
|
||||
cannot join an 802.11s mesh. They enter the mesh through a normal AP
|
||||
on the same router (see constraints below), or over BLE.
|
||||
|
||||
## Requirements
|
||||
|
||||
- OpenWrt 22.03+ with the FIPS package installed.
|
||||
- A radio whose driver supports mesh point interfaces. Check with:
|
||||
|
||||
```sh
|
||||
iw list | grep -A 10 "Supported interface modes" | grep "mesh point"
|
||||
```
|
||||
|
||||
The mainstream OpenWrt chips (ath9k, ath10k, mt76) all qualify.
|
||||
- Ideally a dual- or tri-band router, so one band can be dedicated to
|
||||
the backhaul (see constraints).
|
||||
|
||||
## Step 1 — create the mesh interface(s)
|
||||
|
||||
On **each** router, run the helper once per radio you want in the
|
||||
backhaul:
|
||||
|
||||
```sh
|
||||
fips-mesh-setup radio1
|
||||
```
|
||||
|
||||
This creates an open 802.11s interface with mesh ID `fips-mesh` and
|
||||
HWMP forwarding off, attaches it to an unmanaged netifd interface (no
|
||||
IP configuration — none is needed), uncomments the matching `meshN`
|
||||
transport entry in `/etc/fips/fips.yaml` (see Step 2), and reloads the
|
||||
radio. Interfaces are named by radio index: `radio0` → `fips-mesh0`,
|
||||
`radio1` → `fips-mesh1`. Pass a second argument to use a different
|
||||
mesh ID.
|
||||
|
||||
Note: the helper runs `wifi reload`, which re-applies the whole
|
||||
wireless config and so briefly drops every client AP on all radios for
|
||||
a few seconds. `fips-mesh-setup remove` reloads the same way. Expect
|
||||
the blip if clients are connected.
|
||||
|
||||
On dual-band routers, meshing **both** bands is worth it: 2.4 GHz
|
||||
reaches further at lower rates, 5 GHz carries more over shorter
|
||||
links. Note this is **failover, not multipath**: FIPS keeps one
|
||||
active link per peer, so traffic uses one band at a time — the other
|
||||
is a standby that re-establishes the peer if the active link dies
|
||||
(detection via keepalive timeout, so a cutover takes seconds, not
|
||||
milliseconds):
|
||||
|
||||
```sh
|
||||
fips-mesh-setup radio0
|
||||
fips-mesh-setup radio1
|
||||
```
|
||||
|
||||
**Pin the same channel on every backhaul router, per band.** Mesh
|
||||
points only peer on the same channel, and the mesh inherits whatever
|
||||
the radio is set to — with `channel 'auto'` (the default on many
|
||||
devices) each router picks its own and the mesh silently never forms.
|
||||
The script prints the radio's current band and channel and warns on
|
||||
`auto`:
|
||||
|
||||
```sh
|
||||
uci set wireless.radio1.channel='36'
|
||||
uci commit wireless && wifi reload
|
||||
```
|
||||
|
||||
Prefer a non-DFS channel (36–48 on 5 GHz): on DFS channels the radio
|
||||
must wait ~60 s in CAC before transmitting after every reload.
|
||||
|
||||
Equivalent manual UCI (per radio), if you prefer to see what it does:
|
||||
|
||||
```sh
|
||||
uci batch <<'EOF'
|
||||
set wireless.fips_mesh_radio1=wifi-iface
|
||||
set wireless.fips_mesh_radio1.device='radio1'
|
||||
set wireless.fips_mesh_radio1.mode='mesh'
|
||||
set wireless.fips_mesh_radio1.mesh_id='fips-mesh'
|
||||
set wireless.fips_mesh_radio1.encryption='none'
|
||||
set wireless.fips_mesh_radio1.mesh_fwding='0'
|
||||
set wireless.fips_mesh_radio1.ifname='fips-mesh1'
|
||||
set wireless.fips_mesh_radio1.network='fips_mesh_radio1'
|
||||
set network.fips_mesh_radio1=interface
|
||||
set network.fips_mesh_radio1.proto='none'
|
||||
EOF
|
||||
uci commit
|
||||
wifi reload
|
||||
```
|
||||
|
||||
## Step 2 — check the FIPS transport binding
|
||||
|
||||
The `fips.yaml` shipped in the OpenWrt package carries one transport
|
||||
entry per radio, but **commented out** — so a stock install that never
|
||||
runs this helper logs no per-boot "interface missing" warning.
|
||||
`fips-mesh-setup` uncommented the matching `meshN` entry in Step 1, so
|
||||
there is normally nothing to do here. If you maintain your own config
|
||||
(or ran the manual UCI above instead of the helper), make sure the
|
||||
entries are present and uncommented:
|
||||
|
||||
```yaml
|
||||
transports:
|
||||
ethernet:
|
||||
mesh0:
|
||||
interface: "fips-mesh0"
|
||||
discovery: true
|
||||
announce: true
|
||||
auto_connect: true
|
||||
accept_connections: true
|
||||
mesh1:
|
||||
interface: "fips-mesh1"
|
||||
discovery: true
|
||||
announce: true
|
||||
auto_connect: true
|
||||
accept_connections: true
|
||||
```
|
||||
|
||||
## Step 3 — restart the daemon (order matters)
|
||||
|
||||
```sh
|
||||
/etc/init.d/fips restart
|
||||
```
|
||||
|
||||
Restart fips **after** the mesh interface is up. A transport whose
|
||||
interface is missing at startup is logged and skipped, not retried —
|
||||
so if the daemon comes up before the radio, the mesh transport stays
|
||||
dead until the next restart. (An interface that *vanishes and
|
||||
returns* after startup is recovered automatically; only the missing-
|
||||
at-startup case needs this ordering.)
|
||||
|
||||
## Verify
|
||||
|
||||
L2 first — the 802.11s peering, with a second configured router in
|
||||
range:
|
||||
|
||||
```sh
|
||||
iw dev fips-mesh0 station dump
|
||||
```
|
||||
|
||||
You should see one station entry per neighbor router, with signal
|
||||
levels. No entries means a radio problem, not a FIPS problem — triage
|
||||
in this order:
|
||||
|
||||
1. **Channel mismatch** (the most common cause): compare
|
||||
`iw dev fips-mesh0 info` on both routers — mesh ID *and* channel
|
||||
must match exactly.
|
||||
2. **The mesh interface never joined** — `iw dev fips-meshX info`
|
||||
shows `type mesh point` but **no channel line**, and `station dump`
|
||||
is empty. Usual cause: a client (`sta`) interface on the same
|
||||
radio. A STA must follow its upstream AP's channel, the whole
|
||||
radio follows the STA, and a mesh pinned to a different channel
|
||||
silently stays down. Check for a STA sharing the radio
|
||||
(`iw dev`, look for `type managed` on the same phy), compare
|
||||
`iw dev <sta-iface> info | grep channel`, and re-pin the mesh
|
||||
channel to match — on every backhaul router.
|
||||
3. **Is the other router transmitting at all?**
|
||||
|
||||
```sh
|
||||
iw dev fips-mesh0 scan | grep -i -B4 "MESH ID"
|
||||
```
|
||||
|
||||
Its mesh ID visible → transmission works, peering is failing
|
||||
(mesh ID typo, or one side has encryption set). Nothing visible →
|
||||
check `wifi status` on the other router, remember the ~60 s DFS
|
||||
CAC wait, and confirm the country code is set
|
||||
(`uci get wireless.radio1.country`) — an unset regdomain can
|
||||
block channels entirely.
|
||||
4. `logread | grep -iE "mesh|fips-mesh0"` on both sides.
|
||||
|
||||
Then the FIPS layer on top:
|
||||
|
||||
```sh
|
||||
logread | grep -i beacon # beacons flowing on the new transport
|
||||
fipsctl show peers # neighbor authenticated and connected
|
||||
fipsctl show links # link on the 'ethernet' transport
|
||||
```
|
||||
|
||||
Discovery is automatic: each node beacons its pubkey every few
|
||||
seconds, and `auto_connect` initiates the Noise handshake on first
|
||||
sight.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Airtime is shared per radio.** All virtual interfaces on one
|
||||
radio (AP + mesh) share one channel, and multi-hop forwarding on a
|
||||
single radio roughly halves throughput per hop. On dual/tri-band
|
||||
hardware, dedicate one band to `fips-mesh0` and serve clients on
|
||||
the others.
|
||||
- **AP + mesh coexistence is driver-dependent.** It works on the
|
||||
mainstream chips (this is the standard Freifunk/Gluon setup), but
|
||||
check `iw list` under "valid interface combinations" for your
|
||||
hardware.
|
||||
- **Clients can't join.** Phones and laptops reach the mesh through
|
||||
the router's normal AP or via BLE — never through the 802.11s
|
||||
interface.
|
||||
- **Radio links are lossy.** A neighbor at the edge of range will
|
||||
form an 802.11s peering yet deliver a fraction of its frames.
|
||||
Expect link-quality effects that don't exist on wired Ethernet.
|
||||
- **A client (STA) uplink on the same radio owns the channel.** The
|
||||
STA must follow whatever channel its upstream AP uses; every other
|
||||
interface on that radio follows the STA. A mesh pinned to a
|
||||
different channel silently never joins, and it does **not** recover
|
||||
when the STA disconnects — a `wifi reload` (plus a fips restart) is
|
||||
needed. A *roaming* uplink (travel-router / hotspot-chasing setups)
|
||||
is fundamentally incompatible with a fixed-channel mesh on the same
|
||||
radio: dedicate the mesh to the radio the STA never uses, and treat
|
||||
any mesh sharing a STA radio as best-effort.
|
||||
@@ -277,7 +277,7 @@ Controls tree construction and parent selection.
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `node.bloom.update_debounce_ms` | u64 | `500` | Debounce interval for filter update propagation |
|
||||
| `node.bloom.max_inbound_fpr` | f64 | `0.10` | Antipoison cap: reject inbound `FilterAnnounce` frames whose advertised false-positive rate exceeds this value. Valid range `(0.0, 1.0)`. The default `0.10` corresponds to fill 0.631 at k=5 (≈1,630 entries on the 1 KB filter); a saturated/poisoned filter is still ~100% FPR and rejected |
|
||||
| `node.bloom.max_inbound_fpr` | f64 | `0.20` | Antipoison cap: reject inbound `FilterAnnounce` frames whose advertised false-positive rate exceeds this value. Valid range `(0.0, 1.0)`. The default `0.20` corresponds to fill 0.7248 at k=5 (≈2,114 entries on the 1 KB filter); a saturated/poisoned filter is still ~100% FPR and rejected |
|
||||
|
||||
Bloom filter size (1 KB), hash count (5), and size classes are protocol
|
||||
constants and not configurable.
|
||||
@@ -436,7 +436,7 @@ Requires `CAP_NET_RAW` or running as root. Linux only.
|
||||
| `mtu` | u16 | *(auto)* | Override MTU. Default: interface MTU minus 3 (for frame type + length prefix) |
|
||||
| `recv_buf_size` | usize | `2097152` | Socket receive buffer size in bytes (2 MB) |
|
||||
| `send_buf_size` | usize | `2097152` | Socket send buffer size in bytes (2 MB) |
|
||||
| `discovery` | bool | `true` | Listen for discovery beacons from other nodes |
|
||||
| `listen` | bool | `true` | Listen for neighbor beacons from other nodes |
|
||||
| `announce` | bool | `false` | Broadcast announcement beacons on the LAN |
|
||||
| `auto_connect` | bool | `false` | Auto-connect to discovered peers |
|
||||
| `accept_connections` | bool | `false` | Accept incoming connection attempts from discovered peers |
|
||||
@@ -450,7 +450,7 @@ transports:
|
||||
ethernet:
|
||||
lan:
|
||||
interface: "eth0"
|
||||
discovery: true
|
||||
listen: true
|
||||
announce: true
|
||||
backbone:
|
||||
interface: "eth1"
|
||||
@@ -458,7 +458,7 @@ transports:
|
||||
```
|
||||
|
||||
Each named instance operates independently with its own socket and
|
||||
discovery state. The instance name is used in log messages and the
|
||||
neighbor state. The instance name is used in log messages and the
|
||||
`name()` method on the Transport trait.
|
||||
|
||||
### TCP (`transports.tcp.*`)
|
||||
@@ -840,7 +840,7 @@ peers:
|
||||
### Mixed UDP + Ethernet Example
|
||||
|
||||
A node bridging internet peers (UDP) and a local Ethernet segment with
|
||||
beacon discovery:
|
||||
neighbor beacons:
|
||||
|
||||
```yaml
|
||||
node:
|
||||
@@ -856,7 +856,7 @@ transports:
|
||||
mtu: 1472
|
||||
ethernet:
|
||||
interface: "eth0"
|
||||
discovery: true
|
||||
listen: true
|
||||
announce: true
|
||||
auto_connect: true
|
||||
accept_connections: true
|
||||
@@ -944,7 +944,7 @@ node:
|
||||
flap_dampening_secs: 120 # extended hold-down on flap
|
||||
bloom:
|
||||
update_debounce_ms: 500
|
||||
max_inbound_fpr: 0.10 # antipoison cap on inbound FilterAnnounce FPR
|
||||
max_inbound_fpr: 0.20 # antipoison cap on inbound FilterAnnounce FPR
|
||||
session:
|
||||
default_ttl: 64
|
||||
pending_packets_per_dest: 16
|
||||
@@ -999,7 +999,7 @@ transports:
|
||||
# mtu: null # null = interface MTU - 3 (typically 1497)
|
||||
# recv_buf_size: 2097152 # 2 MB
|
||||
# send_buf_size: 2097152 # 2 MB
|
||||
# discovery: true # listen for beacons
|
||||
# listen: true # listen for beacons
|
||||
# announce: false # broadcast beacons
|
||||
# auto_connect: false # connect to discovered peers
|
||||
# accept_connections: false # accept inbound handshakes
|
||||
|
||||
@@ -209,7 +209,7 @@ for the metadata-privacy model and the rejection of onion routing.
|
||||
| --------- | --------------- | ------------ | ------ |
|
||||
| UDP | None until `bind_addr` set | `0.0.0.0:2121` typical | Operator sets `transports.udp.bind_addr` |
|
||||
| TCP | None until `bind_addr` set | None — outbound-only without bind | Operator sets `transports.tcp.bind_addr` |
|
||||
| Ethernet | Listens on configured interface (raw `AF_PACKET`) | EtherType 0x2121 on selected interface | Per-flag `discovery`, `announce`, `auto_connect`, `accept_connections` |
|
||||
| Ethernet | Listens on configured interface (raw `AF_PACKET`) | EtherType 0x2121 on selected interface | Per-flag `listen`, `announce`, `auto_connect`, `accept_connections` |
|
||||
| Tor | None until `directory_service` configured | `127.0.0.1:8443` (loopback only) | Operator sets `transports.tor.directory_service` and configures `HiddenServiceDir` in `torrc` |
|
||||
| BLE | Off by default | n/a | Operator enables `transports.ble.*` |
|
||||
| Nostr discovery | Off by default | n/a (relay client, not a listener) | Operator sets `node.discovery.nostr.enabled: true` |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# FIPS v0.4.0
|
||||
|
||||
**Released**: 2026-06-21 (provisional)
|
||||
**Released**: 2026-06-27
|
||||
|
||||
v0.4.0 is the throughput-and-observability release on the v0.3.x wire
|
||||
format. It adds two new ways for nodes to find and reach each other (the
|
||||
|
||||
146
docs/releases/release-notes-v0.4.1.md
Normal file
146
docs/releases/release-notes-v0.4.1.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# FIPS v0.4.1
|
||||
|
||||
**Released**: 2026-07-19
|
||||
|
||||
v0.4.1 is a maintenance release on the v0.4.x line. It raises the default
|
||||
antipoison cap on inbound bloom filter announcements, removes a redundant
|
||||
spanning-tree metric counter, fixes two convergence and path-MTU bugs, and
|
||||
cuts per-packet CPU in the bloom and identity paths. There is no wire
|
||||
format change and no new feature surface.
|
||||
|
||||
v0.4.1 is wire-compatible with v0.4.0. Nodes can be upgraded one at a time
|
||||
with no coordinated restart, though one behavior change below is worth
|
||||
reading before you start a rolling upgrade.
|
||||
|
||||
## At a glance
|
||||
|
||||
- `node.bloom.max_inbound_fpr` default moves from `0.10` to `0.20`.
|
||||
- The `parent_switched` metric counter is gone. Use `parent_switches`.
|
||||
- Spanning tree no longer serves stale coordinates after a parent link is
|
||||
lost through peer removal.
|
||||
- Discovery no longer loosens a path MTU clamp it had correctly tightened.
|
||||
- Bloom probing and identity operations do measurably less work per call,
|
||||
with identical results.
|
||||
|
||||
## Behavior changes worth flagging
|
||||
|
||||
### The inbound filter FPR cap default doubles again
|
||||
|
||||
`node.bloom.max_inbound_fpr` goes from `0.10` to `0.20`. The cap rejects
|
||||
inbound `FilterAnnounce` frames whose advertised false positive rate
|
||||
exceeds it. On the fixed 1 KB, k=5 filter, `0.10` corresponds to a fill of
|
||||
0.631 and roughly 1,630 reachable entries, and the busiest nodes'
|
||||
aggregates had started reaching that ceiling as the mesh grew. `0.20`
|
||||
corresponds to a fill of 0.7248 and roughly 2,114 entries.
|
||||
|
||||
Be aware that this is the second time in two releases that this default
|
||||
has doubled, for the same reason both times. That is worth stating plainly
|
||||
rather than repeating the previous release's framing: raising the cap buys
|
||||
headroom, it does not fix anything. The real constraint is the fixed 1 KB
|
||||
filter size, which is a protocol constant. The structural remedy is the v2
|
||||
filter work, where filter capacity scales with the mesh instead of being
|
||||
pinned. This release is an interim step to keep legitimate aggregates from
|
||||
being rejected until that lands. It is not the start of a pattern of
|
||||
raising the cap once per release, and if you are sizing capacity planning
|
||||
around this number, plan against the v2 work rather than against a third
|
||||
raise.
|
||||
|
||||
The antipoison property the cap exists for is preserved. A saturated or
|
||||
deliberately poisoned filter still presents an FPR near 100% and is still
|
||||
rejected.
|
||||
|
||||
**This matters during a rolling upgrade.** A v0.4.1 node accepts a
|
||||
`FilterAnnounce` with a derived FPR between 0.10 and 0.20; a v0.4.0 node
|
||||
drops the same frame, and the drop is silent on the wire with no NACK. The
|
||||
cap also gates the mesh size estimator, which declines to produce a value
|
||||
when any contributing filter is over the cap. So while a mesh is partly
|
||||
upgraded, upgraded and not-yet-upgraded nodes can legitimately report
|
||||
different mesh sizes, or one can report a size while the other reports
|
||||
unknown. This resolves once every node is on v0.4.1. If you want to avoid
|
||||
the window entirely, set `node.bloom.max_inbound_fpr: 0.10` explicitly in
|
||||
your config before upgrading and remove it after the last node is done.
|
||||
|
||||
### The `parent_switched` counter is removed
|
||||
|
||||
`parent_switched` was incremented on the line immediately before
|
||||
`parent_switches` at every site and never independently, so the two
|
||||
counters always held the same value. `parent_switched` is now gone from
|
||||
the tree metrics, the control socket snapshot, and the `fipstop` tree
|
||||
view. `parent_switches` remains and is unchanged.
|
||||
|
||||
If you scrape the control socket, or have dashboards or alerts referencing
|
||||
`parent_switched`, point them at `parent_switches`. Anything still asking
|
||||
for `parent_switched` will find nothing rather than a zero.
|
||||
|
||||
## Notable bug fixes
|
||||
|
||||
### Stale coordinates after losing a parent through peer removal
|
||||
|
||||
When a node's parent link dropped via peer removal, the node correctly
|
||||
reparented or self-rooted, but skipped the coordinate cache invalidation
|
||||
that every other position-change path performs. Cached entries for
|
||||
downstream destinations kept the node's old coordinate prefix. This did
|
||||
not self-correct the way a stale cache entry normally would: routing
|
||||
access refreshes an entry's TTL, so an entry that was actively being
|
||||
routed through never expired, and was only fixed by an unrelated fresh
|
||||
insert. Both invalidation classes now run on this path, matching the
|
||||
loop-detection branch.
|
||||
|
||||
### Discovery could loosen a tightened path MTU clamp
|
||||
|
||||
An originator handling a `LookupResponse` overwrote its cached path MTU
|
||||
unconditionally. If a reactive `MtuExceeded` or `PathMtuNotification` had
|
||||
already taught it a tighter value, a later, looser discovery estimate
|
||||
would clobber that and re-loosen the clamp, risking a return to dropped
|
||||
oversized packets. The cached and received values are now compared and the
|
||||
tighter one is kept.
|
||||
|
||||
## Upgrade notes
|
||||
|
||||
This is a drop-in upgrade from v0.4.0 with no wire format change, no
|
||||
config migration, and no coordinated restart. Upgrade nodes in whatever
|
||||
order you like.
|
||||
|
||||
Two things to do rather than assume:
|
||||
|
||||
1. If you monitor `parent_switched`, move to `parent_switches` before
|
||||
upgrading, or your dashboards will go blank rather than error.
|
||||
2. During the rolling window, expect upgraded and not-yet-upgraded nodes
|
||||
to potentially disagree about mesh size, per the FPR cap section above.
|
||||
This is expected and self-resolves. Do not chase it as a bug unless it
|
||||
persists after every node reports `0.4.1`.
|
||||
|
||||
If you have pinned `node.bloom.max_inbound_fpr` explicitly in your config,
|
||||
your setting is honored and nothing changes for you. The change only
|
||||
affects nodes taking the default.
|
||||
|
||||
Downgrading to v0.4.0 is supported and needs no special handling.
|
||||
|
||||
## Getting v0.4.1
|
||||
|
||||
- **Linux x86_64 / aarch64**: `.deb` and tarball at the
|
||||
[v0.4.1 release page](https://github.com/jmcorgan/fips/releases/tag/v0.4.1).
|
||||
- **Arch Linux**: `fips` from the AUR.
|
||||
- **macOS**: `.pkg` at the v0.4.1 release page.
|
||||
- **Windows**: ZIP at the v0.4.1 release page.
|
||||
- **OpenWrt**: `.ipk` (OpenWrt 24.x and earlier) or `.apk` (OpenWrt 25+)
|
||||
at the v0.4.1 release page.
|
||||
- **From source**: `cargo build --release` from a checkout of the v0.4.1
|
||||
tag (Rust 1.94.1 per `rust-toolchain.toml`; `libclang-dev` is a
|
||||
required Linux build prerequisite).
|
||||
- **Nix / NixOS**: `nix build .#fips` from a checkout of the v0.4.1 tag
|
||||
builds the binaries from source with the pinned toolchain and no manual
|
||||
prerequisites (see the Nix section of `packaging/README.md`).
|
||||
|
||||
The full per-commit changelog lives in
|
||||
[`CHANGELOG.md`](../../CHANGELOG.md). Issues and discussion at
|
||||
[github.com/jmcorgan/fips](https://github.com/jmcorgan/fips).
|
||||
|
||||
## Contributors
|
||||
|
||||
Thanks to everyone who contributed code, packaging work, bug reports, or
|
||||
reviews to this release.
|
||||
|
||||
- [@jcorgan](https://github.com/jmcorgan): release shepherd, spanning-tree
|
||||
and discovery fixes, bloom and identity performance work, antipoison cap
|
||||
change, and testing.
|
||||
@@ -71,7 +71,7 @@ supplies the rest:
|
||||
- **Addressing**: the `fips0` adapter takes an `fd97:...` ULA
|
||||
derived from the npub. No DHCP. No SLAAC. The address is
|
||||
cryptographically tied to the identity.
|
||||
- **Discovery**: each daemon broadcasts a small beacon on the
|
||||
- **Neighbor detection**: each daemon broadcasts a small beacon on the
|
||||
link advertising its npub; the other daemon's listener picks
|
||||
it up and dials in over the same link.
|
||||
- **Routing**: the FIPS mesh layer builds its own spanning tree
|
||||
@@ -177,7 +177,7 @@ different interface names — that is normal.
|
||||
|
||||
Edit `/etc/fips/fips.yaml` on **both** nodes. Under
|
||||
`transports:`, add an `ethernet:` block. The key settings are
|
||||
the four discovery flags — both nodes must opt in to all four,
|
||||
the four neighbor flags — both nodes must opt in to all four,
|
||||
and they default to off:
|
||||
|
||||
```yaml
|
||||
@@ -185,7 +185,7 @@ transports:
|
||||
ethernet:
|
||||
interface: "<eth>" # the name from Step 1
|
||||
announce: true # broadcast our beacon on the link
|
||||
discovery: true # listen for beacons (default; shown for clarity)
|
||||
listen: true # listen for beacons (default; shown for clarity)
|
||||
auto_connect: true # dial peers we discover
|
||||
accept_connections: true # accept dial-ins from peers we discover
|
||||
```
|
||||
@@ -194,7 +194,7 @@ Each flag does one thing:
|
||||
|
||||
- `announce: true` — emit a small beacon every
|
||||
`beacon_interval_secs` (default 30s) carrying our npub.
|
||||
- `discovery: true` — listen for incoming beacons; populate a
|
||||
- `listen: true` — listen for incoming beacons; populate a
|
||||
candidate-peer list keyed by source MAC and observed npub.
|
||||
- `auto_connect: true` — when we see a beacon from an npub
|
||||
we have not yet peered with, initiate the outbound Noise
|
||||
@@ -218,7 +218,7 @@ is "all four flags on both ends."
|
||||
> lan:
|
||||
> interface: "eth0"
|
||||
> announce: true
|
||||
> discovery: true
|
||||
> listen: true
|
||||
> auto_connect: true
|
||||
> accept_connections: true
|
||||
> dongle:
|
||||
@@ -227,7 +227,7 @@ is "all four flags on both ends."
|
||||
> # ...
|
||||
> ```
|
||||
>
|
||||
> Each named instance runs its own socket and discovery state.
|
||||
> Each named instance runs its own socket and neighbor state.
|
||||
> A single ground-up link only needs the flat form shown
|
||||
> first; named instances become useful when the same node
|
||||
> bridges multiple physical segments.
|
||||
@@ -390,7 +390,7 @@ What you do need on the AP side:
|
||||
networks and "secure" enterprise APs ship with it on.
|
||||
When client isolation is on, the AP refuses to forward
|
||||
station-to-station frames — the broadcast beacons never
|
||||
arrive at the other node, and discovery fails silently.
|
||||
arrive at the other node, and neighbor detection fails silently.
|
||||
If beacons aren't crossing, this is the first thing to
|
||||
check.
|
||||
|
||||
@@ -401,7 +401,7 @@ adapter name.
|
||||
### Bluetooth LE (experimental but works)
|
||||
|
||||
BLE is a separate transport (`transports.ble.*`) with its own
|
||||
discovery model — L2CAP advertisements rather than raw L2
|
||||
neighbor-detection model — L2CAP advertisements rather than raw L2
|
||||
broadcasts. The shape of the tutorial is the same (advertise +
|
||||
scan + auto-connect + accept), but the prerequisites are
|
||||
different: BlueZ, `bluetoothd`, an HCI adapter, and the
|
||||
@@ -424,7 +424,7 @@ Windows builds skip it.
|
||||
a radio link), `CAP_NET_RAW`, and a few config flags on each
|
||||
end are sufficient. The mesh supplies its own identity,
|
||||
addressing, discovery, and routing.
|
||||
- **Discovery is a four-flag opt-in.** `announce`, `discovery`,
|
||||
- **Neighbor detection is a four-flag opt-in.** `announce`, `listen`,
|
||||
`auto_connect`, and `accept_connections` each control one
|
||||
thing; both ends must agree before a link will form.
|
||||
- **The two modes coexist.** Overlay peers and ground-up peers
|
||||
|
||||
@@ -178,7 +178,7 @@ The resolution itself happens at debug-log level, so you will
|
||||
not see it in the default-level journal. The user-facing way to
|
||||
confirm everything worked is `fipsctl show peers` in the next
|
||||
step. (To watch the resolution in the journal, run the daemon
|
||||
manually with `RUST_LOG=fips::discovery::nostr=debug`; not
|
||||
manually with `RUST_LOG=fips::nostr=debug`; not
|
||||
necessary for this tutorial.)
|
||||
|
||||
## Step 5: Verify the resolved endpoint
|
||||
|
||||
@@ -10,12 +10,21 @@ node:
|
||||
#
|
||||
# Or set an explicit key (overrides persistent):
|
||||
# nsec: "nsec1..."
|
||||
discovery:
|
||||
# Optional Nostr-mediated overlay endpoint discovery.
|
||||
# Mesh-lookup protocol (node.lookup.*): the overlay coordinate-lookup engine
|
||||
# (mesh address -> coordinates). Defaults shown; uncomment to override.
|
||||
# lookup:
|
||||
# ttl: 64
|
||||
# attempt_timeouts_secs: [1, 2, 4, 8]
|
||||
# recent_expiry_secs: 10
|
||||
# backoff_base_secs: 0
|
||||
# backoff_max_secs: 0
|
||||
# forward_min_interval_secs: 2
|
||||
rendezvous:
|
||||
# Optional Nostr-mediated overlay endpoint rendezvous.
|
||||
# nostr:
|
||||
# enabled: true
|
||||
# policy: configured_only # disabled | configured_only | open
|
||||
# open_discovery_max_pending: 64 # caps queued open-discovery retries
|
||||
# open_discovery_max_pending: 64 # caps queued open-rendezvous retries
|
||||
# app: "fips-overlay-v1"
|
||||
# advertise: true
|
||||
# advert_relays:
|
||||
@@ -34,17 +43,17 @@ node:
|
||||
# - "stun:stun.cloudflare.com:3478"
|
||||
# - "stun:global.stun.twilio.com:3478"
|
||||
#
|
||||
# Optional mDNS-based LAN discovery for sub-second same-LAN pairing.
|
||||
# Optional mDNS-based LAN rendezvous for sub-second same-LAN pairing.
|
||||
# Opt-in (default false): default-off avoids a per-LAN identity
|
||||
# broadcast on nodes that have deliberately disabled other discovery
|
||||
# broadcast on nodes that have deliberately disabled other rendezvous
|
||||
# channels, and avoids any multicast surprise on upgrade. Requires an
|
||||
# operational UDP transport (the advertised port is the one peers dial).
|
||||
# lan:
|
||||
# enabled: false
|
||||
# # Optional application/network scope carried in the LAN-only TXT
|
||||
# # record. Browsers that set a scope ignore adverts for other scopes.
|
||||
# # Kept separate from the Nostr discovery `app` tag so relay-visible
|
||||
# # adverts can stay generic while LAN discovery stays per-private-network.
|
||||
# # Kept separate from the Nostr rendezvous `app` tag so relay-visible
|
||||
# # adverts can stay generic while LAN rendezvous stays per-private-network.
|
||||
# # scope: "lab-floor-3"
|
||||
# # Advanced: overrides the mDNS service type. Leave unset in normal
|
||||
# # use — only needed to run multiple isolated services on one
|
||||
@@ -89,7 +98,7 @@ transports:
|
||||
# Ethernet transport — uncomment and set your interface name.
|
||||
# ethernet:
|
||||
# interface: "eth0"
|
||||
# discovery: true
|
||||
# listen: true
|
||||
# announce: true
|
||||
# auto_connect: true
|
||||
# accept_connections: true
|
||||
@@ -147,5 +156,5 @@ peers: []
|
||||
# - transport: udp
|
||||
# addr: "test-us01.fips.network:2121" # IP or hostname (e.g., "peer.example.com:2121")
|
||||
# - transport: udp
|
||||
# addr: "nat" # Use node.discovery.nostr for Nostr/STUN hole punching
|
||||
# addr: "nat" # Use node.rendezvous.nostr for Nostr/STUN hole punching
|
||||
# connect_policy: auto_connect
|
||||
|
||||
@@ -182,6 +182,7 @@ install -m 0755 "$RELEASE_DIR/fips" "$STAGE_DIR/usr/bin/fips"
|
||||
install -m 0755 "$RELEASE_DIR/fipsctl" "$STAGE_DIR/usr/bin/fipsctl"
|
||||
install -m 0755 "$RELEASE_DIR/fipstop" "$STAGE_DIR/usr/bin/fipstop"
|
||||
install -m 0755 "$RELEASE_DIR/fips-gateway" "$STAGE_DIR/usr/bin/fips-gateway"
|
||||
install -m 0755 "$FILES_DIR/usr/bin/fips-mesh-setup" "$STAGE_DIR/usr/bin/fips-mesh-setup"
|
||||
|
||||
install -d "$STAGE_DIR/etc/init.d"
|
||||
install -m 0755 "$FILES_DIR/etc/init.d/fips" "$STAGE_DIR/etc/init.d/fips"
|
||||
@@ -191,6 +192,13 @@ install -d "$STAGE_DIR/etc/fips"
|
||||
install -m 0600 "$FILES_DIR/etc/fips/fips.yaml" "$STAGE_DIR/etc/fips/fips.yaml"
|
||||
install -m 0755 "$FILES_DIR/etc/fips/firewall.sh" "$STAGE_DIR/etc/fips/firewall.sh"
|
||||
|
||||
# The shared fips.yaml ships ethernet.wan.interface: "eth0", the OpenWrt 24
|
||||
# default. This .apk package targets OpenWrt 25+ (DSA), where the WAN port is
|
||||
# named "wan", so ship "wan" as the default. Patching the staged copy keeps the
|
||||
# as-installed config correct for the platform without maintaining a second copy
|
||||
# of the file; operators can still edit /etc/fips/fips.yaml for non-standard boards.
|
||||
sed -i 's|interface: "eth0"|interface: "wan"|' "$STAGE_DIR/etc/fips/fips.yaml"
|
||||
|
||||
install -d "$STAGE_DIR/etc/dnsmasq.d"
|
||||
install -m 0644 "$FILES_DIR/etc/dnsmasq.d/fips.conf" "$STAGE_DIR/etc/dnsmasq.d/fips.conf"
|
||||
|
||||
|
||||
@@ -96,6 +96,9 @@ define Package/fips/install
|
||||
$(INSTALL_BIN) $(RUST_RELEASE_DIR)/fipstop $(1)/usr/bin/fipstop
|
||||
$(INSTALL_BIN) $(RUST_RELEASE_DIR)/fips-gateway $(1)/usr/bin/fips-gateway
|
||||
|
||||
# 802.11s mesh backhaul setup helper
|
||||
$(INSTALL_BIN) $(CURDIR)/files/usr/bin/fips-mesh-setup $(1)/usr/bin/fips-mesh-setup
|
||||
|
||||
# procd init script
|
||||
$(INSTALL_DIR) $(1)/etc/init.d
|
||||
$(INSTALL_BIN) $(CURDIR)/files/etc/init.d/fips $(1)/etc/init.d/fips
|
||||
|
||||
@@ -14,6 +14,7 @@ For ad-hoc deployment without the build system, see
|
||||
| `/usr/bin/fipsctl` | CLI control tool (`fipsctl show peers`, `fipsctl show links`, …) |
|
||||
| `/usr/bin/fipstop` | Live TUI dashboard |
|
||||
| `/usr/bin/fips-gateway` | Outbound LAN gateway service (not started by default) |
|
||||
| `/usr/bin/fips-mesh-setup` | Opt-in helper — creates an open 802.11s mesh interface for router↔router backhaul |
|
||||
| `/etc/init.d/fips` | procd service for the daemon (auto-start, crash respawn) |
|
||||
| `/etc/init.d/fips-gateway` | procd service for the gateway (disabled by default) |
|
||||
| `/etc/fips/fips.yaml` | Node configuration (edit before first start) |
|
||||
@@ -132,7 +133,10 @@ The default config enables:
|
||||
|
||||
For Ethernet transport, uncomment the `ethernet:` section and set the correct
|
||||
physical interface names for your router. **Always use physical port names
|
||||
(`eth0`, `eth1`), never bridge names (`br-lan`).** See
|
||||
(`eth0`, `eth1`, or DSA port names like `wan`/`lan1`), never bridge names
|
||||
(`br-lan`).** The shipped default WAN port is `eth0` (OpenWrt 24); on OpenWrt
|
||||
25 (DSA) boards the WAN port is named `wan` — the `.apk` package ships that
|
||||
default. Run `ip link show` to confirm the names on your board. See
|
||||
[`deploy/native/README.md`](../../deploy/native/README.md) for details.
|
||||
|
||||
## Service management
|
||||
|
||||
@@ -161,6 +161,7 @@ install -m 0755 "$RELEASE_DIR/fips" "$DATA_DIR/usr/bin/fips"
|
||||
install -m 0755 "$RELEASE_DIR/fipsctl" "$DATA_DIR/usr/bin/fipsctl"
|
||||
install -m 0755 "$RELEASE_DIR/fipstop" "$DATA_DIR/usr/bin/fipstop"
|
||||
install -m 0755 "$RELEASE_DIR/fips-gateway" "$DATA_DIR/usr/bin/fips-gateway"
|
||||
install -m 0755 "$FILES_DIR/usr/bin/fips-mesh-setup" "$DATA_DIR/usr/bin/fips-mesh-setup"
|
||||
|
||||
install -d "$DATA_DIR/etc/init.d"
|
||||
install -m 0755 "$FILES_DIR/etc/init.d/fips" "$DATA_DIR/etc/init.d/fips"
|
||||
|
||||
@@ -10,12 +10,21 @@ node:
|
||||
#
|
||||
# Or set an explicit key (overrides persistent):
|
||||
# nsec: "nsec1..."
|
||||
discovery:
|
||||
# Optional Nostr-mediated overlay endpoint discovery.
|
||||
# Mesh-lookup protocol (node.lookup.*): the overlay coordinate-lookup engine
|
||||
# (mesh address -> coordinates). Defaults shown; uncomment to override.
|
||||
# lookup:
|
||||
# ttl: 64
|
||||
# attempt_timeouts_secs: [1, 2, 4, 8]
|
||||
# recent_expiry_secs: 10
|
||||
# backoff_base_secs: 0
|
||||
# backoff_max_secs: 0
|
||||
# forward_min_interval_secs: 2
|
||||
rendezvous:
|
||||
# Optional Nostr-mediated overlay endpoint rendezvous.
|
||||
# nostr:
|
||||
# enabled: true
|
||||
# policy: configured_only # disabled | configured_only | open
|
||||
# open_discovery_max_pending: 64 # caps queued open-discovery retries
|
||||
# open_discovery_max_pending: 64 # caps queued open-rendezvous retries
|
||||
# app: "fips-overlay-v1"
|
||||
# advertise: true
|
||||
# advert_relays:
|
||||
@@ -74,23 +83,49 @@ transports:
|
||||
ethernet:
|
||||
wan:
|
||||
interface: "eth0"
|
||||
discovery: true
|
||||
listen: true
|
||||
announce: true
|
||||
auto_connect: true
|
||||
accept_connections: true
|
||||
wwan:
|
||||
interface: "phy0-sta0"
|
||||
discovery: true
|
||||
listen: true
|
||||
announce: true
|
||||
auto_connect: true
|
||||
accept_connections: true
|
||||
lan:
|
||||
interface: "br-lan"
|
||||
discovery: true
|
||||
listen: true
|
||||
announce: true
|
||||
auto_connect: true
|
||||
accept_connections: true
|
||||
|
||||
# 802.11s mesh backhaul between FIPS routers. These entries ship
|
||||
# commented out so a stock install that never creates fips-mesh*
|
||||
# logs no per-boot "interface missing" bind warning. Running
|
||||
# 'fips-mesh-setup <radio>' creates the interface AND uncomments the
|
||||
# matching block here (once per radio; radio0 -> fips-mesh0, radio1 ->
|
||||
# fips-mesh1); 'fips-mesh-setup remove' re-comments it. Restart fips
|
||||
# after — a transport whose interface is missing at startup is skipped,
|
||||
# not retried. Dual-band routers can mesh on both bands at once —
|
||||
# failover, not multipath: FIPS keeps one active link per peer, the
|
||||
# other band stands by. The mesh runs OPEN (no SAE) with 802.11s
|
||||
# forwarding off: FIPS's Noise handshake is the encryption and
|
||||
# authentication, and FIPS is the routing layer. See
|
||||
# docs/how-to/set-up-80211s-mesh-backhaul.md.
|
||||
# mesh0:
|
||||
# interface: "fips-mesh0"
|
||||
# discovery: true
|
||||
# announce: true
|
||||
# auto_connect: true
|
||||
# accept_connections: true
|
||||
# mesh1:
|
||||
# interface: "fips-mesh1"
|
||||
# discovery: true
|
||||
# announce: true
|
||||
# auto_connect: true
|
||||
# accept_connections: true
|
||||
|
||||
# Bluetooth Low Energy transport — requires BlueZ and the 'ble' feature.
|
||||
# ble:
|
||||
# adapter: "hci0"
|
||||
@@ -121,5 +156,5 @@ peers: []
|
||||
# - transport: udp
|
||||
# addr: "test-us01.fips.network:2121" # IP or hostname (e.g., "peer.example.com:2121")
|
||||
# - transport: udp
|
||||
# addr: "nat" # Use node.discovery.nostr for Nostr/STUN hole punching
|
||||
# addr: "nat" # Use node.rendezvous.nostr for Nostr/STUN hole punching
|
||||
# connect_policy: auto_connect
|
||||
|
||||
259
packaging/openwrt-ipk/files/usr/bin/fips-mesh-setup
Executable file
259
packaging/openwrt-ipk/files/usr/bin/fips-mesh-setup
Executable file
@@ -0,0 +1,259 @@
|
||||
#!/bin/sh
|
||||
# fips-mesh-setup — configure open 802.11s mesh interfaces for FIPS backhaul.
|
||||
#
|
||||
# Usage:
|
||||
# fips-mesh-setup <radio> [mesh-id] e.g. fips-mesh-setup radio1
|
||||
# fips-mesh-setup remove [radio] no radio: remove all instances
|
||||
#
|
||||
# Creates a mesh-point interface on the given radio and leaves everything
|
||||
# above L2 to FIPS. Run once per radio: dual-band routers can mesh on both
|
||||
# bands at once (2.4 GHz reaches further, 5 GHz carries more). Note this is
|
||||
# failover, not multipath — FIPS keeps one active link per peer; the other
|
||||
# band stands by and reconnects the peer if the active link dies.
|
||||
#
|
||||
# - encryption 'none' — the mesh is OPEN on purpose. FIPS's Noise IK
|
||||
# handshake authenticates and encrypts every peer link, so SAE would
|
||||
# only duplicate that (and on ath10k it forces the slower raw Tx/Rx
|
||||
# firmware mode). A stranger can form an 802.11s peering but cannot
|
||||
# pass the FIPS handshake.
|
||||
# - mesh_fwding '0' — disables 802.11s HWMP forwarding so each mesh
|
||||
# link is a plain L2 neighbor link. FIPS is the routing layer; two
|
||||
# routing layers would fight.
|
||||
#
|
||||
# Interfaces are named per radio index (radio0 -> fips-mesh0, radio1 ->
|
||||
# fips-mesh1) and are intentionally NOT bridged into br-lan: the FIPS
|
||||
# Ethernet transport binds each directly and runs discovery beacons over it.
|
||||
#
|
||||
# The shipped /etc/fips/fips.yaml carries 'mesh0' and 'mesh1' entries under
|
||||
# 'transports.ethernet' bound to these names, but commented out — a stock
|
||||
# install that never creates fips-mesh* then logs no bind warning. This
|
||||
# helper uncomments the matching entry when it creates an interface and
|
||||
# re-comments it on remove, so the daemon binds the transport without a
|
||||
# manual config edit. After an interface is up, restart fips.
|
||||
# See docs/how-to/set-up-80211s-mesh-backhaul.md for the full guide.
|
||||
|
||||
DEFAULT_MESH_ID="fips-mesh"
|
||||
CONFIG="/etc/fips/fips.yaml"
|
||||
|
||||
# Replace $CONFIG with the rewritten $CONFIG.tmp. Force mode 0600 first: the
|
||||
# package installs fips.yaml 0600 (it may hold an inline 'nsec' private key),
|
||||
# and a fresh tmp file would otherwise land world-readable after the move.
|
||||
mesh_config_write() {
|
||||
chmod 600 "$CONFIG.tmp" && mv "$CONFIG.tmp" "$CONFIG"
|
||||
}
|
||||
|
||||
# Uncomment the 'mesh<idx>' transports.ethernet block in $CONFIG (created by
|
||||
# 'fips-mesh-setup'). Reversible with mesh_config_disable. Returns:
|
||||
# 0 enabled (or already active) 1 no config file 2 no such block
|
||||
mesh_config_enable() {
|
||||
idx="$1"
|
||||
[ -f "$CONFIG" ] || return 1
|
||||
grep -q "^ mesh$idx:" "$CONFIG" && return 0
|
||||
grep -q "^ # mesh$idx:" "$CONFIG" || return 2
|
||||
awk -v idx="$idx" '
|
||||
$0 ~ ("^ # mesh" idx ":[ \t]*$") { blk = 1; sub(/^ # /, " "); print; next }
|
||||
blk && /^ # / { sub(/^ # /, " "); print; next }
|
||||
{ blk = 0; print }
|
||||
' "$CONFIG" > "$CONFIG.tmp" && mesh_config_write
|
||||
}
|
||||
|
||||
# Re-comment the 'mesh<idx>' block so the daemon stops binding it (and stops
|
||||
# warning about the now-missing interface). Inverse of mesh_config_enable.
|
||||
mesh_config_disable() {
|
||||
idx="$1"
|
||||
[ -f "$CONFIG" ] || return 1
|
||||
grep -q "^ mesh$idx:" "$CONFIG" || return 0
|
||||
awk -v idx="$idx" '
|
||||
$0 ~ ("^ mesh" idx ":[ \t]*$") { blk = 1; sub(/^ /, " # "); print; next }
|
||||
blk && /^ / { sub(/^ /, " # "); print; next }
|
||||
{ blk = 0; print }
|
||||
' "$CONFIG" > "$CONFIG.tmp" && mesh_config_write
|
||||
}
|
||||
|
||||
usage() {
|
||||
echo "Usage: fips-mesh-setup <radio> [mesh-id]" >&2
|
||||
echo " fips-mesh-setup remove [radio]" >&2
|
||||
echo "Radios on this device:" >&2
|
||||
uci show wireless 2>/dev/null | sed -n "s/^wireless\.\([^.]*\)=wifi-device$/ \1/p" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# List the UCI section names of fips-managed mesh wifi-ifaces.
|
||||
mesh_sections() {
|
||||
uci show wireless 2>/dev/null | sed -n "s/^wireless\.\(fips_mesh[^.=]*\)=wifi-iface$/\1/p"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# remove [radio] — delete the wireless and network sections created below
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if [ "$1" = "remove" ]; then
|
||||
if [ -n "$2" ]; then
|
||||
SECTIONS="fips_mesh_$(printf '%s' "$2" | tr -c 'a-zA-Z0-9_' '_')"
|
||||
else
|
||||
SECTIONS="$(mesh_sections)"
|
||||
fi
|
||||
[ -n "$SECTIONS" ] || {
|
||||
echo "No fips mesh instances configured."
|
||||
exit 0
|
||||
}
|
||||
for section in $SECTIONS; do
|
||||
ifname="$(uci -q get "wireless.$section.ifname")"
|
||||
uci -q delete "wireless.$section"
|
||||
uci -q delete "network.$section"
|
||||
# Re-comment the matching mesh<N> transport in fips.yaml so the
|
||||
# daemon stops warning about the interface we just removed.
|
||||
idx="$(printf '%s' "$ifname" | sed -n 's/.*[^0-9]\([0-9]\{1,\}\)$/\1/p')"
|
||||
[ -n "$idx" ] && mesh_config_disable "$idx"
|
||||
echo "Removed ${ifname:-$section}."
|
||||
done
|
||||
uci commit wireless
|
||||
uci commit network
|
||||
# 'wifi reload' re-applies the whole wireless config, so it briefly drops
|
||||
# every client AP on all radios (a few seconds) — expected on remove.
|
||||
wifi reload
|
||||
echo "Restart fips: /etc/init.d/fips restart"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
RADIO="$1"
|
||||
MESH_ID="${2:-$DEFAULT_MESH_ID}"
|
||||
|
||||
[ -n "$RADIO" ] || usage
|
||||
|
||||
if [ "$(uci -q get "wireless.$RADIO")" != "wifi-device" ]; then
|
||||
echo "Error: '$RADIO' is not a wifi-device in /etc/config/wireless." >&2
|
||||
usage
|
||||
fi
|
||||
|
||||
# One instance per radio: section fips_mesh_<radio>, netdev fips-mesh<N>
|
||||
# where N is the radio's trailing index (radio0 -> fips-mesh0). For radios
|
||||
# named without a trailing number, fall back to the first free index.
|
||||
SECTION="fips_mesh_$(printf '%s' "$RADIO" | tr -c 'a-zA-Z0-9_' '_')"
|
||||
IDX="$(printf '%s' "$RADIO" | sed -n 's/.*[^0-9]\([0-9]\{1,\}\)$/\1/p')"
|
||||
[ -n "$IDX" ] || IDX="$(printf '%s' "$RADIO" | sed -n 's/^\([0-9]\{1,\}\)$/\1/p')"
|
||||
if [ -z "$IDX" ]; then
|
||||
IDX=0
|
||||
while uci show wireless 2>/dev/null | grep -q "\.ifname='fips-mesh$IDX'"; do
|
||||
IDX=$((IDX + 1))
|
||||
done
|
||||
fi
|
||||
MESH_IFNAME="fips-mesh$IDX"
|
||||
|
||||
# Refuse a name collision from another radio's instance (e.g. two radios
|
||||
# whose names end in the same digit) rather than silently hijacking it.
|
||||
OWNER="$(uci show wireless 2>/dev/null \
|
||||
| sed -n "s/^wireless\.\(fips_mesh[^.=]*\)\.ifname='$MESH_IFNAME'$/\1/p")"
|
||||
if [ -n "$OWNER" ] && [ "$OWNER" != "$SECTION" ]; then
|
||||
echo "Error: $MESH_IFNAME is already used by section '$OWNER'." >&2
|
||||
echo "Remove it first: fips-mesh-setup remove" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Driver capability check (advisory — config below is harmless either way)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if command -v iw >/dev/null 2>&1; then
|
||||
if ! iw list 2>/dev/null | grep -q "\* mesh point"; then
|
||||
echo "Warning: no radio on this device advertises 'mesh point' support" >&2
|
||||
echo "(iw list | grep 'mesh point'). The interface may fail to come up." >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wireless: open 802.11s mesh point, HWMP forwarding off
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
uci -q delete "wireless.$SECTION"
|
||||
uci set "wireless.$SECTION=wifi-iface"
|
||||
uci set "wireless.$SECTION.device=$RADIO"
|
||||
uci set "wireless.$SECTION.mode=mesh"
|
||||
uci set "wireless.$SECTION.mesh_id=$MESH_ID"
|
||||
uci set "wireless.$SECTION.encryption=none"
|
||||
uci set "wireless.$SECTION.mesh_fwding=0"
|
||||
uci set "wireless.$SECTION.ifname=$MESH_IFNAME"
|
||||
uci set "wireless.$SECTION.network=$SECTION"
|
||||
|
||||
# Radios ship disabled on fresh OpenWrt installs; a disabled radio would
|
||||
# leave the mesh interface down with no error anywhere visible.
|
||||
if [ "$(uci -q get "wireless.$RADIO.disabled")" = "1" ]; then
|
||||
echo "Note: enabling $RADIO (was disabled)."
|
||||
uci -q delete "wireless.$RADIO.disabled"
|
||||
fi
|
||||
|
||||
# The mesh inherits the radio's channel, and mesh points only peer on the
|
||||
# same channel. 'auto' lets each router pick its own — the classic silent
|
||||
# non-peering cause — so surface the setting loudly.
|
||||
CHANNEL="$(uci -q get "wireless.$RADIO.channel")"
|
||||
BAND="$(uci -q get "wireless.$RADIO.band")"
|
||||
if [ -z "$CHANNEL" ] || [ "$CHANNEL" = "auto" ]; then
|
||||
echo "Warning: $RADIO channel is '${CHANNEL:-unset}' — each router may" >&2
|
||||
echo "auto-select a different channel and mesh points only peer on the" >&2
|
||||
echo "same one. Pin the same channel on every backhaul router, e.g.:" >&2
|
||||
echo " uci set wireless.$RADIO.channel='36' && uci commit wireless && wifi reload" >&2
|
||||
fi
|
||||
|
||||
# A client (sta) interface on the same radio follows its upstream AP's
|
||||
# channel and drags every other interface with it — a mesh pinned to a
|
||||
# different channel silently never joins, and does not recover when the
|
||||
# STA disconnects.
|
||||
for s in $(uci show wireless 2>/dev/null | sed -n "s/^wireless\.\([^.]*\)\.mode='sta'$/\1/p"); do
|
||||
if [ "$(uci -q get "wireless.$s.device")" = "$RADIO" ]; then
|
||||
echo "Warning: $RADIO also carries client interface '$s' (mode 'sta')." >&2
|
||||
echo "The whole radio follows that STA's upstream channel — a mesh" >&2
|
||||
echo "pinned to a different channel stays down silently. Align the" >&2
|
||||
echo "mesh channel with the upstream AP, or put the mesh on a radio" >&2
|
||||
echo "without a STA (a roaming uplink is incompatible with a" >&2
|
||||
echo "fixed-channel mesh on the same radio)." >&2
|
||||
fi
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Network: unmanaged interface so netifd brings the netdev up. No IP config —
|
||||
# the FIPS Ethernet transport speaks raw frames on it.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
uci -q delete "network.$SECTION"
|
||||
uci set "network.$SECTION=interface"
|
||||
uci set "network.$SECTION.proto=none"
|
||||
|
||||
uci commit wireless
|
||||
uci commit network
|
||||
# 'wifi reload' re-applies the whole wireless config, so it briefly drops
|
||||
# every client AP on all radios (a few seconds) — expected when adding a mesh.
|
||||
wifi reload
|
||||
|
||||
# Enable the matching mesh<N> transport in the shipped fips.yaml (it ships
|
||||
# commented out). Tailor the restart hint to what we could do.
|
||||
mesh_config_enable "$IDX"
|
||||
case $? in
|
||||
0) TRANSPORT_NOTE="The mesh$IDX transport in $CONFIG that binds '$MESH_IFNAME' is
|
||||
now uncommented and enabled." ;;
|
||||
1) TRANSPORT_NOTE="No $CONFIG found — add a transports.ethernet entry binding
|
||||
interface '$MESH_IFNAME' by hand." ;;
|
||||
*) TRANSPORT_NOTE="No 'mesh$IDX' entry in $CONFIG — add a transports.ethernet
|
||||
entry binding interface '$MESH_IFNAME' by hand (copy the mesh0 block)." ;;
|
||||
esac
|
||||
|
||||
cat <<EOF
|
||||
Created open 802.11s mesh '$MESH_ID' as $MESH_IFNAME on $RADIO \
|
||||
(band ${BAND:-?}, channel ${CHANNEL:-auto}).
|
||||
|
||||
ALL routers in this backhaul must share this mesh ID AND channel
|
||||
(per band). On a dual-band router, run fips-mesh-setup for the other
|
||||
radio too — second band is a standby path (failover, not multipath).
|
||||
|
||||
Next steps:
|
||||
1. $TRANSPORT_NOTE
|
||||
Restart the daemon AFTER the interface is up — a transport whose
|
||||
interface is missing at startup is skipped, not retried:
|
||||
/etc/init.d/fips restart
|
||||
2. Verify L2 peering with a second FIPS router in range:
|
||||
iw dev $MESH_IFNAME station dump
|
||||
and the FIPS link on top of it:
|
||||
fipsctl show peers
|
||||
|
||||
Run 'fips-mesh-setup remove' to undo all instances, or
|
||||
'fips-mesh-setup remove $RADIO' for just this one.
|
||||
EOF
|
||||
@@ -68,7 +68,7 @@ and set the interface name:
|
||||
transports:
|
||||
ethernet:
|
||||
interface: "eth0"
|
||||
discovery: true
|
||||
listen: true
|
||||
announce: true
|
||||
auto_connect: true
|
||||
accept_connections: true
|
||||
|
||||
@@ -8,7 +8,7 @@ use fips::config::{IdentitySource, resolve_identity};
|
||||
use fips::version;
|
||||
use fips::{Config, Node};
|
||||
use std::path::PathBuf;
|
||||
use tracing::{debug, error, info, warn};
|
||||
use tracing::{debug, error, info};
|
||||
use tracing_subscriber::{EnvFilter, fmt};
|
||||
|
||||
/// FIPS mesh network daemon
|
||||
@@ -157,26 +157,23 @@ async fn run_daemon(
|
||||
|
||||
info!("FIPS running");
|
||||
|
||||
// Run the RX event loop until shutdown signal.
|
||||
// stop() drops the packet channel, causing run_rx_loop to exit.
|
||||
tokio::select! {
|
||||
result = node.run_rx_loop() => {
|
||||
match result {
|
||||
Ok(()) => info!("RX loop exited"),
|
||||
Err(e) => error!("RX loop error: {}", e),
|
||||
}
|
||||
}
|
||||
_ = shutdown_signal => {
|
||||
info!("Shutdown signal received");
|
||||
}
|
||||
// Serve until the shutdown signal, then drain in place before returning.
|
||||
// The rx loop observes the signal directly, so its channels are never
|
||||
// destructively cancelled — they live in the loop's locals across serve and
|
||||
// drain, and are dropped only on clean exit (after which teardown does not
|
||||
// need them). On the signal the loop broadcasts a shutdown Disconnect and
|
||||
// waits (bounded by node.drain_timeout_secs) for peers to clear.
|
||||
match node.run_rx_loop_with_shutdown(shutdown_signal).await {
|
||||
Ok(()) => info!("RX loop exited"),
|
||||
Err(e) => error!("RX loop error: {}", e),
|
||||
}
|
||||
|
||||
info!("FIPS shutting down");
|
||||
|
||||
// Stop the node (shuts down transports, TUN, I/O threads)
|
||||
if let Err(e) = node.stop().await {
|
||||
warn!("Error during shutdown: {}", e);
|
||||
}
|
||||
// Close the drain window (if the loop drained) and tear down. A drained
|
||||
// loop tears down without re-broadcasting; a loop that exited some other
|
||||
// way falls back to the immediate stop().
|
||||
node.finish_shutdown().await;
|
||||
|
||||
info!("FIPS shutdown complete");
|
||||
}
|
||||
|
||||
@@ -134,8 +134,8 @@ fn draw_routing_stats(
|
||||
let cols =
|
||||
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).split(inner);
|
||||
|
||||
// Shorthand for a nested counter value (e.g. discovery.req_received).
|
||||
let disc = |key: &str| helpers::nested_u64(data, "discovery", key);
|
||||
// Shorthand for a nested counter value (e.g. lookup.req_received).
|
||||
let lookup = |key: &str| helpers::nested_u64(data, "lookup", key);
|
||||
let err = |key: &str| helpers::nested_u64(data, "error_signals", key);
|
||||
let cong = |key: &str| helpers::nested_u64(data, "congestion", key);
|
||||
|
||||
@@ -174,32 +174,32 @@ fn draw_routing_stats(
|
||||
));
|
||||
left.push(Line::from(""));
|
||||
left.extend(section(
|
||||
"Discovery Requests",
|
||||
"Lookup Requests",
|
||||
&[
|
||||
("Received", disc("req_received")),
|
||||
("Forwarded", disc("req_forwarded")),
|
||||
("Initiated", disc("req_initiated")),
|
||||
("Deduplicated", disc("req_deduplicated")),
|
||||
("Target Is Us", disc("req_target_is_us")),
|
||||
("Duplicate", disc("req_duplicate")),
|
||||
("Bloom Miss", disc("req_bloom_miss")),
|
||||
("Backoff Suppressed", disc("req_backoff_suppressed")),
|
||||
("Fwd Rate Limited", disc("req_forward_rate_limited")),
|
||||
("TTL Exhausted", disc("req_ttl_exhausted")),
|
||||
("Decode Error", disc("req_decode_error")),
|
||||
("Received", lookup("req_received")),
|
||||
("Forwarded", lookup("req_forwarded")),
|
||||
("Initiated", lookup("req_initiated")),
|
||||
("Deduplicated", lookup("req_deduplicated")),
|
||||
("Target Is Us", lookup("req_target_is_us")),
|
||||
("Duplicate", lookup("req_duplicate")),
|
||||
("Bloom Miss", lookup("req_bloom_miss")),
|
||||
("Backoff Suppressed", lookup("req_backoff_suppressed")),
|
||||
("Fwd Rate Limited", lookup("req_forward_rate_limited")),
|
||||
("TTL Exhausted", lookup("req_ttl_exhausted")),
|
||||
("Decode Error", lookup("req_decode_error")),
|
||||
],
|
||||
));
|
||||
left.push(Line::from(""));
|
||||
left.extend(section(
|
||||
"Discovery Responses",
|
||||
"Lookup Responses",
|
||||
&[
|
||||
("Received", disc("resp_received")),
|
||||
("Accepted", disc("resp_accepted")),
|
||||
("Forwarded", disc("resp_forwarded")),
|
||||
("Timed Out", disc("resp_timed_out")),
|
||||
("Identity Miss", disc("resp_identity_miss")),
|
||||
("Proof Failed", disc("resp_proof_failed")),
|
||||
("Decode Error", disc("resp_decode_error")),
|
||||
("Received", lookup("resp_received")),
|
||||
("Accepted", lookup("resp_accepted")),
|
||||
("Forwarded", lookup("resp_forwarded")),
|
||||
("Timed Out", lookup("resp_timed_out")),
|
||||
("Identity Miss", lookup("resp_identity_miss")),
|
||||
("Proof Failed", lookup("resp_proof_failed")),
|
||||
("Decode Error", lookup("resp_decode_error")),
|
||||
],
|
||||
));
|
||||
|
||||
|
||||
@@ -174,10 +174,6 @@ fn draw_stats(frame: &mut Frame, data: &serde_json::Value, scroll: u16, focused:
|
||||
&helpers::nested_u64(data, "stats", "sig_failed"),
|
||||
),
|
||||
helpers::kv_line("Stale", &helpers::nested_u64(data, "stats", "stale")),
|
||||
helpers::kv_line(
|
||||
"Parent Switched",
|
||||
&helpers::nested_u64(data, "stats", "parent_switched"),
|
||||
),
|
||||
helpers::kv_line(
|
||||
"Loop Detected",
|
||||
&helpers::nested_u64(data, "stats", "loop_detected"),
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
//! Bloom Filter Implementation
|
||||
//!
|
||||
//! 1KB Bloom filters for reachability in FIPS routing. Each node
|
||||
//! maintains filters that summarize which destinations are reachable
|
||||
//! through each peer, enabling efficient routing decisions without
|
||||
//! global network knowledge.
|
||||
//!
|
||||
//! ## v1 Parameters
|
||||
//!
|
||||
//! - Size: 1 KB (8,192 bits) - sized for actual ~400-800 entry occupancy
|
||||
//! - Hash functions: k=5 - optimal at ~1,200 entries, good for 800-1,600
|
||||
//! - Bandwidth: 1 KB/announce (75% reduction from original 4KB design)
|
||||
//!
|
||||
//! These parameters are right-sized for typical network occupancy of
|
||||
//! ~250-800 entries per node.
|
||||
|
||||
mod filter;
|
||||
mod state;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
pub use filter::BloomFilter;
|
||||
pub use state::BloomState;
|
||||
|
||||
/// Default filter size in bits (1KB = 8,192 bits).
|
||||
///
|
||||
/// Sized for ~800-1,600 entries. FPR ~0.05% at 400 entries, ~0.9% at 800.
|
||||
/// This is v1 protocol default (size_class=1).
|
||||
pub const DEFAULT_FILTER_SIZE_BITS: usize = 8192;
|
||||
|
||||
/// Default filter size in bytes (1KB).
|
||||
pub const DEFAULT_FILTER_SIZE_BYTES: usize = DEFAULT_FILTER_SIZE_BITS / 8;
|
||||
|
||||
/// Default number of hash functions.
|
||||
///
|
||||
/// k=5 is optimal at ~1,200 entries and a good compromise for 800-1,600.
|
||||
/// At 400 entries: FPR ~0.05%. At 800 entries: FPR ~0.9%.
|
||||
pub const DEFAULT_HASH_COUNT: u8 = 5;
|
||||
|
||||
/// Size class for v1 protocol (1 KB filters).
|
||||
pub const V1_SIZE_CLASS: u8 = 1;
|
||||
|
||||
/// Filter sizes by size_class: bytes = 512 << size_class
|
||||
pub const SIZE_CLASS_BYTES: [usize; 4] = [512, 1024, 2048, 4096];
|
||||
|
||||
/// Errors related to Bloom filter operations.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum BloomError {
|
||||
#[error("invalid filter size: expected {expected} bits, got {got}")]
|
||||
InvalidSize { expected: usize, got: usize },
|
||||
|
||||
#[error("filter size must be a multiple of 8, got {0}")]
|
||||
SizeNotByteAligned(usize),
|
||||
|
||||
#[error("hash count must be positive")]
|
||||
ZeroHashCount,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
2
src/cache/coord_cache.rs
vendored
2
src/cache/coord_cache.rs
vendored
@@ -9,7 +9,7 @@ use std::collections::HashMap;
|
||||
use super::CacheStats;
|
||||
use super::entry::CacheEntry;
|
||||
use crate::NodeAddr;
|
||||
use crate::tree::TreeCoordinate;
|
||||
use crate::proto::stp::TreeCoordinate;
|
||||
|
||||
/// Default maximum entries in coordinate cache.
|
||||
pub const DEFAULT_COORD_CACHE_SIZE: usize = 50_000;
|
||||
|
||||
2
src/cache/entry.rs
vendored
2
src/cache/entry.rs
vendored
@@ -1,6 +1,6 @@
|
||||
//! Cache entry with TTL and LRU tracking.
|
||||
|
||||
use crate::tree::TreeCoordinate;
|
||||
use crate::proto::stp::TreeCoordinate;
|
||||
|
||||
/// A cached coordinate entry.
|
||||
#[derive(Clone, Debug)]
|
||||
|
||||
@@ -33,9 +33,9 @@ use thiserror::Error;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use gateway::{ConntrackConfig, GatewayConfig, GatewayDnsConfig, PortForward, Proto};
|
||||
pub use node::{
|
||||
BloomConfig, BuffersConfig, CacheConfig, ControlConfig, DiscoveryConfig, LimitsConfig,
|
||||
NodeConfig, NostrDiscoveryConfig, NostrDiscoveryPolicy, RateLimitConfig, RekeyConfig,
|
||||
RetryConfig, SessionConfig, SessionMmpConfig, TreeConfig,
|
||||
BloomConfig, BuffersConfig, CacheConfig, ControlConfig, LimitsConfig, LookupConfig, MmpConfig,
|
||||
NodeConfig, NostrRendezvousConfig, NostrRendezvousPolicy, RateLimitConfig, RekeyConfig,
|
||||
RendezvousConfig, RetryConfig, SessionConfig, SessionMmpConfig, TreeConfig,
|
||||
};
|
||||
pub use peer::{ConnectPolicy, PeerAddress, PeerConfig};
|
||||
pub use transport::{
|
||||
@@ -489,10 +489,59 @@ impl Config {
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
serde_yaml::from_str(&contents).map_err(|e| ConfigError::ParseYaml {
|
||||
path: path.to_path_buf(),
|
||||
source: e,
|
||||
})
|
||||
let mut config: Config =
|
||||
serde_yaml::from_str(&contents).map_err(|e| ConfigError::ParseYaml {
|
||||
path: path.to_path_buf(),
|
||||
source: e,
|
||||
})?;
|
||||
config.normalize_deprecated_keys();
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// COMPAT (drop at the v2 cutover): fold a deprecated `node.discovery:`
|
||||
/// block into the `node.lookup.*` (mesh-lookup scalars) and
|
||||
/// `node.rendezvous.*` (nostr/LAN peer rendezvous) tables that replaced it.
|
||||
///
|
||||
/// Runs at every deserialize boundary (see `load_file`). A present legacy
|
||||
/// field fills the corresponding new-table field, so a config that predates
|
||||
/// the split keeps behaving identically. When a legacy block is seen, a
|
||||
/// one-time deprecation warning names the old→new key moves. Exposed to the
|
||||
/// crate so config tests that deserialize directly can invoke it.
|
||||
pub(crate) fn normalize_deprecated_keys(&mut self) {
|
||||
let Some(compat) = self.node.discovery.take() else {
|
||||
return;
|
||||
};
|
||||
tracing::warn!(
|
||||
target: "fips::config",
|
||||
"`node.discovery.*` is deprecated and will be removed: mesh-lookup \
|
||||
scalars moved to `node.lookup.*`, and peer-rendezvous keys moved to \
|
||||
`node.rendezvous.nostr.*` / `node.rendezvous.lan.*`. Please migrate; \
|
||||
a legacy `node.discovery` block still applies for now."
|
||||
);
|
||||
if let Some(v) = compat.ttl {
|
||||
self.node.lookup.ttl = v;
|
||||
}
|
||||
if let Some(v) = compat.attempt_timeouts_secs {
|
||||
self.node.lookup.attempt_timeouts_secs = v;
|
||||
}
|
||||
if let Some(v) = compat.recent_expiry_secs {
|
||||
self.node.lookup.recent_expiry_secs = v;
|
||||
}
|
||||
if let Some(v) = compat.backoff_base_secs {
|
||||
self.node.lookup.backoff_base_secs = v;
|
||||
}
|
||||
if let Some(v) = compat.backoff_max_secs {
|
||||
self.node.lookup.backoff_max_secs = v;
|
||||
}
|
||||
if let Some(v) = compat.forward_min_interval_secs {
|
||||
self.node.lookup.forward_min_interval_secs = v;
|
||||
}
|
||||
if let Some(v) = compat.nostr {
|
||||
self.node.rendezvous.nostr = v;
|
||||
}
|
||||
if let Some(v) = compat.lan {
|
||||
self.node.rendezvous.lan = v;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the standard search paths in priority order (lowest to highest).
|
||||
@@ -600,7 +649,7 @@ impl Config {
|
||||
|
||||
/// Validate cross-field configuration invariants.
|
||||
pub fn validate(&self) -> Result<(), ConfigError> {
|
||||
let nostr = &self.node.discovery.nostr;
|
||||
let nostr = &self.node.rendezvous.nostr;
|
||||
|
||||
let any_transport_advertises_on_nostr = self
|
||||
.transports
|
||||
@@ -620,13 +669,13 @@ impl Config {
|
||||
|
||||
if any_transport_advertises_on_nostr && !nostr.enabled {
|
||||
return Err(ConfigError::Validation(
|
||||
"at least one transport has `advertise_on_nostr = true`, but `node.discovery.nostr.enabled` is false".to_string(),
|
||||
"at least one transport has `advertise_on_nostr = true`, but `node.rendezvous.nostr.enabled` is false".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if self.peers.iter().any(|peer| peer.via_nostr) && !nostr.enabled {
|
||||
return Err(ConfigError::Validation(
|
||||
"at least one peer has `via_nostr = true`, but `node.discovery.nostr.enabled` is false".to_string(),
|
||||
"at least one peer has `via_nostr = true`, but `node.rendezvous.nostr.enabled` is false".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -648,12 +697,12 @@ impl Config {
|
||||
if nostr.enabled && has_nat_udp_advert {
|
||||
if nostr.dm_relays.is_empty() {
|
||||
return Err(ConfigError::Validation(
|
||||
"NAT UDP advert publishing requires `node.discovery.nostr.dm_relays` to be non-empty".to_string(),
|
||||
"NAT UDP advert publishing requires `node.rendezvous.nostr.dm_relays` to be non-empty".to_string(),
|
||||
));
|
||||
}
|
||||
if nostr.stun_servers.is_empty() {
|
||||
return Err(ConfigError::Validation(
|
||||
"NAT UDP advert publishing requires `node.discovery.nostr.stun_servers` to be non-empty".to_string(),
|
||||
"NAT UDP advert publishing requires `node.rendezvous.nostr.stun_servers` to be non-empty".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -721,6 +770,73 @@ node:
|
||||
assert!(config.has_identity());
|
||||
}
|
||||
|
||||
/// The fips.yaml shipped in the OpenWrt package must keep parsing as the
|
||||
/// config schema evolves. The 802.11s mesh backhaul entries (one per
|
||||
/// radio, so dual-band routers can mesh on both bands) ship commented
|
||||
/// out — a stock install that never creates fips-mesh* logs no per-boot
|
||||
/// bind warning; `fips-mesh-setup` uncomments the matching block when it
|
||||
/// creates the interface (docs/how-to/set-up-80211s-mesh-backhaul.md).
|
||||
/// Verify both states parse: as shipped (mesh inactive), and after the
|
||||
/// uncomment `fips-mesh-setup` performs.
|
||||
#[test]
|
||||
fn shipped_openwrt_config_parses() {
|
||||
let yaml = include_str!("../../packaging/openwrt-ipk/files/etc/fips/fips.yaml");
|
||||
|
||||
// As shipped: parses, and the mesh entries are commented out (a
|
||||
// running daemon binds no fips-mesh* transport, so no bind warning).
|
||||
let config: Config = serde_yaml::from_str(yaml).expect("shipped OpenWrt fips.yaml");
|
||||
for name in ["mesh0", "mesh1"] {
|
||||
assert!(
|
||||
!config
|
||||
.transports
|
||||
.ethernet
|
||||
.iter()
|
||||
.any(|(n, _)| n == Some(name)),
|
||||
"{name} must ship commented out, not active, in fips.yaml"
|
||||
);
|
||||
}
|
||||
|
||||
// What `fips-mesh-setup` produces: uncomment each mesh block, which
|
||||
// must still parse into a transport entry bound to the right netdev.
|
||||
let config: Config = serde_yaml::from_str(&uncomment_mesh_blocks(yaml))
|
||||
.expect("fips.yaml with mesh transports uncommented");
|
||||
for (name, interface) in [("mesh0", "fips-mesh0"), ("mesh1", "fips-mesh1")] {
|
||||
assert!(
|
||||
config
|
||||
.transports
|
||||
.ethernet
|
||||
.iter()
|
||||
.any(|(n, eth)| n == Some(name) && eth.interface == interface),
|
||||
"{name} backhaul entry missing after uncommenting shipped fips.yaml"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirror `fips-mesh-setup`'s block uncomment: strip the ` # ` prefix
|
||||
/// from each `# mesh<N>:` header and its ` # ` continuation lines,
|
||||
/// leaving every other comment untouched.
|
||||
fn uncomment_mesh_blocks(yaml: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut in_block = false;
|
||||
for line in yaml.lines() {
|
||||
let is_header = line
|
||||
.strip_prefix(" # mesh")
|
||||
.and_then(|r| r.strip_suffix(':'))
|
||||
.is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()));
|
||||
if is_header {
|
||||
in_block = true;
|
||||
out.push_str(&line.replacen(" # ", " ", 1));
|
||||
} else if in_block && line.starts_with(" # ") {
|
||||
out.push_str(&line.replacen(" # ", " ", 1));
|
||||
} else {
|
||||
in_block = false;
|
||||
out.push_str(line);
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_yaml_with_hex() {
|
||||
let yaml = r#"
|
||||
@@ -1261,7 +1377,9 @@ peers:
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_nostr_discovery_config() {
|
||||
fn test_parse_legacy_discovery_nostr_config_compat() {
|
||||
// COMPAT (drop at the v2 cutover): a deprecated `node.discovery.nostr`
|
||||
// block must fold into `node.rendezvous.nostr` via normalize.
|
||||
let yaml = r#"
|
||||
node:
|
||||
discovery:
|
||||
@@ -1285,26 +1403,27 @@ peers:
|
||||
- transport: udp
|
||||
addr: "nat"
|
||||
"#;
|
||||
let config: Config = serde_yaml::from_str(yaml).unwrap();
|
||||
assert!(config.node.discovery.nostr.enabled);
|
||||
assert!(!config.node.discovery.nostr.advertise);
|
||||
assert_eq!(config.node.discovery.nostr.app, "fips.nat.test.v1");
|
||||
assert_eq!(config.node.discovery.nostr.signal_ttl_secs, 45);
|
||||
let mut config: Config = serde_yaml::from_str(yaml).unwrap();
|
||||
config.normalize_deprecated_keys();
|
||||
assert!(config.node.rendezvous.nostr.enabled);
|
||||
assert!(!config.node.rendezvous.nostr.advertise);
|
||||
assert_eq!(config.node.rendezvous.nostr.app, "fips.nat.test.v1");
|
||||
assert_eq!(config.node.rendezvous.nostr.signal_ttl_secs, 45);
|
||||
assert_eq!(
|
||||
config.node.discovery.nostr.policy,
|
||||
NostrDiscoveryPolicy::ConfiguredOnly
|
||||
config.node.rendezvous.nostr.policy,
|
||||
NostrRendezvousPolicy::ConfiguredOnly
|
||||
);
|
||||
assert_eq!(config.node.discovery.nostr.open_discovery_max_pending, 12);
|
||||
assert_eq!(config.node.rendezvous.nostr.open_discovery_max_pending, 12);
|
||||
assert_eq!(
|
||||
config.node.discovery.nostr.advert_relays,
|
||||
config.node.rendezvous.nostr.advert_relays,
|
||||
vec!["wss://relay-a.example".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
config.node.discovery.nostr.dm_relays,
|
||||
config.node.rendezvous.nostr.dm_relays,
|
||||
vec!["wss://relay-b.example".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
config.node.discovery.nostr.stun_servers,
|
||||
config.node.rendezvous.nostr.stun_servers,
|
||||
vec!["stun:stun.example.org:3478".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -1314,6 +1433,55 @@ peers:
|
||||
assert!(config.peers[0].via_nostr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_lookup_and_rendezvous_new_keys() {
|
||||
// The post-split keys parse directly, with no deprecated block and no
|
||||
// normalize warning.
|
||||
let yaml = r#"
|
||||
node:
|
||||
lookup:
|
||||
ttl: 7
|
||||
attempt_timeouts_secs: [3, 6]
|
||||
forward_min_interval_secs: 9
|
||||
rendezvous:
|
||||
nostr:
|
||||
enabled: true
|
||||
app: "fips.new.keys.v1"
|
||||
"#;
|
||||
let mut config: Config = serde_yaml::from_str(yaml).unwrap();
|
||||
config.normalize_deprecated_keys();
|
||||
assert_eq!(config.node.lookup.ttl, 7);
|
||||
assert_eq!(config.node.lookup.attempt_timeouts_secs, vec![3, 6]);
|
||||
assert_eq!(config.node.lookup.forward_min_interval_secs, 9);
|
||||
// Unset scalar keeps its default.
|
||||
assert_eq!(config.node.lookup.recent_expiry_secs, 10);
|
||||
assert!(config.node.rendezvous.nostr.enabled);
|
||||
assert_eq!(config.node.rendezvous.nostr.app, "fips.new.keys.v1");
|
||||
assert!(config.node.discovery.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_legacy_discovery_lookup_scalars_compat() {
|
||||
// COMPAT (drop at the v2 cutover): legacy `node.discovery` mesh-lookup
|
||||
// scalars must fold into `node.lookup`; unset keys keep their defaults.
|
||||
let yaml = r#"
|
||||
node:
|
||||
discovery:
|
||||
ttl: 5
|
||||
backoff_base_secs: 4
|
||||
backoff_max_secs: 30
|
||||
"#;
|
||||
let mut config: Config = serde_yaml::from_str(yaml).unwrap();
|
||||
config.normalize_deprecated_keys();
|
||||
assert_eq!(config.node.lookup.ttl, 5);
|
||||
assert_eq!(config.node.lookup.backoff_base_secs, 4);
|
||||
assert_eq!(config.node.lookup.backoff_max_secs, 30);
|
||||
// Unset legacy scalar leaves the new-table default intact.
|
||||
assert_eq!(config.node.lookup.attempt_timeouts_secs, vec![1, 2, 4, 8]);
|
||||
// The compat block is consumed by normalize.
|
||||
assert!(config.node.discovery.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_transport_advert_requires_nostr_enabled() {
|
||||
let mut config = Config::default();
|
||||
@@ -1321,7 +1489,7 @@ peers:
|
||||
advertise_on_nostr: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
config.node.discovery.nostr.enabled = false;
|
||||
config.node.rendezvous.nostr.enabled = false;
|
||||
|
||||
let err = config.validate().expect_err("validation should fail");
|
||||
assert!(err.to_string().contains("advertise_on_nostr"));
|
||||
@@ -1337,7 +1505,7 @@ peers:
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
config.node.discovery.nostr.enabled = false;
|
||||
config.node.rendezvous.nostr.enabled = false;
|
||||
|
||||
let err = config.validate().expect_err("validation should fail");
|
||||
assert!(err.to_string().contains("via_nostr"));
|
||||
@@ -1358,7 +1526,7 @@ peers:
|
||||
|
||||
// Empty addresses + via_nostr=true + nostr.enabled=true → ok.
|
||||
config.peers[0].via_nostr = true;
|
||||
config.node.discovery.nostr.enabled = true;
|
||||
config.node.rendezvous.nostr.enabled = true;
|
||||
config
|
||||
.validate()
|
||||
.expect("via_nostr should allow empty addresses");
|
||||
@@ -1367,8 +1535,8 @@ peers:
|
||||
#[test]
|
||||
fn test_validate_nat_udp_advert_requires_relays_and_stun() {
|
||||
let mut config = Config::default();
|
||||
config.node.discovery.nostr.enabled = true;
|
||||
config.node.discovery.nostr.dm_relays.clear();
|
||||
config.node.rendezvous.nostr.enabled = true;
|
||||
config.node.rendezvous.nostr.dm_relays.clear();
|
||||
config.transports.udp = TransportInstances::Single(UdpConfig {
|
||||
advertise_on_nostr: Some(true),
|
||||
public: Some(false),
|
||||
@@ -1378,8 +1546,8 @@ peers:
|
||||
let err = config.validate().expect_err("validation should fail");
|
||||
assert!(err.to_string().contains("dm_relays"));
|
||||
|
||||
config.node.discovery.nostr.dm_relays = vec!["wss://relay.example".to_string()];
|
||||
config.node.discovery.nostr.stun_servers.clear();
|
||||
config.node.rendezvous.nostr.dm_relays = vec!["wss://relay.example".to_string()];
|
||||
config.node.rendezvous.nostr.stun_servers.clear();
|
||||
let err = config.validate().expect_err("validation should fail");
|
||||
assert!(err.to_string().contains("stun_servers"));
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::IdentityConfig;
|
||||
use crate::mmp::{DEFAULT_LOG_INTERVAL_SECS, DEFAULT_OWD_WINDOW_SIZE, MmpConfig, MmpMode};
|
||||
use crate::proto::mmp::{DEFAULT_LOG_INTERVAL_SECS, DEFAULT_OWD_WINDOW_SIZE, MmpMode};
|
||||
|
||||
// ============================================================================
|
||||
// Node Configuration Subsections
|
||||
@@ -186,48 +186,42 @@ impl CacheConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Discovery protocol (`node.discovery.*`).
|
||||
/// Mesh-lookup protocol (`node.lookup.*`): the overlay coordinate-lookup
|
||||
/// engine (address → coordinates). The peer-rendezvous keys that used to
|
||||
/// share this table (`nostr`/`lan`) now live under [`RendezvousConfig`]
|
||||
/// (`node.rendezvous.*`).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DiscoveryConfig {
|
||||
/// Hop limit for LookupRequest flood (`node.discovery.ttl`).
|
||||
#[serde(default = "DiscoveryConfig::default_ttl")]
|
||||
pub struct LookupConfig {
|
||||
/// Hop limit for LookupRequest flood (`node.lookup.ttl`).
|
||||
#[serde(default = "LookupConfig::default_ttl")]
|
||||
pub ttl: u8,
|
||||
/// Per-attempt timeouts in seconds (`node.discovery.attempt_timeouts_secs`).
|
||||
/// Per-attempt timeouts in seconds (`node.lookup.attempt_timeouts_secs`).
|
||||
/// Each entry is the time to wait for a response before sending the next
|
||||
/// LookupRequest (with a fresh request_id). Sequence length determines the
|
||||
/// total number of attempts before declaring the destination unreachable.
|
||||
/// Default `[1, 2, 4, 8]` gives 4 attempts and a 15s total budget.
|
||||
#[serde(default = "DiscoveryConfig::default_attempt_timeouts_secs")]
|
||||
#[serde(default = "LookupConfig::default_attempt_timeouts_secs")]
|
||||
pub attempt_timeouts_secs: Vec<u64>,
|
||||
/// Dedup cache expiry in seconds (`node.discovery.recent_expiry_secs`).
|
||||
#[serde(default = "DiscoveryConfig::default_recent_expiry_secs")]
|
||||
/// Dedup cache expiry in seconds (`node.lookup.recent_expiry_secs`).
|
||||
#[serde(default = "LookupConfig::default_recent_expiry_secs")]
|
||||
pub recent_expiry_secs: u64,
|
||||
/// Base backoff after lookup failure in seconds (`node.discovery.backoff_base_secs`).
|
||||
/// Base backoff after lookup failure in seconds (`node.lookup.backoff_base_secs`).
|
||||
/// Doubles per consecutive failure up to `backoff_max_secs`. Defaults to 0
|
||||
/// (no post-failure suppression); the per-attempt sequence in
|
||||
/// `attempt_timeouts_secs` provides the only retry pacing.
|
||||
#[serde(default = "DiscoveryConfig::default_backoff_base_secs")]
|
||||
#[serde(default = "LookupConfig::default_backoff_base_secs")]
|
||||
pub backoff_base_secs: u64,
|
||||
/// Maximum backoff cap in seconds (`node.discovery.backoff_max_secs`).
|
||||
#[serde(default = "DiscoveryConfig::default_backoff_max_secs")]
|
||||
/// Maximum backoff cap in seconds (`node.lookup.backoff_max_secs`).
|
||||
#[serde(default = "LookupConfig::default_backoff_max_secs")]
|
||||
pub backoff_max_secs: u64,
|
||||
/// Minimum interval between forwarded lookups for the same target in seconds
|
||||
/// (`node.discovery.forward_min_interval_secs`).
|
||||
/// (`node.lookup.forward_min_interval_secs`).
|
||||
/// Defense-in-depth against misbehaving nodes.
|
||||
#[serde(default = "DiscoveryConfig::default_forward_min_interval_secs")]
|
||||
#[serde(default = "LookupConfig::default_forward_min_interval_secs")]
|
||||
pub forward_min_interval_secs: u64,
|
||||
/// Nostr-mediated overlay endpoint discovery.
|
||||
#[serde(default = "DiscoveryConfig::default_nostr")]
|
||||
pub nostr: NostrDiscoveryConfig,
|
||||
/// mDNS / DNS-SD peer discovery on the local link. Identity surface
|
||||
/// is a strict subset of what `nostr.advertise` already publishes
|
||||
/// publicly, so there's no marginal privacy cost; the latency win
|
||||
/// for same-LAN peers is large (sub-second pairing, no relay).
|
||||
#[serde(default = "DiscoveryConfig::default_lan")]
|
||||
pub lan: crate::discovery::lan::LanDiscoveryConfig,
|
||||
}
|
||||
|
||||
impl Default for DiscoveryConfig {
|
||||
impl Default for LookupConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
ttl: 64,
|
||||
@@ -236,13 +230,11 @@ impl Default for DiscoveryConfig {
|
||||
backoff_base_secs: 0,
|
||||
backoff_max_secs: 0,
|
||||
forward_min_interval_secs: 2,
|
||||
nostr: NostrDiscoveryConfig::default(),
|
||||
lan: crate::discovery::lan::LanDiscoveryConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DiscoveryConfig {
|
||||
impl LookupConfig {
|
||||
fn default_ttl() -> u8 {
|
||||
64
|
||||
}
|
||||
@@ -261,12 +253,45 @@ impl DiscoveryConfig {
|
||||
fn default_forward_min_interval_secs() -> u64 {
|
||||
2
|
||||
}
|
||||
fn default_nostr() -> NostrDiscoveryConfig {
|
||||
NostrDiscoveryConfig::default()
|
||||
}
|
||||
fn default_lan() -> crate::discovery::lan::LanDiscoveryConfig {
|
||||
crate::discovery::lan::LanDiscoveryConfig::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Peer rendezvous (`node.rendezvous.*`): how the node finds peers to connect
|
||||
/// to at all — Nostr-mediated overlay endpoints and mDNS/DNS-SD on the local
|
||||
/// link. Distinct from mesh lookup ([`LookupConfig`]), which finds coordinates
|
||||
/// for an already-known mesh address.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct RendezvousConfig {
|
||||
/// Nostr-mediated overlay endpoint rendezvous (`node.rendezvous.nostr.*`).
|
||||
#[serde(default)]
|
||||
pub nostr: NostrRendezvousConfig,
|
||||
/// mDNS / DNS-SD peer rendezvous on the local link (`node.rendezvous.lan.*`).
|
||||
/// Identity surface is a strict subset of what `nostr.advertise` already
|
||||
/// publishes publicly, so there's no marginal privacy cost; the latency
|
||||
/// win for same-LAN peers is large (sub-second pairing, no relay).
|
||||
#[serde(default)]
|
||||
pub lan: crate::mdns::LanRendezvousConfig,
|
||||
}
|
||||
|
||||
/// COMPAT (drop at the v2 cutover): a deprecated legacy `node.discovery:` block.
|
||||
///
|
||||
/// The `node.discovery.*` table was split into `node.lookup.*` (mesh-lookup
|
||||
/// scalars) and `node.rendezvous.*` (nostr/LAN peer rendezvous). Because
|
||||
/// `NodeConfig` does not deny unknown fields, a still-deployed `node.discovery:`
|
||||
/// block would otherwise deserialize into nothing and silently revert every
|
||||
/// lookup/rendezvous setting to its default. This all-`Option` mirror captures
|
||||
/// it so [`Config::normalize_deprecated_keys`] can fold it into the new tables
|
||||
/// with a one-time deprecation warning; unset legacy keys stay `None` and leave
|
||||
/// the new-table defaults intact.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct DiscoveryConfigCompat {
|
||||
pub ttl: Option<u8>,
|
||||
pub attempt_timeouts_secs: Option<Vec<u64>>,
|
||||
pub recent_expiry_secs: Option<u64>,
|
||||
pub backoff_base_secs: Option<u64>,
|
||||
pub backoff_max_secs: Option<u64>,
|
||||
pub forward_min_interval_secs: Option<u64>,
|
||||
pub nostr: Option<NostrRendezvousConfig>,
|
||||
pub lan: Option<crate::mdns::LanRendezvousConfig>,
|
||||
}
|
||||
|
||||
/// Nostr advert discovery policy.
|
||||
@@ -278,33 +303,33 @@ impl DiscoveryConfig {
|
||||
/// - `open`: also consider adverts for non-configured peers
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum NostrDiscoveryPolicy {
|
||||
pub enum NostrRendezvousPolicy {
|
||||
Disabled,
|
||||
#[default]
|
||||
ConfiguredOnly,
|
||||
Open,
|
||||
}
|
||||
|
||||
/// Nostr-mediated overlay endpoint discovery (`node.discovery.nostr.*`).
|
||||
/// Nostr-mediated overlay endpoint discovery (`node.rendezvous.nostr.*`).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct NostrDiscoveryConfig {
|
||||
pub struct NostrRendezvousConfig {
|
||||
/// Enable Nostr-signaled traversal bootstrap.
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
/// Publish service advertisements so remote peers can bootstrap inbound.
|
||||
#[serde(default = "NostrDiscoveryConfig::default_advertise")]
|
||||
#[serde(default = "NostrRendezvousConfig::default_advertise")]
|
||||
pub advertise: bool,
|
||||
/// Relay URLs used for service advertisements.
|
||||
#[serde(default = "NostrDiscoveryConfig::default_advert_relays")]
|
||||
#[serde(default = "NostrRendezvousConfig::default_advert_relays")]
|
||||
pub advert_relays: Vec<String>,
|
||||
/// Relay URLs used for encrypted signaling events.
|
||||
#[serde(default = "NostrDiscoveryConfig::default_dm_relays")]
|
||||
#[serde(default = "NostrRendezvousConfig::default_dm_relays")]
|
||||
pub dm_relays: Vec<String>,
|
||||
/// STUN servers used for local reflexive address discovery.
|
||||
/// Outbound observation uses only this local list; peer-advertised STUN
|
||||
/// values are informational and are not treated as egress targets.
|
||||
#[serde(default = "NostrDiscoveryConfig::default_stun_servers")]
|
||||
#[serde(default = "NostrRendezvousConfig::default_stun_servers")]
|
||||
pub stun_servers: Vec<String>,
|
||||
/// Whether to advertise local (RFC 1918 / ULA) interface addresses as
|
||||
/// host candidates in the traversal offer.
|
||||
@@ -318,85 +343,85 @@ pub struct NostrDiscoveryConfig {
|
||||
#[serde(default)]
|
||||
pub share_local_candidates: bool,
|
||||
/// Traversal application namespace and advert identifier suffix.
|
||||
#[serde(default = "NostrDiscoveryConfig::default_app")]
|
||||
#[serde(default = "NostrRendezvousConfig::default_app")]
|
||||
pub app: String,
|
||||
/// Signaling TTL in seconds.
|
||||
#[serde(default = "NostrDiscoveryConfig::default_signal_ttl_secs")]
|
||||
#[serde(default = "NostrRendezvousConfig::default_signal_ttl_secs")]
|
||||
pub signal_ttl_secs: u64,
|
||||
/// Policy for advert-derived endpoint discovery.
|
||||
#[serde(default)]
|
||||
pub policy: NostrDiscoveryPolicy,
|
||||
pub policy: NostrRendezvousPolicy,
|
||||
/// Max number of open-discovery peers queued for outbound retry/connection
|
||||
/// at once. Prevents unbounded queue growth from ambient advert traffic.
|
||||
#[serde(default = "NostrDiscoveryConfig::default_open_discovery_max_pending")]
|
||||
#[serde(default = "NostrRendezvousConfig::default_open_discovery_max_pending")]
|
||||
pub open_discovery_max_pending: usize,
|
||||
/// Max concurrent inbound traversal offers processed at once.
|
||||
/// Acts as a rate limit against offer spam from relays.
|
||||
#[serde(default = "NostrDiscoveryConfig::default_max_concurrent_incoming_offers")]
|
||||
#[serde(default = "NostrRendezvousConfig::default_max_concurrent_incoming_offers")]
|
||||
pub max_concurrent_incoming_offers: usize,
|
||||
/// Max cached overlay adverts retained from relay traffic.
|
||||
/// Bounds memory under ambient advert volume.
|
||||
#[serde(default = "NostrDiscoveryConfig::default_advert_cache_max_entries")]
|
||||
#[serde(default = "NostrRendezvousConfig::default_advert_cache_max_entries")]
|
||||
pub advert_cache_max_entries: usize,
|
||||
/// Max seen-session IDs retained for replay detection.
|
||||
/// Oldest entries are evicted when the cap is exceeded.
|
||||
#[serde(default = "NostrDiscoveryConfig::default_seen_sessions_max_entries")]
|
||||
#[serde(default = "NostrRendezvousConfig::default_seen_sessions_max_entries")]
|
||||
pub seen_sessions_max_entries: usize,
|
||||
/// Overall punch attempt timeout in seconds.
|
||||
#[serde(default = "NostrDiscoveryConfig::default_attempt_timeout_secs")]
|
||||
#[serde(default = "NostrRendezvousConfig::default_attempt_timeout_secs")]
|
||||
pub attempt_timeout_secs: u64,
|
||||
/// Replay tracking retention window in seconds.
|
||||
#[serde(default = "NostrDiscoveryConfig::default_replay_window_secs")]
|
||||
#[serde(default = "NostrRendezvousConfig::default_replay_window_secs")]
|
||||
pub replay_window_secs: u64,
|
||||
/// Delay before punch traffic starts.
|
||||
#[serde(default = "NostrDiscoveryConfig::default_punch_start_delay_ms")]
|
||||
#[serde(default = "NostrRendezvousConfig::default_punch_start_delay_ms")]
|
||||
pub punch_start_delay_ms: u64,
|
||||
/// Interval between punch packets.
|
||||
#[serde(default = "NostrDiscoveryConfig::default_punch_interval_ms")]
|
||||
#[serde(default = "NostrRendezvousConfig::default_punch_interval_ms")]
|
||||
pub punch_interval_ms: u64,
|
||||
/// How long to keep punching before failure.
|
||||
#[serde(default = "NostrDiscoveryConfig::default_punch_duration_ms")]
|
||||
#[serde(default = "NostrRendezvousConfig::default_punch_duration_ms")]
|
||||
pub punch_duration_ms: u64,
|
||||
/// Advert TTL in seconds.
|
||||
#[serde(default = "NostrDiscoveryConfig::default_advert_ttl_secs")]
|
||||
#[serde(default = "NostrRendezvousConfig::default_advert_ttl_secs")]
|
||||
pub advert_ttl_secs: u64,
|
||||
/// How often adverts are refreshed in seconds.
|
||||
#[serde(default = "NostrDiscoveryConfig::default_advert_refresh_secs")]
|
||||
#[serde(default = "NostrRendezvousConfig::default_advert_refresh_secs")]
|
||||
pub advert_refresh_secs: u64,
|
||||
/// Settle delay in seconds after Nostr discovery starts before the
|
||||
/// one-shot startup sweep of cached adverts runs. Allows the relay
|
||||
/// subscription backlog to populate the in-memory advert cache.
|
||||
/// Only used under `policy: open`. Default: 5.
|
||||
#[serde(default = "NostrDiscoveryConfig::default_startup_sweep_delay_secs")]
|
||||
#[serde(default = "NostrRendezvousConfig::default_startup_sweep_delay_secs")]
|
||||
pub startup_sweep_delay_secs: u64,
|
||||
/// Maximum age in seconds for cached adverts considered by the
|
||||
/// one-shot startup sweep. Adverts whose `created_at` is older than
|
||||
/// `now - startup_sweep_max_age_secs` are skipped. Only used under
|
||||
/// `policy: open`. Default: 3600 (1 hour).
|
||||
#[serde(default = "NostrDiscoveryConfig::default_startup_sweep_max_age_secs")]
|
||||
#[serde(default = "NostrRendezvousConfig::default_startup_sweep_max_age_secs")]
|
||||
pub startup_sweep_max_age_secs: u64,
|
||||
/// Number of consecutive NAT-traversal failures against a peer before
|
||||
/// an extended cooldown is applied to throttle further offer publishes.
|
||||
/// At this threshold the daemon also actively re-fetches the peer's
|
||||
/// advert from `advert_relays` to evict cache entries for peers that
|
||||
/// have gone away. Default: 5.
|
||||
#[serde(default = "NostrDiscoveryConfig::default_failure_streak_threshold")]
|
||||
#[serde(default = "NostrRendezvousConfig::default_failure_streak_threshold")]
|
||||
pub failure_streak_threshold: u32,
|
||||
/// Cooldown applied to a peer once `failure_streak_threshold` is hit.
|
||||
/// Suppresses both open-discovery sweep enqueues and per-attempt
|
||||
/// retry firings until elapsed. Default: 1800 (30 minutes).
|
||||
#[serde(default = "NostrDiscoveryConfig::default_extended_cooldown_secs")]
|
||||
#[serde(default = "NostrRendezvousConfig::default_extended_cooldown_secs")]
|
||||
pub extended_cooldown_secs: u64,
|
||||
/// Minimum interval between `NAT traversal failed` WARN log lines for
|
||||
/// the same peer. Subsequent failures inside the window log at DEBUG.
|
||||
/// Reduces log spam on public-test nodes with many cache-learned
|
||||
/// peers. Default: 300 (5 minutes).
|
||||
#[serde(default = "NostrDiscoveryConfig::default_warn_log_interval_secs")]
|
||||
#[serde(default = "NostrRendezvousConfig::default_warn_log_interval_secs")]
|
||||
pub warn_log_interval_secs: u64,
|
||||
/// Maximum entries retained in the per-npub failure-state map.
|
||||
/// Bounds memory under high cache turnover. Oldest entries (by last
|
||||
/// failure time) evicted when the cap is exceeded. Default: 4096.
|
||||
#[serde(default = "NostrDiscoveryConfig::default_failure_state_max_entries")]
|
||||
#[serde(default = "NostrRendezvousConfig::default_failure_state_max_entries")]
|
||||
pub failure_state_max_entries: usize,
|
||||
/// Cooldown applied after observing a fatal protocol mismatch on a
|
||||
/// Nostr-adopted bootstrap transport (e.g. `Unknown FMP version`
|
||||
@@ -404,11 +429,11 @@ pub struct NostrDiscoveryConfig {
|
||||
/// of `extended_cooldown_secs` and much longer because the mismatch
|
||||
/// is structural — re-traversing the peer is wasted effort until one
|
||||
/// side upgrades. Default: 86400 (24 hours).
|
||||
#[serde(default = "NostrDiscoveryConfig::default_protocol_mismatch_cooldown_secs")]
|
||||
#[serde(default = "NostrRendezvousConfig::default_protocol_mismatch_cooldown_secs")]
|
||||
pub protocol_mismatch_cooldown_secs: u64,
|
||||
}
|
||||
|
||||
impl Default for NostrDiscoveryConfig {
|
||||
impl Default for NostrRendezvousConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
@@ -419,7 +444,7 @@ impl Default for NostrDiscoveryConfig {
|
||||
share_local_candidates: false,
|
||||
app: Self::default_app(),
|
||||
signal_ttl_secs: Self::default_signal_ttl_secs(),
|
||||
policy: NostrDiscoveryPolicy::default(),
|
||||
policy: NostrRendezvousPolicy::default(),
|
||||
open_discovery_max_pending: Self::default_open_discovery_max_pending(),
|
||||
max_concurrent_incoming_offers: Self::default_max_concurrent_incoming_offers(),
|
||||
advert_cache_max_entries: Self::default_advert_cache_max_entries(),
|
||||
@@ -442,7 +467,7 @@ impl Default for NostrDiscoveryConfig {
|
||||
}
|
||||
}
|
||||
|
||||
impl NostrDiscoveryConfig {
|
||||
impl NostrRendezvousConfig {
|
||||
fn default_advertise() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -635,8 +660,8 @@ pub struct BloomConfig {
|
||||
pub update_debounce_ms: u64,
|
||||
/// Antipoison cap: reject inbound FilterAnnounce whose FPR exceeds
|
||||
/// this value (`node.bloom.max_inbound_fpr`). Valid range `(0.0, 1.0)`.
|
||||
/// Default `0.10` ≈ fill 0.631 at k=5 ≈ ~1,630 entries on the 1 KB
|
||||
/// filter (Swamidass–Baldi). Raised from 0.05 so aggregates that are
|
||||
/// Default `0.20` ≈ fill 0.7248 at k=5 ≈ ~2,114 entries on the 1 KB
|
||||
/// filter (Swamidass–Baldi). Raised from 0.10 so aggregates that are
|
||||
/// legitimately near their operating ceiling are not rejected before
|
||||
/// the network reaches the fixed-filter capacity limit; conceptually
|
||||
/// distinct from future autoscaling hysteresis setpoints — same unit,
|
||||
@@ -648,8 +673,8 @@ pub struct BloomConfig {
|
||||
impl Default for BloomConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
update_debounce_ms: 500,
|
||||
max_inbound_fpr: 0.10,
|
||||
update_debounce_ms: Self::default_update_debounce_ms(),
|
||||
max_inbound_fpr: Self::default_max_inbound_fpr(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -659,7 +684,7 @@ impl BloomConfig {
|
||||
500
|
||||
}
|
||||
fn default_max_inbound_fpr() -> f64 {
|
||||
0.10
|
||||
0.20
|
||||
}
|
||||
}
|
||||
|
||||
@@ -726,6 +751,41 @@ impl SessionConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// MMP configuration (`node.mmp.*`).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MmpConfig {
|
||||
/// Operating mode (`node.mmp.mode`).
|
||||
#[serde(default)]
|
||||
pub mode: MmpMode,
|
||||
|
||||
/// Periodic operator log interval in seconds (`node.mmp.log_interval_secs`).
|
||||
#[serde(default = "MmpConfig::default_log_interval_secs")]
|
||||
pub log_interval_secs: u64,
|
||||
|
||||
/// OWD trend ring buffer size (`node.mmp.owd_window_size`).
|
||||
#[serde(default = "MmpConfig::default_owd_window_size")]
|
||||
pub owd_window_size: usize,
|
||||
}
|
||||
|
||||
impl Default for MmpConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: MmpMode::default(),
|
||||
log_interval_secs: DEFAULT_LOG_INTERVAL_SECS,
|
||||
owd_window_size: DEFAULT_OWD_WINDOW_SIZE,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MmpConfig {
|
||||
fn default_log_interval_secs() -> u64 {
|
||||
DEFAULT_LOG_INTERVAL_SECS
|
||||
}
|
||||
fn default_owd_window_size() -> usize {
|
||||
DEFAULT_OWD_WINDOW_SIZE
|
||||
}
|
||||
}
|
||||
|
||||
/// Session-layer Metrics Measurement Protocol (`node.session_mmp.*`).
|
||||
///
|
||||
/// Separate from link-layer `node.mmp.*` to allow independent mode/interval
|
||||
@@ -970,6 +1030,19 @@ pub struct NodeConfig {
|
||||
#[serde(default = "NodeConfig::default_link_dead_timeout_secs")]
|
||||
pub link_dead_timeout_secs: u64,
|
||||
|
||||
/// Graceful-shutdown drain deadline in seconds (`node.drain_timeout_secs`).
|
||||
/// The bounded `Draining` phase broadcasts a shutdown `Disconnect` and then
|
||||
/// waits up to this long for peers to clear before tearing down, early-
|
||||
/// exiting as soon as all peers are gone. `None` selects the 2-second
|
||||
/// default (see [`NodeConfig::drain_timeout`]).
|
||||
///
|
||||
/// Kept `Option` deliberately: `NodeConfig` has no `deny_unknown_fields`, so
|
||||
/// a naive non-`Option` add with a `default` fn would silently rewrite the
|
||||
/// value into deployed configs on the next serialize. The `Option` +
|
||||
/// `skip_serializing_if` keeps absent configs absent.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub drain_timeout_secs: Option<u64>,
|
||||
|
||||
/// Resource limits (`node.limits.*`).
|
||||
#[serde(default)]
|
||||
pub limits: LimitsConfig,
|
||||
@@ -986,9 +1059,19 @@ pub struct NodeConfig {
|
||||
#[serde(default)]
|
||||
pub cache: CacheConfig,
|
||||
|
||||
/// Discovery protocol (`node.discovery.*`).
|
||||
/// Mesh-lookup protocol (`node.lookup.*`).
|
||||
#[serde(default)]
|
||||
pub discovery: DiscoveryConfig,
|
||||
pub lookup: LookupConfig,
|
||||
|
||||
/// Peer rendezvous (`node.rendezvous.*`).
|
||||
#[serde(default)]
|
||||
pub rendezvous: RendezvousConfig,
|
||||
|
||||
/// COMPAT (drop at the v2 cutover): a deprecated legacy `node.discovery:`
|
||||
/// block, folded into `lookup`/`rendezvous` by
|
||||
/// [`Config::normalize_deprecated_keys`]. Never re-serialized.
|
||||
#[serde(default, skip_serializing)]
|
||||
pub(crate) discovery: Option<DiscoveryConfigCompat>,
|
||||
|
||||
/// Spanning tree (`node.tree.*`).
|
||||
#[serde(default)]
|
||||
@@ -1041,11 +1124,14 @@ impl Default for NodeConfig {
|
||||
base_rtt_ms: 100,
|
||||
heartbeat_interval_secs: 10,
|
||||
link_dead_timeout_secs: 30,
|
||||
drain_timeout_secs: None,
|
||||
limits: LimitsConfig::default(),
|
||||
rate_limit: RateLimitConfig::default(),
|
||||
retry: RetryConfig::default(),
|
||||
cache: CacheConfig::default(),
|
||||
discovery: DiscoveryConfig::default(),
|
||||
lookup: LookupConfig::default(),
|
||||
rendezvous: RendezvousConfig::default(),
|
||||
discovery: None,
|
||||
tree: TreeConfig::default(),
|
||||
bloom: BloomConfig::default(),
|
||||
session: SessionConfig::default(),
|
||||
@@ -1089,12 +1175,72 @@ impl NodeConfig {
|
||||
fn default_link_dead_timeout_secs() -> u64 {
|
||||
30
|
||||
}
|
||||
|
||||
/// Graceful-shutdown drain deadline as a `Duration`.
|
||||
///
|
||||
/// Returns the configured `drain_timeout_secs`, or the 2-second default
|
||||
/// when unset. Used by the daemon's bounded `Draining` phase.
|
||||
pub fn drain_timeout(&self) -> std::time::Duration {
|
||||
std::time::Duration::from_secs(self.drain_timeout_secs.unwrap_or(2))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_config_default() {
|
||||
let config = MmpConfig::default();
|
||||
assert_eq!(config.mode, MmpMode::Full);
|
||||
assert_eq!(config.log_interval_secs, 30);
|
||||
assert_eq!(config.owd_window_size, 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_yaml_parse() {
|
||||
let yaml = r#"
|
||||
mode: lightweight
|
||||
log_interval_secs: 60
|
||||
owd_window_size: 48
|
||||
"#;
|
||||
let config: MmpConfig = serde_yaml::from_str(yaml).unwrap();
|
||||
assert_eq!(config.mode, MmpMode::Lightweight);
|
||||
assert_eq!(config.log_interval_secs, 60);
|
||||
assert_eq!(config.owd_window_size, 48);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_yaml_partial() {
|
||||
let yaml = "mode: minimal";
|
||||
let config: MmpConfig = serde_yaml::from_str(yaml).unwrap();
|
||||
assert_eq!(config.mode, MmpMode::Minimal);
|
||||
assert_eq!(config.log_interval_secs, DEFAULT_LOG_INTERVAL_SECS);
|
||||
assert_eq!(config.owd_window_size, DEFAULT_OWD_WINDOW_SIZE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_drain_timeout_default_and_override() {
|
||||
// Unset → the 2-second default.
|
||||
let c = NodeConfig::default();
|
||||
assert_eq!(c.drain_timeout_secs, None);
|
||||
assert_eq!(c.drain_timeout(), std::time::Duration::from_secs(2));
|
||||
|
||||
// Explicit override is honored.
|
||||
let c2 = NodeConfig {
|
||||
drain_timeout_secs: Some(10),
|
||||
..NodeConfig::default()
|
||||
};
|
||||
assert_eq!(c2.drain_timeout(), std::time::Duration::from_secs(10));
|
||||
|
||||
// A zero override is a valid (immediate) drain, not the default.
|
||||
let c3 = NodeConfig {
|
||||
drain_timeout_secs: Some(0),
|
||||
..NodeConfig::default()
|
||||
};
|
||||
assert_eq!(c3.drain_timeout(), std::time::Duration::from_secs(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ecn_config_defaults() {
|
||||
let c = EcnConfig::default();
|
||||
@@ -1123,27 +1269,27 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nostr_discovery_startup_sweep_defaults() {
|
||||
let c = NostrDiscoveryConfig::default();
|
||||
fn test_nostr_rendezvous_startup_sweep_defaults() {
|
||||
let c = NostrRendezvousConfig::default();
|
||||
assert_eq!(c.startup_sweep_delay_secs, 5);
|
||||
assert_eq!(c.startup_sweep_max_age_secs, 3_600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nostr_discovery_startup_sweep_yaml_override() {
|
||||
fn test_nostr_rendezvous_startup_sweep_yaml_override() {
|
||||
let yaml = "enabled: true\npolicy: open\nstartup_sweep_delay_secs: 10\nstartup_sweep_max_age_secs: 1800\n";
|
||||
let c: NostrDiscoveryConfig = serde_yaml::from_str(yaml).unwrap();
|
||||
let c: NostrRendezvousConfig = serde_yaml::from_str(yaml).unwrap();
|
||||
assert!(c.enabled);
|
||||
assert_eq!(c.policy, NostrDiscoveryPolicy::Open);
|
||||
assert_eq!(c.policy, NostrRendezvousPolicy::Open);
|
||||
assert_eq!(c.startup_sweep_delay_secs, 10);
|
||||
assert_eq!(c.startup_sweep_max_age_secs, 1_800);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nostr_discovery_startup_sweep_partial_yaml_uses_defaults() {
|
||||
fn test_nostr_rendezvous_startup_sweep_partial_yaml_uses_defaults() {
|
||||
// Only override delay; max_age should fall back to default.
|
||||
let yaml = "enabled: true\nstartup_sweep_delay_secs: 30\n";
|
||||
let c: NostrDiscoveryConfig = serde_yaml::from_str(yaml).unwrap();
|
||||
let c: NostrRendezvousConfig = serde_yaml::from_str(yaml).unwrap();
|
||||
assert_eq!(c.startup_sweep_delay_secs, 30);
|
||||
assert_eq!(c.startup_sweep_max_age_secs, 3_600);
|
||||
}
|
||||
|
||||
@@ -282,9 +282,10 @@ pub struct EthernetConfig {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub send_buf_size: Option<usize>,
|
||||
|
||||
/// Listen for discovery beacons from other nodes. Default: true.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub discovery: Option<bool>,
|
||||
/// Listen for neighbor beacons from other nodes. Default: true.
|
||||
/// (Renamed from `discovery`; the old key is still accepted.)
|
||||
#[serde(default, alias = "discovery", skip_serializing_if = "Option::is_none")]
|
||||
pub listen: Option<bool>,
|
||||
|
||||
/// Broadcast announcement beacons on the LAN. Default: false.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -319,9 +320,9 @@ impl EthernetConfig {
|
||||
self.send_buf_size.unwrap_or(DEFAULT_ETHERNET_SEND_BUF)
|
||||
}
|
||||
|
||||
/// Whether to listen for discovery beacons. Default: true.
|
||||
pub fn discovery(&self) -> bool {
|
||||
self.discovery.unwrap_or(true)
|
||||
/// Whether to listen for neighbor beacons. Default: true.
|
||||
pub fn listen(&self) -> bool {
|
||||
self.listen.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// Whether to broadcast announcement beacons. Default: false.
|
||||
@@ -1046,4 +1047,22 @@ mod tests {
|
||||
assert_eq!(parse_bind_port("[::]:443"), Some(443));
|
||||
assert_eq!(parse_bind_port("not-a-socket-addr"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ethernet_listen_accepts_legacy_discovery_alias_and_rejects_unknown() {
|
||||
// (a) The legacy `discovery:` key is still accepted via serde alias.
|
||||
let legacy: EthernetConfig =
|
||||
serde_yaml::from_str("interface: eth0\ndiscovery: true\n").unwrap();
|
||||
assert_eq!(legacy.listen, Some(true));
|
||||
|
||||
// (b) The new canonical `listen:` key parses into the renamed field.
|
||||
let renamed: EthernetConfig =
|
||||
serde_yaml::from_str("interface: eth0\nlisten: true\n").unwrap();
|
||||
assert_eq!(renamed.listen, Some(true));
|
||||
|
||||
// (c) `deny_unknown_fields` still rejects an unknown ethernet key.
|
||||
let bogus: Result<EthernetConfig, _> =
|
||||
serde_yaml::from_str("interface: eth0\nbogus: true\n");
|
||||
assert!(bogus.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,7 +236,7 @@ pub fn show_peers(node: &Node) -> Value {
|
||||
// Per-npub Nostr-traversal failure-state snapshot, indexed by npub
|
||||
// for O(1) per-peer lookup. Empty if Nostr discovery is disabled.
|
||||
let nostr_state: std::collections::HashMap<String, _> = node
|
||||
.nostr_discovery_handle()
|
||||
.nostr_rendezvous_handle()
|
||||
.map(|d| {
|
||||
d.failure_state_snapshot()
|
||||
.into_iter()
|
||||
@@ -1303,17 +1303,18 @@ pub fn show_connections(node: &Node) -> Value {
|
||||
let now = now_ms();
|
||||
let connections: Vec<Value> = node
|
||||
.connections()
|
||||
.map(|conn| {
|
||||
.map(|(_, machine)| {
|
||||
let link_id = machine.link_id();
|
||||
let mut conn_json = json!({
|
||||
"link_id": conn.link_id().as_u64(),
|
||||
"direction": format!("{}", conn.direction()),
|
||||
"handshake_state": format!("{}", conn.handshake_state()),
|
||||
"started_at_ms": conn.started_at(),
|
||||
"idle_ms": now.saturating_sub(conn.last_activity()),
|
||||
"resend_count": conn.resend_count(),
|
||||
"link_id": link_id.as_u64(),
|
||||
"direction": format!("{}", machine.conn_direction()),
|
||||
"handshake_state": node.connection_handshake_state(link_id),
|
||||
"started_at_ms": node.connection_started_at(link_id),
|
||||
"idle_ms": now.saturating_sub(node.connection_last_activity(link_id)),
|
||||
"resend_count": node.connection_resend_count(link_id),
|
||||
});
|
||||
|
||||
if let Some(identity) = conn.expected_identity() {
|
||||
if let Some(identity) = node.connection_expected_identity(link_id) {
|
||||
conn_json["expected_peer"] = json!(identity.npub());
|
||||
}
|
||||
|
||||
@@ -1487,7 +1488,9 @@ pub fn show_routing(node: &Node) -> Value {
|
||||
"recent_requests": node.recent_request_count(),
|
||||
"retries": retries,
|
||||
"forwarding": serde_json::to_value(metrics.forwarding.snapshot()).unwrap_or_default(),
|
||||
"discovery": serde_json::to_value(metrics.discovery.snapshot()).unwrap_or_default(),
|
||||
// COMPAT: `discovery` is the deprecated alias for `lookup`; drop at the v2 cutover.
|
||||
"discovery": serde_json::to_value(metrics.lookup.snapshot()).unwrap_or_default(),
|
||||
"lookup": serde_json::to_value(metrics.lookup.snapshot()).unwrap_or_default(),
|
||||
"error_signals": serde_json::to_value(metrics.errors.snapshot()).unwrap_or_default(),
|
||||
"congestion": serde_json::to_value(metrics.congestion.snapshot()).unwrap_or_default(),
|
||||
})
|
||||
@@ -1543,7 +1546,9 @@ pub(crate) fn show_routing_from_handle(handle: &super::read_handle::ControlReadH
|
||||
"recent_requests": view.recent_requests,
|
||||
"retries": retries,
|
||||
"forwarding": serde_json::to_value(metrics.forwarding.snapshot()).unwrap_or_default(),
|
||||
"discovery": serde_json::to_value(metrics.discovery.snapshot()).unwrap_or_default(),
|
||||
// COMPAT: `discovery` is the deprecated alias for `lookup`; drop at the v2 cutover.
|
||||
"discovery": serde_json::to_value(metrics.lookup.snapshot()).unwrap_or_default(),
|
||||
"lookup": serde_json::to_value(metrics.lookup.snapshot()).unwrap_or_default(),
|
||||
"error_signals": serde_json::to_value(metrics.errors.snapshot()).unwrap_or_default(),
|
||||
"congestion": serde_json::to_value(metrics.congestion.snapshot()).unwrap_or_default(),
|
||||
})
|
||||
@@ -2328,7 +2333,9 @@ pub(crate) fn show_metrics_from_handle(handle: &super::read_handle::ControlReadH
|
||||
let m = handle.metrics();
|
||||
json!({
|
||||
"forwarding": m.forwarding.snapshot(),
|
||||
"discovery": m.discovery.snapshot(),
|
||||
// COMPAT: `discovery` is the deprecated alias for `lookup`; drop at the v2 cutover.
|
||||
"discovery": m.lookup.snapshot(),
|
||||
"lookup": m.lookup.snapshot(),
|
||||
"tree": m.tree.snapshot(),
|
||||
"bloom": m.bloom.snapshot(),
|
||||
"congestion": m.congestion.snapshot(),
|
||||
@@ -2761,12 +2768,12 @@ mod tests {
|
||||
/// Structural confirmation that the rx_loop no longer dispatches `show_*`:
|
||||
/// the rx_loop source carries no `queries::dispatch` call and no
|
||||
/// `starts_with("show_")` routing branch. Reads the committed source of
|
||||
/// `src/node/handlers/rx_loop.rs` and asserts both markers are absent. This
|
||||
/// `src/node/dataplane/rx_loop.rs` and asserts both markers are absent. This
|
||||
/// is the milestone's "remove `show_*` from the data-plane dispatch path"
|
||||
/// invariant, guarded against regression.
|
||||
#[test]
|
||||
fn rx_loop_has_no_show_dispatch() {
|
||||
let src = include_str!("../node/handlers/rx_loop.rs");
|
||||
let src = include_str!("../node/dataplane/rx_loop.rs");
|
||||
assert!(
|
||||
!src.contains("queries::dispatch"),
|
||||
"rx_loop must not call queries::dispatch (show_* served off-loop)"
|
||||
@@ -2794,7 +2801,9 @@ mod tests {
|
||||
|
||||
let expected_families = [
|
||||
("forwarding", "received_packets"),
|
||||
// `discovery` is the deprecated dual-emit alias for `lookup`; drop at the v2 cutover.
|
||||
("discovery", "req_received"),
|
||||
("lookup", "req_received"),
|
||||
("tree", "accepted"),
|
||||
("bloom", "accepted"),
|
||||
("congestion", "ce_forwarded"),
|
||||
|
||||
@@ -63,6 +63,30 @@
|
||||
"ttl_exhausted_packets": 0
|
||||
},
|
||||
"identity_cache_entries": 0,
|
||||
"lookup": {
|
||||
"req_backoff_suppressed": 0,
|
||||
"req_bloom_miss": 0,
|
||||
"req_decode_error": 0,
|
||||
"req_dedup_cache_full": 0,
|
||||
"req_deduplicated": 0,
|
||||
"req_duplicate": 0,
|
||||
"req_fallback_forwarded": 0,
|
||||
"req_forward_rate_limited": 0,
|
||||
"req_forwarded": 0,
|
||||
"req_initiated": 0,
|
||||
"req_no_tree_peer": 0,
|
||||
"req_received": 0,
|
||||
"req_target_is_us": 0,
|
||||
"req_ttl_exhausted": 0,
|
||||
"resp_accepted": 0,
|
||||
"resp_decode_error": 0,
|
||||
"resp_forwarded": 0,
|
||||
"resp_identity_miss": 0,
|
||||
"resp_no_route": 0,
|
||||
"resp_proof_failed": 0,
|
||||
"resp_received": 0,
|
||||
"resp_timed_out": 0
|
||||
},
|
||||
"pending_lookups": [],
|
||||
"pending_tun_destinations": 0,
|
||||
"pending_tun_packets": 0,
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
"loop_detected": 0,
|
||||
"outbound_sign_failed": 0,
|
||||
"parent_losses": 0,
|
||||
"parent_switched": 0,
|
||||
"parent_switches": 0,
|
||||
"rate_limited": 0,
|
||||
"received": 0,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Authentication challenge-response protocol.
|
||||
|
||||
use rand::Rng;
|
||||
use secp256k1::{Secp256k1, XOnlyPublicKey};
|
||||
use secp256k1::XOnlyPublicKey;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use super::{IdentityError, NodeAddr};
|
||||
@@ -34,9 +34,9 @@ impl AuthChallenge {
|
||||
/// Verify a response to this challenge.
|
||||
pub fn verify(&self, response: &AuthResponse) -> Result<NodeAddr, IdentityError> {
|
||||
let digest = auth_challenge_digest(&self.0, response.timestamp);
|
||||
let secp = Secp256k1::new();
|
||||
|
||||
secp.verify_schnorr(&response.signature, &digest, &response.pubkey)
|
||||
super::SECP
|
||||
.verify_schnorr(&response.signature, &digest, &response.pubkey)
|
||||
.map_err(|_| IdentityError::SignatureVerificationFailed)?;
|
||||
|
||||
Ok(NodeAddr::from_pubkey(&response.pubkey))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Local node identity with signing capability.
|
||||
|
||||
use secp256k1::{Keypair, PublicKey, Secp256k1, SecretKey, XOnlyPublicKey};
|
||||
use secp256k1::{Keypair, PublicKey, SecretKey, XOnlyPublicKey};
|
||||
use std::fmt;
|
||||
|
||||
use super::auth::{AuthResponse, auth_challenge_digest};
|
||||
@@ -42,8 +42,7 @@ impl Identity {
|
||||
|
||||
/// Create an identity from a secret key.
|
||||
pub fn from_secret_key(secret_key: SecretKey) -> Self {
|
||||
let secp = Secp256k1::new();
|
||||
let keypair = Keypair::from_secret_key(&secp, &secret_key);
|
||||
let keypair = Keypair::from_secret_key(&super::SECP, &secret_key);
|
||||
Self::from_keypair(keypair)
|
||||
}
|
||||
|
||||
@@ -93,9 +92,8 @@ impl Identity {
|
||||
|
||||
/// Sign arbitrary data with this identity's secret key.
|
||||
pub fn sign(&self, data: &[u8]) -> secp256k1::schnorr::Signature {
|
||||
let secp = Secp256k1::new();
|
||||
let digest = sha256(data);
|
||||
secp.sign_schnorr(&digest, &self.keypair)
|
||||
super::SECP.sign_schnorr(&digest, &self.keypair)
|
||||
}
|
||||
|
||||
/// Create an authentication response for a challenge.
|
||||
@@ -103,8 +101,7 @@ impl Identity {
|
||||
/// The response signs: SHA256("fips-auth-v1" || challenge || timestamp)
|
||||
pub fn sign_challenge(&self, challenge: &[u8; 32], timestamp: u64) -> AuthResponse {
|
||||
let digest = auth_challenge_digest(challenge, timestamp);
|
||||
let secp = Secp256k1::new();
|
||||
let signature = secp.sign_schnorr(&digest, &self.keypair);
|
||||
let signature = super::SECP.sign_schnorr(&digest, &self.keypair);
|
||||
AuthResponse {
|
||||
pubkey: self.pubkey(),
|
||||
timestamp,
|
||||
|
||||
@@ -11,6 +11,9 @@ mod local;
|
||||
mod node_addr;
|
||||
mod peer;
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use secp256k1::{All, Secp256k1};
|
||||
use sha2::{Digest, Sha256};
|
||||
use thiserror::Error;
|
||||
|
||||
@@ -21,6 +24,15 @@ pub use local::Identity;
|
||||
pub use node_addr::NodeAddr;
|
||||
pub use peer::PeerIdentity;
|
||||
|
||||
/// Shared secp256k1 context reused across all identity operations.
|
||||
///
|
||||
/// `Secp256k1::new()` allocates a `Secp256k1<All>` and runs randomization /
|
||||
/// blinding table setup; it is designed to be created once and reused rather
|
||||
/// than rebuilt per sign / verify / key-derive call. This single `All` context
|
||||
/// serves both signing and verification across the identity module and still
|
||||
/// performs the standard construction-time blinding.
|
||||
pub(crate) static SECP: LazyLock<Secp256k1<All>> = LazyLock::new(Secp256k1::new);
|
||||
|
||||
/// FIPS address prefix (IPv6 ULA range).
|
||||
pub const FIPS_ADDRESS_PREFIX: u8 = 0xfd;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Remote peer identity (public key only, no signing capability).
|
||||
|
||||
use secp256k1::{Parity, PublicKey, Secp256k1, XOnlyPublicKey};
|
||||
use secp256k1::{Parity, PublicKey, XOnlyPublicKey};
|
||||
use std::fmt;
|
||||
|
||||
use super::encoding::{decode_npub, encode_npub};
|
||||
@@ -107,9 +107,9 @@ impl PeerIdentity {
|
||||
|
||||
/// Verify a signature from this peer.
|
||||
pub fn verify(&self, data: &[u8], signature: &secp256k1::schnorr::Signature) -> bool {
|
||||
let secp = Secp256k1::new();
|
||||
let digest = sha256(data);
|
||||
secp.verify_schnorr(signature, &digest, &self.pubkey)
|
||||
super::SECP
|
||||
.verify_schnorr(signature, &digest, &self.pubkey)
|
||||
.is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::collections::HashSet;
|
||||
use std::net::Ipv6Addr;
|
||||
|
||||
use secp256k1::{Keypair, Secp256k1, SecretKey};
|
||||
use secp256k1::{Keypair, SecretKey};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -161,10 +161,10 @@ fn test_identity_sign() {
|
||||
let sig = identity.sign(data);
|
||||
|
||||
// Verify the signature manually
|
||||
let secp = secp256k1::Secp256k1::new();
|
||||
let digest = super::sha256(data);
|
||||
assert!(
|
||||
secp.verify_schnorr(&sig, &digest, &identity.pubkey())
|
||||
super::SECP
|
||||
.verify_schnorr(&sig, &digest, &identity.pubkey())
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
@@ -580,13 +580,12 @@ fn test_peer_identity_pubkey_full_even_parity_fallback() {
|
||||
#[test]
|
||||
fn test_peer_identity_pubkey_full_preserved_parity() {
|
||||
// Create two identities and find one with odd parity to make this test meaningful
|
||||
let secp = Secp256k1::new();
|
||||
let secret_bytes: [u8; 32] = [
|
||||
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
|
||||
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e,
|
||||
0x1f, 0x20,
|
||||
];
|
||||
let keypair = Keypair::from_seckey_slice(&secp, &secret_bytes).unwrap();
|
||||
let keypair = Keypair::from_seckey_slice(&super::SECP, &secret_bytes).unwrap();
|
||||
let full_pubkey = keypair.public_key();
|
||||
|
||||
let peer = PeerIdentity::from_pubkey_full(full_pubkey);
|
||||
|
||||
68
src/lib.rs
68
src/lib.rs
@@ -3,22 +3,29 @@
|
||||
//! A distributed, decentralized network routing protocol for mesh nodes
|
||||
//! connecting over arbitrary transports.
|
||||
|
||||
pub mod bloom;
|
||||
// Name the `alloc` crate directly so the sans-IO protocol cores can spell their
|
||||
// heap-type imports in `no_std`-forward form (`alloc::sync::Arc`,
|
||||
// `alloc::collections::BTreeMap`). The crate remains `std`; this only reduces the
|
||||
// distance to extracting the pure cores into a `no_std` crate later.
|
||||
extern crate alloc;
|
||||
|
||||
pub mod cache;
|
||||
pub mod config;
|
||||
pub mod control;
|
||||
pub mod discovery;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod gateway;
|
||||
pub mod identity;
|
||||
pub mod mmp;
|
||||
pub mod mdns;
|
||||
pub mod node;
|
||||
pub mod noise;
|
||||
pub mod nostr;
|
||||
pub mod peer;
|
||||
pub mod perf_profile;
|
||||
pub mod protocol;
|
||||
pub(crate) mod proto;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod testutil;
|
||||
mod time;
|
||||
pub mod transport;
|
||||
pub mod tree;
|
||||
pub mod upper;
|
||||
pub mod utils;
|
||||
pub mod version;
|
||||
@@ -33,14 +40,16 @@ pub use identity::{
|
||||
pub use config::{Config, ConfigError, IdentityConfig, NymConfig, TorConfig, UdpConfig};
|
||||
pub use upper::config::{DnsConfig, TunConfig};
|
||||
|
||||
// Re-export discovery types
|
||||
pub use discovery::{BootstrapHandoffResult, EstablishedTraversal};
|
||||
// Re-export nostr rendezvous handoff types
|
||||
pub use nostr::{BootstrapHandoffResult, EstablishedTraversal, is_punch_packet};
|
||||
|
||||
// Re-export tree types
|
||||
pub use tree::{CoordEntry, ParentDeclaration, TreeCoordinate, TreeError, TreeState};
|
||||
// Re-export tree types (relocated from tree:: to proto::stp)
|
||||
pub use proto::stp::{
|
||||
CoordEntry, CoordError, ParentDeclaration, TreeCoordinate, TreeError, TreeState,
|
||||
};
|
||||
|
||||
// Re-export bloom filter types
|
||||
pub use bloom::{BloomError, BloomFilter, BloomState};
|
||||
// Re-export bloom filter types (relocated from bloom:: to proto::bloom)
|
||||
pub use proto::bloom::{BloomError, BloomFilter, BloomState};
|
||||
|
||||
// Re-export transport types
|
||||
pub use transport::udp::UdpTransport;
|
||||
@@ -50,21 +59,40 @@ pub use transport::{
|
||||
TransportState, TransportType, packet_channel,
|
||||
};
|
||||
|
||||
// Re-export protocol types
|
||||
pub use protocol::{
|
||||
CoordsRequired, FilterAnnounce, HandshakeMessageType, LinkMessageType, LookupRequest,
|
||||
LookupResponse, PathBroken, ProtocolError, SessionAck, SessionDatagram, SessionFlags,
|
||||
SessionMessageType, SessionSetup, TreeAnnounce,
|
||||
// Re-export link-layer types (relocated from protocol:: to proto::link)
|
||||
pub use proto::link::{LinkMessageType, SessionDatagram};
|
||||
|
||||
// Re-export the shared protocol error (relocated from protocol:: to proto::Error)
|
||||
pub use proto::Error;
|
||||
|
||||
// Re-export FSP session wire types (relocated from protocol:: to proto::fsp)
|
||||
pub use proto::fsp::{SessionAck, SessionFlags, SessionMessageType, SessionSetup};
|
||||
|
||||
// Re-export STP wire types (relocated from protocol:: to proto::stp)
|
||||
pub use proto::stp::TreeAnnounce;
|
||||
|
||||
// Re-export bloom wire types (relocated from protocol:: to proto::bloom)
|
||||
pub use proto::bloom::FilterAnnounce;
|
||||
|
||||
// Re-export discovery wire types (relocated from protocol:: to proto::lookup)
|
||||
pub use proto::lookup::{LookupRequest, LookupResponse};
|
||||
|
||||
// Re-export routing wire types (relocated from protocol:: to proto::routing)
|
||||
pub use proto::routing::{
|
||||
COORDS_REQUIRED_SIZE, CoordsRequired, MTU_EXCEEDED_SIZE, MtuExceeded, PathBroken,
|
||||
};
|
||||
|
||||
// Re-export FMP link-framing wire type (relocated from protocol:: to proto::fmp)
|
||||
pub use proto::fmp::HandshakeMessageType;
|
||||
|
||||
// Re-export cache types
|
||||
pub use cache::{CacheEntry, CacheError, CacheStats, CoordCache};
|
||||
|
||||
// Re-export FMP tie-break helper and promotion result (relocated from peer:: to proto::fmp)
|
||||
pub use proto::fmp::{PromotionResult, cross_connection_winner};
|
||||
|
||||
// Re-export peer types
|
||||
pub use peer::{
|
||||
ActivePeer, ConnectivityState, HandshakeState, PeerConnection, PeerError, PeerSlot,
|
||||
PromotionResult, cross_connection_winner,
|
||||
};
|
||||
pub use peer::{ActivePeer, ConnectivityState, PeerError};
|
||||
|
||||
// Re-export node types
|
||||
pub use node::{Node, NodeError, NodeState, UpdatePeersOutcome};
|
||||
|
||||
@@ -54,8 +54,12 @@ pub const TXT_KEY_SCOPE: &str = "scope";
|
||||
/// `PROTOCOL_VERSION`).
|
||||
pub const TXT_KEY_VERSION: &str = "v";
|
||||
|
||||
/// FIPS protocol version advertised in the mDNS TXT `v` key. Kept in sync
|
||||
/// with the Nostr rendezvous `PROTOCOL_VERSION` (same value, `"1"`).
|
||||
const TXT_PROTOCOL_VERSION: &str = "1";
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum LanDiscoveryError {
|
||||
pub enum LanRendezvousError {
|
||||
#[error("mDNS daemon init failed: {0}")]
|
||||
Daemon(String),
|
||||
#[error("mDNS register failed: {0}")]
|
||||
@@ -79,7 +83,7 @@ pub struct LanDiscoveredPeer {
|
||||
pub observed_at: Instant,
|
||||
}
|
||||
|
||||
/// Browser-side events surfaced by `LanDiscovery::drain_events`.
|
||||
/// Browser-side events surfaced by `LanRendezvous::drain_events`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum LanEvent {
|
||||
Discovered(LanDiscoveredPeer),
|
||||
@@ -87,17 +91,17 @@ pub enum LanEvent {
|
||||
|
||||
/// Runtime configuration for the mDNS responder + browser.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct LanDiscoveryConfig {
|
||||
pub struct LanRendezvousConfig {
|
||||
/// Master switch. Default: `false` — LAN discovery is opt-in. Operators
|
||||
/// who want sub-second same-LAN pairing enable it via
|
||||
/// `node.discovery.lan.enabled: true`. Default-off avoids reintroducing
|
||||
/// `node.rendezvous.lan.enabled: true`. Default-off avoids reintroducing
|
||||
/// a per-LAN identity broadcast on nodes that have deliberately disabled
|
||||
/// other discovery channels, and avoids any multicast surprise on upgrade.
|
||||
#[serde(default = "LanDiscoveryConfig::default_enabled")]
|
||||
#[serde(default = "LanRendezvousConfig::default_enabled")]
|
||||
pub enabled: bool,
|
||||
/// Overridable service type, primarily so integration tests can run
|
||||
/// multiple isolated services on the same loopback interface.
|
||||
#[serde(default = "LanDiscoveryConfig::default_service_type")]
|
||||
#[serde(default = "LanRendezvousConfig::default_service_type")]
|
||||
pub service_type: String,
|
||||
/// Optional application/network scope carried in the LAN-only TXT
|
||||
/// record. Browsers that set a scope ignore adverts for other scopes.
|
||||
@@ -109,7 +113,7 @@ pub struct LanDiscoveryConfig {
|
||||
pub scope: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for LanDiscoveryConfig {
|
||||
impl Default for LanRendezvousConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: Self::default_enabled(),
|
||||
@@ -119,7 +123,7 @@ impl Default for LanDiscoveryConfig {
|
||||
}
|
||||
}
|
||||
|
||||
impl LanDiscoveryConfig {
|
||||
impl LanRendezvousConfig {
|
||||
fn default_enabled() -> bool {
|
||||
false
|
||||
}
|
||||
@@ -129,7 +133,7 @@ impl LanDiscoveryConfig {
|
||||
}
|
||||
|
||||
/// Running mDNS responder + browser bound to the node's UDP advert port.
|
||||
pub struct LanDiscovery {
|
||||
pub struct LanRendezvous {
|
||||
daemon: ServiceDaemon,
|
||||
own_npub: String,
|
||||
instance_fullname: String,
|
||||
@@ -137,7 +141,12 @@ pub struct LanDiscovery {
|
||||
event_pump: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl LanDiscovery {
|
||||
impl LanRendezvous {
|
||||
/// Whether the mDNS event-pump task has exited (runtime liveness).
|
||||
pub fn is_finished(&self) -> bool {
|
||||
self.event_pump.is_finished()
|
||||
}
|
||||
|
||||
/// Start the mDNS responder and browser.
|
||||
///
|
||||
/// `advertised_port` is the UDP port the operational UDP transport
|
||||
@@ -148,16 +157,16 @@ impl LanDiscovery {
|
||||
identity: &Identity,
|
||||
scope: Option<String>,
|
||||
advertised_port: u16,
|
||||
config: LanDiscoveryConfig,
|
||||
) -> Result<Arc<Self>, LanDiscoveryError> {
|
||||
config: LanRendezvousConfig,
|
||||
) -> Result<Arc<Self>, LanRendezvousError> {
|
||||
if !config.enabled {
|
||||
return Err(LanDiscoveryError::Disabled);
|
||||
return Err(LanRendezvousError::Disabled);
|
||||
}
|
||||
if advertised_port == 0 {
|
||||
return Err(LanDiscoveryError::NoAdvertisedPort);
|
||||
return Err(LanRendezvousError::NoAdvertisedPort);
|
||||
}
|
||||
|
||||
let daemon = ServiceDaemon::new().map_err(|e| LanDiscoveryError::Daemon(e.to_string()))?;
|
||||
let daemon = ServiceDaemon::new().map_err(|e| LanRendezvousError::Daemon(e.to_string()))?;
|
||||
|
||||
let npub = identity.npub();
|
||||
// mDNS DNS labels are capped at 63 bytes. 16 bech32 chars of npub
|
||||
@@ -176,7 +185,7 @@ impl LanDiscovery {
|
||||
}
|
||||
props.insert(
|
||||
TXT_KEY_VERSION.to_string(),
|
||||
super::nostr::PROTOCOL_VERSION.to_string(),
|
||||
TXT_PROTOCOL_VERSION.to_string(),
|
||||
);
|
||||
|
||||
// host_ipv4 is set to "127.0.0.1" *and* enable_addr_auto() is
|
||||
@@ -193,18 +202,18 @@ impl LanDiscovery {
|
||||
advertised_port,
|
||||
Some(props),
|
||||
)
|
||||
.map_err(|e| LanDiscoveryError::Register(e.to_string()))?
|
||||
.map_err(|e| LanRendezvousError::Register(e.to_string()))?
|
||||
.enable_addr_auto();
|
||||
|
||||
let instance_fullname = service_info.get_fullname().to_string();
|
||||
|
||||
daemon
|
||||
.register(service_info)
|
||||
.map_err(|e| LanDiscoveryError::Register(e.to_string()))?;
|
||||
.map_err(|e| LanRendezvousError::Register(e.to_string()))?;
|
||||
|
||||
let browse_rx = daemon
|
||||
.browse(&config.service_type)
|
||||
.map_err(|e| LanDiscoveryError::Browse(e.to_string()))?;
|
||||
.map_err(|e| LanRendezvousError::Browse(e.to_string()))?;
|
||||
|
||||
let (events_tx, events_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let own_npub = npub.clone();
|
||||
@@ -4,7 +4,7 @@ use std::time::Duration;
|
||||
use crate::Identity;
|
||||
use mdns_sd::ScopedIp;
|
||||
|
||||
use super::{LanDiscovery, LanDiscoveryConfig, LanEvent};
|
||||
use super::{LanEvent, LanRendezvous, LanRendezvousConfig};
|
||||
|
||||
/// Distinct service type per test run so concurrent cargo-test workers
|
||||
/// on the same machine don't cross-feed each other's adverts via the
|
||||
@@ -15,8 +15,8 @@ fn isolated_service_type(tag: &str) -> String {
|
||||
format!("_fipstest-{tag}-{rand:08x}._udp.local.")
|
||||
}
|
||||
|
||||
fn config_for(service_type: String) -> LanDiscoveryConfig {
|
||||
LanDiscoveryConfig {
|
||||
fn config_for(service_type: String) -> LanRendezvousConfig {
|
||||
LanRendezvousConfig {
|
||||
enabled: true,
|
||||
service_type,
|
||||
scope: None,
|
||||
@@ -47,7 +47,7 @@ fn non_link_local_ipv6_advert_is_preserved() {
|
||||
}
|
||||
|
||||
async fn wait_for_peer(
|
||||
discovery: &LanDiscovery,
|
||||
discovery: &LanRendezvous,
|
||||
expected_npub: &str,
|
||||
timeout: Duration,
|
||||
) -> Option<super::LanDiscoveredPeer> {
|
||||
@@ -64,7 +64,7 @@ async fn wait_for_peer(
|
||||
None
|
||||
}
|
||||
|
||||
/// Two LanDiscovery instances on isolated service types — `a` browses
|
||||
/// Two LanRendezvous instances on isolated service types — `a` browses
|
||||
/// only its own type and never sees `b`, and vice versa. Sanity check
|
||||
/// that the scope-isolation defense works (we'd lose isolation if mdns-
|
||||
/// sd ever leaked across service types).
|
||||
@@ -76,7 +76,7 @@ async fn isolated_service_types_do_not_cross_feed() {
|
||||
let service_a = isolated_service_type("isolated-a");
|
||||
let service_b = isolated_service_type("isolated-b");
|
||||
|
||||
let lan_a = LanDiscovery::start(
|
||||
let lan_a = LanRendezvous::start(
|
||||
&identity_a,
|
||||
Some("scope-x".to_string()),
|
||||
61001,
|
||||
@@ -84,7 +84,7 @@ async fn isolated_service_types_do_not_cross_feed() {
|
||||
)
|
||||
.await
|
||||
.expect("start a");
|
||||
let lan_b = LanDiscovery::start(
|
||||
let lan_b = LanRendezvous::start(
|
||||
&identity_b,
|
||||
Some("scope-x".to_string()),
|
||||
61002,
|
||||
@@ -118,7 +118,7 @@ async fn isolated_service_types_do_not_cross_feed() {
|
||||
assert!(!saw_a_from_b, "isolated service types must not cross-feed");
|
||||
}
|
||||
|
||||
/// Two LanDiscovery instances on the same service type and the same
|
||||
/// Two LanRendezvous instances on the same service type and the same
|
||||
/// scope: each should observe the other's advert within a few seconds.
|
||||
/// Exercises the responder + browser + TXT plumbing end-to-end.
|
||||
///
|
||||
@@ -136,7 +136,7 @@ async fn matched_scope_peers_observe_each_other() {
|
||||
|
||||
let service = isolated_service_type("matched");
|
||||
|
||||
let lan_a = LanDiscovery::start(
|
||||
let lan_a = LanRendezvous::start(
|
||||
&identity_a,
|
||||
Some("scope-shared".to_string()),
|
||||
61101,
|
||||
@@ -144,7 +144,7 @@ async fn matched_scope_peers_observe_each_other() {
|
||||
)
|
||||
.await
|
||||
.expect("start a");
|
||||
let lan_b = LanDiscovery::start(
|
||||
let lan_b = LanRendezvous::start(
|
||||
&identity_b,
|
||||
Some("scope-shared".to_string()),
|
||||
61102,
|
||||
@@ -181,7 +181,7 @@ async fn cross_scope_advert_is_filtered() {
|
||||
|
||||
let service = isolated_service_type("cross-scope");
|
||||
|
||||
let lan_a = LanDiscovery::start(
|
||||
let lan_a = LanRendezvous::start(
|
||||
&identity_a,
|
||||
Some("scope-a".to_string()),
|
||||
61201,
|
||||
@@ -189,7 +189,7 @@ async fn cross_scope_advert_is_filtered() {
|
||||
)
|
||||
.await
|
||||
.expect("start a");
|
||||
let lan_b = LanDiscovery::start(
|
||||
let lan_b = LanRendezvous::start(
|
||||
&identity_b,
|
||||
Some("scope-b".to_string()),
|
||||
61202,
|
||||
@@ -1,556 +0,0 @@
|
||||
//! MMP derived metrics.
|
||||
//!
|
||||
//! `MmpMetrics` processes incoming ReceiverReports (from our peer) and
|
||||
//! maintains derived metrics: SRTT, loss rate, goodput, ETX, and dual
|
||||
//! EWMA trend indicators. Updated by the sender side when it receives
|
||||
//! a ReceiverReport about its own traffic.
|
||||
|
||||
use crate::mmp::algorithms::{DualEwma, SrttEstimator, compute_etx};
|
||||
use crate::mmp::report::ReceiverReport;
|
||||
use std::time::Instant;
|
||||
use tracing::trace;
|
||||
|
||||
/// Derived MMP metrics, updated from incoming ReceiverReports.
|
||||
///
|
||||
/// This lives on the sender side: when we receive a ReceiverReport from
|
||||
/// our peer describing what they observed about our traffic, we process
|
||||
/// it here to compute RTT, loss, goodput, and trend indicators.
|
||||
pub struct MmpMetrics {
|
||||
/// Smoothed RTT from timestamp echo.
|
||||
pub srtt: SrttEstimator,
|
||||
|
||||
/// Dual EWMA trend detectors.
|
||||
pub rtt_trend: DualEwma,
|
||||
pub loss_trend: DualEwma,
|
||||
pub goodput_trend: DualEwma,
|
||||
pub jitter_trend: DualEwma,
|
||||
pub etx_trend: DualEwma,
|
||||
|
||||
/// Forward delivery ratio (what fraction of our frames the peer received).
|
||||
pub delivery_ratio_forward: f64,
|
||||
/// Reverse delivery ratio (set when we compute from our own receiver state).
|
||||
pub delivery_ratio_reverse: f64,
|
||||
/// ETX computed from bidirectional delivery ratios.
|
||||
pub etx: f64,
|
||||
|
||||
/// Smoothed goodput in bytes/sec (forward direction: what the peer received from us).
|
||||
pub goodput_bps: f64,
|
||||
|
||||
// --- State for delta computation ---
|
||||
/// Previous ReceiverReport's cumulative counters (for computing interval deltas).
|
||||
prev_rr_cum_packets: u64,
|
||||
prev_rr_cum_bytes: u64,
|
||||
prev_rr_highest_counter: u64,
|
||||
prev_rr_ecn_ce: u32,
|
||||
prev_rr_reorder: u32,
|
||||
/// Time of previous ReceiverReport (for goodput rate computation).
|
||||
prev_rr_time: Option<Instant>,
|
||||
/// Whether we have a previous ReceiverReport for delta computation.
|
||||
has_prev_rr: bool,
|
||||
|
||||
// --- State for reverse delivery ratio delta computation ---
|
||||
/// Previous reverse-side cumulative packets received (our receiver state).
|
||||
prev_reverse_packets: u64,
|
||||
/// Previous reverse-side highest counter (our receiver state).
|
||||
prev_reverse_highest: u64,
|
||||
/// Whether we have a previous reverse-side snapshot for delta computation.
|
||||
has_prev_reverse: bool,
|
||||
}
|
||||
|
||||
impl MmpMetrics {
|
||||
/// Reset state derived from ReceiverReport counters for rekey cutover.
|
||||
///
|
||||
/// The new session starts with counter 0, so the prev_rr deltas must
|
||||
/// be reset to avoid computing bogus loss/goodput from the counter
|
||||
/// discontinuity. RTT (SRTT) is preserved since it remains valid.
|
||||
pub fn reset_for_rekey(&mut self) {
|
||||
self.prev_rr_cum_packets = 0;
|
||||
self.prev_rr_cum_bytes = 0;
|
||||
self.prev_rr_highest_counter = 0;
|
||||
self.prev_rr_ecn_ce = 0;
|
||||
self.prev_rr_reorder = 0;
|
||||
self.prev_rr_time = None;
|
||||
self.has_prev_rr = false;
|
||||
self.delivery_ratio_forward = 1.0;
|
||||
self.prev_reverse_packets = 0;
|
||||
self.prev_reverse_highest = 0;
|
||||
self.has_prev_reverse = false;
|
||||
// Keep srtt, etx, trends, goodput_bps — they'll refresh from data
|
||||
}
|
||||
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
srtt: SrttEstimator::new(),
|
||||
rtt_trend: DualEwma::new(),
|
||||
loss_trend: DualEwma::new(),
|
||||
goodput_trend: DualEwma::new(),
|
||||
jitter_trend: DualEwma::new(),
|
||||
etx_trend: DualEwma::new(),
|
||||
delivery_ratio_forward: 1.0,
|
||||
delivery_ratio_reverse: 1.0,
|
||||
etx: 1.0,
|
||||
goodput_bps: 0.0,
|
||||
prev_rr_cum_packets: 0,
|
||||
prev_rr_cum_bytes: 0,
|
||||
prev_rr_highest_counter: 0,
|
||||
prev_rr_ecn_ce: 0,
|
||||
prev_rr_reorder: 0,
|
||||
prev_rr_time: None,
|
||||
has_prev_rr: false,
|
||||
prev_reverse_packets: 0,
|
||||
prev_reverse_highest: 0,
|
||||
has_prev_reverse: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Process an incoming ReceiverReport (from the peer about our traffic).
|
||||
///
|
||||
/// `our_timestamp_ms` is the current session-relative time in ms (for RTT).
|
||||
/// `now` is the current monotonic time (for goodput rate computation).
|
||||
///
|
||||
/// Returns `true` if this report produced the first SRTT measurement
|
||||
/// (transition from uninitialized to initialized).
|
||||
pub fn process_receiver_report(
|
||||
&mut self,
|
||||
rr: &ReceiverReport,
|
||||
our_timestamp_ms: u32,
|
||||
now: Instant,
|
||||
) -> bool {
|
||||
let had_srtt = self.srtt.initialized();
|
||||
|
||||
if self.has_prev_rr {
|
||||
let counters_regressed = rr.highest_counter < self.prev_rr_highest_counter
|
||||
|| rr.cumulative_packets_recv < self.prev_rr_cum_packets
|
||||
|| rr.cumulative_bytes_recv < self.prev_rr_cum_bytes
|
||||
|| rr.ecn_ce_count < self.prev_rr_ecn_ce
|
||||
|| rr.cumulative_reorder_count < self.prev_rr_reorder;
|
||||
let duplicate_counters = rr.highest_counter == self.prev_rr_highest_counter
|
||||
&& rr.cumulative_packets_recv == self.prev_rr_cum_packets
|
||||
&& rr.cumulative_bytes_recv == self.prev_rr_cum_bytes
|
||||
&& rr.ecn_ce_count == self.prev_rr_ecn_ce
|
||||
&& rr.cumulative_reorder_count == self.prev_rr_reorder;
|
||||
// Safe to drop: reports are only built after interval data, so
|
||||
// a fresh report always advances at least one cumulative counter.
|
||||
if counters_regressed || duplicate_counters {
|
||||
trace!(
|
||||
highest_counter = rr.highest_counter,
|
||||
prev_highest_counter = self.prev_rr_highest_counter,
|
||||
cumulative_packets_recv = rr.cumulative_packets_recv,
|
||||
prev_cumulative_packets_recv = self.prev_rr_cum_packets,
|
||||
cumulative_bytes_recv = rr.cumulative_bytes_recv,
|
||||
prev_cumulative_bytes_recv = self.prev_rr_cum_bytes,
|
||||
"Ignoring stale MMP ReceiverReport"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- RTT from timestamp echo ---
|
||||
// RTT = now - echoed_timestamp - dwell_time
|
||||
if rr.timestamp_echo > 0 {
|
||||
let echo_ms = rr.timestamp_echo;
|
||||
let dwell_ms = u32::from(rr.dwell_time);
|
||||
let rtt_sample_ms = echo_ms
|
||||
.checked_add(dwell_ms)
|
||||
.and_then(|send_done_ms| our_timestamp_ms.checked_sub(send_done_ms));
|
||||
|
||||
match rtt_sample_ms {
|
||||
Some(rtt_ms) if rtt_ms > 0 => {
|
||||
let rtt_us = (rtt_ms as i64) * 1000;
|
||||
trace!(
|
||||
our_ts = our_timestamp_ms,
|
||||
echo = echo_ms,
|
||||
dwell = dwell_ms,
|
||||
rtt_ms = rtt_ms,
|
||||
srtt_ms = self.srtt.srtt_us() as f64 / 1000.0,
|
||||
"RTT sample from timestamp echo"
|
||||
);
|
||||
self.srtt.update(rtt_us);
|
||||
self.rtt_trend.update(rtt_us as f64);
|
||||
}
|
||||
_ => {
|
||||
trace!(
|
||||
our_ts = our_timestamp_ms,
|
||||
echo = echo_ms,
|
||||
dwell = dwell_ms,
|
||||
"Ignoring invalid MMP RTT sample"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Loss rate from cumulative counters ---
|
||||
// Delta: frames the peer should have received vs. actually received
|
||||
if self.has_prev_rr {
|
||||
let counter_span = rr
|
||||
.highest_counter
|
||||
.saturating_sub(self.prev_rr_highest_counter);
|
||||
let packets_delta = rr
|
||||
.cumulative_packets_recv
|
||||
.saturating_sub(self.prev_rr_cum_packets);
|
||||
|
||||
if counter_span > 0 {
|
||||
let delivery = (packets_delta as f64) / (counter_span as f64);
|
||||
self.delivery_ratio_forward = delivery.clamp(0.0, 1.0);
|
||||
let loss_rate = 1.0 - self.delivery_ratio_forward;
|
||||
self.loss_trend.update(loss_rate);
|
||||
self.etx = compute_etx(self.delivery_ratio_forward, self.delivery_ratio_reverse);
|
||||
self.etx_trend.update(self.etx);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Goodput from cumulative bytes + time delta ---
|
||||
if self.has_prev_rr {
|
||||
let bytes_delta = rr
|
||||
.cumulative_bytes_recv
|
||||
.saturating_sub(self.prev_rr_cum_bytes);
|
||||
self.goodput_trend.update(bytes_delta as f64);
|
||||
|
||||
// Compute bytes/sec if we have a time reference
|
||||
if let Some(prev_time) = self.prev_rr_time {
|
||||
let elapsed = now.duration_since(prev_time);
|
||||
let secs = elapsed.as_secs_f64();
|
||||
if secs > 0.0 {
|
||||
let bps = bytes_delta as f64 / secs;
|
||||
// EWMA smoothing: α = 1/4
|
||||
if self.goodput_bps == 0.0 {
|
||||
self.goodput_bps = bps;
|
||||
} else {
|
||||
self.goodput_bps += (bps - self.goodput_bps) * 0.25;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Jitter trend ---
|
||||
self.jitter_trend.update(rr.jitter as f64);
|
||||
|
||||
// --- Save for next delta ---
|
||||
self.prev_rr_cum_packets = rr.cumulative_packets_recv;
|
||||
self.prev_rr_cum_bytes = rr.cumulative_bytes_recv;
|
||||
self.prev_rr_highest_counter = rr.highest_counter;
|
||||
self.prev_rr_ecn_ce = rr.ecn_ce_count;
|
||||
self.prev_rr_reorder = rr.cumulative_reorder_count;
|
||||
self.prev_rr_time = Some(now);
|
||||
self.has_prev_rr = true;
|
||||
|
||||
!had_srtt && self.srtt.initialized()
|
||||
}
|
||||
|
||||
/// Update the reverse delivery ratio from our own receiver state.
|
||||
///
|
||||
/// Computes a per-interval delta (same as forward ratio) rather than
|
||||
/// a lifetime cumulative ratio, so ETX responds to recent conditions.
|
||||
pub fn update_reverse_delivery(&mut self, our_recv_packets: u64, peer_highest: u64) {
|
||||
if self.has_prev_reverse {
|
||||
let counter_span = peer_highest.saturating_sub(self.prev_reverse_highest);
|
||||
let packets_delta = our_recv_packets.saturating_sub(self.prev_reverse_packets);
|
||||
|
||||
if counter_span > 0 {
|
||||
let delivery = (packets_delta as f64) / (counter_span as f64);
|
||||
self.delivery_ratio_reverse = delivery.clamp(0.0, 1.0);
|
||||
self.etx = compute_etx(self.delivery_ratio_forward, self.delivery_ratio_reverse);
|
||||
self.etx_trend.update(self.etx);
|
||||
}
|
||||
}
|
||||
|
||||
self.prev_reverse_packets = our_recv_packets;
|
||||
self.prev_reverse_highest = peer_highest;
|
||||
self.has_prev_reverse = true;
|
||||
}
|
||||
|
||||
/// Current smoothed RTT in milliseconds, or `None` if not yet measured.
|
||||
pub fn srtt_ms(&self) -> Option<f64> {
|
||||
if self.srtt.initialized() {
|
||||
Some(self.srtt.srtt_us() as f64 / 1000.0)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Current loss rate (0.0 = no loss, 1.0 = total loss).
|
||||
pub fn loss_rate(&self) -> f64 {
|
||||
1.0 - self.delivery_ratio_forward
|
||||
}
|
||||
|
||||
/// Smoothed loss rate (long-term EWMA), or `None` if not yet initialized.
|
||||
pub fn smoothed_loss(&self) -> Option<f64> {
|
||||
if self.loss_trend.initialized() {
|
||||
Some(self.loss_trend.long())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Smoothed ETX (long-term EWMA), or `None` if not yet initialized.
|
||||
pub fn smoothed_etx(&self) -> Option<f64> {
|
||||
if self.etx_trend.initialized() {
|
||||
Some(self.etx_trend.long())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Current smoothed goodput in bytes/sec, or 0 if not yet measured.
|
||||
pub fn goodput_bps(&self) -> f64 {
|
||||
self.goodput_bps
|
||||
}
|
||||
|
||||
/// Cumulative ECN CE count from the most recent ReceiverReport.
|
||||
pub fn last_ecn_ce_count(&self) -> u32 {
|
||||
self.prev_rr_ecn_ce
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MmpMetrics {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
fn make_rr(
|
||||
highest_counter: u64,
|
||||
cum_packets: u64,
|
||||
cum_bytes: u64,
|
||||
timestamp_echo: u32,
|
||||
dwell: u16,
|
||||
jitter: u32,
|
||||
) -> ReceiverReport {
|
||||
ReceiverReport {
|
||||
highest_counter,
|
||||
cumulative_packets_recv: cum_packets,
|
||||
cumulative_bytes_recv: cum_bytes,
|
||||
timestamp_echo,
|
||||
dwell_time: dwell,
|
||||
max_burst_loss: 0,
|
||||
mean_burst_loss: 0,
|
||||
jitter,
|
||||
ecn_ce_count: 0,
|
||||
owd_trend: 0,
|
||||
burst_loss_count: 0,
|
||||
cumulative_reorder_count: 0,
|
||||
interval_packets_recv: 0,
|
||||
interval_bytes_recv: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rtt_from_echo() {
|
||||
let mut m = MmpMetrics::new();
|
||||
let now = Instant::now();
|
||||
// Peer echoes timestamp 1000ms, dwell=5ms, our current time=1050ms
|
||||
let rr = make_rr(10, 10, 5000, 1000, 5, 0);
|
||||
m.process_receiver_report(&rr, 1050, now);
|
||||
|
||||
assert!(m.srtt.initialized());
|
||||
// RTT = 1050 - 1000 - 5 = 45ms
|
||||
let srtt_ms = m.srtt_ms().unwrap();
|
||||
assert!((srtt_ms - 45.0).abs() < 1.0, "srtt={srtt_ms}, expected ~45");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ignores_duplicate_receiver_report_after_valid_sample() {
|
||||
let mut m = MmpMetrics::new();
|
||||
let t0 = Instant::now();
|
||||
|
||||
let rr1 = make_rr(10, 10, 5_000, 1_000, 5, 0);
|
||||
m.process_receiver_report(&rr1, 1_050, t0);
|
||||
|
||||
let rr2 = make_rr(20, 18, 14_000, 1_100, 5, 0);
|
||||
m.process_receiver_report(&rr2, 1_150, t0 + Duration::from_secs(1));
|
||||
let baseline_srtt_ms = m.srtt_ms().unwrap();
|
||||
let baseline_loss = m.loss_rate();
|
||||
let baseline_goodput = m.goodput_bps();
|
||||
|
||||
assert!(baseline_loss > 0.0);
|
||||
assert!(baseline_goodput > 0.0);
|
||||
|
||||
// A duplicate of the same counters arriving later would be a 4.895s
|
||||
// RTT sample if accepted. It is stale and must not move metrics.
|
||||
m.process_receiver_report(&rr2, 6_000, t0 + Duration::from_secs(5));
|
||||
|
||||
assert_eq!(m.srtt_ms().unwrap(), baseline_srtt_ms);
|
||||
assert_eq!(m.loss_rate(), baseline_loss);
|
||||
assert_eq!(m.goodput_bps(), baseline_goodput);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ignores_out_of_order_receiver_report_after_valid_sample() {
|
||||
let mut m = MmpMetrics::new();
|
||||
let now = Instant::now();
|
||||
|
||||
let valid_rr = make_rr(20, 20, 10000, 1000, 5, 0);
|
||||
m.process_receiver_report(&valid_rr, 1050, now);
|
||||
let baseline_srtt_ms = m.srtt_ms().unwrap();
|
||||
|
||||
let old_rr = make_rr(10, 10, 5000, 1000, 0, 0);
|
||||
m.process_receiver_report(&old_rr, 6000, now + Duration::from_secs(5));
|
||||
|
||||
let srtt_ms = m.srtt_ms().unwrap();
|
||||
assert_eq!(srtt_ms, baseline_srtt_ms);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ignores_wrapped_rtt_sample() {
|
||||
let mut m = MmpMetrics::new();
|
||||
let now = Instant::now();
|
||||
|
||||
let wrapped_rr = make_rr(10, 10, 5000, u32::MAX - 10, 20, 0);
|
||||
m.process_receiver_report(&wrapped_rr, 15, now);
|
||||
|
||||
assert!(m.srtt_ms().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ignores_future_rtt_sample() {
|
||||
let mut m = MmpMetrics::new();
|
||||
let now = Instant::now();
|
||||
|
||||
let future_rr = make_rr(10, 10, 5_000, 2_000, 5, 0);
|
||||
m.process_receiver_report(&future_rr, 1_000, now);
|
||||
|
||||
assert!(m.srtt_ms().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_loss_rate_computation() {
|
||||
let mut m = MmpMetrics::new();
|
||||
let t0 = Instant::now();
|
||||
|
||||
// First report: baseline
|
||||
let rr1 = make_rr(100, 100, 50000, 0, 0, 0);
|
||||
m.process_receiver_report(&rr1, 0, t0);
|
||||
|
||||
// Second report: 200 counters sent, 190 received (5% loss)
|
||||
let rr2 = make_rr(300, 290, 145000, 0, 0, 0);
|
||||
m.process_receiver_report(&rr2, 0, t0 + Duration::from_secs(1));
|
||||
|
||||
let loss = m.loss_rate();
|
||||
assert!((loss - 0.05).abs() < 0.01, "loss={loss}, expected ~0.05");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_etx_updates() {
|
||||
let mut m = MmpMetrics::new();
|
||||
assert_eq!(m.etx, 1.0); // initial: perfect
|
||||
|
||||
// Simulate some loss via forward ratio
|
||||
m.delivery_ratio_forward = 0.9;
|
||||
|
||||
// First call establishes the baseline (no ETX update yet)
|
||||
m.update_reverse_delivery(100, 100);
|
||||
assert_eq!(m.etx, 1.0); // still perfect — baseline only
|
||||
|
||||
// Second call: 190 of 200 frames received (5% loss)
|
||||
m.update_reverse_delivery(290, 300);
|
||||
assert!(m.etx > 1.0);
|
||||
assert!(m.etx < 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_rtt_without_echo() {
|
||||
let mut m = MmpMetrics::new();
|
||||
let now = Instant::now();
|
||||
let rr = make_rr(10, 10, 5000, 0, 0, 0);
|
||||
m.process_receiver_report(&rr, 1000, now);
|
||||
assert!(m.srtt_ms().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jitter_trend() {
|
||||
let mut m = MmpMetrics::new();
|
||||
let t0 = Instant::now();
|
||||
let rr1 = make_rr(10, 10, 5000, 0, 0, 100);
|
||||
m.process_receiver_report(&rr1, 0, t0);
|
||||
|
||||
let rr2 = make_rr(20, 20, 10000, 0, 0, 500);
|
||||
m.process_receiver_report(&rr2, 0, t0 + Duration::from_secs(1));
|
||||
|
||||
assert!(m.jitter_trend.initialized());
|
||||
// Short-term should be closer to 500 than long-term
|
||||
assert!(m.jitter_trend.short() > m.jitter_trend.long());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_goodput_bps() {
|
||||
let mut m = MmpMetrics::new();
|
||||
let t0 = Instant::now();
|
||||
|
||||
// First report: baseline (50KB received)
|
||||
let rr1 = make_rr(100, 100, 50_000, 0, 0, 0);
|
||||
m.process_receiver_report(&rr1, 0, t0);
|
||||
assert_eq!(m.goodput_bps(), 0.0); // no rate yet (first report)
|
||||
|
||||
// Second report 1s later: 150KB total (100KB delta in 1s = 100KB/s)
|
||||
let rr2 = make_rr(300, 290, 150_000, 0, 0, 0);
|
||||
m.process_receiver_report(&rr2, 0, t0 + Duration::from_secs(1));
|
||||
assert!(
|
||||
m.goodput_bps() > 90_000.0,
|
||||
"goodput={}, expected ~100000",
|
||||
m.goodput_bps()
|
||||
);
|
||||
assert!(
|
||||
m.goodput_bps() < 110_000.0,
|
||||
"goodput={}, expected ~100000",
|
||||
m.goodput_bps()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reverse_delivery_delta() {
|
||||
let mut m = MmpMetrics::new();
|
||||
|
||||
// First call: baseline only, no ratio update
|
||||
m.update_reverse_delivery(100, 100);
|
||||
assert_eq!(m.delivery_ratio_reverse, 1.0); // unchanged from default
|
||||
|
||||
// Second call: perfect delivery (200 new frames, all received)
|
||||
m.update_reverse_delivery(300, 300);
|
||||
assert!((m.delivery_ratio_reverse - 1.0).abs() < 0.001);
|
||||
|
||||
// Third call: 50% loss (100 frames sent, 50 received)
|
||||
m.update_reverse_delivery(350, 400);
|
||||
assert!(
|
||||
(m.delivery_ratio_reverse - 0.5).abs() < 0.001,
|
||||
"reverse={}, expected 0.5",
|
||||
m.delivery_ratio_reverse
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reverse_delivery_rekey_reset() {
|
||||
let mut m = MmpMetrics::new();
|
||||
|
||||
// Establish baseline and one measurement
|
||||
m.update_reverse_delivery(100, 100);
|
||||
m.update_reverse_delivery(300, 300);
|
||||
assert!((m.delivery_ratio_reverse - 1.0).abs() < 0.001);
|
||||
|
||||
// Rekey resets reverse state
|
||||
m.reset_for_rekey();
|
||||
|
||||
// First call after rekey: baseline only
|
||||
m.update_reverse_delivery(50, 50);
|
||||
// delivery_ratio_reverse was reset to 1.0 by reset_for_rekey's
|
||||
// clearing of delivery_ratio_forward; reverse is not explicitly
|
||||
// reset — but the delta state is, so next call computes fresh.
|
||||
assert_eq!(m.delivery_ratio_reverse, 1.0);
|
||||
|
||||
// Second call after rekey: 80% delivery
|
||||
m.update_reverse_delivery(90, 100);
|
||||
assert!(
|
||||
(m.delivery_ratio_reverse - 0.8).abs() < 0.001,
|
||||
"reverse={}, expected 0.8",
|
||||
m.delivery_ratio_reverse
|
||||
);
|
||||
}
|
||||
}
|
||||
555
src/mmp/mod.rs
555
src/mmp/mod.rs
@@ -1,555 +0,0 @@
|
||||
//! Metrics Measurement Protocol (MMP) — link-layer instantiation.
|
||||
//!
|
||||
//! Measures link quality between adjacent peers: RTT, loss, jitter,
|
||||
//! throughput, one-way delay trend, and ETX. Operates on the per-frame
|
||||
//! hooks (counter, timestamp, flags) introduced by the FMP wire format
|
||||
//! revision.
|
||||
//!
|
||||
//! Three operating modes trade measurement fidelity for overhead:
|
||||
//! - **Full**: sender + receiver reports at RTT-adaptive intervals
|
||||
//! - **Lightweight**: receiver reports only (infer loss from counters)
|
||||
//! - **Minimal**: spin bit + CE echo only, no reports
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt::{self, Debug};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
// Sub-modules
|
||||
pub mod algorithms;
|
||||
pub mod metrics;
|
||||
pub mod receiver;
|
||||
pub mod report;
|
||||
pub mod sender;
|
||||
|
||||
// Re-exports
|
||||
pub use algorithms::{
|
||||
DualEwma, JitterEstimator, OwdTrendDetector, SpinBitState, SrttEstimator, compute_etx,
|
||||
};
|
||||
pub use metrics::MmpMetrics;
|
||||
pub use receiver::ReceiverState;
|
||||
pub use report::{ReceiverReport, SenderReport};
|
||||
pub use sender::SenderState;
|
||||
|
||||
// Session-layer re-exports
|
||||
// MmpSessionState and PathMtuState are defined in this file
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
/// SenderReport body size (after msg_type byte): 3 reserved + 44 payload = 47.
|
||||
pub const SENDER_REPORT_BODY_SIZE: usize = 47;
|
||||
|
||||
/// ReceiverReport body size (after msg_type byte): 3 reserved + 64 payload = 67.
|
||||
pub const RECEIVER_REPORT_BODY_SIZE: usize = 67;
|
||||
|
||||
/// SenderReport total wire size including inner header: 5 + 47 = 52.
|
||||
pub const SENDER_REPORT_WIRE_SIZE: usize = 52;
|
||||
|
||||
/// ReceiverReport total wire size including inner header: 5 + 67 = 72.
|
||||
pub const RECEIVER_REPORT_WIRE_SIZE: usize = 72;
|
||||
|
||||
// --- EWMA parameters (as shift amounts for integer arithmetic) ---
|
||||
|
||||
/// Jitter EWMA: α = 1/16 (RFC 3550 §6.4.1).
|
||||
pub const JITTER_ALPHA_SHIFT: u32 = 4;
|
||||
|
||||
/// SRTT: α = 1/8 (Jacobson, RFC 6298).
|
||||
pub const SRTT_ALPHA_SHIFT: u32 = 3;
|
||||
|
||||
/// RTTVAR: β = 1/4 (Jacobson, RFC 6298).
|
||||
pub const RTTVAR_BETA_SHIFT: u32 = 2;
|
||||
|
||||
/// Dual EWMA short-term: α = 1/4.
|
||||
pub const EWMA_SHORT_ALPHA: f64 = 0.25;
|
||||
|
||||
/// Dual EWMA long-term: α = 1/32.
|
||||
pub const EWMA_LONG_ALPHA: f64 = 1.0 / 32.0;
|
||||
|
||||
// --- Timing defaults (milliseconds) ---
|
||||
|
||||
/// Default report interval before SRTT is available (cold start).
|
||||
pub const DEFAULT_COLD_START_INTERVAL_MS: u64 = 200;
|
||||
|
||||
/// Minimum report interval (SRTT clamp floor).
|
||||
///
|
||||
/// Raised from 100ms to 1000ms: parent re-evaluation runs every 60s,
|
||||
/// so 60 samples/cycle is more than sufficient for EWMA convergence (~10).
|
||||
/// The cold-start phase uses `DEFAULT_COLD_START_INTERVAL_MS` (200ms) for
|
||||
/// fast initial SRTT convergence before transitioning to this floor.
|
||||
pub const MIN_REPORT_INTERVAL_MS: u64 = 1_000;
|
||||
|
||||
/// Maximum report interval (SRTT clamp ceiling).
|
||||
pub const MAX_REPORT_INTERVAL_MS: u64 = 5_000;
|
||||
|
||||
/// Number of SRTT samples before transitioning from cold-start to normal floor.
|
||||
///
|
||||
/// During cold-start, report intervals use `DEFAULT_COLD_START_INTERVAL_MS` as
|
||||
/// the floor to gather SRTT samples quickly. After this many updates, the floor
|
||||
/// switches to `MIN_REPORT_INTERVAL_MS`.
|
||||
pub const COLD_START_SAMPLES: u32 = 5;
|
||||
|
||||
/// Default OWD ring buffer capacity.
|
||||
pub const DEFAULT_OWD_WINDOW_SIZE: usize = 32;
|
||||
|
||||
/// Default operator log interval in seconds.
|
||||
pub const DEFAULT_LOG_INTERVAL_SECS: u64 = 30;
|
||||
|
||||
// --- Session-layer timing defaults ---
|
||||
// Session reports are routed end-to-end (bandwidth cost on every transit link),
|
||||
// so intervals are higher than link-layer.
|
||||
|
||||
/// Session-layer minimum report interval.
|
||||
pub const MIN_SESSION_REPORT_INTERVAL_MS: u64 = 500;
|
||||
|
||||
/// Session-layer maximum report interval.
|
||||
pub const MAX_SESSION_REPORT_INTERVAL_MS: u64 = 10_000;
|
||||
|
||||
/// Session-layer cold-start report interval (before SRTT is available).
|
||||
pub const SESSION_COLD_START_INTERVAL_MS: u64 = 1_000;
|
||||
|
||||
// ============================================================================
|
||||
// Operating Mode
|
||||
// ============================================================================
|
||||
|
||||
/// MMP operating mode.
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MmpMode {
|
||||
/// Sender + receiver reports at RTT-adaptive intervals. Maximum fidelity.
|
||||
#[default]
|
||||
Full,
|
||||
/// Receiver reports only. Loss inferred from counter gaps.
|
||||
Lightweight,
|
||||
/// Spin bit + CE echo only. No reports exchanged.
|
||||
Minimal,
|
||||
}
|
||||
|
||||
impl fmt::Display for MmpMode {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
MmpMode::Full => write!(f, "full"),
|
||||
MmpMode::Lightweight => write!(f, "lightweight"),
|
||||
MmpMode::Minimal => write!(f, "minimal"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Configuration
|
||||
// ============================================================================
|
||||
|
||||
/// MMP configuration (`node.mmp.*`).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MmpConfig {
|
||||
/// Operating mode (`node.mmp.mode`).
|
||||
#[serde(default)]
|
||||
pub mode: MmpMode,
|
||||
|
||||
/// Periodic operator log interval in seconds (`node.mmp.log_interval_secs`).
|
||||
#[serde(default = "MmpConfig::default_log_interval_secs")]
|
||||
pub log_interval_secs: u64,
|
||||
|
||||
/// OWD trend ring buffer size (`node.mmp.owd_window_size`).
|
||||
#[serde(default = "MmpConfig::default_owd_window_size")]
|
||||
pub owd_window_size: usize,
|
||||
}
|
||||
|
||||
impl Default for MmpConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: MmpMode::default(),
|
||||
log_interval_secs: DEFAULT_LOG_INTERVAL_SECS,
|
||||
owd_window_size: DEFAULT_OWD_WINDOW_SIZE,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MmpConfig {
|
||||
fn default_log_interval_secs() -> u64 {
|
||||
DEFAULT_LOG_INTERVAL_SECS
|
||||
}
|
||||
fn default_owd_window_size() -> usize {
|
||||
DEFAULT_OWD_WINDOW_SIZE
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Per-Peer MMP State
|
||||
// ============================================================================
|
||||
|
||||
/// Combined MMP state for a single peer link.
|
||||
///
|
||||
/// Wraps sender, receiver, metrics, and spin bit state. One instance
|
||||
/// per `ActivePeer`.
|
||||
pub struct MmpPeerState {
|
||||
pub sender: SenderState,
|
||||
pub receiver: ReceiverState,
|
||||
pub metrics: MmpMetrics,
|
||||
pub spin_bit: SpinBitState,
|
||||
mode: MmpMode,
|
||||
log_interval: Duration,
|
||||
last_log_time: Option<Instant>,
|
||||
}
|
||||
|
||||
impl MmpPeerState {
|
||||
/// Create MMP state for a new peer link.
|
||||
///
|
||||
/// `is_initiator`: true if this node initiated the Noise handshake
|
||||
/// (determines spin bit role).
|
||||
pub fn new(config: &MmpConfig, is_initiator: bool) -> Self {
|
||||
Self {
|
||||
sender: SenderState::new(),
|
||||
receiver: ReceiverState::new(config.owd_window_size),
|
||||
metrics: MmpMetrics::new(),
|
||||
spin_bit: SpinBitState::new(is_initiator),
|
||||
mode: config.mode,
|
||||
log_interval: Duration::from_secs(config.log_interval_secs),
|
||||
last_log_time: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset counter-dependent state for rekey cutover.
|
||||
pub fn reset_for_rekey(&mut self, now: Instant) {
|
||||
self.receiver.reset_for_rekey(now);
|
||||
self.metrics.reset_for_rekey();
|
||||
}
|
||||
|
||||
/// Current operating mode.
|
||||
pub fn mode(&self) -> MmpMode {
|
||||
self.mode
|
||||
}
|
||||
|
||||
/// Check if it's time to emit a periodic metrics log.
|
||||
pub fn should_log(&self, now: Instant) -> bool {
|
||||
match self.last_log_time {
|
||||
None => true,
|
||||
Some(last) => now.duration_since(last) >= self.log_interval,
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark that a periodic log was emitted.
|
||||
pub fn mark_logged(&mut self, now: Instant) {
|
||||
self.last_log_time = Some(now);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Per-Session MMP State (session-layer instantiation)
|
||||
// ============================================================================
|
||||
|
||||
/// Combined MMP state for a single end-to-end session.
|
||||
///
|
||||
/// Wraps sender, receiver, metrics, spin bit, and path MTU state.
|
||||
/// One instance per established `SessionEntry`.
|
||||
pub struct MmpSessionState {
|
||||
pub sender: SenderState,
|
||||
pub receiver: ReceiverState,
|
||||
pub metrics: MmpMetrics,
|
||||
pub spin_bit: SpinBitState,
|
||||
mode: MmpMode,
|
||||
log_interval: Duration,
|
||||
last_log_time: Option<Instant>,
|
||||
pub path_mtu: PathMtuState,
|
||||
}
|
||||
|
||||
impl MmpSessionState {
|
||||
/// Create MMP state for a new session.
|
||||
///
|
||||
/// `is_initiator`: true if this node initiated the Noise handshake
|
||||
/// (determines spin bit role).
|
||||
pub fn new(config: &crate::config::SessionMmpConfig, is_initiator: bool) -> Self {
|
||||
Self {
|
||||
sender: SenderState::new_with_cold_start(SESSION_COLD_START_INTERVAL_MS),
|
||||
receiver: ReceiverState::new_with_cold_start(
|
||||
config.owd_window_size,
|
||||
SESSION_COLD_START_INTERVAL_MS,
|
||||
),
|
||||
metrics: MmpMetrics::new(),
|
||||
spin_bit: SpinBitState::new(is_initiator),
|
||||
mode: config.mode,
|
||||
log_interval: Duration::from_secs(config.log_interval_secs),
|
||||
last_log_time: None,
|
||||
path_mtu: PathMtuState::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset counter-dependent state for rekey cutover.
|
||||
pub fn reset_for_rekey(&mut self, now: Instant) {
|
||||
self.receiver.reset_for_rekey(now);
|
||||
self.metrics.reset_for_rekey();
|
||||
}
|
||||
|
||||
/// Current operating mode.
|
||||
pub fn mode(&self) -> MmpMode {
|
||||
self.mode
|
||||
}
|
||||
|
||||
/// Check if it's time to emit a periodic metrics log.
|
||||
pub fn should_log(&self, now: Instant) -> bool {
|
||||
match self.last_log_time {
|
||||
None => true,
|
||||
Some(last) => now.duration_since(last) >= self.log_interval,
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark that a periodic log was emitted.
|
||||
pub fn mark_logged(&mut self, now: Instant) {
|
||||
self.last_log_time = Some(now);
|
||||
}
|
||||
}
|
||||
|
||||
impl Debug for MmpSessionState {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("MmpSessionState")
|
||||
.field("mode", &self.mode)
|
||||
.field("path_mtu", &self.path_mtu.current_mtu())
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Path MTU State (session-layer only)
|
||||
// ============================================================================
|
||||
|
||||
/// Path MTU tracking for a single session.
|
||||
///
|
||||
/// Destination side: observes `path_mtu` from incoming SessionDatagram envelopes
|
||||
/// and generates PathMtuNotification messages back to the source.
|
||||
///
|
||||
/// Source side: applies received PathMtuNotification to limit outbound datagram
|
||||
/// size. Decrease is immediate; increase requires 3 consecutive notifications.
|
||||
pub struct PathMtuState {
|
||||
/// Current effective path MTU (what we use for sending).
|
||||
current_mtu: u16,
|
||||
/// Last observed path MTU from incoming datagrams (destination-side).
|
||||
last_observed_mtu: u16,
|
||||
/// Whether the observed MTU has changed since the last notification.
|
||||
observed_changed: bool,
|
||||
/// Last time a PathMtuNotification was sent.
|
||||
last_notification_time: Option<Instant>,
|
||||
/// Notification interval: max(10s, 5 * SRTT). Default 10s.
|
||||
notification_interval: Duration,
|
||||
/// For source-side increase tracking: consecutive higher-value notifications.
|
||||
consecutive_increase_count: u8,
|
||||
/// Time of the first notification in the current increase sequence.
|
||||
first_increase_time: Option<Instant>,
|
||||
/// The MTU value being proposed for increase.
|
||||
pending_increase_mtu: u16,
|
||||
}
|
||||
|
||||
impl PathMtuState {
|
||||
/// Create path MTU state with no initial measurement.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
current_mtu: u16::MAX,
|
||||
last_observed_mtu: u16::MAX,
|
||||
observed_changed: false,
|
||||
last_notification_time: None,
|
||||
notification_interval: Duration::from_secs(10),
|
||||
consecutive_increase_count: 0,
|
||||
first_increase_time: None,
|
||||
pending_increase_mtu: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Current effective path MTU (source-side, for sending).
|
||||
pub fn current_mtu(&self) -> u16 {
|
||||
self.current_mtu
|
||||
}
|
||||
|
||||
/// Last observed incoming path MTU (destination-side).
|
||||
pub fn last_observed_mtu(&self) -> u16 {
|
||||
self.last_observed_mtu
|
||||
}
|
||||
|
||||
/// Update notification interval from SRTT: max(10s, 5 * SRTT).
|
||||
pub fn update_interval_from_srtt(&mut self, srtt_ms: f64) {
|
||||
let five_srtt = Duration::from_millis((srtt_ms * 5.0) as u64);
|
||||
self.notification_interval = five_srtt.max(Duration::from_secs(10));
|
||||
}
|
||||
|
||||
/// Seed source-side current_mtu from outbound transport MTU.
|
||||
///
|
||||
/// Called on each send. Only decreases (never increases) the current_mtu
|
||||
/// so the destination's PathMtuNotification can still raise it later.
|
||||
/// Ensures current_mtu doesn't stay at u16::MAX before any notification
|
||||
/// arrives from the destination.
|
||||
pub fn seed_source_mtu(&mut self, outbound_mtu: u16) {
|
||||
if outbound_mtu < self.current_mtu {
|
||||
self.current_mtu = outbound_mtu;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Destination side ---
|
||||
|
||||
/// Observe the path_mtu from an incoming SessionDatagram envelope.
|
||||
///
|
||||
/// Called on the destination (receiver) side for every session message.
|
||||
pub fn observe_incoming_mtu(&mut self, path_mtu: u16) {
|
||||
if path_mtu != self.last_observed_mtu {
|
||||
self.observed_changed = true;
|
||||
self.last_observed_mtu = path_mtu;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a PathMtuNotification should be sent.
|
||||
///
|
||||
/// Send on first measurement, on decrease (immediate), or periodic
|
||||
/// confirmation at the notification interval.
|
||||
pub fn should_send_notification(&self, now: Instant) -> bool {
|
||||
if self.last_observed_mtu == u16::MAX {
|
||||
return false; // No measurement yet
|
||||
}
|
||||
match self.last_notification_time {
|
||||
None => true, // First measurement
|
||||
Some(last) => {
|
||||
// Immediate on decrease
|
||||
if self.observed_changed && self.last_observed_mtu < self.current_mtu {
|
||||
return true;
|
||||
}
|
||||
// Periodic confirmation
|
||||
now.duration_since(last) >= self.notification_interval
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a PathMtuNotification from current state.
|
||||
///
|
||||
/// Returns the path_mtu value to send. Caller handles encoding.
|
||||
pub fn build_notification(&mut self, now: Instant) -> Option<u16> {
|
||||
if self.last_observed_mtu == u16::MAX {
|
||||
return None;
|
||||
}
|
||||
self.last_notification_time = Some(now);
|
||||
self.observed_changed = false;
|
||||
Some(self.last_observed_mtu)
|
||||
}
|
||||
|
||||
// --- Source side ---
|
||||
|
||||
/// Apply a received PathMtuNotification.
|
||||
///
|
||||
/// - Decrease: immediate (take the lower value).
|
||||
/// - Increase: require 3 consecutive notifications with the same higher
|
||||
/// value, spanning at least 2 * notification_interval.
|
||||
///
|
||||
/// Returns `true` if the effective MTU changed.
|
||||
pub fn apply_notification(&mut self, reported_mtu: u16, now: Instant) -> bool {
|
||||
if reported_mtu < self.current_mtu {
|
||||
// Decrease: immediate
|
||||
self.current_mtu = reported_mtu;
|
||||
self.consecutive_increase_count = 0;
|
||||
self.first_increase_time = None;
|
||||
return true;
|
||||
}
|
||||
|
||||
if reported_mtu > self.current_mtu {
|
||||
// Increase: track consecutive notifications
|
||||
if reported_mtu == self.pending_increase_mtu {
|
||||
self.consecutive_increase_count += 1;
|
||||
} else {
|
||||
// Different value: reset sequence
|
||||
self.pending_increase_mtu = reported_mtu;
|
||||
self.consecutive_increase_count = 1;
|
||||
self.first_increase_time = Some(now);
|
||||
}
|
||||
|
||||
// Accept increase after 3 consecutive spanning 2 * interval
|
||||
if self.consecutive_increase_count >= 3
|
||||
&& let Some(first_time) = self.first_increase_time
|
||||
{
|
||||
let required = self.notification_interval * 2;
|
||||
if now.duration_since(first_time) >= required {
|
||||
self.current_mtu = reported_mtu;
|
||||
self.consecutive_increase_count = 0;
|
||||
self.first_increase_time = None;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No change (equal or increase not yet confirmed)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PathMtuState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Debug for MmpPeerState {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("MmpPeerState")
|
||||
.field("mode", &self.mode)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_mode_default() {
|
||||
assert_eq!(MmpMode::default(), MmpMode::Full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mode_display() {
|
||||
assert_eq!(MmpMode::Full.to_string(), "full");
|
||||
assert_eq!(MmpMode::Lightweight.to_string(), "lightweight");
|
||||
assert_eq!(MmpMode::Minimal.to_string(), "minimal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mode_serde_roundtrip() {
|
||||
let yaml = "full";
|
||||
let mode: MmpMode = serde_yaml::from_str(yaml).unwrap();
|
||||
assert_eq!(mode, MmpMode::Full);
|
||||
|
||||
let yaml = "lightweight";
|
||||
let mode: MmpMode = serde_yaml::from_str(yaml).unwrap();
|
||||
assert_eq!(mode, MmpMode::Lightweight);
|
||||
|
||||
let yaml = "minimal";
|
||||
let mode: MmpMode = serde_yaml::from_str(yaml).unwrap();
|
||||
assert_eq!(mode, MmpMode::Minimal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_default() {
|
||||
let config = MmpConfig::default();
|
||||
assert_eq!(config.mode, MmpMode::Full);
|
||||
assert_eq!(config.log_interval_secs, 30);
|
||||
assert_eq!(config.owd_window_size, 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_yaml_parse() {
|
||||
let yaml = r#"
|
||||
mode: lightweight
|
||||
log_interval_secs: 60
|
||||
owd_window_size: 48
|
||||
"#;
|
||||
let config: MmpConfig = serde_yaml::from_str(yaml).unwrap();
|
||||
assert_eq!(config.mode, MmpMode::Lightweight);
|
||||
assert_eq!(config.log_interval_secs, 60);
|
||||
assert_eq!(config.owd_window_size, 48);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_yaml_partial() {
|
||||
let yaml = "mode: minimal";
|
||||
let config: MmpConfig = serde_yaml::from_str(yaml).unwrap();
|
||||
assert_eq!(config.mode, MmpMode::Minimal);
|
||||
assert_eq!(config.log_interval_secs, DEFAULT_LOG_INTERVAL_SECS);
|
||||
assert_eq!(config.owd_window_size, DEFAULT_OWD_WINDOW_SIZE);
|
||||
}
|
||||
}
|
||||
@@ -1,385 +0,0 @@
|
||||
//! MMP report wire format: SenderReport and ReceiverReport.
|
||||
//!
|
||||
//! Serialization and deserialization for the two report types exchanged
|
||||
//! between link-layer peers. Wire format follows the MMP design doc.
|
||||
|
||||
use crate::protocol::ProtocolError;
|
||||
|
||||
// ============================================================================
|
||||
// SenderReport (msg_type 0x01, 48-byte body including type byte)
|
||||
// ============================================================================
|
||||
|
||||
/// Link-layer sender report.
|
||||
///
|
||||
/// Wire layout (48 bytes total, sent as link message):
|
||||
/// ```text
|
||||
/// [0] msg_type = 0x01
|
||||
/// [1-3] reserved (zero)
|
||||
/// [4-11] interval_start_counter: u64 LE
|
||||
/// [12-19] interval_end_counter: u64 LE
|
||||
/// [20-23] interval_start_timestamp: u32 LE
|
||||
/// [24-27] interval_end_timestamp: u32 LE
|
||||
/// [28-31] interval_bytes_sent: u32 LE
|
||||
/// [32-39] cumulative_packets_sent: u64 LE
|
||||
/// [40-47] cumulative_bytes_sent: u64 LE
|
||||
/// ```
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SenderReport {
|
||||
pub interval_start_counter: u64,
|
||||
pub interval_end_counter: u64,
|
||||
pub interval_start_timestamp: u32,
|
||||
pub interval_end_timestamp: u32,
|
||||
pub interval_bytes_sent: u32,
|
||||
pub cumulative_packets_sent: u64,
|
||||
pub cumulative_bytes_sent: u64,
|
||||
}
|
||||
|
||||
/// ReceiverReport (msg_type 0x02, 68-byte body including type byte)
|
||||
///
|
||||
/// Wire layout (68 bytes total, sent as link message):
|
||||
/// ```text
|
||||
/// [0] msg_type = 0x02
|
||||
/// [1-3] reserved (zero)
|
||||
/// [4-11] highest_counter: u64 LE
|
||||
/// [12-19] cumulative_packets_recv: u64 LE
|
||||
/// [20-27] cumulative_bytes_recv: u64 LE
|
||||
/// [28-31] timestamp_echo: u32 LE
|
||||
/// [32-33] dwell_time: u16 LE
|
||||
/// [34-35] max_burst_loss: u16 LE
|
||||
/// [36-37] mean_burst_loss: u16 LE (u8.8 fixed-point)
|
||||
/// [38-39] reserved: u16 LE
|
||||
/// [40-43] jitter: u32 LE (microseconds)
|
||||
/// [44-47] ecn_ce_count: u32 LE
|
||||
/// [48-51] owd_trend: i32 LE (µs/s)
|
||||
/// [52-55] burst_loss_count: u32 LE
|
||||
/// [56-59] cumulative_reorder_count: u32 LE
|
||||
/// [60-63] interval_packets_recv: u32 LE
|
||||
/// [64-67] interval_bytes_recv: u32 LE
|
||||
/// ```
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReceiverReport {
|
||||
pub highest_counter: u64,
|
||||
pub cumulative_packets_recv: u64,
|
||||
pub cumulative_bytes_recv: u64,
|
||||
pub timestamp_echo: u32,
|
||||
pub dwell_time: u16,
|
||||
pub max_burst_loss: u16,
|
||||
pub mean_burst_loss: u16,
|
||||
pub jitter: u32,
|
||||
pub ecn_ce_count: u32,
|
||||
pub owd_trend: i32,
|
||||
pub burst_loss_count: u32,
|
||||
pub cumulative_reorder_count: u32,
|
||||
pub interval_packets_recv: u32,
|
||||
pub interval_bytes_recv: u32,
|
||||
}
|
||||
|
||||
// Encode/decode will be implemented in Step 2.
|
||||
|
||||
impl SenderReport {
|
||||
/// Encode to wire format (48 bytes: msg_type + 3 reserved + 44 payload).
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
let mut buf = Vec::with_capacity(48);
|
||||
buf.push(0x01); // msg_type
|
||||
buf.extend_from_slice(&[0u8; 3]); // reserved
|
||||
buf.extend_from_slice(&self.interval_start_counter.to_le_bytes());
|
||||
buf.extend_from_slice(&self.interval_end_counter.to_le_bytes());
|
||||
buf.extend_from_slice(&self.interval_start_timestamp.to_le_bytes());
|
||||
buf.extend_from_slice(&self.interval_end_timestamp.to_le_bytes());
|
||||
buf.extend_from_slice(&self.interval_bytes_sent.to_le_bytes());
|
||||
buf.extend_from_slice(&self.cumulative_packets_sent.to_le_bytes());
|
||||
buf.extend_from_slice(&self.cumulative_bytes_sent.to_le_bytes());
|
||||
buf
|
||||
}
|
||||
|
||||
/// Decode from payload after msg_type byte has been consumed.
|
||||
///
|
||||
/// `payload` starts at the reserved bytes (offset 1 in the wire format).
|
||||
pub fn decode(payload: &[u8]) -> Result<Self, ProtocolError> {
|
||||
if payload.len() < 47 {
|
||||
return Err(ProtocolError::MessageTooShort {
|
||||
expected: 47,
|
||||
got: payload.len(),
|
||||
});
|
||||
}
|
||||
// Skip 3 reserved bytes
|
||||
let p = &payload[3..];
|
||||
Ok(Self {
|
||||
interval_start_counter: u64::from_le_bytes(p[0..8].try_into().unwrap()),
|
||||
interval_end_counter: u64::from_le_bytes(p[8..16].try_into().unwrap()),
|
||||
interval_start_timestamp: u32::from_le_bytes(p[16..20].try_into().unwrap()),
|
||||
interval_end_timestamp: u32::from_le_bytes(p[20..24].try_into().unwrap()),
|
||||
interval_bytes_sent: u32::from_le_bytes(p[24..28].try_into().unwrap()),
|
||||
cumulative_packets_sent: u64::from_le_bytes(p[28..36].try_into().unwrap()),
|
||||
cumulative_bytes_sent: u64::from_le_bytes(p[36..44].try_into().unwrap()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ReceiverReport {
|
||||
/// Encode to wire format (68 bytes: msg_type + 3 reserved + 64 payload).
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
let mut buf = Vec::with_capacity(68);
|
||||
buf.push(0x02); // msg_type
|
||||
buf.extend_from_slice(&[0u8; 3]); // reserved
|
||||
buf.extend_from_slice(&self.highest_counter.to_le_bytes());
|
||||
buf.extend_from_slice(&self.cumulative_packets_recv.to_le_bytes());
|
||||
buf.extend_from_slice(&self.cumulative_bytes_recv.to_le_bytes());
|
||||
buf.extend_from_slice(&self.timestamp_echo.to_le_bytes());
|
||||
buf.extend_from_slice(&self.dwell_time.to_le_bytes());
|
||||
buf.extend_from_slice(&self.max_burst_loss.to_le_bytes());
|
||||
buf.extend_from_slice(&self.mean_burst_loss.to_le_bytes());
|
||||
buf.extend_from_slice(&[0u8; 2]); // reserved
|
||||
buf.extend_from_slice(&self.jitter.to_le_bytes());
|
||||
buf.extend_from_slice(&self.ecn_ce_count.to_le_bytes());
|
||||
buf.extend_from_slice(&self.owd_trend.to_le_bytes());
|
||||
buf.extend_from_slice(&self.burst_loss_count.to_le_bytes());
|
||||
buf.extend_from_slice(&self.cumulative_reorder_count.to_le_bytes());
|
||||
buf.extend_from_slice(&self.interval_packets_recv.to_le_bytes());
|
||||
buf.extend_from_slice(&self.interval_bytes_recv.to_le_bytes());
|
||||
buf
|
||||
}
|
||||
|
||||
/// Decode from payload after msg_type byte has been consumed.
|
||||
///
|
||||
/// `payload` starts at the reserved bytes (offset 1 in the wire format).
|
||||
pub fn decode(payload: &[u8]) -> Result<Self, ProtocolError> {
|
||||
if payload.len() < 67 {
|
||||
return Err(ProtocolError::MessageTooShort {
|
||||
expected: 67,
|
||||
got: payload.len(),
|
||||
});
|
||||
}
|
||||
// Skip 3 reserved bytes
|
||||
let p = &payload[3..];
|
||||
Ok(Self {
|
||||
highest_counter: u64::from_le_bytes(p[0..8].try_into().unwrap()),
|
||||
cumulative_packets_recv: u64::from_le_bytes(p[8..16].try_into().unwrap()),
|
||||
cumulative_bytes_recv: u64::from_le_bytes(p[16..24].try_into().unwrap()),
|
||||
timestamp_echo: u32::from_le_bytes(p[24..28].try_into().unwrap()),
|
||||
dwell_time: u16::from_le_bytes(p[28..30].try_into().unwrap()),
|
||||
max_burst_loss: u16::from_le_bytes(p[30..32].try_into().unwrap()),
|
||||
mean_burst_loss: u16::from_le_bytes(p[32..34].try_into().unwrap()),
|
||||
// skip 2 reserved bytes at p[34..36]
|
||||
jitter: u32::from_le_bytes(p[36..40].try_into().unwrap()),
|
||||
ecn_ce_count: u32::from_le_bytes(p[40..44].try_into().unwrap()),
|
||||
owd_trend: i32::from_le_bytes(p[44..48].try_into().unwrap()),
|
||||
burst_loss_count: u32::from_le_bytes(p[48..52].try_into().unwrap()),
|
||||
cumulative_reorder_count: u32::from_le_bytes(p[52..56].try_into().unwrap()),
|
||||
interval_packets_recv: u32::from_le_bytes(p[56..60].try_into().unwrap()),
|
||||
interval_bytes_recv: u32::from_le_bytes(p[60..64].try_into().unwrap()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Conversions between link-layer and session-layer report types
|
||||
// ============================================================================
|
||||
|
||||
use crate::protocol::{SessionReceiverReport, SessionSenderReport};
|
||||
|
||||
impl From<&SenderReport> for SessionSenderReport {
|
||||
fn from(r: &SenderReport) -> Self {
|
||||
Self {
|
||||
interval_start_counter: r.interval_start_counter,
|
||||
interval_end_counter: r.interval_end_counter,
|
||||
interval_start_timestamp: r.interval_start_timestamp,
|
||||
interval_end_timestamp: r.interval_end_timestamp,
|
||||
interval_bytes_sent: r.interval_bytes_sent,
|
||||
cumulative_packets_sent: r.cumulative_packets_sent,
|
||||
cumulative_bytes_sent: r.cumulative_bytes_sent,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&SessionSenderReport> for SenderReport {
|
||||
fn from(r: &SessionSenderReport) -> Self {
|
||||
Self {
|
||||
interval_start_counter: r.interval_start_counter,
|
||||
interval_end_counter: r.interval_end_counter,
|
||||
interval_start_timestamp: r.interval_start_timestamp,
|
||||
interval_end_timestamp: r.interval_end_timestamp,
|
||||
interval_bytes_sent: r.interval_bytes_sent,
|
||||
cumulative_packets_sent: r.cumulative_packets_sent,
|
||||
cumulative_bytes_sent: r.cumulative_bytes_sent,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&ReceiverReport> for SessionReceiverReport {
|
||||
fn from(r: &ReceiverReport) -> Self {
|
||||
Self {
|
||||
highest_counter: r.highest_counter,
|
||||
cumulative_packets_recv: r.cumulative_packets_recv,
|
||||
cumulative_bytes_recv: r.cumulative_bytes_recv,
|
||||
timestamp_echo: r.timestamp_echo,
|
||||
dwell_time: r.dwell_time,
|
||||
max_burst_loss: r.max_burst_loss,
|
||||
mean_burst_loss: r.mean_burst_loss,
|
||||
jitter: r.jitter,
|
||||
ecn_ce_count: r.ecn_ce_count,
|
||||
owd_trend: r.owd_trend,
|
||||
burst_loss_count: r.burst_loss_count,
|
||||
cumulative_reorder_count: r.cumulative_reorder_count,
|
||||
interval_packets_recv: r.interval_packets_recv,
|
||||
interval_bytes_recv: r.interval_bytes_recv,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&SessionReceiverReport> for ReceiverReport {
|
||||
fn from(r: &SessionReceiverReport) -> Self {
|
||||
Self {
|
||||
highest_counter: r.highest_counter,
|
||||
cumulative_packets_recv: r.cumulative_packets_recv,
|
||||
cumulative_bytes_recv: r.cumulative_bytes_recv,
|
||||
timestamp_echo: r.timestamp_echo,
|
||||
dwell_time: r.dwell_time,
|
||||
max_burst_loss: r.max_burst_loss,
|
||||
mean_burst_loss: r.mean_burst_loss,
|
||||
jitter: r.jitter,
|
||||
ecn_ce_count: r.ecn_ce_count,
|
||||
owd_trend: r.owd_trend,
|
||||
burst_loss_count: r.burst_loss_count,
|
||||
cumulative_reorder_count: r.cumulative_reorder_count,
|
||||
interval_packets_recv: r.interval_packets_recv,
|
||||
interval_bytes_recv: r.interval_bytes_recv,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_sender_report() -> SenderReport {
|
||||
SenderReport {
|
||||
interval_start_counter: 100,
|
||||
interval_end_counter: 200,
|
||||
interval_start_timestamp: 5000,
|
||||
interval_end_timestamp: 6000,
|
||||
interval_bytes_sent: 50_000,
|
||||
cumulative_packets_sent: 10_000,
|
||||
cumulative_bytes_sent: 5_000_000,
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_receiver_report() -> ReceiverReport {
|
||||
ReceiverReport {
|
||||
highest_counter: 195,
|
||||
cumulative_packets_recv: 9_500,
|
||||
cumulative_bytes_recv: 4_750_000,
|
||||
timestamp_echo: 5900,
|
||||
dwell_time: 5,
|
||||
max_burst_loss: 3,
|
||||
mean_burst_loss: 384, // 1.5 in u8.8
|
||||
jitter: 1200,
|
||||
ecn_ce_count: 0,
|
||||
owd_trend: -50,
|
||||
burst_loss_count: 2,
|
||||
cumulative_reorder_count: 10,
|
||||
interval_packets_recv: 95,
|
||||
interval_bytes_recv: 47_500,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sender_report_encode_size() {
|
||||
let sr = sample_sender_report();
|
||||
let encoded = sr.encode();
|
||||
assert_eq!(encoded.len(), 48);
|
||||
assert_eq!(encoded[0], 0x01); // msg_type
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sender_report_roundtrip() {
|
||||
let sr = sample_sender_report();
|
||||
let encoded = sr.encode();
|
||||
// decode expects payload after msg_type
|
||||
let decoded = SenderReport::decode(&encoded[1..]).unwrap();
|
||||
assert_eq!(sr, decoded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sender_report_too_short() {
|
||||
let result = SenderReport::decode(&[0u8; 10]);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_receiver_report_encode_size() {
|
||||
let rr = sample_receiver_report();
|
||||
let encoded = rr.encode();
|
||||
assert_eq!(encoded.len(), 68);
|
||||
assert_eq!(encoded[0], 0x02); // msg_type
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_receiver_report_roundtrip() {
|
||||
let rr = sample_receiver_report();
|
||||
let encoded = rr.encode();
|
||||
// decode expects payload after msg_type
|
||||
let decoded = ReceiverReport::decode(&encoded[1..]).unwrap();
|
||||
assert_eq!(rr, decoded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_receiver_report_too_short() {
|
||||
let result = ReceiverReport::decode(&[0u8; 10]);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sender_report_zero_values() {
|
||||
let sr = SenderReport {
|
||||
interval_start_counter: 0,
|
||||
interval_end_counter: 0,
|
||||
interval_start_timestamp: 0,
|
||||
interval_end_timestamp: 0,
|
||||
interval_bytes_sent: 0,
|
||||
cumulative_packets_sent: 0,
|
||||
cumulative_bytes_sent: 0,
|
||||
};
|
||||
let encoded = sr.encode();
|
||||
let decoded = SenderReport::decode(&encoded[1..]).unwrap();
|
||||
assert_eq!(sr, decoded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_receiver_report_max_values() {
|
||||
let rr = ReceiverReport {
|
||||
highest_counter: u64::MAX,
|
||||
cumulative_packets_recv: u64::MAX,
|
||||
cumulative_bytes_recv: u64::MAX,
|
||||
timestamp_echo: u32::MAX,
|
||||
dwell_time: u16::MAX,
|
||||
max_burst_loss: u16::MAX,
|
||||
mean_burst_loss: u16::MAX,
|
||||
jitter: u32::MAX,
|
||||
ecn_ce_count: u32::MAX,
|
||||
owd_trend: i32::MAX,
|
||||
burst_loss_count: u32::MAX,
|
||||
cumulative_reorder_count: u32::MAX,
|
||||
interval_packets_recv: u32::MAX,
|
||||
interval_bytes_recv: u32::MAX,
|
||||
};
|
||||
let encoded = rr.encode();
|
||||
let decoded = ReceiverReport::decode(&encoded[1..]).unwrap();
|
||||
assert_eq!(rr, decoded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_receiver_report_negative_owd_trend() {
|
||||
let rr = ReceiverReport {
|
||||
owd_trend: -12345,
|
||||
..sample_receiver_report()
|
||||
};
|
||||
let encoded = rr.encode();
|
||||
let decoded = ReceiverReport::decode(&encoded[1..]).unwrap();
|
||||
assert_eq!(decoded.owd_trend, -12345);
|
||||
}
|
||||
}
|
||||
@@ -1,418 +0,0 @@
|
||||
//! MMP sender state machine.
|
||||
//!
|
||||
//! Tracks what this node has sent to a specific peer and produces
|
||||
//! SenderReport messages on demand. One `SenderState` per active peer.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::mmp::report::SenderReport;
|
||||
use crate::mmp::{
|
||||
COLD_START_SAMPLES, DEFAULT_COLD_START_INTERVAL_MS, MAX_REPORT_INTERVAL_MS,
|
||||
MIN_REPORT_INTERVAL_MS,
|
||||
};
|
||||
|
||||
/// Per-peer sender-side MMP state.
|
||||
///
|
||||
/// Records cumulative and interval counters for every frame transmitted
|
||||
/// to this peer. Produces `SenderReport` snapshots on demand.
|
||||
pub struct SenderState {
|
||||
// --- Cumulative (lifetime) ---
|
||||
cumulative_packets_sent: u64,
|
||||
cumulative_bytes_sent: u64,
|
||||
|
||||
// --- Current interval ---
|
||||
interval_start_counter: u64,
|
||||
interval_start_timestamp: u32,
|
||||
interval_bytes_sent: u32,
|
||||
/// Counter of the most recently sent frame.
|
||||
last_counter: u64,
|
||||
/// Timestamp of the most recently sent frame.
|
||||
last_timestamp: u32,
|
||||
/// Whether any frames have been sent in the current interval.
|
||||
interval_has_data: bool,
|
||||
|
||||
// --- Report timing ---
|
||||
last_report_time: Option<Instant>,
|
||||
report_interval: Duration,
|
||||
|
||||
// --- Send failure backoff ---
|
||||
/// Consecutive send failure count for backoff calculation.
|
||||
consecutive_send_failures: u32,
|
||||
|
||||
// --- Cold-start tracking ---
|
||||
/// Number of SRTT-based interval updates received.
|
||||
srtt_sample_count: u32,
|
||||
}
|
||||
|
||||
impl SenderState {
|
||||
pub fn new() -> Self {
|
||||
Self::new_with_cold_start(DEFAULT_COLD_START_INTERVAL_MS)
|
||||
}
|
||||
|
||||
/// Create with a custom cold-start interval (ms).
|
||||
///
|
||||
/// Used by session-layer MMP which needs a longer initial interval
|
||||
/// since reports consume bandwidth on every transit link.
|
||||
pub fn new_with_cold_start(cold_start_ms: u64) -> Self {
|
||||
Self {
|
||||
cumulative_packets_sent: 0,
|
||||
cumulative_bytes_sent: 0,
|
||||
interval_start_counter: 0,
|
||||
interval_start_timestamp: 0,
|
||||
interval_bytes_sent: 0,
|
||||
last_counter: 0,
|
||||
last_timestamp: 0,
|
||||
interval_has_data: false,
|
||||
last_report_time: None,
|
||||
report_interval: Duration::from_millis(cold_start_ms),
|
||||
consecutive_send_failures: 0,
|
||||
srtt_sample_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a frame sent to this peer.
|
||||
///
|
||||
/// Called on the TX path for every encrypted link message.
|
||||
/// `counter` is the AEAD nonce/counter, `timestamp` is the inner header
|
||||
/// session-relative timestamp (ms), `bytes` is the wire payload size.
|
||||
pub fn record_sent(&mut self, counter: u64, timestamp: u32, bytes: usize) {
|
||||
if !self.interval_has_data {
|
||||
self.interval_start_counter = counter;
|
||||
self.interval_start_timestamp = timestamp;
|
||||
self.interval_has_data = true;
|
||||
}
|
||||
self.last_counter = counter;
|
||||
self.last_timestamp = timestamp;
|
||||
self.interval_bytes_sent = self.interval_bytes_sent.saturating_add(bytes as u32);
|
||||
self.cumulative_packets_sent += 1;
|
||||
self.cumulative_bytes_sent += bytes as u64;
|
||||
}
|
||||
|
||||
/// Build a SenderReport from current state and reset the interval.
|
||||
///
|
||||
/// Returns `None` if no frames have been sent since the last report.
|
||||
pub fn build_report(&mut self, now: Instant) -> Option<SenderReport> {
|
||||
if !self.interval_has_data {
|
||||
return None;
|
||||
}
|
||||
|
||||
let report = SenderReport {
|
||||
interval_start_counter: self.interval_start_counter,
|
||||
interval_end_counter: self.last_counter,
|
||||
interval_start_timestamp: self.interval_start_timestamp,
|
||||
interval_end_timestamp: self.last_timestamp,
|
||||
interval_bytes_sent: self.interval_bytes_sent,
|
||||
cumulative_packets_sent: self.cumulative_packets_sent,
|
||||
cumulative_bytes_sent: self.cumulative_bytes_sent,
|
||||
};
|
||||
|
||||
// Reset interval
|
||||
self.interval_has_data = false;
|
||||
self.interval_bytes_sent = 0;
|
||||
self.last_report_time = Some(now);
|
||||
|
||||
Some(report)
|
||||
}
|
||||
|
||||
/// Check if it's time to send a report.
|
||||
///
|
||||
/// When consecutive send failures have occurred, the effective interval
|
||||
/// is multiplied by an exponential backoff factor (2^failures, capped at 32×).
|
||||
pub fn should_send_report(&self, now: Instant) -> bool {
|
||||
if !self.interval_has_data {
|
||||
return false;
|
||||
}
|
||||
match self.last_report_time {
|
||||
None => true, // Never sent a report — send immediately
|
||||
Some(last) => {
|
||||
let effective = self
|
||||
.report_interval
|
||||
.mul_f64(self.send_failure_backoff_multiplier());
|
||||
now.duration_since(last) >= effective
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a send failure. Returns the new consecutive failure count.
|
||||
pub fn record_send_failure(&mut self) -> u32 {
|
||||
self.consecutive_send_failures += 1;
|
||||
self.consecutive_send_failures
|
||||
}
|
||||
|
||||
/// Record a successful send. Returns the previous failure count (for summary logging).
|
||||
pub fn record_send_success(&mut self) -> u32 {
|
||||
let prev = self.consecutive_send_failures;
|
||||
self.consecutive_send_failures = 0;
|
||||
prev
|
||||
}
|
||||
|
||||
/// Get the backoff multiplier based on consecutive failures.
|
||||
///
|
||||
/// Returns 1.0 for no failures, 2.0 for 1 failure, 4.0 for 2, ...
|
||||
/// capped at 32.0 (5 failures).
|
||||
pub fn send_failure_backoff_multiplier(&self) -> f64 {
|
||||
if self.consecutive_send_failures == 0 {
|
||||
1.0
|
||||
} else {
|
||||
2.0_f64.powi(self.consecutive_send_failures.min(5) as i32)
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the report interval based on SRTT (link-layer defaults).
|
||||
///
|
||||
/// Sender reports at 2× SRTT clamped to [floor, MAX]. During cold-start
|
||||
/// (first `COLD_START_SAMPLES` updates), the floor is the cold-start
|
||||
/// interval (200ms) for fast SRTT convergence. After that, it rises to
|
||||
/// `MIN_REPORT_INTERVAL_MS` (1000ms) for steady-state efficiency.
|
||||
pub fn update_report_interval_from_srtt(&mut self, srtt_us: i64) {
|
||||
self.srtt_sample_count = self.srtt_sample_count.saturating_add(1);
|
||||
let floor = if self.srtt_sample_count <= COLD_START_SAMPLES {
|
||||
DEFAULT_COLD_START_INTERVAL_MS
|
||||
} else {
|
||||
MIN_REPORT_INTERVAL_MS
|
||||
};
|
||||
self.update_report_interval_with_bounds(srtt_us, floor, MAX_REPORT_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/// Update the report interval based on SRTT with custom bounds.
|
||||
///
|
||||
/// Used by session-layer MMP which needs higher clamp values since
|
||||
/// each report consumes bandwidth on every transit link.
|
||||
pub fn update_report_interval_with_bounds(&mut self, srtt_us: i64, min_ms: u64, max_ms: u64) {
|
||||
if srtt_us <= 0 {
|
||||
return;
|
||||
}
|
||||
let interval_us = (srtt_us * 2) as u64;
|
||||
let interval_ms = (interval_us / 1000).clamp(min_ms, max_ms);
|
||||
self.report_interval = Duration::from_millis(interval_ms);
|
||||
}
|
||||
|
||||
// --- Accessors ---
|
||||
|
||||
pub fn cumulative_packets_sent(&self) -> u64 {
|
||||
self.cumulative_packets_sent
|
||||
}
|
||||
|
||||
pub fn cumulative_bytes_sent(&self) -> u64 {
|
||||
self.cumulative_bytes_sent
|
||||
}
|
||||
|
||||
pub fn report_interval(&self) -> Duration {
|
||||
self.report_interval
|
||||
}
|
||||
|
||||
pub fn consecutive_send_failures(&self) -> u32 {
|
||||
self.consecutive_send_failures
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SenderState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_new_sender_state() {
|
||||
let s = SenderState::new();
|
||||
assert_eq!(s.cumulative_packets_sent(), 0);
|
||||
assert_eq!(s.cumulative_bytes_sent(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_sent() {
|
||||
let mut s = SenderState::new();
|
||||
s.record_sent(1, 100, 500);
|
||||
s.record_sent(2, 200, 600);
|
||||
assert_eq!(s.cumulative_packets_sent(), 2);
|
||||
assert_eq!(s.cumulative_bytes_sent(), 1100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_report_empty() {
|
||||
let mut s = SenderState::new();
|
||||
assert!(s.build_report(Instant::now()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_report() {
|
||||
let mut s = SenderState::new();
|
||||
s.record_sent(10, 1000, 500);
|
||||
s.record_sent(11, 1100, 600);
|
||||
s.record_sent(12, 1200, 400);
|
||||
|
||||
let report = s.build_report(Instant::now()).unwrap();
|
||||
assert_eq!(report.interval_start_counter, 10);
|
||||
assert_eq!(report.interval_end_counter, 12);
|
||||
assert_eq!(report.interval_start_timestamp, 1000);
|
||||
assert_eq!(report.interval_end_timestamp, 1200);
|
||||
assert_eq!(report.interval_bytes_sent, 1500);
|
||||
assert_eq!(report.cumulative_packets_sent, 3);
|
||||
assert_eq!(report.cumulative_bytes_sent, 1500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_report_resets_interval() {
|
||||
let mut s = SenderState::new();
|
||||
s.record_sent(1, 100, 500);
|
||||
let _ = s.build_report(Instant::now());
|
||||
|
||||
// Second report with no new data returns None
|
||||
assert!(s.build_report(Instant::now()).is_none());
|
||||
|
||||
// New data starts a fresh interval
|
||||
s.record_sent(2, 200, 300);
|
||||
let report = s.build_report(Instant::now()).unwrap();
|
||||
assert_eq!(report.interval_start_counter, 2);
|
||||
assert_eq!(report.interval_bytes_sent, 300);
|
||||
// Cumulative continues
|
||||
assert_eq!(report.cumulative_packets_sent, 2);
|
||||
assert_eq!(report.cumulative_bytes_sent, 800);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_send_report_no_data() {
|
||||
let s = SenderState::new();
|
||||
assert!(!s.should_send_report(Instant::now()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_send_report_first_time() {
|
||||
let mut s = SenderState::new();
|
||||
s.record_sent(1, 100, 500);
|
||||
assert!(s.should_send_report(Instant::now()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_send_report_respects_interval() {
|
||||
let mut s = SenderState::new();
|
||||
let t0 = Instant::now();
|
||||
s.record_sent(1, 100, 500);
|
||||
let _ = s.build_report(t0);
|
||||
|
||||
s.record_sent(2, 200, 500);
|
||||
// Immediately after report — should not send
|
||||
assert!(!s.should_send_report(t0));
|
||||
|
||||
// After interval elapses
|
||||
let t1 = t0 + s.report_interval() + Duration::from_millis(1);
|
||||
assert!(s.should_send_report(t1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_report_interval_cold_start() {
|
||||
let mut s = SenderState::new();
|
||||
// During cold-start, floor is 200ms (DEFAULT_COLD_START_INTERVAL_MS)
|
||||
// 50ms RTT → 100ms sender interval (2× SRTT), clamped to cold-start floor 200ms
|
||||
s.update_report_interval_from_srtt(50_000);
|
||||
assert_eq!(s.report_interval(), Duration::from_millis(200));
|
||||
|
||||
// 500ms RTT → 1000ms sender interval (above cold-start floor)
|
||||
s.update_report_interval_from_srtt(500_000);
|
||||
assert_eq!(s.report_interval(), Duration::from_millis(1000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_report_interval_after_cold_start() {
|
||||
let mut s = SenderState::new();
|
||||
// Burn through cold-start samples (COLD_START_SAMPLES = 5)
|
||||
for _ in 0..COLD_START_SAMPLES {
|
||||
s.update_report_interval_from_srtt(500_000);
|
||||
}
|
||||
|
||||
// 6th sample: now in steady state, floor is MIN_REPORT_INTERVAL_MS (1000ms)
|
||||
// 50ms RTT → 100ms sender interval (2× SRTT), clamped to 1000ms
|
||||
s.update_report_interval_from_srtt(50_000);
|
||||
assert_eq!(
|
||||
s.report_interval(),
|
||||
Duration::from_millis(MIN_REPORT_INTERVAL_MS)
|
||||
);
|
||||
|
||||
// 3s RTT → 6s, clamped to max 5s
|
||||
s.update_report_interval_from_srtt(3_000_000);
|
||||
assert_eq!(
|
||||
s.report_interval(),
|
||||
Duration::from_millis(MAX_REPORT_INTERVAL_MS)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backoff_multiplier_progression() {
|
||||
let mut s = SenderState::new();
|
||||
|
||||
// No failures → multiplier 1.0
|
||||
assert_eq!(s.send_failure_backoff_multiplier(), 1.0);
|
||||
assert_eq!(s.consecutive_send_failures(), 0);
|
||||
|
||||
// Progressive failures: 2^1, 2^2, 2^3, 2^4, 2^5
|
||||
let expected = [2.0, 4.0, 8.0, 16.0, 32.0];
|
||||
for (i, &exp) in expected.iter().enumerate() {
|
||||
let count = s.record_send_failure();
|
||||
assert_eq!(count, (i + 1) as u32);
|
||||
assert_eq!(s.send_failure_backoff_multiplier(), exp);
|
||||
}
|
||||
|
||||
// Beyond 5 failures: stays capped at 32.0
|
||||
s.record_send_failure(); // 6th
|
||||
assert_eq!(s.send_failure_backoff_multiplier(), 32.0);
|
||||
s.record_send_failure(); // 7th
|
||||
assert_eq!(s.send_failure_backoff_multiplier(), 32.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backoff_reset_on_success() {
|
||||
let mut s = SenderState::new();
|
||||
|
||||
// Accumulate failures
|
||||
s.record_send_failure();
|
||||
s.record_send_failure();
|
||||
s.record_send_failure();
|
||||
assert_eq!(s.consecutive_send_failures(), 3);
|
||||
assert_eq!(s.send_failure_backoff_multiplier(), 8.0);
|
||||
|
||||
// Success resets and returns previous count
|
||||
let prev = s.record_send_success();
|
||||
assert_eq!(prev, 3);
|
||||
assert_eq!(s.consecutive_send_failures(), 0);
|
||||
assert_eq!(s.send_failure_backoff_multiplier(), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backoff_success_with_no_prior_failures() {
|
||||
let mut s = SenderState::new();
|
||||
|
||||
// Success with no failures returns 0
|
||||
let prev = s.record_send_success();
|
||||
assert_eq!(prev, 0);
|
||||
assert_eq!(s.consecutive_send_failures(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_send_report_respects_backoff() {
|
||||
let mut s = SenderState::new();
|
||||
let t0 = Instant::now();
|
||||
s.record_sent(1, 100, 500);
|
||||
let _ = s.build_report(t0);
|
||||
|
||||
// Record a failure: multiplier becomes 2.0
|
||||
s.record_send_failure();
|
||||
|
||||
s.record_sent(2, 200, 500);
|
||||
|
||||
// At 1× interval: should NOT send (backoff requires 2×)
|
||||
let t1 = t0 + s.report_interval() + Duration::from_millis(1);
|
||||
assert!(!s.should_send_report(t1));
|
||||
|
||||
// At 2× interval: should send
|
||||
let t2 = t0 + s.report_interval() * 2 + Duration::from_millis(1);
|
||||
assert!(s.should_send_report(t2));
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,12 @@
|
||||
//! including debounced propagation to peers.
|
||||
|
||||
use crate::NodeAddr;
|
||||
use crate::bloom::BloomFilter;
|
||||
use crate::protocol::FilterAnnounce;
|
||||
use crate::proto::bloom::BloomFilter;
|
||||
use crate::proto::bloom::FilterAnnounce;
|
||||
|
||||
use super::reject::BloomReject;
|
||||
use super::{Node, NodeError};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::BTreeMap;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
impl Node {
|
||||
@@ -17,8 +17,8 @@ impl Node {
|
||||
///
|
||||
/// Returns a map of (peer_node_addr -> filter) for peers that
|
||||
/// have sent us a FilterAnnounce.
|
||||
pub(super) fn peer_inbound_filters(&self) -> HashMap<NodeAddr, BloomFilter> {
|
||||
let mut filters = HashMap::new();
|
||||
pub(super) fn peer_inbound_filters(&self) -> BTreeMap<NodeAddr, BloomFilter> {
|
||||
let mut filters = BTreeMap::new();
|
||||
for (addr, peer) in &self.peers {
|
||||
if self.is_tree_peer(addr)
|
||||
&& let Some(filter) = peer.inbound_filter()
|
||||
@@ -87,7 +87,7 @@ impl Node {
|
||||
// operator to see one clear message, not spam.
|
||||
let max_fpr = self.config().node.bloom.max_inbound_fpr;
|
||||
let out_fill = sent_filter.fill_ratio();
|
||||
let out_fpr = out_fill.powi(sent_filter.hash_count() as i32);
|
||||
let out_fpr = sent_filter.fpr();
|
||||
if out_fpr > max_fpr {
|
||||
let now = std::time::Instant::now();
|
||||
let should_warn = self
|
||||
@@ -213,7 +213,7 @@ impl Node {
|
||||
// to wipe a victim's contribution to aggregation.
|
||||
let max_fpr = self.config().node.bloom.max_inbound_fpr;
|
||||
let fill = announce.filter.fill_ratio();
|
||||
let fpr = fill.powi(announce.filter.hash_count() as i32);
|
||||
let fpr = announce.filter.fpr();
|
||||
if fpr > max_fpr {
|
||||
self.metrics()
|
||||
.bloom
|
||||
|
||||
@@ -45,12 +45,10 @@ impl Node {
|
||||
/// (e.g. only non-UDP transports). Enabled on Linux and macOS:
|
||||
/// both kernels route a matching peer 5-tuple to the connected
|
||||
/// socket when it shares the wildcard listen port via SO_REUSEPORT.
|
||||
/// Only compiled on Linux/macOS — the sole caller (the rx_loop tick) is
|
||||
/// gated the same way, so on other targets (android) there is nothing to do.
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
pub(in crate::node) async fn activate_connected_udp_sessions(&mut self) {
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
{
|
||||
// No-op on platforms without the connected-UDP fast path.
|
||||
}
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
{
|
||||
if !connected_udp_enabled() {
|
||||
return;
|
||||
@@ -142,20 +140,24 @@ impl Node {
|
||||
(peer_sa, local, recv_buf, send_buf, tx)
|
||||
};
|
||||
|
||||
// Open the connected socket on the kernel side.
|
||||
let socket = std::sync::Arc::new(
|
||||
crate::transport::udp::connected_peer::ConnectedPeerSocket::open(
|
||||
local_addr,
|
||||
peer_socket_addr,
|
||||
recv_buf,
|
||||
send_buf,
|
||||
)
|
||||
.map_err(|e| format!("ConnectedPeerSocket::open: {e}"))?,
|
||||
);
|
||||
// Open the connected socket on the kernel side, then adopt the
|
||||
// fd into the owning handle.
|
||||
let owned = crate::transport::udp::open_connected_fd(
|
||||
local_addr,
|
||||
peer_socket_addr,
|
||||
recv_buf,
|
||||
send_buf,
|
||||
)
|
||||
.map_err(|e| format!("open_connected_fd: {e}"))?;
|
||||
let socket = std::sync::Arc::new(crate::peer::connected_udp::ConnectedPeerSocket::from_fd(
|
||||
owned,
|
||||
peer_socket_addr,
|
||||
local_addr,
|
||||
));
|
||||
|
||||
// Spawn the drain thread. It feeds `packet_tx` exactly like
|
||||
// the wildcard listen socket — rx_loop dispatches identically.
|
||||
let drain = crate::transport::udp::peer_drain::PeerRecvDrain::spawn(
|
||||
let drain = crate::peer::connected_udp::PeerRecvDrain::spawn(
|
||||
socket.clone(),
|
||||
transport_id,
|
||||
peer_socket_addr,
|
||||
@@ -73,7 +73,7 @@ impl Node {
|
||||
/// entries — other removal paths (link-dead, decrypt failure, peer
|
||||
/// restart) all schedule reconnect.
|
||||
pub(in crate::node) fn handle_disconnect(&mut self, from: &NodeAddr, payload: &[u8]) {
|
||||
let disconnect = match crate::protocol::Disconnect::decode(payload) {
|
||||
let disconnect = match crate::proto::fmp::Disconnect::decode(payload) {
|
||||
Ok(msg) => msg,
|
||||
Err(e) => {
|
||||
debug!(from = %self.peer_display_name(from), error = %e, "Malformed disconnect message");
|
||||
@@ -93,7 +93,7 @@ impl Node {
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
self.schedule_reconnect(addr, now_ms);
|
||||
self.note_link_dead(addr, now_ms);
|
||||
}
|
||||
|
||||
/// Remove an active peer and clean up all associated state.
|
||||
@@ -187,6 +187,15 @@ impl Node {
|
||||
|
||||
// Remove link and address mapping
|
||||
self.remove_link(&link_id);
|
||||
// Bound `peer_machines`: drop this peer's machine
|
||||
// entry, keyed by the `link_id` derived above BEFORE the `peers` removal.
|
||||
// This cleans up the OLD peer's machine on an inbound restart and prevents
|
||||
// unbounded growth on the establish success path. NEUTRAL: nothing on the
|
||||
// live path reads `peer_machines` except the establish executor, which only
|
||||
// ever touches the in-flight establish's (distinct) `link_id`; no reader
|
||||
// depends on a stale entry, so removal changes no behavior — it only bounds
|
||||
// the map.
|
||||
self.remove_peer_machine(link_id);
|
||||
if let Some(transport_id) = transport_id {
|
||||
self.cleanup_bootstrap_transport_if_unused(transport_id);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
//! Encrypted frame handling (hot path).
|
||||
|
||||
use crate::node::Node;
|
||||
use crate::node::wire::{EncryptedHeader, FLAG_CE, FLAG_KEY_EPOCH, FLAG_SP, strip_inner_header};
|
||||
use crate::noise::NoiseError;
|
||||
use crate::proto::fmp::wire::{
|
||||
EncryptedHeader, FLAG_CE, FLAG_KEY_EPOCH, FLAG_SP, strip_inner_header,
|
||||
};
|
||||
use crate::transport::ReceivedPacket;
|
||||
use std::time::Instant;
|
||||
use tracing::{debug, trace, warn};
|
||||
|
||||
/// Force-remove a peer after this many consecutive decryption failures.
|
||||
@@ -171,7 +172,7 @@ impl Node {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let cache_key = (packet.transport_id, header.receiver_idx.as_u32());
|
||||
if let Some(workers) = self.decrypt_workers.as_ref().cloned()
|
||||
if let Some(workers) = self.supervisor.decrypt_workers.as_ref().cloned()
|
||||
&& self.decrypt_registered_sessions.contains(&cache_key)
|
||||
{
|
||||
let job = crate::node::decrypt_worker::DecryptJob {
|
||||
@@ -259,7 +260,7 @@ impl Node {
|
||||
};
|
||||
|
||||
// MMP per-frame processing and statistics
|
||||
let now = Instant::now();
|
||||
let now_ms = crate::time::mono_ms();
|
||||
let ce_flag = header.flags & FLAG_CE != 0;
|
||||
let sp_flag = header.flags & FLAG_SP != 0;
|
||||
|
||||
@@ -270,9 +271,9 @@ impl Node {
|
||||
timestamp,
|
||||
packet.data.len(),
|
||||
ce_flag,
|
||||
now,
|
||||
now_ms,
|
||||
);
|
||||
let _spin_rtt = mmp.spin_bit.rx_observe(sp_flag, header.counter, now);
|
||||
let _spin_rtt = mmp.spin_bit.rx_observe(sp_flag, header.counter, now_ms);
|
||||
}
|
||||
peer.set_current_addr(packet.transport_id, packet.remote_addr.clone());
|
||||
peer.link_stats_mut()
|
||||
@@ -355,7 +356,7 @@ impl Node {
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
let now = Instant::now();
|
||||
let now_ms = crate::time::mono_ms();
|
||||
let mut address_changed = false;
|
||||
if let Some(peer) = self.peers.get_mut(node_addr) {
|
||||
peer.reset_decrypt_failures();
|
||||
@@ -365,8 +366,8 @@ impl Node {
|
||||
peer.touch(packet_timestamp_ms);
|
||||
if let Some(mmp) = peer.mmp_mut() {
|
||||
mmp.receiver
|
||||
.record_recv(fmp_counter, inner_ts, packet_len, ce_flag, now);
|
||||
let _spin_rtt = mmp.spin_bit.rx_observe(sp_flag, fmp_counter, now);
|
||||
.record_recv(fmp_counter, inner_ts, packet_len, ce_flag, now_ms);
|
||||
let _spin_rtt = mmp.spin_bit.rx_observe(sp_flag, fmp_counter, now_ms);
|
||||
}
|
||||
}
|
||||
// Address rotation invalidates the per-peer connect()-ed UDP
|
||||
@@ -450,7 +451,7 @@ impl Node {
|
||||
/// black-hole the session.
|
||||
#[cfg(unix)]
|
||||
pub(in crate::node) fn register_decrypt_worker_session(&mut self, node_addr: &crate::NodeAddr) {
|
||||
let Some(workers) = self.decrypt_workers.as_ref().cloned() else {
|
||||
let Some(workers) = self.supervisor.decrypt_workers.as_ref().cloned() else {
|
||||
return;
|
||||
};
|
||||
let (cache_key, state) = {
|
||||
@@ -491,7 +492,7 @@ impl Node {
|
||||
&mut self,
|
||||
cache_key: (crate::transport::TransportId, u32),
|
||||
) {
|
||||
if let Some(workers) = self.decrypt_workers.as_ref() {
|
||||
if let Some(workers) = self.supervisor.decrypt_workers.as_ref() {
|
||||
workers.unregister_session(cache_key);
|
||||
}
|
||||
self.decrypt_registered_sessions.remove(&cache_key);
|
||||
@@ -533,7 +534,7 @@ impl Node {
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
self.schedule_reconnect(addr, now_ms);
|
||||
self.note_link_dead(addr, now_ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,15 +7,14 @@
|
||||
|
||||
use crate::NodeAddr;
|
||||
use crate::node::reject::ForwardingReject;
|
||||
use crate::node::session_wire::{
|
||||
use crate::node::{Node, NodeError, NodeRoutingView};
|
||||
use crate::proto::fsp::wire::{
|
||||
FSP_COMMON_PREFIX_SIZE, FSP_HEADER_SIZE, FSP_PHASE_ESTABLISHED, FSP_PHASE_MSG1, FSP_PHASE_MSG2,
|
||||
FspCommonPrefix, parse_encrypted_coords,
|
||||
};
|
||||
use crate::node::{Node, NodeError};
|
||||
use crate::protocol::{
|
||||
CoordsRequired, MtuExceeded, PathBroken, SessionAck, SessionDatagram, SessionDatagramRef,
|
||||
SessionSetup,
|
||||
};
|
||||
use crate::proto::fsp::{SessionAck, SessionSetup};
|
||||
use crate::proto::link::{SessionDatagram, SessionDatagramRef};
|
||||
use crate::proto::routing::{DropReason, NextHop, RouteAction, RouteOutcome};
|
||||
use std::time::{Duration, Instant};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
@@ -43,123 +42,166 @@ impl Node {
|
||||
}
|
||||
};
|
||||
|
||||
// TTL enforcement: decrement for forwarding and drop only if the
|
||||
// received datagram was already exhausted.
|
||||
if datagram_ref.ttl == 0 {
|
||||
self.metrics()
|
||||
.forwarding
|
||||
.record_reject_bytes(ForwardingReject::TtlExhausted, payload.len());
|
||||
debug!(
|
||||
src = %datagram_ref.src_addr,
|
||||
dest = %datagram_ref.dest_addr,
|
||||
"SessionDatagram TTL exhausted, dropping"
|
||||
);
|
||||
return;
|
||||
}
|
||||
let forwarded_ttl = datagram_ref.ttl - 1;
|
||||
let my_addr = *self.node_addr();
|
||||
|
||||
// Coordinate cache warming from plaintext session-layer headers
|
||||
self.try_warm_coord_cache_ref(&datagram_ref);
|
||||
|
||||
// Local delivery: dispatch to session layer handlers without
|
||||
// materializing an owned SessionDatagram payload Vec.
|
||||
if datagram_ref.dest_addr == *self.node_addr() {
|
||||
self.metrics().forwarding.record_delivered(payload.len());
|
||||
self.handle_session_payload(
|
||||
&datagram_ref.src_addr,
|
||||
datagram_ref.payload,
|
||||
datagram_ref.path_mtu,
|
||||
incoming_ce,
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
// Coordinate cache warming from plaintext session-layer headers. Gated
|
||||
// on a non-exhausted TTL so a datagram the core will drop as
|
||||
// TTL-exhausted does not warm the cache, matching the pre-refactor
|
||||
// ordering (warming ran only after the TTL early-return).
|
||||
if datagram_ref.ttl != 0 {
|
||||
self.try_warm_coord_cache_ref(&datagram_ref);
|
||||
}
|
||||
|
||||
let mut datagram = datagram_ref.into_owned();
|
||||
datagram.ttl = forwarded_ttl;
|
||||
// Pre-resolve the next hop only for genuine transit packets (TTL > 0
|
||||
// and not locally destined) so `find_next_hop`'s coord-cache LRU-touch
|
||||
// side effect keeps the same scope it had inline. Warming above has
|
||||
// already run, so the resolution observes freshly cached coords.
|
||||
let next_hop = if datagram_ref.ttl != 0 && datagram_ref.dest_addr != my_addr {
|
||||
self.resolve_next_hop(&datagram_ref.dest_addr)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Find next hop toward destination
|
||||
let next_hop_addr = match self.find_next_hop(&datagram.dest_addr) {
|
||||
Some(peer) => *peer.node_addr(),
|
||||
None => {
|
||||
// Read local congestion once and reuse it for both the CE decision
|
||||
// (via the view) and the congestion metric/log below, keeping
|
||||
// `detect_congestion` the single source of truth.
|
||||
let congested = next_hop
|
||||
.as_ref()
|
||||
.map(|nh| self.detect_congestion(&nh.addr))
|
||||
.unwrap_or(false);
|
||||
|
||||
// Borrow the routing tables disjointly from `&mut self.routing` for
|
||||
// the pure decision, then release both before driving the outcome.
|
||||
let outcome = {
|
||||
let view = NodeRoutingView {
|
||||
coord_cache: &self.coord_cache,
|
||||
peers: &self.peers,
|
||||
tree_state: &self.tree_state,
|
||||
congested,
|
||||
};
|
||||
self.routing
|
||||
.route(&datagram_ref, &my_addr, incoming_ce, next_hop, &view)
|
||||
};
|
||||
|
||||
match outcome {
|
||||
RouteOutcome::Drop {
|
||||
reason: DropReason::TtlExhausted,
|
||||
} => {
|
||||
self.metrics()
|
||||
.forwarding
|
||||
.record_reject_bytes(ForwardingReject::TtlExhausted, payload.len());
|
||||
debug!(
|
||||
src = %datagram_ref.src_addr,
|
||||
dest = %datagram_ref.dest_addr,
|
||||
"SessionDatagram TTL exhausted, dropping"
|
||||
);
|
||||
}
|
||||
RouteOutcome::DeliverLocal => {
|
||||
// Local delivery: dispatch to session layer handlers without
|
||||
// materializing an owned SessionDatagram payload Vec.
|
||||
self.metrics().forwarding.record_delivered(payload.len());
|
||||
self.handle_session_payload(
|
||||
&datagram_ref.src_addr,
|
||||
datagram_ref.payload,
|
||||
datagram_ref.path_mtu,
|
||||
incoming_ce,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
RouteOutcome::NoRoute => {
|
||||
self.metrics()
|
||||
.forwarding
|
||||
.record_reject_bytes(ForwardingReject::NoRoute, payload.len());
|
||||
let original = datagram_ref.into_owned();
|
||||
debug!(
|
||||
src = %self.peer_display_name(&datagram.src_addr),
|
||||
dest = %self.peer_display_name(&datagram.dest_addr),
|
||||
src = %self.peer_display_name(&original.src_addr),
|
||||
dest = %self.peer_display_name(&original.dest_addr),
|
||||
bytes = payload.len(),
|
||||
"Dropping transit SessionDatagram: no route to destination"
|
||||
);
|
||||
self.send_routing_error(&datagram).await;
|
||||
return;
|
||||
self.send_routing_error(&original).await;
|
||||
}
|
||||
};
|
||||
RouteOutcome::Forward {
|
||||
next_hop,
|
||||
bytes,
|
||||
outgoing_ce,
|
||||
} => {
|
||||
let dest = datagram_ref.dest_addr;
|
||||
|
||||
// Apply path_mtu min() from the outgoing link's transport MTU
|
||||
if let Some(peer) = self.peers.get(&next_hop_addr)
|
||||
// ECN CE relay: congestion was detected locally above; emit the
|
||||
// metric and rate-limited log at the transit chokepoint.
|
||||
if congested {
|
||||
self.metrics().congestion.congestion_detected.inc();
|
||||
let now = Instant::now();
|
||||
let should_log = self
|
||||
.last_congestion_log
|
||||
.map(|t| now.duration_since(t) >= Duration::from_secs(5))
|
||||
.unwrap_or(true);
|
||||
if should_log {
|
||||
self.last_congestion_log = Some(now);
|
||||
debug!(next_hop = %next_hop, "Congestion detected, CE flag set on forwarded packet");
|
||||
}
|
||||
}
|
||||
|
||||
match self
|
||||
.send_encrypted_link_message_with_ce(&next_hop, &bytes, outgoing_ce)
|
||||
.await
|
||||
{
|
||||
Err(NodeError::MtuExceeded { mtu, .. }) => {
|
||||
self.metrics()
|
||||
.forwarding
|
||||
.record_reject_bytes(ForwardingReject::MtuExceeded, payload.len());
|
||||
self.send_mtu_exceeded_error(dest, datagram_ref.src_addr, mtu)
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
self.metrics()
|
||||
.forwarding
|
||||
.record_reject_bytes(ForwardingReject::SendError, payload.len());
|
||||
debug!(
|
||||
next_hop = %next_hop,
|
||||
dest = %dest,
|
||||
error = %e,
|
||||
"Failed to forward SessionDatagram"
|
||||
);
|
||||
}
|
||||
Ok(()) => {
|
||||
self.metrics().forwarding.record_forwarded(bytes.len());
|
||||
// Classify this transit forward by route class (partition
|
||||
// of forwarded_packets). Done here, at the data-plane
|
||||
// chokepoint, so the error-signal routing callers of
|
||||
// find_next_hop are excluded.
|
||||
let class = self.classify_forward(&dest, &next_hop);
|
||||
self.metrics().forwarding.record_route_class(class);
|
||||
if outgoing_ce {
|
||||
self.metrics().congestion.ce_forwarded.inc();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the next hop toward `dest` into its address plus the outgoing
|
||||
/// link's transport MTU. Returns `None` when there is no route.
|
||||
///
|
||||
/// The MTU defaults to `u16::MAX` (a no-op min-fold) when the peer's
|
||||
/// transport is not resolvable, matching the pre-refactor inline behavior
|
||||
/// where the MTU `if let` chain simply did not fire.
|
||||
fn resolve_next_hop(&mut self, dest: &NodeAddr) -> Option<NextHop> {
|
||||
let addr = *self.find_next_hop(dest)?.node_addr();
|
||||
let link_mtu = if let Some(peer) = self.peers.get(&addr)
|
||||
&& let Some(tid) = peer.transport_id()
|
||||
&& let Some(transport) = self.transports.get(&tid)
|
||||
{
|
||||
if let Some(addr) = peer.current_addr() {
|
||||
datagram.path_mtu = datagram.path_mtu.min(transport.link_mtu(addr));
|
||||
} else {
|
||||
datagram.path_mtu = datagram.path_mtu.min(transport.mtu());
|
||||
}
|
||||
}
|
||||
|
||||
// ECN CE relay: propagate incoming CE and detect local congestion
|
||||
let local_congestion = self.detect_congestion(&next_hop_addr);
|
||||
let outgoing_ce = incoming_ce || local_congestion;
|
||||
if local_congestion {
|
||||
self.metrics().congestion.congestion_detected.inc();
|
||||
let now = Instant::now();
|
||||
let should_log = self
|
||||
.last_congestion_log
|
||||
.map(|t| now.duration_since(t) >= Duration::from_secs(5))
|
||||
.unwrap_or(true);
|
||||
if should_log {
|
||||
self.last_congestion_log = Some(now);
|
||||
debug!(next_hop = %next_hop_addr, "Congestion detected, CE flag set on forwarded packet");
|
||||
}
|
||||
}
|
||||
|
||||
// Forward: re-encode (includes 0x00 type byte) and send
|
||||
let encoded = datagram.encode();
|
||||
if let Err(e) = self
|
||||
.send_encrypted_link_message_with_ce(&next_hop_addr, &encoded, outgoing_ce)
|
||||
.await
|
||||
{
|
||||
match e {
|
||||
NodeError::MtuExceeded { mtu, .. } => {
|
||||
self.metrics()
|
||||
.forwarding
|
||||
.record_reject_bytes(ForwardingReject::MtuExceeded, payload.len());
|
||||
self.send_mtu_exceeded_error(&datagram, mtu).await;
|
||||
}
|
||||
_ => {
|
||||
self.metrics()
|
||||
.forwarding
|
||||
.record_reject_bytes(ForwardingReject::SendError, payload.len());
|
||||
debug!(
|
||||
next_hop = %next_hop_addr,
|
||||
dest = %datagram.dest_addr,
|
||||
error = %e,
|
||||
"Failed to forward SessionDatagram"
|
||||
);
|
||||
}
|
||||
match peer.current_addr() {
|
||||
Some(link_addr) => transport.link_mtu(link_addr),
|
||||
None => transport.mtu(),
|
||||
}
|
||||
} else {
|
||||
self.metrics().forwarding.record_forwarded(encoded.len());
|
||||
// Classify this transit forward by route class (partition of
|
||||
// forwarded_packets). Done here, at the data-plane chokepoint, so
|
||||
// the error-signal routing callers of find_next_hop are excluded.
|
||||
let class = self.classify_forward(&datagram.dest_addr, &next_hop_addr);
|
||||
self.metrics().forwarding.record_route_class(class);
|
||||
if outgoing_ce {
|
||||
self.metrics().congestion.ce_forwarded.inc();
|
||||
}
|
||||
}
|
||||
u16::MAX
|
||||
};
|
||||
Some(NextHop { addr, link_mtu })
|
||||
}
|
||||
|
||||
/// Attempt to warm the coordinate cache from session-layer payload headers.
|
||||
@@ -260,35 +302,41 @@ impl Node {
|
||||
/// If we can't route the error back to the source either, drop silently.
|
||||
/// No cascading errors.
|
||||
async fn send_routing_error(&mut self, original: &SessionDatagram) {
|
||||
// Rate limit: one error signal per destination per 100ms
|
||||
if !self
|
||||
.routing_error_rate_limiter
|
||||
.should_send(&original.dest_addr)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let my_addr = *self.node_addr();
|
||||
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
let default_ttl = self.config().node.session.default_ttl;
|
||||
|
||||
let error_payload =
|
||||
if let Some(coords) = self.coord_cache().get(&original.dest_addr, now_ms) {
|
||||
let coords = coords.clone();
|
||||
PathBroken::new(original.dest_addr, my_addr)
|
||||
.with_last_coords(coords)
|
||||
.encode()
|
||||
} else {
|
||||
CoordsRequired::new(original.dest_addr, my_addr).encode()
|
||||
// Pure decision: rate-limit gate + PathBroken/CoordsRequired choice +
|
||||
// error-PDU encode. Borrow the routing tables disjointly from
|
||||
// `&mut self.routing`, then release them before the reverse-hop lookup.
|
||||
let action = {
|
||||
let view = NodeRoutingView {
|
||||
coord_cache: &self.coord_cache,
|
||||
peers: &self.peers,
|
||||
tree_state: &self.tree_state,
|
||||
congested: false,
|
||||
};
|
||||
self.routing.synth_routing_error(
|
||||
&original.dest_addr,
|
||||
&original.src_addr,
|
||||
&my_addr,
|
||||
&view,
|
||||
now_ms,
|
||||
default_ttl,
|
||||
)
|
||||
};
|
||||
let RouteAction::SendError { toward, bytes } = match action {
|
||||
Some(action) => action,
|
||||
// Rate limited: drop silently. No cascading errors.
|
||||
None => return,
|
||||
};
|
||||
|
||||
let error_dg = SessionDatagram::new(my_addr, original.src_addr, error_payload)
|
||||
.with_ttl(self.config().node.session.default_ttl);
|
||||
|
||||
let next_hop_addr = match self.find_next_hop(&original.src_addr) {
|
||||
// Resolve the reverse link hop only now, after the gate passed, so
|
||||
// `find_next_hop`'s coord-cache touch keeps its pre-refactor scope.
|
||||
let next_hop_addr = match self.find_next_hop(&toward) {
|
||||
Some(peer) => *peer.node_addr(),
|
||||
None => {
|
||||
debug!(
|
||||
@@ -300,9 +348,8 @@ impl Node {
|
||||
}
|
||||
};
|
||||
|
||||
let encoded = error_dg.encode();
|
||||
if let Err(e) = self
|
||||
.send_encrypted_link_message(&next_hop_addr, &encoded)
|
||||
.send_encrypted_link_message(&next_hop_addr, &bytes)
|
||||
.await
|
||||
{
|
||||
debug!(
|
||||
@@ -324,37 +371,50 @@ impl Node {
|
||||
/// Called when `send_encrypted_link_message()` fails with
|
||||
/// `NodeError::MtuExceeded` during forwarding. The signal tells the
|
||||
/// source the bottleneck MTU so it can immediately reduce its path MTU.
|
||||
async fn send_mtu_exceeded_error(&mut self, original: &SessionDatagram, bottleneck_mtu: u16) {
|
||||
// Rate limit: reuse routing_error_rate_limiter keyed on dest_addr
|
||||
if !self
|
||||
.routing_error_rate_limiter
|
||||
.should_send(&original.dest_addr)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
///
|
||||
/// `dest` is the failed datagram's destination (rate-limit key); `toward`
|
||||
/// is its source, where the signal is routed back.
|
||||
async fn send_mtu_exceeded_error(
|
||||
&mut self,
|
||||
dest: NodeAddr,
|
||||
toward: NodeAddr,
|
||||
bottleneck_mtu: u16,
|
||||
) {
|
||||
let my_addr = *self.node_addr();
|
||||
let now_ms = Self::now_ms();
|
||||
let default_ttl = self.config().node.session.default_ttl;
|
||||
|
||||
let error_payload = MtuExceeded::new(original.dest_addr, my_addr, bottleneck_mtu).encode();
|
||||
// Pure decision: rate-limit gate + MtuExceeded PDU + encode.
|
||||
let action = self.routing.synth_mtu_exceeded(
|
||||
&dest,
|
||||
&toward,
|
||||
&my_addr,
|
||||
bottleneck_mtu,
|
||||
now_ms,
|
||||
default_ttl,
|
||||
);
|
||||
let RouteAction::SendError { toward, bytes } = match action {
|
||||
Some(action) => action,
|
||||
// Rate limited: drop silently. No cascading errors.
|
||||
None => return,
|
||||
};
|
||||
|
||||
let error_dg = SessionDatagram::new(my_addr, original.src_addr, error_payload)
|
||||
.with_ttl(self.config().node.session.default_ttl);
|
||||
|
||||
let next_hop_addr = match self.find_next_hop(&original.src_addr) {
|
||||
// Resolve the reverse link hop only now, after the gate passed, so
|
||||
// `find_next_hop`'s coord-cache touch keeps its pre-refactor scope.
|
||||
let next_hop_addr = match self.find_next_hop(&toward) {
|
||||
Some(peer) => *peer.node_addr(),
|
||||
None => {
|
||||
debug!(
|
||||
src = %original.src_addr,
|
||||
dest = %original.dest_addr,
|
||||
src = %toward,
|
||||
dest = %dest,
|
||||
"Cannot route MtuExceeded signal back to source, dropping"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let encoded = error_dg.encode();
|
||||
if let Err(e) = self
|
||||
.send_encrypted_link_message(&next_hop_addr, &encoded)
|
||||
.send_encrypted_link_message(&next_hop_addr, &bytes)
|
||||
.await
|
||||
{
|
||||
debug!(
|
||||
@@ -364,8 +424,8 @@ impl Node {
|
||||
);
|
||||
} else {
|
||||
debug!(
|
||||
original_dest = %original.dest_addr,
|
||||
error_dest = %original.src_addr,
|
||||
original_dest = %dest,
|
||||
error_dest = %toward,
|
||||
bottleneck_mtu,
|
||||
"Sent MtuExceeded error signal"
|
||||
);
|
||||
18
src/node/dataplane/mod.rs
Normal file
18
src/node/dataplane/mod.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
//! Data plane: the RX `select!` loop and the per-packet forwarding path.
|
||||
//!
|
||||
//! Holds the whole hot path in one home: the `select!` run loop
|
||||
//! (`rx_loop`), transit/local datagram forwarding (`forwarding`), the
|
||||
//! link-message router (`dispatch`), the RX decrypt path including responder
|
||||
//! K-bit cutover and address-roam writes (`encrypted`), and the per-peer
|
||||
//! connected-UDP fast-path socket activation (`connected_udp`). Each module
|
||||
//! contributes `impl Node` methods driven by the run loop.
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) mod connected_udp;
|
||||
mod dispatch;
|
||||
mod encrypted;
|
||||
mod forwarding;
|
||||
mod peer_actions;
|
||||
mod rx_loop;
|
||||
|
||||
pub(in crate::node) use peer_actions::PeerActionCtx;
|
||||
557
src/node/dataplane/peer_actions.rs
Normal file
557
src/node/dataplane/peer_actions.rs
Normal file
@@ -0,0 +1,557 @@
|
||||
//! Executor for the per-peer control machine's [`PeerAction`]s.
|
||||
//!
|
||||
//! The per-peer FSM in [`crate::peer::machine`] is a sans-IO reducer: it decides
|
||||
//! *what* must happen and returns a `Vec<PeerAction>`; this module is the *doing*
|
||||
//! half — the thin driver that maps each action onto the exact shell call it
|
||||
//! stands for (`build_msg2` + `transport.send`, `promote_connection`,
|
||||
//! `remove_active_peer`, `index_allocator.free`, `note_link_dead`, …).
|
||||
//!
|
||||
//! ## Progressive cutover
|
||||
//!
|
||||
//! The executor is wired incrementally. Live today: the inbound establish
|
||||
//! (`handle_msg1` → `step(InboundMsg1)`), the outbound msg2 promote
|
||||
//! (`handle_msg2` looks up the dial-persisted machine), the connectionless
|
||||
//! outbound msg1 send (`SendHandshake` with `their_index == None` →
|
||||
//! `send_stored_msg1`, driven from `initiate_connection`), the
|
||||
//! connection-oriented dial (`OpenTransport` performs the non-blocking
|
||||
//! `transport.connect`; `TransportConnected` drives the connect-resolution msg1
|
||||
//! send from `poll_pending_connects`), the rekey cadence (`check_rekey` →
|
||||
//! `route_rekey_cadence` → `RekeyConsume`, driving the `SwapSendState` and
|
||||
//! `CompleteDrain` arms), and the liveness reap (`route_link_dead` →
|
||||
//! `LinkDeadSuspected`, driving `InvalidateSendState` → `remove_active_peer`).
|
||||
//!
|
||||
//! The genuine inert stubs remaining are `SendRekey`, `SendLinkMessage`, and
|
||||
//! the connected-UDP arms. `RegisterDecryptSession` is a deliberate no-op —
|
||||
//! see its arm for the note.
|
||||
//!
|
||||
//! The timer arms (`SetTimer`/`CancelTimer`) populate/clear the per-peer timer
|
||||
//! store (`peer_timers`). The `HandshakeRetransmit` and `HandshakeTimeout`
|
||||
//! deadlines are read and fired by `drive_peer_timers` (the handshake resend +
|
||||
//! reap home). The rekey/liveness kinds are still SHADOW — driven by their own
|
||||
//! shell drivers — so populating them stays behavior-neutral.
|
||||
|
||||
use crate::PeerIdentity;
|
||||
use crate::node::Node;
|
||||
use crate::node::reject::{HandshakeReject, RejectReason};
|
||||
use crate::peer::machine::{LostKind, PeerAction, PeerEvent};
|
||||
use crate::proto::fmp::PromotionResult;
|
||||
use crate::proto::fmp::wire::build_msg2;
|
||||
use crate::transport::{LinkId, TransportAddr, TransportId};
|
||||
use crate::utils::index::SessionIndex;
|
||||
use std::collections::VecDeque;
|
||||
use tracing::{debug, trace, warn};
|
||||
|
||||
/// Ambient shell facts a [`PeerAction`] executor needs that the machine's
|
||||
/// runtime-agnostic action payloads deliberately omit (verified identity,
|
||||
/// transport target, the msg2 framing indices, the promotion timestamp).
|
||||
///
|
||||
/// Unlike a machine event/action payload this is **executor-side**, so it may
|
||||
/// hold real values resolved from the wire context (cf. `handle_msg1`'s
|
||||
/// `wire`/`packet` locals and `promote_connection`'s ambient args). It is
|
||||
/// built fresh per driven step by the caller at cutover time.
|
||||
#[allow(dead_code)]
|
||||
pub(in crate::node) struct PeerActionCtx {
|
||||
/// The authenticated peer identity: `PromoteToActive` /
|
||||
/// `InvalidateSendState` resolve their `NodeAddr` from this.
|
||||
pub(in crate::node) verified_identity: PeerIdentity,
|
||||
/// The transport the exchange is happening over (msg2 send target, decrypt
|
||||
/// cache-key transport half).
|
||||
pub(in crate::node) transport_id: TransportId,
|
||||
/// The peer's wire address (msg2 send target).
|
||||
pub(in crate::node) remote_addr: TransportAddr,
|
||||
/// Our session index for this exchange (msg2 framing sender_idx).
|
||||
pub(in crate::node) our_index: Option<SessionIndex>,
|
||||
/// The peer's session index for this exchange (msg2 framing
|
||||
/// receiver_idx).
|
||||
pub(in crate::node) their_index: Option<SessionIndex>,
|
||||
/// The wire timestamp driving this step (promotion ts / loss-report clock).
|
||||
pub(in crate::node) now_ms: u64,
|
||||
/// Establish direction for this exchange. Discriminates the
|
||||
/// `PromoteToActive` failure cleanup: the pre-refactor inbound
|
||||
/// (`handle_msg1`) and outbound (`handle_msg2`) promote-Err arms were NOT
|
||||
/// byte-identical, so the executor must reproduce each. `false` = inbound
|
||||
/// (drop link + reverse map + free index), `true` = outbound (record the
|
||||
/// reject only; leave the dead link/`addr_to_link` for the stale-connection
|
||||
/// reaper, matching old `handle_msg2`).
|
||||
pub(in crate::node) is_outbound: bool,
|
||||
}
|
||||
|
||||
impl Node {
|
||||
/// Advance the machine for `link` by one event and execute the resulting
|
||||
/// actions.
|
||||
///
|
||||
/// The borrow structure the whole seam turns on: the machine
|
||||
/// needs `&mut IndexAllocator` as a synchronous capability *while it is
|
||||
/// itself borrowed mutably out of `peer_machines`*. `peer_machines` and
|
||||
/// `index_allocator` are **distinct `Node` fields**, so the collect below is
|
||||
/// a disjoint two-field borrow the checker accepts; once the actions are
|
||||
/// collected both borrows drop and the executor runs against `&mut self`.
|
||||
pub(in crate::node) async fn advance_peer_machine(
|
||||
&mut self,
|
||||
link: LinkId,
|
||||
event: PeerEvent,
|
||||
now: u64,
|
||||
ambient: &PeerActionCtx,
|
||||
) {
|
||||
let actions = match self.peer_machines.get_mut(&link) {
|
||||
// Disjoint field borrow: `self.peer_machines` (the map entry) and
|
||||
// `self.index_allocator` (the capability) are separate fields.
|
||||
Some(machine) => machine.step(event, now, &mut self.index_allocator),
|
||||
None => return,
|
||||
};
|
||||
self.execute_peer_actions(link, ambient, actions).await;
|
||||
}
|
||||
|
||||
/// Map each [`PeerAction`] onto its shell call.
|
||||
///
|
||||
/// `PromoteToActive` feeds its [`PromotionResult`](crate::proto::fmp::PromotionResult)
|
||||
/// back into the machine and appends the follow-up actions to the same
|
||||
/// worklist — a queue rather than self-recursion so the async executor stays a
|
||||
/// single flat future (no boxing) and the emitted order is preserved (the
|
||||
/// establish sequences always end in `PromoteToActive`, so its follow-ups run
|
||||
/// after any siblings).
|
||||
pub(in crate::node) async fn execute_peer_actions(
|
||||
&mut self,
|
||||
link: LinkId,
|
||||
ambient: &PeerActionCtx,
|
||||
actions: Vec<PeerAction>,
|
||||
) {
|
||||
let mut queue: VecDeque<PeerAction> = actions.into();
|
||||
while let Some(action) = queue.pop_front() {
|
||||
match action {
|
||||
PeerAction::OpenTransport {
|
||||
transport_id,
|
||||
remote_addr,
|
||||
} => {
|
||||
// Outbound connection-oriented dial. `initiate_connection`'s
|
||||
// oriented branch drove the machine to `Connecting`, which
|
||||
// emitted this action. Perform the non-blocking
|
||||
// `transport.connect` and, on success, push the
|
||||
// `PendingConnect` for `poll_pending_connects` to resolve. On
|
||||
// connect error, tear down the dial-window state (link,
|
||||
// reverse map, control machine) and abort the queue — the
|
||||
// executor-local mirror of the old inline
|
||||
// `initiate_connection` connect+push.
|
||||
if let Some(transport) = self.transports.get(&transport_id) {
|
||||
match transport.connect(&remote_addr).await {
|
||||
Ok(()) => {
|
||||
debug!(
|
||||
transport_id = %transport_id,
|
||||
remote_addr = %remote_addr,
|
||||
link_id = %link,
|
||||
"Transport connect initiated (non-blocking)"
|
||||
);
|
||||
self.peering
|
||||
.pending_connects
|
||||
.push(crate::node::PendingConnect {
|
||||
link_id: link,
|
||||
transport_id,
|
||||
remote_addr,
|
||||
peer_identity: ambient.verified_identity,
|
||||
});
|
||||
}
|
||||
Err(_e) => {
|
||||
self.links.remove(&link);
|
||||
self.addr_to_link.remove(&(transport_id, remote_addr));
|
||||
self.remove_peer_machine(link);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
PeerAction::SendHandshake { bytes } => {
|
||||
// Two outbound directions share this action, discriminated by
|
||||
// `their_index`:
|
||||
// msg2 (`their_index == Some`): the machine payload is the
|
||||
// UNFRAMED Noise msg2; frame it with our/their index
|
||||
// (`build_msg2`) and send.
|
||||
// msg1 (`their_index == None`): a fresh outbound handshake;
|
||||
// the machine's empty payload is ignored — the shell already
|
||||
// allocated the index, ran the Noise leaf, and armed the
|
||||
// wire at dial (`prepare_outbound_msg1`); this just sends the
|
||||
// stored wire (see `send_stored_msg1`).
|
||||
if let (Some(sender_idx), Some(receiver_idx)) =
|
||||
(ambient.our_index, ambient.their_index)
|
||||
{
|
||||
let frame = build_msg2(sender_idx, receiver_idx, &bytes);
|
||||
// Surface the send Result. A missing transport skips
|
||||
// the send and continues (mirrors `handle_msg1`'s
|
||||
// `if let Some(transport)` guard); a send *error* runs the
|
||||
// pre-refactor msg2-send-failure cleanup (`handle_msg1`
|
||||
// L494-503) and ABORTS the remaining queue so the queued
|
||||
// `PromoteToActive` never runs.
|
||||
let send_err = match self.transports.get(&ambient.transport_id) {
|
||||
Some(transport) => {
|
||||
transport.send(&ambient.remote_addr, &frame).await.err()
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
if let Some(e) = send_err {
|
||||
// Restored pre-refactor msg2-send-failure warn!
|
||||
// (`handle_msg1` L665): the send error text is surfaced
|
||||
// at the executor point where the failure is now handled.
|
||||
warn!(link_id = %link, error = %e, "Failed to send msg2");
|
||||
self.links.remove(&link);
|
||||
self.addr_to_link
|
||||
.remove(&(ambient.transport_id, ambient.remote_addr.clone()));
|
||||
if let Some(idx) = ambient.our_index {
|
||||
let _ = self.index_allocator.free(idx);
|
||||
}
|
||||
self.remove_peer_machine(link);
|
||||
self.stats_mut()
|
||||
.record_reject(RejectReason::Handshake(HandshakeReject::BadState));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// msg1: the shell already allocated the index, ran the
|
||||
// Noise leaf, and armed the wire on the connection at dial
|
||||
// (`prepare_outbound_msg1`); send the stored wire. The
|
||||
// machine's empty payload is ignored.
|
||||
let _ = bytes;
|
||||
self.send_stored_msg1(
|
||||
link,
|
||||
ambient.transport_id,
|
||||
&ambient.remote_addr,
|
||||
ambient.now_ms,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
PeerAction::SendRekey { .. } => {
|
||||
// Rekey msg2 framing (`build_msg2(our_new_index, …)`,
|
||||
// `handshake.rs:365`) + send. Rekey fold is not yet wired.
|
||||
}
|
||||
PeerAction::SendLinkMessage { .. } => {
|
||||
// Encrypt + send a link-control frame (heartbeat / filter
|
||||
// / tree / disconnect). Data-plane-owned; not yet wired.
|
||||
}
|
||||
PeerAction::PromoteToActive { link: promote_link } => {
|
||||
// Ambient supplies the verified identity + promotion ts
|
||||
// that `promote_connection` needs (resolved from the wire ctx).
|
||||
match self.promote_connection(
|
||||
promote_link,
|
||||
ambient.verified_identity,
|
||||
ambient.now_ms,
|
||||
) {
|
||||
Ok(result) => {
|
||||
// The decrypt-worker registration relocated
|
||||
// OUT of `promote_connection` into THIS single executor
|
||||
// arm — the one live caller of `promote_connection` (both
|
||||
// the inbound `handle_msg1` and outbound `handle_msg2`
|
||||
// net-new establish paths reach it here). Register iff the
|
||||
// promotion actually created or replaced a peer
|
||||
// (`Promoted | CrossConnectionWon`), NEVER on
|
||||
// `CrossConnectionLost`. Run synchronously right after
|
||||
// `promote_connection` returns, before feeding
|
||||
// `PromotionResolved` and before any await — the exact
|
||||
// synchronous point (and Promoted/Won gating) of the
|
||||
// pre-refactor in-`promote_connection` call. No-op when
|
||||
// the worker pool isn't spawned (`register_...` early-
|
||||
// returns), so the direct `promote_connection` test
|
||||
// callers (which bypass this executor) are unaffected.
|
||||
#[cfg(unix)]
|
||||
match result {
|
||||
PromotionResult::Promoted(node_addr)
|
||||
| PromotionResult::CrossConnectionWon { node_addr, .. } => {
|
||||
self.register_decrypt_worker_session(&node_addr);
|
||||
}
|
||||
PromotionResult::CrossConnectionLost { .. } => {}
|
||||
}
|
||||
|
||||
// Feed the outcome back into the machine and fold the
|
||||
// follow-up actions (RegisterDecryptSession — now a
|
||||
// redundant no-op, see its arm — and the cross-conn index
|
||||
// frees) into the worklist. Disjoint field borrow again.
|
||||
let follow = match self.peer_machines.get_mut(&promote_link) {
|
||||
Some(machine) => machine.step(
|
||||
PeerEvent::PromotionResolved { result },
|
||||
ambient.now_ms,
|
||||
&mut self.index_allocator,
|
||||
),
|
||||
None => Vec::new(),
|
||||
};
|
||||
queue.extend(follow);
|
||||
|
||||
// Defensive cross-connection loser-link surgery.
|
||||
// LINK-ONLY: close the losing transport connection, drop
|
||||
// its link, and re-point `addr_to_link`, reproducing the
|
||||
// pre-refactor inline `handle_msg2`/`handle_msg1` per-arm
|
||||
// order EXACTLY. The index-plane frees/unregisters are
|
||||
// owned by the machine's `PromotionResolved{Won/Lost}`
|
||||
// follow-up (queued just above), so NOTHING here touches
|
||||
// an index — no double-free.
|
||||
//
|
||||
// UNREACHABLE on every current driven path: the inbound
|
||||
// and outbound net-new establish arms only route to the
|
||||
// machine when no promoted peer exists for the node_addr
|
||||
// (and `RestartThenPromote` removes the old peer first),
|
||||
// so `promote_connection` always returns `Promoted`. The
|
||||
// `debug_assert!(false, ..)` catches any future path that
|
||||
// drives a cross-connection through the executor without
|
||||
// the matching send-state handling.
|
||||
match result {
|
||||
PromotionResult::CrossConnectionWon { loser_link_id, .. } => {
|
||||
debug_assert!(
|
||||
false,
|
||||
"executor CrossConnectionWon is unreachable on \
|
||||
driven net-new establish paths"
|
||||
);
|
||||
// Close the losing transport connection (no-op for
|
||||
// connectionless) via the LOSER link's own
|
||||
// transport/addr, then drop the losing link.
|
||||
if let Some(loser_link) = self.links.get(&loser_link_id) {
|
||||
let loser_tid = loser_link.transport_id();
|
||||
let loser_addr = loser_link.remote_addr().clone();
|
||||
if let Some(transport) = self.transports.get(&loser_tid) {
|
||||
transport.close_connection(&loser_addr).await;
|
||||
}
|
||||
}
|
||||
self.remove_link(&loser_link_id);
|
||||
// Point `addr_to_link` at the winning (current)
|
||||
// link.
|
||||
self.addr_to_link.insert(
|
||||
(ambient.transport_id, ambient.remote_addr.clone()),
|
||||
promote_link,
|
||||
);
|
||||
}
|
||||
PromotionResult::CrossConnectionLost { winner_link_id } => {
|
||||
debug_assert!(
|
||||
false,
|
||||
"executor CrossConnectionLost is unreachable on \
|
||||
driven net-new establish paths"
|
||||
);
|
||||
// Close this (losing) connection, drop its link,
|
||||
// and restore `addr_to_link` to the winner.
|
||||
if let Some(transport) =
|
||||
self.transports.get(&ambient.transport_id)
|
||||
{
|
||||
transport.close_connection(&ambient.remote_addr).await;
|
||||
}
|
||||
self.remove_link(&promote_link);
|
||||
self.addr_to_link.insert(
|
||||
(ambient.transport_id, ambient.remote_addr.clone()),
|
||||
winner_link_id,
|
||||
);
|
||||
}
|
||||
PromotionResult::Promoted(_) => {}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// Promotion failed. `promote_connection` already
|
||||
// removed `connections[link]` and (on error) handled its
|
||||
// own index internally. The pre-refactor inbound and
|
||||
// outbound promote-Err arms were NOT byte-identical, so
|
||||
// discriminate on `ambient.is_outbound`. The queue is
|
||||
// drained (PromoteToActive is the last establish action),
|
||||
// so no explicit abort.
|
||||
if ambient.is_outbound {
|
||||
// OLD outbound (`handle_msg2` promote-Err): warn +
|
||||
// record_reject ONLY. NO `remove_link`, NO
|
||||
// `index_allocator.free`, NO `addr_to_link` removal —
|
||||
// the dead link/addr_to_link/pending_outbound were
|
||||
// left for the 30s stale-connection reaper
|
||||
// (`promote_connection` already handled
|
||||
// `connections[link]`/its index on error). Restored
|
||||
// pre-refactor outbound warn! ("Failed to promote
|
||||
// connection").
|
||||
//
|
||||
// The outbound machine was persisted at dial; it is
|
||||
// additive state that did not exist pre-refactor, so
|
||||
// removing it on promote failure is neutral vs old and
|
||||
// prevents a leak.
|
||||
warn!(
|
||||
target: "fips::node::handlers::handshake",
|
||||
link_id = %promote_link,
|
||||
error = %e,
|
||||
"Failed to promote connection"
|
||||
);
|
||||
self.stats_mut().record_reject(RejectReason::Handshake(
|
||||
HandshakeReject::BadState,
|
||||
));
|
||||
self.remove_peer_machine(promote_link);
|
||||
} else {
|
||||
// OLD inbound (`handle_msg1` L587-591): drop the link
|
||||
// + reverse map, free our index, discard the machine,
|
||||
// and record the reject. Restored pre-refactor inbound
|
||||
// promote-failure warn! (`handle_msg1` L757).
|
||||
warn!(
|
||||
target: "fips::node::handlers::handshake",
|
||||
link_id = %promote_link,
|
||||
error = %e,
|
||||
"Failed to promote inbound connection"
|
||||
);
|
||||
self.remove_link(&promote_link);
|
||||
if let Some(idx) = ambient.our_index {
|
||||
let _ = self.index_allocator.free(idx);
|
||||
}
|
||||
self.remove_peer_machine(promote_link);
|
||||
self.stats_mut().record_reject(RejectReason::Handshake(
|
||||
HandshakeReject::BadState,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
PeerAction::ResolveCrossConnection { .. } => {
|
||||
// A decision token, not an effect: the outbound msg2
|
||||
// handler intercepts it and runs the inline swap/keep
|
||||
// resolution itself, so it must never reach the executor.
|
||||
debug_assert!(
|
||||
false,
|
||||
"ResolveCrossConnection is intercepted by the msg2 \
|
||||
handler and must never reach the executor"
|
||||
);
|
||||
}
|
||||
PeerAction::SwapSendState { .. } => {
|
||||
// Initiator cutover: the live authoritative rekey-cadence
|
||||
// path, routed here from `check_rekey` via
|
||||
// `route_rekey_cadence` → `PeerEvent::RekeyConsume`; the
|
||||
// inline body survives only as `cutover_peer_inline`, a
|
||||
// debug-assert release fallback. `addr` is resolved
|
||||
// from the ambient verified identity (as `InvalidateSendState`
|
||||
// does). The decrypt re-register folds HERE, gated on
|
||||
// `did_cutover` — the generic `RegisterDecryptSession` arm stays a
|
||||
// no-op so a promote never double-registers.
|
||||
let node_addr = *ambient.verified_identity.node_addr();
|
||||
let did_cutover = if let Some(peer) = self.peers.get_mut(&node_addr) {
|
||||
if let Some(_old_our_index) = peer.cutover_to_new_session() {
|
||||
// New index was pre-registered in peers_by_index
|
||||
// during msg2 handling (handshake.rs).
|
||||
debug_assert!(
|
||||
peer.transport_id().is_some()
|
||||
&& peer.our_index().is_some()
|
||||
&& self.peers_by_index.contains_key(&(
|
||||
peer.transport_id().unwrap(),
|
||||
peer.our_index().unwrap().as_u32()
|
||||
)),
|
||||
"peers_by_index should contain pre-registered new index after cutover"
|
||||
);
|
||||
debug!(
|
||||
// Pin the target to the pre-refactor module: this
|
||||
// cutover log relocated from handlers/rekey.rs into
|
||||
// the executor, but operators (and the test harness)
|
||||
// filter it under fips::node::handlers::rekey. Keeping
|
||||
// the target preserves the observable log contract.
|
||||
target: "fips::node::handlers::rekey",
|
||||
peer = %self.peer_display_name(&node_addr),
|
||||
"Rekey cutover complete (initiator), K-bit flipped"
|
||||
);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
// Re-register the new session with the decrypt worker — the
|
||||
// cache_key (transport_id, our_index) just changed, so the
|
||||
// old worker entry is stale and every packet on the new
|
||||
// session would miss the worker's HashMap lookup.
|
||||
#[cfg(unix)]
|
||||
if did_cutover {
|
||||
self.register_decrypt_worker_session(&node_addr);
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = did_cutover;
|
||||
}
|
||||
PeerAction::CompleteDrain { peer: node_addr } => {
|
||||
// Initiator drain completion: the live authoritative
|
||||
// rekey-cadence path, routed here from `check_rekey` via
|
||||
// `route_rekey_cadence` → `PeerEvent::RekeyConsume`; the
|
||||
// inline body survives only as `drain_peer_inline`, a
|
||||
// debug-assert release fallback. Extract the real previous
|
||||
// index + transport_id under the peer borrow, drop the
|
||||
// borrow, then run the cache_key cleanup (which takes
|
||||
// &mut self for unregister_decrypt_worker_session).
|
||||
let drained = self.peers.get_mut(&node_addr).and_then(|peer| {
|
||||
peer.complete_drain().map(|idx| (idx, peer.transport_id()))
|
||||
});
|
||||
if let Some((old_our_index, transport_id)) = drained {
|
||||
if let Some(tid) = transport_id {
|
||||
let cache_key = (tid, old_our_index.as_u32());
|
||||
self.peers_by_index.remove(&cache_key);
|
||||
#[cfg(unix)]
|
||||
self.unregister_decrypt_worker_session(cache_key);
|
||||
}
|
||||
let _ = self.index_allocator.free(old_our_index);
|
||||
trace!(
|
||||
// Pin to the pre-refactor module (see the cutover log
|
||||
// above) so the relocated drain log stays visible under
|
||||
// the operator's fips::node::handlers::rekey filter.
|
||||
target: "fips::node::handlers::rekey",
|
||||
peer = %self.peer_display_name(&node_addr),
|
||||
old_index = %old_our_index,
|
||||
"Drain complete, previous session erased"
|
||||
);
|
||||
}
|
||||
}
|
||||
PeerAction::InvalidateSendState => {
|
||||
// The FULL teardown. `remove_active_peer`
|
||||
// (`dispatch.rs:107`) frees the four index slots
|
||||
// (current/rekey/pending/previous), drops `peers_by_index`,
|
||||
// unregisters the decrypt worker, removes the FSP `sessions`
|
||||
// entry and `pending_tun_packets`. The machine emits NO
|
||||
// `FreeIndex` for those slots, so there is no double-free.
|
||||
self.remove_active_peer(ambient.verified_identity.node_addr());
|
||||
}
|
||||
PeerAction::RegisterDecryptSession { index } => {
|
||||
let _ = index;
|
||||
// No-op by design. The decrypt-worker
|
||||
// registration relocated into the `PromoteToActive` Ok arm above, gated on
|
||||
// the returned `PromotionResult`, so it runs once per live
|
||||
// promote (Promoted/Won) at the pre-refactor synchronous point.
|
||||
// This machine-emitted action is now redundant with that arm;
|
||||
// kept as an inert no-op (rather than removing the emission) so
|
||||
// the machine's action sequence and its unit tests stay
|
||||
// unchanged. The keyed-by-NodeAddr register does not need the
|
||||
// machine's `index` payload.
|
||||
}
|
||||
PeerAction::UnregisterDecryptSession { index } => {
|
||||
// Executor supplies `transport_id` from ambient; keyed by
|
||||
// (tid, index) like `remove_active_peer` / the rekey drain path.
|
||||
#[cfg(unix)]
|
||||
self.unregister_decrypt_worker_session((ambient.transport_id, index.as_u32()));
|
||||
#[cfg(not(unix))]
|
||||
let _ = index;
|
||||
}
|
||||
PeerAction::FreeIndex { index } => {
|
||||
let _ = self.index_allocator.free(index);
|
||||
}
|
||||
PeerAction::ActivateConnectedUdp | PeerAction::TeardownConnectedUdp => {
|
||||
// Connected-UDP plane ownership (`connected_udp.rs`).
|
||||
}
|
||||
PeerAction::SetTimer { kind, at_ms } => {
|
||||
// Populate the per-peer timer store (overwrite = reschedule).
|
||||
// The `HandshakeRetransmit` and `HandshakeTimeout` deadlines
|
||||
// are read + fired by `drive_peer_timers`. Rekey/liveness kinds
|
||||
// are still SHADOW here — they keep their own shell drivers —
|
||||
// so populating them stays behavior-neutral.
|
||||
self.peer_timers
|
||||
.entry(link)
|
||||
.or_default()
|
||||
.insert(kind, at_ms);
|
||||
}
|
||||
PeerAction::CancelTimer { kind } => {
|
||||
if let Some(timers) = self.peer_timers.get_mut(&link) {
|
||||
timers.remove(&kind);
|
||||
}
|
||||
}
|
||||
PeerAction::ReportLost { peer, kind } => {
|
||||
// The single loss token, routed to the reconciler reflex the
|
||||
// `kind` names: an un-promoted handshake attempt takes the
|
||||
// connected-guarded `note_handshake_timeout` (`driver.rs:28`),
|
||||
// an established peer's link-death takes the unconditional
|
||||
// `note_link_dead` (`driver.rs:48`).
|
||||
match kind {
|
||||
LostKind::HandshakeTimeout => {
|
||||
self.note_handshake_timeout(peer, ambient.now_ms);
|
||||
}
|
||||
LostKind::LinkDead => {
|
||||
self.note_link_dead(peer, ambient.now_ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
//! RX event loop and packet dispatch.
|
||||
|
||||
use crate::control::{ControlSocket, commands};
|
||||
use crate::node::wire::{
|
||||
use crate::node::{Node, NodeError};
|
||||
use crate::proto::fmp::wire::{
|
||||
COMMON_PREFIX_SIZE, CommonPrefix, FMP_VERSION, PHASE_ESTABLISHED, PHASE_MSG1, PHASE_MSG2,
|
||||
};
|
||||
use crate::node::{Node, NodeError};
|
||||
use crate::transport::ReceivedPacket;
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, info, warn};
|
||||
@@ -42,12 +42,42 @@ impl Node {
|
||||
/// This method takes ownership of the packet_rx channel and runs
|
||||
/// until the channel is closed (typically when stop() is called).
|
||||
pub async fn run_rx_loop(&mut self) -> Result<(), NodeError> {
|
||||
// No shutdown observer → today's infinite loop, byte-identical. All
|
||||
// existing callers/tests use this; `pending()` never fires, so the
|
||||
// shutdown/deadline arms below stay permanently disabled.
|
||||
self.run_rx_loop_with_shutdown(std::future::pending()).await
|
||||
}
|
||||
|
||||
/// The rx event loop, which serves until `shutdown` fires and then drains
|
||||
/// **in place** before returning.
|
||||
///
|
||||
/// The channel receivers are moved into this frame's locals and live across
|
||||
/// both serve and drain, so — unlike a `select!`-cancelled loop — they are
|
||||
/// never destructively dropped mid-flight; they are released only on clean
|
||||
/// exit, after which teardown does not need them.
|
||||
///
|
||||
/// - While serving (`drain_deadline == None`) the loop is behaviorally
|
||||
/// identical to before: the shutdown arm, the deadline arm, and the
|
||||
/// peers-empty early-exit are all guarded off, so the hot per-packet path
|
||||
/// and the `biased` order of the real arms are unchanged.
|
||||
/// - When `shutdown` fires, the loop calls [`Node::enter_drain`] once
|
||||
/// (broadcast Disconnect, gate the reconciler off) and arms the bounded
|
||||
/// deadline, then keeps servicing inbound/tick/peer-removal until all
|
||||
/// peers clear or the deadline elapses, then returns. The caller
|
||||
/// ([`Node::finish_shutdown`]) closes the window and tears down.
|
||||
pub async fn run_rx_loop_with_shutdown(
|
||||
&mut self,
|
||||
shutdown: impl std::future::Future<Output = ()>,
|
||||
) -> Result<(), NodeError> {
|
||||
tokio::pin!(shutdown);
|
||||
// `None` = serving; `Some(deadline)` = draining (bounded window).
|
||||
let mut drain_deadline: Option<tokio::time::Instant> = None;
|
||||
let mut packet_rx = self.packet_rx.take().ok_or(NodeError::NotStarted)?;
|
||||
|
||||
// Take the TUN outbound receiver, or create a dummy channel that never
|
||||
// produces messages (when TUN is disabled). Holding the sender prevents
|
||||
// the channel from closing.
|
||||
let (mut tun_outbound_rx, _tun_guard) = match self.tun_outbound_rx.take() {
|
||||
let (mut tun_outbound_rx, _tun_guard) = match self.supervisor.tun_outbound_rx.take() {
|
||||
Some(rx) => (rx, None),
|
||||
None => {
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(1);
|
||||
@@ -57,7 +87,19 @@ impl Node {
|
||||
|
||||
// Take the DNS identity receiver, or create a dummy channel (when DNS
|
||||
// is disabled). Same pattern as TUN outbound.
|
||||
let (mut dns_identity_rx, _dns_guard) = match self.dns_identity_rx.take() {
|
||||
let (mut dns_identity_rx, _dns_guard) = match self.supervisor.dns_identity_rx.take() {
|
||||
Some(rx) => (rx, None),
|
||||
None => {
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(1);
|
||||
(rx, Some(tx))
|
||||
}
|
||||
};
|
||||
|
||||
// Take the runtime child-liveness receiver, or a dummy channel (when the
|
||||
// node was seeded straight into Running without a start()). Holding the
|
||||
// dummy sender in the guard keeps the channel open. Same pattern as TUN
|
||||
// outbound / DNS identity.
|
||||
let (mut child_exit_rx, _child_exit_guard) = match self.child_exit_rx.take() {
|
||||
Some(rx) => (rx, None),
|
||||
None => {
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(1);
|
||||
@@ -122,6 +164,13 @@ impl Node {
|
||||
crate::perf_profile::maybe_spawn_reporter();
|
||||
|
||||
loop {
|
||||
// Bounded drain mode: break as soon as all peers have cleared. In
|
||||
// normal mode (`None`) this short-circuits before touching
|
||||
// `self.peers`, so the loop is byte-identical.
|
||||
if drain_deadline.is_some() && self.peers.is_empty() {
|
||||
info!("Drain complete: all peers cleared, ending drain loop");
|
||||
break;
|
||||
}
|
||||
tokio::select! {
|
||||
biased;
|
||||
// Decrypt-worker fallback drains FIRST. Under sustained
|
||||
@@ -214,6 +263,27 @@ impl Node {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Runtime child-liveness. Placed AFTER `packet_rx` so the hot
|
||||
// inbound path keeps its `biased` priority. A directly-observable
|
||||
// child (TUN threads, DNS/mDNS/Nostr) exited on its own; feed the
|
||||
// FSM, which republishes health (Degraded here — a Running node
|
||||
// always has ≥1 transport up). `on_child_exited` only ever emits
|
||||
// `PublishState`; other variants are ignored defensively.
|
||||
maybe_child = child_exit_rx.recv() => {
|
||||
if let Some(child) = maybe_child {
|
||||
let actions = self
|
||||
.supervisor
|
||||
.fsm
|
||||
.step(crate::node::lifecycle::supervisor::Event::ChildExited { child });
|
||||
for action in actions {
|
||||
if let crate::node::lifecycle::supervisor::Action::PublishState(ns) =
|
||||
action
|
||||
{
|
||||
self.supervisor.state = ns;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(ipv6_packet) = tun_outbound_rx.recv() => {
|
||||
self.handle_tun_outbound(ipv6_packet).await;
|
||||
let mut drained = 0;
|
||||
@@ -257,14 +327,14 @@ impl Node {
|
||||
// is polled separately from `reload_peer_acl` because the
|
||||
// ACL's embedded alias reloader and this snapshot are
|
||||
// distinct resources; the `path_mtu_lookup` cache and the
|
||||
// `nostr_discovery` subsystem are deliberately excluded
|
||||
// `nostr_rendezvous` subsystem are deliberately excluded
|
||||
// from `Reloadable` since neither reloads from a backing
|
||||
// file (see `node::reloadable`).
|
||||
self.reload_host_map().await;
|
||||
self.poll_pending_connects().await;
|
||||
self.poll_nostr_discovery().await;
|
||||
self.poll_lan_discovery().await;
|
||||
self.resend_pending_handshakes(now_ms).await;
|
||||
self.poll_nostr_rendezvous().await;
|
||||
self.poll_lan_rendezvous().await;
|
||||
self.drive_peer_timers(now_ms).await;
|
||||
self.resend_pending_rekeys(now_ms).await;
|
||||
self.resend_pending_session_handshakes(now_ms).await;
|
||||
self.resend_pending_session_msg3(now_ms).await;
|
||||
@@ -284,6 +354,32 @@ impl Node {
|
||||
self.sample_transport_congestion();
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
self.activate_connected_udp_sessions().await;
|
||||
// Debug-build sweep of the peer-lifecycle map invariant
|
||||
// (leaked machines / machine-less legs); two map scans,
|
||||
// compiled out of release builds.
|
||||
#[cfg(debug_assertions)]
|
||||
self.debug_assert_peer_maps_coherent();
|
||||
}
|
||||
// Shutdown signal → enter the bounded drain in place, ONCE.
|
||||
// Gated on `is_none()` so it only fires while serving; after
|
||||
// entering drain the arm is disabled (the completed signal is
|
||||
// never polled again) and the deadline arm below bounds the
|
||||
// window. Placed after the real arms so their `biased` priority
|
||||
// is unchanged, and inert while serving with `pending()`.
|
||||
_ = &mut shutdown, if drain_deadline.is_none() => {
|
||||
self.enter_drain().await;
|
||||
drain_deadline =
|
||||
Some(tokio::time::Instant::now() + self.config().node.drain_timeout());
|
||||
}
|
||||
// Bounded drain deadline (drain mode only). Placed LAST so the
|
||||
// `biased` priority of the normal arms is unchanged, and gated
|
||||
// on `is_some()` so in normal mode the branch is disabled — the
|
||||
// future is created but never polled and never fires.
|
||||
_ = tokio::time::sleep_until(
|
||||
drain_deadline.unwrap_or_else(tokio::time::Instant::now)
|
||||
), if drain_deadline.is_some() => {
|
||||
info!("Drain deadline elapsed, ending drain loop");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -319,12 +415,16 @@ impl Node {
|
||||
// though no msg1/msg2 exchange can ever succeed. Bump the
|
||||
// discovery-layer cooldown to the long protocol-mismatch
|
||||
// window and emit a single WARN per fresh observation.
|
||||
if self.bootstrap_transports.contains(&packet.transport_id)
|
||||
if self
|
||||
.supervisor
|
||||
.nostr_rendezvous
|
||||
.is_bootstrap_transport(&packet.transport_id)
|
||||
&& let Some(npub) = self
|
||||
.bootstrap_transport_npubs
|
||||
.get(&packet.transport_id)
|
||||
.supervisor
|
||||
.nostr_rendezvous
|
||||
.bootstrap_transport_npub(&packet.transport_id)
|
||||
.cloned()
|
||||
&& let Some(handle) = self.nostr_discovery_handle()
|
||||
&& let Some(handle) = self.nostr_rendezvous_handle()
|
||||
{
|
||||
let now_ms = Self::now_ms();
|
||||
let cooldown_secs = handle.protocol_mismatch_cooldown_secs();
|
||||
@@ -561,7 +561,7 @@ mod tests {
|
||||
let open_cipher = LessSafeKey::new(unbound2);
|
||||
|
||||
let counter: u64 = 7;
|
||||
const HDR: usize = crate::node::wire::ESTABLISHED_HEADER_SIZE;
|
||||
const HDR: usize = crate::proto::fmp::wire::ESTABLISHED_HEADER_SIZE;
|
||||
// Build a wire packet `[16-byte header][4-byte inner ts][1 byte link msg]`
|
||||
// with capacity for the trailing AEAD tag. Header bytes
|
||||
// double as AAD and as the on-wire prefix.
|
||||
@@ -569,7 +569,7 @@ mod tests {
|
||||
// Header: fill the flags byte (the second byte) with both
|
||||
// FLAG_CE and FLAG_SP set; the rest is uninterpreted by the
|
||||
// worker (it just AADs the whole 16 bytes).
|
||||
let flags_byte = crate::node::wire::FLAG_CE | crate::node::wire::FLAG_SP;
|
||||
let flags_byte = crate::proto::fmp::wire::FLAG_CE | crate::proto::fmp::wire::FLAG_SP;
|
||||
let mut header = [0u8; HDR];
|
||||
header[1] = flags_byte;
|
||||
wire.extend_from_slice(&header);
|
||||
@@ -626,11 +626,11 @@ mod tests {
|
||||
"fmp_flags must round-trip from DecryptJob to DecryptFallback"
|
||||
);
|
||||
assert!(
|
||||
fallback.fmp_flags & crate::node::wire::FLAG_CE != 0,
|
||||
fallback.fmp_flags & crate::proto::fmp::wire::FLAG_CE != 0,
|
||||
"FLAG_CE bit lost on worker path"
|
||||
);
|
||||
assert!(
|
||||
fallback.fmp_flags & crate::node::wire::FLAG_SP != 0,
|
||||
fallback.fmp_flags & crate::proto::fmp::wire::FLAG_SP != 0,
|
||||
"FLAG_SP bit lost on worker path"
|
||||
);
|
||||
}
|
||||
@@ -724,7 +724,7 @@ mod tests {
|
||||
let open_cipher = LessSafeKey::new(unbound);
|
||||
|
||||
let counter: u64 = 11;
|
||||
const HDR: usize = crate::node::wire::ESTABLISHED_HEADER_SIZE;
|
||||
const HDR: usize = crate::proto::fmp::wire::ESTABLISHED_HEADER_SIZE;
|
||||
let header = [0u8; HDR];
|
||||
let mut wire = Vec::with_capacity(HDR + 4 + 1 + 16);
|
||||
wire.extend_from_slice(&header);
|
||||
|
||||
@@ -1,376 +0,0 @@
|
||||
//! Discovery protocol rate limiting and backoff.
|
||||
//!
|
||||
//! Two complementary mechanisms:
|
||||
//!
|
||||
//! - **`DiscoveryBackoff`** (originator-side, optional): Exponential
|
||||
//! suppression of fresh lookups after the per-attempt sequence in
|
||||
//! `node.discovery.attempt_timeouts_secs` has been exhausted.
|
||||
//! **Disabled by default** (base/cap = 0); the per-attempt sequence
|
||||
//! is the only retry pacing in the standard configuration. Reset on
|
||||
//! topology changes (parent change, new peer, first RTT, reconnection).
|
||||
//!
|
||||
//! - **`DiscoveryForwardRateLimiter`** (transit-side): Per-target minimum
|
||||
//! interval for forwarded requests. Defense-in-depth against misbehaving
|
||||
//! nodes generating fresh request_ids at high rate.
|
||||
|
||||
use crate::NodeAddr;
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
// ============================================================================
|
||||
// Originator-side: Discovery Backoff
|
||||
// ============================================================================
|
||||
|
||||
/// Default base backoff after first lookup failure. `0` = disabled.
|
||||
const DEFAULT_BACKOFF_BASE_SECS: u64 = 0;
|
||||
|
||||
/// Default maximum backoff cap. `0` = disabled.
|
||||
const DEFAULT_BACKOFF_MAX_SECS: u64 = 0;
|
||||
|
||||
/// Backoff multiplier per consecutive failure.
|
||||
const BACKOFF_MULTIPLIER: u64 = 2;
|
||||
|
||||
/// Exponential backoff for failed discovery lookups.
|
||||
///
|
||||
/// Tracks targets whose lookups have timed out and suppresses
|
||||
/// re-initiation with increasing delays. Cleared on topology changes.
|
||||
pub struct DiscoveryBackoff {
|
||||
/// Maps target → (suppress_until, consecutive_failures).
|
||||
entries: HashMap<NodeAddr, BackoffEntry>,
|
||||
/// Base backoff duration (first failure).
|
||||
base: Duration,
|
||||
/// Maximum backoff cap.
|
||||
max: Duration,
|
||||
}
|
||||
|
||||
struct BackoffEntry {
|
||||
/// Don't re-initiate until this instant.
|
||||
suppress_until: Instant,
|
||||
/// Consecutive failures (drives exponential backoff).
|
||||
failures: u32,
|
||||
}
|
||||
|
||||
impl DiscoveryBackoff {
|
||||
/// Create with default parameters (disabled — base/cap = 0).
|
||||
pub fn new() -> Self {
|
||||
Self::with_params(DEFAULT_BACKOFF_BASE_SECS, DEFAULT_BACKOFF_MAX_SECS)
|
||||
}
|
||||
|
||||
/// Create with custom base and max backoff in seconds.
|
||||
pub fn with_params(base_secs: u64, max_secs: u64) -> Self {
|
||||
Self {
|
||||
entries: HashMap::new(),
|
||||
base: Duration::from_secs(base_secs),
|
||||
max: Duration::from_secs(max_secs),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a lookup for this target is suppressed.
|
||||
///
|
||||
/// Returns true if the target is in backoff and should not be
|
||||
/// looked up yet.
|
||||
pub fn is_suppressed(&self, target: &NodeAddr) -> bool {
|
||||
if let Some(entry) = self.entries.get(target) {
|
||||
Instant::now() < entry.suppress_until
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a lookup failure (timeout) for a target.
|
||||
///
|
||||
/// Increments the failure count and sets the next suppression
|
||||
/// window using exponential backoff.
|
||||
pub fn record_failure(&mut self, target: &NodeAddr) {
|
||||
let now = Instant::now();
|
||||
let failures = self.entries.get(target).map_or(0, |e| e.failures) + 1;
|
||||
|
||||
let backoff_secs = self
|
||||
.base
|
||||
.as_secs()
|
||||
.saturating_mul(BACKOFF_MULTIPLIER.saturating_pow(failures.saturating_sub(1)));
|
||||
let backoff = Duration::from_secs(backoff_secs.min(self.max.as_secs()));
|
||||
|
||||
self.entries.insert(
|
||||
*target,
|
||||
BackoffEntry {
|
||||
suppress_until: now + backoff,
|
||||
failures,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Record a successful lookup — remove backoff for this target.
|
||||
pub fn record_success(&mut self, target: &NodeAddr) {
|
||||
self.entries.remove(target);
|
||||
}
|
||||
|
||||
/// Clear all backoff entries.
|
||||
///
|
||||
/// Called on topology changes that might make previously-unreachable
|
||||
/// targets reachable (parent change, new peer, first RTT, reconnection).
|
||||
pub fn reset_all(&mut self) {
|
||||
self.entries.clear();
|
||||
}
|
||||
|
||||
/// Whether any entries exist.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// Current number of entries.
|
||||
pub fn entry_count(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
/// Get the failure count for a target (for logging).
|
||||
pub fn failure_count(&self, target: &NodeAddr) -> u32 {
|
||||
self.entries.get(target).map_or(0, |e| e.failures)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DiscoveryBackoff {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Transit-side: Discovery Forward Rate Limiter
|
||||
// ============================================================================
|
||||
|
||||
/// Default minimum interval between forwarded lookups for the same target.
|
||||
const DEFAULT_FORWARD_MIN_INTERVAL: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Maximum age of entries before cleanup.
|
||||
const FORWARD_MAX_AGE: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Rate limiter for forwarded discovery requests.
|
||||
///
|
||||
/// Tracks the last time a LookupRequest was forwarded for each target
|
||||
/// and enforces a minimum interval to prevent floods from misbehaving
|
||||
/// nodes generating fresh request_ids.
|
||||
pub struct DiscoveryForwardRateLimiter {
|
||||
last_forwarded: HashMap<NodeAddr, Instant>,
|
||||
min_interval: Duration,
|
||||
max_age: Duration,
|
||||
}
|
||||
|
||||
impl DiscoveryForwardRateLimiter {
|
||||
/// Create with default parameters (2s interval).
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
last_forwarded: HashMap::new(),
|
||||
min_interval: DEFAULT_FORWARD_MIN_INTERVAL,
|
||||
max_age: FORWARD_MAX_AGE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with a custom minimum interval.
|
||||
pub fn with_interval(min_interval: Duration) -> Self {
|
||||
Self {
|
||||
last_forwarded: HashMap::new(),
|
||||
min_interval,
|
||||
max_age: FORWARD_MAX_AGE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if we should forward a lookup for this target.
|
||||
///
|
||||
/// Returns true if enough time has passed since the last forward
|
||||
/// for this target. Updates internal state when returning true.
|
||||
pub fn should_forward(&mut self, target: &NodeAddr) -> bool {
|
||||
let now = Instant::now();
|
||||
|
||||
if let Some(&last) = self.last_forwarded.get(target)
|
||||
&& now.duration_since(last) < self.min_interval
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
self.last_forwarded.insert(*target, now);
|
||||
self.cleanup(now);
|
||||
true
|
||||
}
|
||||
|
||||
/// Replace the minimum interval (e.g., set to zero to disable).
|
||||
#[cfg(test)]
|
||||
pub fn set_interval(&mut self, interval: Duration) {
|
||||
self.min_interval = interval;
|
||||
}
|
||||
|
||||
/// Remove entries older than max_age.
|
||||
fn cleanup(&mut self, now: Instant) {
|
||||
self.last_forwarded
|
||||
.retain(|_, &mut last| now.duration_since(last) < self.max_age);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn len(&self) -> usize {
|
||||
self.last_forwarded.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DiscoveryForwardRateLimiter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::thread;
|
||||
|
||||
fn addr(val: u8) -> NodeAddr {
|
||||
let mut bytes = [0u8; 16];
|
||||
bytes[0] = val;
|
||||
NodeAddr::from_bytes(bytes)
|
||||
}
|
||||
|
||||
// --- DiscoveryBackoff tests ---
|
||||
|
||||
#[test]
|
||||
fn test_backoff_not_suppressed_initially() {
|
||||
let backoff = DiscoveryBackoff::new();
|
||||
assert!(!backoff.is_suppressed(&addr(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backoff_suppressed_after_failure() {
|
||||
// Backoff is opt-in; exercise the suppression path with explicit params.
|
||||
let mut backoff = DiscoveryBackoff::with_params(30, 300);
|
||||
backoff.record_failure(&addr(1));
|
||||
assert!(backoff.is_suppressed(&addr(1)));
|
||||
// Different target not affected
|
||||
assert!(!backoff.is_suppressed(&addr(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backoff_cleared_on_success() {
|
||||
let mut backoff = DiscoveryBackoff::with_params(30, 300);
|
||||
backoff.record_failure(&addr(1));
|
||||
assert!(backoff.is_suppressed(&addr(1)));
|
||||
|
||||
backoff.record_success(&addr(1));
|
||||
assert!(!backoff.is_suppressed(&addr(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backoff_reset_all() {
|
||||
let mut backoff = DiscoveryBackoff::new();
|
||||
backoff.record_failure(&addr(1));
|
||||
backoff.record_failure(&addr(2));
|
||||
assert_eq!(backoff.len(), 2);
|
||||
|
||||
backoff.reset_all();
|
||||
assert_eq!(backoff.len(), 0);
|
||||
assert!(!backoff.is_suppressed(&addr(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backoff_exponential() {
|
||||
let mut backoff = DiscoveryBackoff::with_params(1, 300);
|
||||
|
||||
// First failure: 1s backoff
|
||||
backoff.record_failure(&addr(1));
|
||||
assert_eq!(backoff.failure_count(&addr(1)), 1);
|
||||
|
||||
// Second failure: 2s backoff
|
||||
backoff.record_failure(&addr(1));
|
||||
assert_eq!(backoff.failure_count(&addr(1)), 2);
|
||||
|
||||
// Third failure: 4s backoff
|
||||
backoff.record_failure(&addr(1));
|
||||
assert_eq!(backoff.failure_count(&addr(1)), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backoff_expires() {
|
||||
let mut backoff = DiscoveryBackoff::with_params(0, 0);
|
||||
backoff.record_failure(&addr(1));
|
||||
// With 0s backoff, should not be suppressed
|
||||
assert!(!backoff.is_suppressed(&addr(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backoff_capped() {
|
||||
let mut backoff = DiscoveryBackoff::with_params(1, 10);
|
||||
|
||||
// Record many failures
|
||||
for _ in 0..20 {
|
||||
backoff.record_failure(&addr(1));
|
||||
}
|
||||
|
||||
// Backoff should be capped at max (10s), not overflow
|
||||
let entry = backoff.entries.get(&addr(1)).unwrap();
|
||||
let remaining = entry.suppress_until.duration_since(Instant::now());
|
||||
assert!(remaining <= Duration::from_secs(11));
|
||||
}
|
||||
|
||||
// --- DiscoveryForwardRateLimiter tests ---
|
||||
|
||||
#[test]
|
||||
fn test_forward_first_allowed() {
|
||||
let mut limiter = DiscoveryForwardRateLimiter::new();
|
||||
assert!(limiter.should_forward(&addr(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_forward_rapid_rate_limited() {
|
||||
let mut limiter = DiscoveryForwardRateLimiter::new();
|
||||
assert!(limiter.should_forward(&addr(1)));
|
||||
assert!(!limiter.should_forward(&addr(1)));
|
||||
assert!(!limiter.should_forward(&addr(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_forward_different_targets_independent() {
|
||||
let mut limiter = DiscoveryForwardRateLimiter::new();
|
||||
assert!(limiter.should_forward(&addr(1)));
|
||||
assert!(limiter.should_forward(&addr(2)));
|
||||
assert!(!limiter.should_forward(&addr(1)));
|
||||
assert!(!limiter.should_forward(&addr(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_forward_allowed_after_interval() {
|
||||
let mut limiter = DiscoveryForwardRateLimiter::with_interval(Duration::from_millis(100));
|
||||
assert!(limiter.should_forward(&addr(1)));
|
||||
|
||||
thread::sleep(Duration::from_millis(110));
|
||||
|
||||
assert!(limiter.should_forward(&addr(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_forward_cleanup_removes_old() {
|
||||
let mut limiter = DiscoveryForwardRateLimiter::new();
|
||||
assert!(limiter.should_forward(&addr(1)));
|
||||
assert!(limiter.should_forward(&addr(2)));
|
||||
assert_eq!(limiter.len(), 2);
|
||||
|
||||
let future = Instant::now() + Duration::from_secs(61);
|
||||
limiter.cleanup(future);
|
||||
assert_eq!(limiter.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_forward_cleanup_preserves_recent() {
|
||||
let mut limiter = DiscoveryForwardRateLimiter::new();
|
||||
assert!(limiter.should_forward(&addr(1)));
|
||||
assert_eq!(limiter.len(), 1);
|
||||
|
||||
limiter.cleanup(Instant::now());
|
||||
assert_eq!(limiter.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -50,9 +50,9 @@
|
||||
// warnings rather than gate every function individually.
|
||||
#![cfg_attr(not(unix), allow(dead_code))]
|
||||
|
||||
use crate::node::session_wire::FSP_HEADER_SIZE;
|
||||
use crate::node::wire::ESTABLISHED_HEADER_SIZE;
|
||||
use crate::transport::udp::socket::AsyncUdpSocket;
|
||||
use crate::proto::fmp::wire::ESTABLISHED_HEADER_SIZE;
|
||||
use crate::proto::fsp::wire::FSP_HEADER_SIZE;
|
||||
use crate::transport::udp::io::AsyncUdpSocket;
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
use crossbeam_channel::{Receiver, SendError, Sender, TrySendError, bounded};
|
||||
use ring::aead::{Aad, LessSafeKey, Nonce};
|
||||
@@ -132,8 +132,7 @@ pub(crate) struct FmpSendJob {
|
||||
/// the job completes and the worker drops it, only the peer's
|
||||
/// strong ref remains.
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
pub connected_socket:
|
||||
Option<std::sync::Arc<crate::transport::udp::connected_peer::ConnectedPeerSocket>>,
|
||||
pub connected_socket: Option<std::sync::Arc<crate::peer::connected_udp::ConnectedPeerSocket>>,
|
||||
/// Bulk endpoint data may be dropped when the kernel reports UDP
|
||||
/// send-queue exhaustion. Control/rekey frames keep retrying so
|
||||
/// congestion cannot strand the session.
|
||||
@@ -716,8 +715,7 @@ fn mac_now_ms() -> u64 {
|
||||
struct MacSequencedSendFlow {
|
||||
key: MacSendFlowKey,
|
||||
socket: AsyncUdpSocket,
|
||||
connected_socket:
|
||||
Option<std::sync::Arc<crate::transport::udp::connected_peer::ConnectedPeerSocket>>,
|
||||
connected_socket: Option<std::sync::Arc<crate::peer::connected_udp::ConnectedPeerSocket>>,
|
||||
dest_addr: SocketAddr,
|
||||
next_seq: std::sync::atomic::AtomicU64,
|
||||
last_used_ms: std::sync::atomic::AtomicU64,
|
||||
@@ -754,9 +752,7 @@ impl MacSequencedSendFlow {
|
||||
fn spawn(
|
||||
key: MacSendFlowKey,
|
||||
socket: AsyncUdpSocket,
|
||||
connected_socket: Option<
|
||||
std::sync::Arc<crate::transport::udp::connected_peer::ConnectedPeerSocket>,
|
||||
>,
|
||||
connected_socket: Option<std::sync::Arc<crate::peer::connected_udp::ConnectedPeerSocket>>,
|
||||
dest_addr: SocketAddr,
|
||||
now_ms: u64,
|
||||
) -> Arc<Self> {
|
||||
@@ -1024,8 +1020,7 @@ fn flush_batch_sync(
|
||||
struct EncryptedGroup {
|
||||
socket: AsyncUdpSocket,
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
connected_socket:
|
||||
Option<std::sync::Arc<crate::transport::udp::connected_peer::ConnectedPeerSocket>>,
|
||||
connected_socket: Option<std::sync::Arc<crate::peer::connected_udp::ConnectedPeerSocket>>,
|
||||
dest_addr: SocketAddr,
|
||||
wire_packets: Vec<Vec<u8>>,
|
||||
drop_on_backpressure: bool,
|
||||
@@ -1710,7 +1705,7 @@ fn send_batch_gso(
|
||||
}
|
||||
|
||||
/// Direct `sendmmsg(2)` wrapper for the sync worker. The
|
||||
/// `transport::udp::socket` module's existing `send_batch` is
|
||||
/// `transport::udp::io` module's existing `send_batch` is
|
||||
/// pub(crate) on `UdpRawSocket`, but we don't have a handle to the
|
||||
/// raw socket from here — we just have the FD. Re-implementing
|
||||
/// inline is ~15 lines and avoids tunnelling the inner socket
|
||||
@@ -1780,7 +1775,7 @@ fn send_batch_raw(
|
||||
#[cfg(all(test, unix))]
|
||||
mod unix_tests {
|
||||
use super::*;
|
||||
use crate::transport::udp::socket::UdpRawSocket;
|
||||
use crate::transport::udp::io::UdpRawSocket;
|
||||
use ring::aead::{LessSafeKey, UnboundKey};
|
||||
use std::net::UdpSocket;
|
||||
|
||||
@@ -1904,10 +1899,12 @@ mod unix_tests {
|
||||
#[test]
|
||||
fn pipelined_send_wire_layout_roundtrips_canonical_decoders() {
|
||||
use crate::NodeAddr;
|
||||
use crate::node::session_wire::build_fsp_header;
|
||||
use crate::node::wire::{EncryptedHeader, FLAG_KEY_EPOCH, build_established_header};
|
||||
use crate::noise::TAG_SIZE;
|
||||
use crate::protocol::{LinkMessageType, SESSION_DATAGRAM_HEADER_SIZE, SessionDatagramRef};
|
||||
use crate::proto::fmp::wire::{EncryptedHeader, FLAG_KEY_EPOCH, build_established_header};
|
||||
use crate::proto::fsp::wire::build_fsp_header;
|
||||
use crate::proto::link::{
|
||||
LinkMessageType, SESSION_DATAGRAM_HEADER_SIZE, SessionDatagramRef,
|
||||
};
|
||||
use crate::utils::index::SessionIndex;
|
||||
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
@@ -2218,7 +2215,7 @@ mod tests {
|
||||
/// AsRawFd impl.
|
||||
#[test]
|
||||
fn flush_batch_routes_each_target_separately() {
|
||||
use crate::transport::udp::socket::UdpRawSocket;
|
||||
use crate::transport::udp::io::UdpRawSocket;
|
||||
use ring::aead::{LessSafeKey, UnboundKey};
|
||||
use std::net::UdpSocket;
|
||||
|
||||
@@ -2264,7 +2261,7 @@ mod tests {
|
||||
const B_WIRE: usize = 16 + B_PLAINTEXT + 16; // 96
|
||||
|
||||
fn make_job(
|
||||
socket: crate::transport::udp::socket::AsyncUdpSocket,
|
||||
socket: crate::transport::udp::io::AsyncUdpSocket,
|
||||
cipher: &LessSafeKey,
|
||||
counter: u64,
|
||||
dest: SocketAddr,
|
||||
|
||||
@@ -1,738 +0,0 @@
|
||||
//! LookupRequest/LookupResponse discovery protocol handlers.
|
||||
//!
|
||||
//! Handles coordinate discovery via bloom-filter-guided tree routing.
|
||||
//! Requests are forwarded only to tree peers (parent + children) whose
|
||||
//! bloom filter contains the target. TTL and request_id dedup provide
|
||||
//! safety bounds.
|
||||
|
||||
use crate::node::reject::DiscoveryReject;
|
||||
use crate::node::{Node, RecentRequest};
|
||||
use crate::protocol::{LookupRequest, LookupResponse};
|
||||
use crate::transport::{TransportAddr, TransportId};
|
||||
use crate::{NodeAddr, PeerIdentity};
|
||||
use tracing::{debug, info, trace, warn};
|
||||
|
||||
const MAX_RECENT_DISCOVERY_REQUESTS: usize = 4096;
|
||||
|
||||
impl Node {
|
||||
/// Handle an incoming LookupRequest from a peer.
|
||||
///
|
||||
/// Processing steps:
|
||||
/// 1. Decode and validate
|
||||
/// 2. Check request_id for duplicates (dedup / reverse-path routing)
|
||||
/// 3. Record request for reverse-path forwarding
|
||||
/// 4. Lazy purge expired entries
|
||||
/// 5. If we're the target, generate and send response
|
||||
/// 6. If TTL > 0, forward to tree peers whose bloom filter matches
|
||||
pub(in crate::node) async fn handle_lookup_request(&mut self, from: &NodeAddr, payload: &[u8]) {
|
||||
self.metrics().discovery.req_received.inc();
|
||||
|
||||
let request = match LookupRequest::decode(payload) {
|
||||
Ok(req) => req,
|
||||
Err(e) => {
|
||||
self.metrics()
|
||||
.discovery
|
||||
.record_reject(DiscoveryReject::ReqDecodeError);
|
||||
debug!(from = %self.peer_display_name(from), error = %e, "Malformed LookupRequest");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let now_ms = Self::now_ms();
|
||||
self.purge_expired_requests(now_ms);
|
||||
|
||||
// Dedup: drop if we've already seen this request_id.
|
||||
// Also serves as loop protection — tree routing is loop-free,
|
||||
// but request_id dedup catches edge cases during tree restructuring.
|
||||
if self.recent_requests.contains_key(&request.request_id) {
|
||||
self.metrics()
|
||||
.discovery
|
||||
.record_reject(DiscoveryReject::ReqDuplicate);
|
||||
debug!(
|
||||
request_id = request.request_id,
|
||||
from = %self.peer_display_name(from),
|
||||
"Duplicate LookupRequest, dropping"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if self.recent_requests.len() >= MAX_RECENT_DISCOVERY_REQUESTS {
|
||||
self.metrics()
|
||||
.discovery
|
||||
.record_reject(DiscoveryReject::ReqDedupCacheFull);
|
||||
debug!(
|
||||
request_id = request.request_id,
|
||||
from = %self.peer_display_name(from),
|
||||
recent_requests = self.recent_requests.len(),
|
||||
max_recent_requests = MAX_RECENT_DISCOVERY_REQUESTS,
|
||||
"Discovery request dedup cache full, dropping LookupRequest"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Record for reverse-path forwarding and dedup
|
||||
self.recent_requests
|
||||
.insert(request.request_id, RecentRequest::new(*from, now_ms));
|
||||
|
||||
// Are we the target?
|
||||
if request.target == *self.node_addr() {
|
||||
self.metrics().discovery.req_target_is_us.inc();
|
||||
debug!(
|
||||
request_id = request.request_id,
|
||||
origin = %self.peer_display_name(&request.origin),
|
||||
"We are the lookup target, generating response"
|
||||
);
|
||||
self.send_lookup_response(&request).await;
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward if TTL permits
|
||||
if request.can_forward() {
|
||||
// Transit-side rate limit: collapse rapid-fire lookups for the
|
||||
// same target from misbehaving nodes generating fresh request_ids.
|
||||
if !self
|
||||
.discovery_forward_limiter
|
||||
.should_forward(&request.target)
|
||||
{
|
||||
self.metrics().discovery.req_forward_rate_limited.inc();
|
||||
debug!(
|
||||
request_id = request.request_id,
|
||||
target = %self.peer_display_name(&request.target),
|
||||
"Forward rate limited, suppressing LookupRequest"
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.metrics().discovery.req_forwarded.inc();
|
||||
self.forward_lookup_request(request).await;
|
||||
} else {
|
||||
self.metrics()
|
||||
.discovery
|
||||
.record_reject(DiscoveryReject::ReqTtlExhausted);
|
||||
debug!(
|
||||
request_id = request.request_id,
|
||||
target = %self.peer_display_name(&request.target),
|
||||
"LookupRequest TTL exhausted"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle an incoming LookupResponse from a peer.
|
||||
///
|
||||
/// Processing steps:
|
||||
/// 1. Decode and validate
|
||||
/// 2. Check recent_requests to determine if we originated or are forwarding
|
||||
/// 3. If originator: verify proof signature, then cache target_coords and path_mtu in coord_cache
|
||||
/// 4. If transit: apply path_mtu min(outgoing_link_mtu), reverse-path forward to from_peer
|
||||
pub(in crate::node) async fn handle_lookup_response(
|
||||
&mut self,
|
||||
from: &NodeAddr,
|
||||
payload: &[u8],
|
||||
) {
|
||||
self.metrics().discovery.resp_received.inc();
|
||||
|
||||
let mut response = match LookupResponse::decode(payload) {
|
||||
Ok(resp) => resp,
|
||||
Err(e) => {
|
||||
self.metrics()
|
||||
.discovery
|
||||
.record_reject(DiscoveryReject::RespDecodeError);
|
||||
debug!(from = %self.peer_display_name(from), error = %e, "Malformed LookupResponse");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let now_ms = Self::now_ms();
|
||||
|
||||
// Check if we forwarded this request (transit node) or originated it
|
||||
if let Some(recent) = self.recent_requests.get_mut(&response.request_id) {
|
||||
// Already forwarded a response for this request — drop to
|
||||
// prevent response routing loops.
|
||||
if recent.response_forwarded {
|
||||
debug!(
|
||||
request_id = response.request_id,
|
||||
target = %self.peer_display_name(&response.target),
|
||||
"Response already forwarded for this request, dropping"
|
||||
);
|
||||
return;
|
||||
}
|
||||
recent.response_forwarded = true;
|
||||
|
||||
// Transit node: reverse-path forward
|
||||
let from_peer = recent.from_peer;
|
||||
self.metrics().discovery.resp_forwarded.inc();
|
||||
|
||||
// Apply path_mtu min() from the outgoing link's transport MTU
|
||||
self.apply_outgoing_link_mtu_to_response(&mut response, &from_peer);
|
||||
|
||||
debug!(
|
||||
request_id = response.request_id,
|
||||
target = %self.peer_display_name(&response.target),
|
||||
next_hop = %self.peer_display_name(&from_peer),
|
||||
path_mtu = response.path_mtu,
|
||||
"Reverse-path forwarding LookupResponse"
|
||||
);
|
||||
|
||||
let encoded = response.encode();
|
||||
if let Err(e) = self.send_encrypted_link_message(&from_peer, &encoded).await {
|
||||
debug!(
|
||||
next_hop = %self.peer_display_name(&from_peer),
|
||||
error = %e,
|
||||
"Failed to forward LookupResponse"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// We originated this request — verify proof before caching
|
||||
let target = response.target;
|
||||
let path_mtu = response.path_mtu;
|
||||
|
||||
// Look up the target's public key from identity_cache
|
||||
let mut prefix = [0u8; 15];
|
||||
prefix.copy_from_slice(&target.as_bytes()[0..15]);
|
||||
let target_pubkey = match self.lookup_by_fips_prefix(&prefix) {
|
||||
Some((_addr, pubkey)) => pubkey,
|
||||
None => {
|
||||
self.metrics()
|
||||
.discovery
|
||||
.record_reject(DiscoveryReject::RespIdentityMiss);
|
||||
warn!(
|
||||
request_id = response.request_id,
|
||||
target = %self.peer_display_name(&target),
|
||||
"identity_cache miss for lookup target, cannot verify proof"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Verify the proof signature
|
||||
let (xonly, _parity) = target_pubkey.x_only_public_key();
|
||||
let peer_id = PeerIdentity::from_pubkey(xonly);
|
||||
let proof_data =
|
||||
LookupResponse::proof_bytes(response.request_id, &target, &response.target_coords);
|
||||
if !peer_id.verify(&proof_data, &response.proof) {
|
||||
self.metrics()
|
||||
.discovery
|
||||
.record_reject(DiscoveryReject::RespProofFailed);
|
||||
warn!(
|
||||
request_id = response.request_id,
|
||||
target = %self.peer_display_name(&target),
|
||||
"LookupResponse proof verification failed, discarding"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
self.metrics().discovery.resp_accepted.inc();
|
||||
|
||||
// Clear backoff on success — target is reachable
|
||||
self.discovery_backoff.record_success(&target);
|
||||
|
||||
info!(
|
||||
request_id = response.request_id,
|
||||
target = %self.peer_display_name(&target),
|
||||
depth = response.target_coords.depth(),
|
||||
path_mtu = path_mtu,
|
||||
"Discovery succeeded, proof verified, route cached"
|
||||
);
|
||||
|
||||
self.coord_cache
|
||||
.insert_with_path_mtu(target, response.target_coords, now_ms, path_mtu);
|
||||
|
||||
// Mirror path_mtu into the FipsAddress-keyed read-only lookup
|
||||
// map used by the TUN reader/writer at TCP MSS clamp time.
|
||||
let fips_addr = crate::FipsAddress::from_node_addr(&target);
|
||||
match self.path_mtu_lookup.write() {
|
||||
Ok(mut map) => {
|
||||
let prior = map.insert(fips_addr, path_mtu);
|
||||
debug!(
|
||||
target = %self.peer_display_name(&target),
|
||||
fips_addr = %fips_addr,
|
||||
path_mtu = path_mtu,
|
||||
prior = ?prior,
|
||||
map_len = map.len(),
|
||||
"Wrote path_mtu_lookup from discovery LookupResponse"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
target = %self.peer_display_name(&target),
|
||||
fips_addr = %fips_addr,
|
||||
path_mtu = path_mtu,
|
||||
error = %e,
|
||||
"path_mtu_lookup write lock poisoned; clamp will not see this update"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up pending lookup tracking
|
||||
self.pending_lookups.remove(&target);
|
||||
|
||||
// If an established session exists, reset the warmup counter.
|
||||
let n = self.config().node.session.coords_warmup_packets;
|
||||
if let Some(entry) = self.sessions.get_mut(&target)
|
||||
&& entry.is_established()
|
||||
{
|
||||
entry.set_coords_warmup_remaining(n);
|
||||
debug!(
|
||||
dest = %self.peer_display_name(&target),
|
||||
warmup_packets = n,
|
||||
"Reset coords warmup after discovery for existing session"
|
||||
);
|
||||
}
|
||||
|
||||
// If we have pending TUN packets for this target, retry session
|
||||
// initiation. The coord_cache now has coords, so find_next_hop()
|
||||
// should succeed.
|
||||
if let Some(packets) = self.pending_tun_packets.get(&target) {
|
||||
debug!(
|
||||
dest = %self.peer_display_name(&target),
|
||||
queued_packets = packets.len(),
|
||||
"Retrying queued packets after discovery"
|
||||
);
|
||||
self.retry_session_after_discovery(target).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate and send a LookupResponse when we are the target.
|
||||
async fn send_lookup_response(&mut self, request: &LookupRequest) {
|
||||
let our_coords = self.tree_state().my_coords().clone();
|
||||
|
||||
// Sign proof: Identity::sign hashes with SHA-256 internally
|
||||
let proof_data =
|
||||
LookupResponse::proof_bytes(request.request_id, &request.target, &our_coords);
|
||||
let proof = self.identity().sign(&proof_data);
|
||||
|
||||
let mut response =
|
||||
LookupResponse::new(request.request_id, request.target, our_coords, proof);
|
||||
|
||||
// Route toward origin via reverse path.
|
||||
let next_hop_addr = if let Some(recent) = self.recent_requests.get(&request.request_id) {
|
||||
recent.from_peer
|
||||
} else {
|
||||
// Fallback: try greedy tree routing toward origin
|
||||
match self.find_next_hop(&request.origin) {
|
||||
Some(peer) => *peer.node_addr(),
|
||||
None => {
|
||||
debug!(
|
||||
origin = %self.peer_display_name(&request.origin),
|
||||
"Cannot route LookupResponse: no reverse path or tree route to origin"
|
||||
);
|
||||
self.metrics()
|
||||
.discovery
|
||||
.record_reject(DiscoveryReject::RespNoRoute);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Fold our outgoing-link MTU into path_mtu so the target-edge link
|
||||
// appears in the bottleneck calculation. Without this, the response
|
||||
// leaves the target with path_mtu = u16::MAX and only intermediate
|
||||
// transits min-fold; the target's first reverse-path hop is missed.
|
||||
self.apply_outgoing_link_mtu_to_response(&mut response, &next_hop_addr);
|
||||
|
||||
debug!(
|
||||
request_id = request.request_id,
|
||||
origin = %self.peer_display_name(&request.origin),
|
||||
next_hop = %self.peer_display_name(&next_hop_addr),
|
||||
path_mtu = response.path_mtu,
|
||||
"Sending LookupResponse"
|
||||
);
|
||||
|
||||
let encoded = response.encode();
|
||||
if let Err(e) = self
|
||||
.send_encrypted_link_message(&next_hop_addr, &encoded)
|
||||
.await
|
||||
{
|
||||
debug!(
|
||||
next_hop = %self.peer_display_name(&next_hop_addr),
|
||||
error = %e,
|
||||
"Failed to send LookupResponse"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward a LookupRequest to eligible peers.
|
||||
///
|
||||
/// Primary path: tree peers (parent + children) whose bloom filter
|
||||
/// contains the target. Restricting to tree peers follows the spanning
|
||||
/// tree partition, producing a single directed path.
|
||||
///
|
||||
/// Fallback: if no tree peer's bloom matches, try non-tree peers whose
|
||||
/// bloom contains the target. This recovers from dead ends caused by
|
||||
/// stale bloom filters, tree restructuring, or transit node failures.
|
||||
async fn forward_lookup_request(&mut self, mut request: LookupRequest) {
|
||||
if !request.forward() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect tree peers whose bloom filter contains the target
|
||||
let forward_to: Vec<NodeAddr> = self
|
||||
.peers
|
||||
.iter()
|
||||
.filter(|(addr, peer)| self.is_tree_peer(addr) && peer.may_reach(&request.target))
|
||||
.map(|(addr, _)| *addr)
|
||||
.collect();
|
||||
|
||||
// Fallback: if no tree peer matches, try non-tree bloom-matching peers
|
||||
let (forward_to, used_fallback) = if forward_to.is_empty() {
|
||||
let fallback: Vec<NodeAddr> = self
|
||||
.peers
|
||||
.iter()
|
||||
.filter(|(addr, peer)| !self.is_tree_peer(addr) && peer.may_reach(&request.target))
|
||||
.map(|(addr, _)| *addr)
|
||||
.collect();
|
||||
if fallback.is_empty() {
|
||||
self.metrics().discovery.req_no_tree_peer.inc();
|
||||
trace!(
|
||||
request_id = request.request_id,
|
||||
"No eligible peers to forward LookupRequest"
|
||||
);
|
||||
return;
|
||||
}
|
||||
(fallback, true)
|
||||
} else {
|
||||
(forward_to, false)
|
||||
};
|
||||
|
||||
if used_fallback {
|
||||
self.metrics().discovery.req_fallback_forwarded.inc();
|
||||
debug!(
|
||||
request_id = request.request_id,
|
||||
target = %self.peer_display_name(&request.target),
|
||||
ttl = request.ttl,
|
||||
peer_count = forward_to.len(),
|
||||
"Forwarding LookupRequest via non-tree fallback"
|
||||
);
|
||||
} else {
|
||||
debug!(
|
||||
request_id = request.request_id,
|
||||
target = %self.peer_display_name(&request.target),
|
||||
ttl = request.ttl,
|
||||
peer_count = forward_to.len(),
|
||||
"Forwarding LookupRequest"
|
||||
);
|
||||
}
|
||||
|
||||
let encoded = request.encode();
|
||||
|
||||
for peer_addr in forward_to {
|
||||
if let Err(e) = self.send_encrypted_link_message(&peer_addr, &encoded).await {
|
||||
debug!(
|
||||
peer = %self.peer_display_name(&peer_addr),
|
||||
error = %e,
|
||||
"Failed to forward LookupRequest to peer"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Initiate a discovery lookup for a target node.
|
||||
///
|
||||
/// Creates a LookupRequest and sends it to tree peers whose bloom
|
||||
/// filters contain the target. Returns the number of peers sent to.
|
||||
/// The originator does NOT record the request_id in recent_requests,
|
||||
/// so when the response arrives, it's recognized as "our request".
|
||||
pub(in crate::node) async fn initiate_lookup(&mut self, target: &NodeAddr, ttl: u8) -> usize {
|
||||
self.metrics().discovery.req_initiated.inc();
|
||||
|
||||
let origin = *self.node_addr();
|
||||
let origin_coords = self.tree_state().my_coords().clone();
|
||||
let request = LookupRequest::generate(*target, origin, origin_coords, ttl, 0);
|
||||
|
||||
// Send only to tree peers whose bloom filter contains the target
|
||||
let peer_addrs: Vec<NodeAddr> = self
|
||||
.peers
|
||||
.iter()
|
||||
.filter(|(addr, peer)| self.is_tree_peer(addr) && peer.may_reach(target))
|
||||
.map(|(addr, _)| *addr)
|
||||
.collect();
|
||||
|
||||
let peer_count = peer_addrs.len();
|
||||
|
||||
debug!(
|
||||
request_id = request.request_id,
|
||||
target = %self.peer_display_name(target),
|
||||
ttl = ttl,
|
||||
peer_count = peer_count,
|
||||
total_peers = self.peers.len(),
|
||||
"Discovery lookup initiated"
|
||||
);
|
||||
|
||||
if peer_count == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let encoded = request.encode();
|
||||
|
||||
for peer_addr in peer_addrs {
|
||||
if let Err(e) = self.send_encrypted_link_message(&peer_addr, &encoded).await {
|
||||
debug!(
|
||||
peer = %self.peer_display_name(&peer_addr),
|
||||
error = %e,
|
||||
"Failed to send LookupRequest to peer"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
peer_count
|
||||
}
|
||||
|
||||
/// Initiate a discovery lookup if one is not already pending for this target.
|
||||
///
|
||||
/// Checks: pending dedup, post-failure backoff (off by default), bloom
|
||||
/// filter pre-check. If all pass, sends the first attempt's LookupRequest.
|
||||
/// Subsequent attempts (with fresh request_ids) are scheduled by
|
||||
/// [`Self::check_pending_lookups`] when each attempt's per-attempt timeout
|
||||
/// expires, using the sequence in `node.discovery.attempt_timeouts_secs`.
|
||||
pub(in crate::node) async fn maybe_initiate_lookup(&mut self, dest: &NodeAddr) {
|
||||
let now_ms = Self::now_ms();
|
||||
|
||||
// Dedup: any pending lookup means we are already trying.
|
||||
if self.pending_lookups.contains_key(dest) {
|
||||
self.metrics().discovery.req_deduplicated.inc();
|
||||
debug!(
|
||||
target_node = %self.peer_display_name(dest),
|
||||
"Discovery lookup deduplicated, already pending"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Optional post-failure suppression. Defaults are 0/0 (inert);
|
||||
// operators can opt in by setting `node.discovery.backoff_*_secs`.
|
||||
if self.discovery_backoff.is_suppressed(dest) {
|
||||
self.metrics().discovery.req_backoff_suppressed.inc();
|
||||
debug!(
|
||||
target_node = %self.peer_display_name(dest),
|
||||
failures = self.discovery_backoff.failure_count(dest),
|
||||
"Discovery lookup suppressed by backoff"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Bloom filter pre-check: if no peer's filter contains the target,
|
||||
// it's not in the mesh — skip the lookup and record as failure.
|
||||
let reachable = self.peers.values().any(|peer| peer.may_reach(dest));
|
||||
if !reachable {
|
||||
self.metrics().discovery.req_bloom_miss.inc();
|
||||
self.discovery_backoff.record_failure(dest);
|
||||
debug!(
|
||||
target_node = %self.peer_display_name(dest),
|
||||
"Discovery skipped, target not in any peer bloom filter"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
self.pending_lookups
|
||||
.insert(*dest, PendingLookup::new(now_ms));
|
||||
let ttl = self.config().node.discovery.ttl;
|
||||
let sent = self.initiate_lookup(dest, ttl).await;
|
||||
|
||||
// If no tree peers had the target, fail immediately
|
||||
if sent == 0 {
|
||||
self.pending_lookups.remove(dest);
|
||||
self.discovery_backoff.record_failure(dest);
|
||||
debug!(
|
||||
target_node = %self.peer_display_name(dest),
|
||||
"Discovery failed, no tree peers with bloom match"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check pending lookups for next-attempt or final timeout.
|
||||
///
|
||||
/// Called periodically from the tick handler. The lookup state machine
|
||||
/// runs through `node.discovery.attempt_timeouts_secs` (default
|
||||
/// `[1, 2, 4, 8]`): each entry is the deadline for one attempt. When the
|
||||
/// current attempt's deadline elapses:
|
||||
/// - If more entries remain: send the next attempt with a fresh
|
||||
/// `request_id`.
|
||||
/// - Otherwise: declare the destination unreachable, drop queued packets,
|
||||
/// and emit ICMPv6 destination-unreachable for each.
|
||||
pub(in crate::node) async fn check_pending_lookups(&mut self, now_ms: u64) {
|
||||
let timeouts = self.config().node.discovery.attempt_timeouts_secs.clone();
|
||||
let max_attempts = timeouts.len() as u8;
|
||||
|
||||
// Collect targets needing action
|
||||
let mut to_retry: Vec<NodeAddr> = Vec::new();
|
||||
let mut to_timeout: Vec<NodeAddr> = Vec::new();
|
||||
|
||||
for (&target, entry) in &self.pending_lookups {
|
||||
let attempt_idx = (entry.attempt as usize).saturating_sub(1);
|
||||
let attempt_timeout_ms = timeouts.get(attempt_idx).copied().unwrap_or(0) * 1000;
|
||||
if now_ms.saturating_sub(entry.last_sent_ms) >= attempt_timeout_ms {
|
||||
if entry.attempt >= max_attempts {
|
||||
to_timeout.push(target);
|
||||
} else {
|
||||
to_retry.push(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process retries
|
||||
for target in to_retry {
|
||||
if let Some(entry) = self.pending_lookups.get_mut(&target) {
|
||||
entry.attempt += 1;
|
||||
entry.last_sent_ms = now_ms;
|
||||
let attempt = entry.attempt;
|
||||
|
||||
let ttl = self.config().node.discovery.ttl;
|
||||
let sent = self.initiate_lookup(&target, ttl).await;
|
||||
if sent > 0 {
|
||||
debug!(
|
||||
target_node = %self.peer_display_name(&target),
|
||||
attempt = attempt,
|
||||
"Discovery retry sent"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process timeouts
|
||||
for addr in to_timeout {
|
||||
self.metrics().discovery.resp_timed_out.inc();
|
||||
self.pending_lookups.remove(&addr);
|
||||
|
||||
// Record failure for optional backoff
|
||||
self.discovery_backoff.record_failure(&addr);
|
||||
let failures = self.discovery_backoff.failure_count(&addr);
|
||||
|
||||
let queued = self.pending_tun_packets.remove(&addr);
|
||||
let pkt_count = queued.as_ref().map_or(0, |p| p.len());
|
||||
info!(
|
||||
target_node = %self.peer_display_name(&addr),
|
||||
queued_packets = pkt_count,
|
||||
failures = failures,
|
||||
"Discovery lookup timed out, destination unreachable"
|
||||
);
|
||||
if let Some(packets) = queued {
|
||||
for pkt in &packets {
|
||||
self.send_icmpv6_dest_unreachable(pkt);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset discovery backoff on topology changes.
|
||||
pub(in crate::node) fn reset_discovery_backoff(&mut self) {
|
||||
if !self.discovery_backoff.is_empty() {
|
||||
debug!(
|
||||
entries = self.discovery_backoff.entry_count(),
|
||||
"Resetting discovery backoff on topology change"
|
||||
);
|
||||
self.discovery_backoff.reset_all();
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove expired entries from the recent_requests cache.
|
||||
fn purge_expired_requests(&mut self, current_time_ms: u64) {
|
||||
let expiry_ms = self.config().node.discovery.recent_expiry_secs * 1000;
|
||||
self.recent_requests
|
||||
.retain(|_, entry| !entry.is_expired(current_time_ms, expiry_ms));
|
||||
}
|
||||
|
||||
/// Min-fold our outgoing-link MTU into a LookupResponse's `path_mtu`.
|
||||
///
|
||||
/// Used at both transit-side reverse-path forward and at the target's
|
||||
/// own send_lookup_response. The link MTU we apply is the MTU of the
|
||||
/// transport+addr we'll use to deliver the response toward `next_hop`.
|
||||
/// No-op when `next_hop` is not a directly-connected peer or its
|
||||
/// transport is not registered.
|
||||
pub(in crate::node) fn apply_outgoing_link_mtu_to_response(
|
||||
&self,
|
||||
response: &mut LookupResponse,
|
||||
next_hop: &NodeAddr,
|
||||
) {
|
||||
if let Some(peer) = self.peers.get(next_hop)
|
||||
&& let Some(tid) = peer.transport_id()
|
||||
&& let Some(transport) = self.transports.get(&tid)
|
||||
{
|
||||
let link_mtu = if let Some(addr) = peer.current_addr() {
|
||||
transport.link_mtu(addr)
|
||||
} else {
|
||||
transport.mtu()
|
||||
};
|
||||
response.path_mtu = response.path_mtu.min(link_mtu);
|
||||
}
|
||||
}
|
||||
|
||||
/// Seed `path_mtu_lookup` for a directly-connected peer.
|
||||
///
|
||||
/// Called when an FMP link-layer peer is promoted to active. The seed
|
||||
/// value is the local outgoing-link MTU on the peer's transport, which
|
||||
/// is the actual link constraint for direct-link traffic. Stored only
|
||||
/// when no tighter value exists: discovery's reverse-path bottleneck
|
||||
/// or MMP `MtuExceeded` reactive learning take precedence when smaller.
|
||||
///
|
||||
/// Without this seed, configured/auto-connect peers (which establish
|
||||
/// sessions without going through the discovery Lookup flow) leave
|
||||
/// `path_mtu_lookup` empty for their FipsAddress, causing
|
||||
/// `per_flow_max_mss` to fall back to the global ceiling and the
|
||||
/// SYN-time TCP MSS clamp to over-estimate the effective path.
|
||||
pub(in crate::node) fn seed_path_mtu_for_link_peer(
|
||||
&self,
|
||||
peer_addr: &NodeAddr,
|
||||
transport_id: TransportId,
|
||||
addr: &TransportAddr,
|
||||
) {
|
||||
let Some(transport) = self.transports.get(&transport_id) else {
|
||||
debug!(
|
||||
peer = %self.peer_display_name(peer_addr),
|
||||
transport_id = %transport_id,
|
||||
"seed_path_mtu_for_link_peer: transport not registered, skipping seed"
|
||||
);
|
||||
return;
|
||||
};
|
||||
let link_mtu = transport.link_mtu(addr);
|
||||
let fips_addr = crate::FipsAddress::from_node_addr(peer_addr);
|
||||
let Ok(mut map) = self.path_mtu_lookup.write() else {
|
||||
warn!(
|
||||
peer = %self.peer_display_name(peer_addr),
|
||||
"seed_path_mtu_for_link_peer: path_mtu_lookup write lock poisoned"
|
||||
);
|
||||
return;
|
||||
};
|
||||
match map.get(&fips_addr).copied() {
|
||||
Some(existing) if existing <= link_mtu => {
|
||||
// Keep the tighter learned value; never loosen the clamp.
|
||||
debug!(
|
||||
peer = %self.peer_display_name(peer_addr),
|
||||
fips_addr = %fips_addr,
|
||||
link_mtu = link_mtu,
|
||||
existing = existing,
|
||||
"seed_path_mtu_for_link_peer: keeping tighter existing value"
|
||||
);
|
||||
}
|
||||
other => {
|
||||
map.insert(fips_addr, link_mtu);
|
||||
debug!(
|
||||
peer = %self.peer_display_name(peer_addr),
|
||||
fips_addr = %fips_addr,
|
||||
link_mtu = link_mtu,
|
||||
prior = ?other,
|
||||
map_len = map.len(),
|
||||
"seed_path_mtu_for_link_peer: wrote link MTU"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracks a pending discovery lookup with retry state.
|
||||
pub struct PendingLookup {
|
||||
/// When the lookup was first initiated.
|
||||
pub initiated_ms: u64,
|
||||
/// When the last attempt was sent.
|
||||
pub last_sent_ms: u64,
|
||||
/// Current attempt number (1 = initial, 2 = first retry, ...).
|
||||
pub attempt: u8,
|
||||
}
|
||||
|
||||
impl PendingLookup {
|
||||
pub fn new(now_ms: u64) -> Self {
|
||||
Self {
|
||||
initiated_ms: now_ms,
|
||||
last_sent_ms: now_ms,
|
||||
attempt: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
739
src/node/handlers/lookup.rs
Normal file
739
src/node/handlers/lookup.rs
Normal file
@@ -0,0 +1,739 @@
|
||||
//! LookupRequest/LookupResponse mesh lookup protocol handlers.
|
||||
//!
|
||||
//! Handles coordinate lookup via bloom-filter-guided tree routing.
|
||||
//! Requests are forwarded only to tree peers (parent + children) whose
|
||||
//! bloom filter contains the target. TTL and request_id dedup provide
|
||||
//! safety bounds.
|
||||
|
||||
use crate::node::Node;
|
||||
use crate::node::reject::DiscoveryReject;
|
||||
use crate::proto::lookup::{
|
||||
LookupAction, LookupRequest, LookupResponse, MAX_RECENT_LOOKUP_REQUESTS,
|
||||
};
|
||||
use crate::transport::{TransportAddr, TransportId};
|
||||
use crate::{NodeAddr, PeerIdentity};
|
||||
use tracing::{debug, info, trace, warn};
|
||||
|
||||
/// Shell adapter exposing the live routing tables to the sans-IO discovery
|
||||
/// core's `RoutingView` read seam. Lives in `node` so it can read `Node`'s
|
||||
/// private `peers` map and call the crate-private tree/bloom predicates.
|
||||
///
|
||||
/// Holding `&Node` whole is fine for the forward path because it does not
|
||||
/// also need `&mut self.lookup` concurrently. A later commit whose core
|
||||
/// step needs `&mut discovery` while reading routing state should narrow this
|
||||
/// to borrow only `peers` + `tree_state` instead of the whole node.
|
||||
struct NodeRoutingView<'a> {
|
||||
node: &'a Node,
|
||||
}
|
||||
|
||||
impl crate::proto::lookup::RoutingView for NodeRoutingView<'_> {
|
||||
fn is_tree_peer(&self, addr: &NodeAddr) -> bool {
|
||||
self.node.is_tree_peer(addr)
|
||||
}
|
||||
fn peers_reaching(&self, target: &NodeAddr) -> Vec<NodeAddr> {
|
||||
self.node
|
||||
.peers
|
||||
.iter()
|
||||
.filter(|(_, peer)| peer.may_reach(target))
|
||||
.map(|(addr, _)| *addr)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Node {
|
||||
/// Handle an incoming LookupRequest from a peer.
|
||||
///
|
||||
/// Processing steps:
|
||||
/// 1. Decode and validate
|
||||
/// 2. Check request_id for duplicates (dedup / reverse-path routing)
|
||||
/// 3. Record request for reverse-path forwarding
|
||||
/// 4. Lazy purge expired entries
|
||||
/// 5. If we're the target, generate and send response
|
||||
/// 6. If TTL > 0, forward to tree peers whose bloom filter matches
|
||||
pub(in crate::node) async fn handle_lookup_request(&mut self, from: &NodeAddr, payload: &[u8]) {
|
||||
self.metrics().lookup.req_received.inc();
|
||||
|
||||
let request = match LookupRequest::decode(payload) {
|
||||
Ok(req) => req,
|
||||
Err(e) => {
|
||||
self.metrics()
|
||||
.lookup
|
||||
.record_reject(DiscoveryReject::ReqDecodeError);
|
||||
debug!(from = %self.peer_display_name(from), error = %e, "Malformed LookupRequest");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let now_ms = Self::now_ms();
|
||||
let recent_expiry_ms = self.config().node.lookup.recent_expiry_secs * 1000;
|
||||
let my_addr = *self.node_addr();
|
||||
use crate::proto::lookup::RequestOutcome;
|
||||
match crate::proto::lookup::classify_request(
|
||||
&mut self.lookup,
|
||||
&request,
|
||||
from,
|
||||
&my_addr,
|
||||
now_ms,
|
||||
recent_expiry_ms,
|
||||
MAX_RECENT_LOOKUP_REQUESTS,
|
||||
) {
|
||||
RequestOutcome::Duplicate => {
|
||||
self.metrics()
|
||||
.lookup
|
||||
.record_reject(DiscoveryReject::ReqDuplicate);
|
||||
debug!(
|
||||
request_id = request.request_id,
|
||||
from = %self.peer_display_name(from),
|
||||
"Duplicate LookupRequest, dropping"
|
||||
);
|
||||
}
|
||||
RequestOutcome::DedupCacheFull { len } => {
|
||||
self.metrics()
|
||||
.lookup
|
||||
.record_reject(DiscoveryReject::ReqDedupCacheFull);
|
||||
debug!(
|
||||
request_id = request.request_id,
|
||||
from = %self.peer_display_name(from),
|
||||
recent_requests = len,
|
||||
max_recent_requests = MAX_RECENT_LOOKUP_REQUESTS,
|
||||
"Discovery request dedup cache full, dropping LookupRequest"
|
||||
);
|
||||
}
|
||||
RequestOutcome::RespondAsTarget => {
|
||||
self.metrics().lookup.req_target_is_us.inc();
|
||||
debug!(
|
||||
request_id = request.request_id,
|
||||
origin = %self.peer_display_name(&request.origin),
|
||||
"We are the lookup target, generating response"
|
||||
);
|
||||
self.send_lookup_response(&request).await;
|
||||
}
|
||||
RequestOutcome::Forward => {
|
||||
self.metrics().lookup.req_forwarded.inc();
|
||||
self.forward_lookup_request(request).await;
|
||||
}
|
||||
RequestOutcome::ForwardRateLimited => {
|
||||
self.metrics().lookup.req_forward_rate_limited.inc();
|
||||
debug!(
|
||||
request_id = request.request_id,
|
||||
target = %self.peer_display_name(&request.target),
|
||||
"Forward rate limited, suppressing LookupRequest"
|
||||
);
|
||||
}
|
||||
RequestOutcome::TtlExhausted => {
|
||||
self.metrics()
|
||||
.lookup
|
||||
.record_reject(DiscoveryReject::ReqTtlExhausted);
|
||||
debug!(
|
||||
request_id = request.request_id,
|
||||
target = %self.peer_display_name(&request.target),
|
||||
"LookupRequest TTL exhausted"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle an incoming LookupResponse from a peer.
|
||||
///
|
||||
/// Processing steps:
|
||||
/// 1. Decode and validate
|
||||
/// 2. Check recent_requests to determine if we originated or are forwarding
|
||||
/// 3. If originator: verify proof signature, then cache target_coords and path_mtu in coord_cache
|
||||
/// 4. If transit: apply path_mtu min(outgoing_link_mtu), reverse-path forward to from_peer
|
||||
pub(in crate::node) async fn handle_lookup_response(
|
||||
&mut self,
|
||||
from: &NodeAddr,
|
||||
payload: &[u8],
|
||||
) {
|
||||
self.metrics().lookup.resp_received.inc();
|
||||
|
||||
let mut response = match LookupResponse::decode(payload) {
|
||||
Ok(resp) => resp,
|
||||
Err(e) => {
|
||||
self.metrics()
|
||||
.lookup
|
||||
.record_reject(DiscoveryReject::RespDecodeError);
|
||||
debug!(from = %self.peer_display_name(from), error = %e, "Malformed LookupResponse");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let now_ms = Self::now_ms();
|
||||
|
||||
// Check if we forwarded this request (transit node) or originated it
|
||||
match crate::proto::lookup::classify_response(&mut self.lookup, response.request_id) {
|
||||
crate::proto::lookup::ResponseRoute::AlreadyForwarded => {
|
||||
// Already forwarded a response for this request — drop to
|
||||
// prevent response routing loops.
|
||||
debug!(
|
||||
request_id = response.request_id,
|
||||
target = %self.peer_display_name(&response.target),
|
||||
"Response already forwarded for this request, dropping"
|
||||
);
|
||||
}
|
||||
crate::proto::lookup::ResponseRoute::Transit { from_peer } => {
|
||||
// Transit node: reverse-path forward
|
||||
self.metrics().lookup.resp_forwarded.inc();
|
||||
|
||||
// Apply path_mtu min() from the outgoing link's transport MTU
|
||||
self.apply_outgoing_link_mtu_to_response(&mut response, &from_peer);
|
||||
|
||||
debug!(
|
||||
request_id = response.request_id,
|
||||
target = %self.peer_display_name(&response.target),
|
||||
next_hop = %self.peer_display_name(&from_peer),
|
||||
path_mtu = response.path_mtu,
|
||||
"Reverse-path forwarding LookupResponse"
|
||||
);
|
||||
|
||||
let encoded = response.encode();
|
||||
if let Err(e) = self.send_encrypted_link_message(&from_peer, &encoded).await {
|
||||
debug!(
|
||||
next_hop = %self.peer_display_name(&from_peer),
|
||||
error = %e,
|
||||
"Failed to forward LookupResponse"
|
||||
);
|
||||
}
|
||||
}
|
||||
crate::proto::lookup::ResponseRoute::Originator => {
|
||||
// We originated this request — verify proof before caching
|
||||
let target = response.target;
|
||||
let path_mtu = response.path_mtu;
|
||||
|
||||
// Look up the target's public key from identity_cache
|
||||
let mut prefix = [0u8; 15];
|
||||
prefix.copy_from_slice(&target.as_bytes()[0..15]);
|
||||
let target_pubkey = match self.lookup_by_fips_prefix(&prefix) {
|
||||
Some((_addr, pubkey)) => pubkey,
|
||||
None => {
|
||||
self.metrics()
|
||||
.lookup
|
||||
.record_reject(DiscoveryReject::RespIdentityMiss);
|
||||
warn!(
|
||||
request_id = response.request_id,
|
||||
target = %self.peer_display_name(&target),
|
||||
"identity_cache miss for lookup target, cannot verify proof"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Verify the proof signature
|
||||
let (xonly, _parity) = target_pubkey.x_only_public_key();
|
||||
let peer_id = PeerIdentity::from_pubkey(xonly);
|
||||
let proof_data = LookupResponse::proof_bytes(
|
||||
response.request_id,
|
||||
&target,
|
||||
&response.target_coords,
|
||||
);
|
||||
if !peer_id.verify(&proof_data, &response.proof) {
|
||||
self.metrics()
|
||||
.lookup
|
||||
.record_reject(DiscoveryReject::RespProofFailed);
|
||||
warn!(
|
||||
request_id = response.request_id,
|
||||
target = %self.peer_display_name(&target),
|
||||
"LookupResponse proof verification failed, discarding"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
self.metrics().lookup.resp_accepted.inc();
|
||||
|
||||
info!(
|
||||
request_id = response.request_id,
|
||||
target = %self.peer_display_name(&target),
|
||||
depth = response.target_coords.depth(),
|
||||
path_mtu = path_mtu,
|
||||
"Discovery succeeded, proof verified, route cached"
|
||||
);
|
||||
|
||||
// Apply the accept-side effects: the core clears the success
|
||||
// state (backoff + pending lookup) and returns the
|
||||
// cross-subsystem effects for us to drive.
|
||||
let actions = crate::proto::lookup::on_response_accepted(
|
||||
&mut self.lookup,
|
||||
&target,
|
||||
response.target_coords,
|
||||
now_ms,
|
||||
path_mtu,
|
||||
);
|
||||
self.drive_response_actions(actions).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive the cross-subsystem effects returned by the discovery core's
|
||||
/// accept-side planning. Each arm reproduces the original inline effect
|
||||
/// exactly (same metrics/logs/writes, same order).
|
||||
async fn drive_response_actions(&mut self, actions: Vec<LookupAction>) {
|
||||
for action in actions {
|
||||
match action {
|
||||
LookupAction::CacheCoords {
|
||||
target,
|
||||
coords,
|
||||
now_ms,
|
||||
path_mtu,
|
||||
} => {
|
||||
self.coord_cache
|
||||
.insert_with_path_mtu(target, coords, now_ms, path_mtu);
|
||||
}
|
||||
LookupAction::WritePathMtu { target, path_mtu } => {
|
||||
// Mirror path_mtu into the FipsAddress-keyed read-only lookup
|
||||
// map used by the TUN reader/writer at TCP MSS clamp time.
|
||||
let fips_addr = crate::FipsAddress::from_node_addr(&target);
|
||||
match self.path_mtu_lookup.write() {
|
||||
Ok(mut map) => match map.get(&fips_addr).copied() {
|
||||
Some(existing) if existing <= path_mtu => {
|
||||
// Keep the tighter learned value; never loosen
|
||||
// the clamp. A reactive MtuExceeded or
|
||||
// PathMtuNotification tighten takes precedence
|
||||
// over a looser discovery estimate
|
||||
// (cross-carrier keep-tighter).
|
||||
debug!(
|
||||
target = %self.peer_display_name(&target),
|
||||
fips_addr = %fips_addr,
|
||||
path_mtu = path_mtu,
|
||||
existing = existing,
|
||||
"LookupResponse: keeping tighter existing path_mtu_lookup value"
|
||||
);
|
||||
}
|
||||
other => {
|
||||
map.insert(fips_addr, path_mtu);
|
||||
debug!(
|
||||
target = %self.peer_display_name(&target),
|
||||
fips_addr = %fips_addr,
|
||||
path_mtu = path_mtu,
|
||||
prior = ?other,
|
||||
map_len = map.len(),
|
||||
"Wrote path_mtu_lookup from discovery LookupResponse"
|
||||
);
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
warn!(
|
||||
target = %self.peer_display_name(&target),
|
||||
fips_addr = %fips_addr,
|
||||
path_mtu = path_mtu,
|
||||
error = %e,
|
||||
"path_mtu_lookup write lock poisoned; clamp will not see this update"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
LookupAction::ResetWarmupIfEstablished { target } => {
|
||||
// If an established session exists, reset the warmup counter.
|
||||
let n = self.config().node.session.coords_warmup_packets;
|
||||
if let Some(entry) = self.sessions.get_mut(&target)
|
||||
&& entry.is_established()
|
||||
{
|
||||
entry.set_coords_warmup_remaining(n);
|
||||
debug!(
|
||||
dest = %self.peer_display_name(&target),
|
||||
warmup_packets = n,
|
||||
"Reset coords warmup after discovery for existing session"
|
||||
);
|
||||
}
|
||||
}
|
||||
LookupAction::RetryQueuedPackets { target } => {
|
||||
// If we have pending TUN packets for this target, retry session
|
||||
// initiation. The coord_cache now has coords, so find_next_hop()
|
||||
// should succeed.
|
||||
if let Some(packets) = self.pending_tun_packets.get(&target) {
|
||||
debug!(
|
||||
dest = %self.peer_display_name(&target),
|
||||
queued_packets = packets.len(),
|
||||
"Retrying queued packets after discovery"
|
||||
);
|
||||
self.retry_session_after_discovery(target).await;
|
||||
}
|
||||
}
|
||||
LookupAction::SendLink { peer, bytes } => {
|
||||
if let Err(e) = self.send_encrypted_link_message(&peer, &bytes).await {
|
||||
debug!(
|
||||
peer = %self.peer_display_name(&peer),
|
||||
error = %e,
|
||||
"Failed to send discovery link message"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate and send a LookupResponse when we are the target.
|
||||
async fn send_lookup_response(&mut self, request: &LookupRequest) {
|
||||
let our_coords = self.tree_state().my_coords().clone();
|
||||
|
||||
// Sign proof: Identity::sign hashes with SHA-256 internally
|
||||
let proof_data =
|
||||
LookupResponse::proof_bytes(request.request_id, &request.target, &our_coords);
|
||||
let proof = self.identity().sign(&proof_data);
|
||||
|
||||
let mut response =
|
||||
LookupResponse::new(request.request_id, request.target, our_coords, proof);
|
||||
|
||||
// Route toward origin. The reverse-path decision (the peer the request
|
||||
// arrived from, recorded in recent_requests) is the sans-IO core's; the
|
||||
// greedy tree-route fallback is a &mut coord-cache op kept in the shell.
|
||||
use crate::proto::lookup::ResponseRouteDecision;
|
||||
let next_hop_addr = match crate::proto::lookup::plan_response_route(
|
||||
&self.lookup,
|
||||
request.request_id,
|
||||
) {
|
||||
ResponseRouteDecision::ReversePath(peer) => peer,
|
||||
ResponseRouteDecision::NeedsTreeRoute => match self.find_next_hop(&request.origin) {
|
||||
Some(peer) => *peer.node_addr(),
|
||||
None => {
|
||||
debug!(
|
||||
origin = %self.peer_display_name(&request.origin),
|
||||
"Cannot route LookupResponse: no reverse path or tree route to origin"
|
||||
);
|
||||
self.metrics()
|
||||
.lookup
|
||||
.record_reject(DiscoveryReject::RespNoRoute);
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Fold our outgoing-link MTU into path_mtu so the target-edge link
|
||||
// appears in the bottleneck calculation. Without this, the response
|
||||
// leaves the target with path_mtu = u16::MAX and only intermediate
|
||||
// transits min-fold; the target's first reverse-path hop is missed.
|
||||
self.apply_outgoing_link_mtu_to_response(&mut response, &next_hop_addr);
|
||||
|
||||
debug!(
|
||||
request_id = request.request_id,
|
||||
origin = %self.peer_display_name(&request.origin),
|
||||
next_hop = %self.peer_display_name(&next_hop_addr),
|
||||
path_mtu = response.path_mtu,
|
||||
"Sending LookupResponse"
|
||||
);
|
||||
|
||||
let encoded = response.encode();
|
||||
if let Err(e) = self
|
||||
.send_encrypted_link_message(&next_hop_addr, &encoded)
|
||||
.await
|
||||
{
|
||||
debug!(
|
||||
next_hop = %self.peer_display_name(&next_hop_addr),
|
||||
error = %e,
|
||||
"Failed to send LookupResponse"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward a LookupRequest to eligible peers.
|
||||
///
|
||||
/// Primary path: tree peers (parent + children) whose bloom filter
|
||||
/// contains the target. Restricting to tree peers follows the spanning
|
||||
/// tree partition, producing a single directed path.
|
||||
///
|
||||
/// Fallback: if no tree peer's bloom matches, try non-tree peers whose
|
||||
/// bloom contains the target. This recovers from dead ends caused by
|
||||
/// stale bloom filters, tree restructuring, or transit node failures.
|
||||
async fn forward_lookup_request(&mut self, mut request: LookupRequest) {
|
||||
// Plan the forward with the sans-IO decision core. The core owns the
|
||||
// TTL decrement, tree/fallback peer selection, and single-encode
|
||||
// fan-out; the shell keeps all metrics/logging and drives the sends.
|
||||
let outcome = {
|
||||
let rv = NodeRoutingView { node: self };
|
||||
crate::proto::lookup::plan_forward(&mut request, &rv)
|
||||
};
|
||||
match outcome {
|
||||
crate::proto::lookup::ForwardOutcome::TtlExhausted => {}
|
||||
crate::proto::lookup::ForwardOutcome::NoPeers => {
|
||||
self.metrics().lookup.req_no_tree_peer.inc();
|
||||
trace!(
|
||||
request_id = request.request_id,
|
||||
"No eligible peers to forward LookupRequest"
|
||||
);
|
||||
}
|
||||
crate::proto::lookup::ForwardOutcome::Forward {
|
||||
actions,
|
||||
used_fallback,
|
||||
} => {
|
||||
let peer_count = actions.len();
|
||||
if used_fallback {
|
||||
self.metrics().lookup.req_fallback_forwarded.inc();
|
||||
debug!(
|
||||
request_id = request.request_id,
|
||||
target = %self.peer_display_name(&request.target),
|
||||
ttl = request.ttl,
|
||||
peer_count,
|
||||
"Forwarding LookupRequest via non-tree fallback"
|
||||
);
|
||||
} else {
|
||||
debug!(
|
||||
request_id = request.request_id,
|
||||
target = %self.peer_display_name(&request.target),
|
||||
ttl = request.ttl,
|
||||
peer_count,
|
||||
"Forwarding LookupRequest"
|
||||
);
|
||||
}
|
||||
for action in actions {
|
||||
if let LookupAction::SendLink { peer, bytes } = action
|
||||
&& let Err(e) = self.send_encrypted_link_message(&peer, &bytes).await
|
||||
{
|
||||
debug!(
|
||||
peer = %self.peer_display_name(&peer),
|
||||
error = %e,
|
||||
"Failed to forward LookupRequest to peer"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Initiate a discovery lookup for a target node.
|
||||
///
|
||||
/// Creates a LookupRequest and sends it to tree peers whose bloom
|
||||
/// filters contain the target. Returns the number of peers sent to.
|
||||
/// The originator does NOT record the request_id in recent_requests,
|
||||
/// so when the response arrives, it's recognized as "our request".
|
||||
pub(in crate::node) async fn initiate_lookup(&mut self, target: &NodeAddr, ttl: u8) -> usize {
|
||||
self.metrics().lookup.req_initiated.inc();
|
||||
|
||||
let origin = *self.node_addr();
|
||||
let origin_coords = self.tree_state().my_coords().clone();
|
||||
let request_id = {
|
||||
use rand::RngExt;
|
||||
rand::rng().random()
|
||||
};
|
||||
let request = LookupRequest::new(request_id, *target, origin, origin_coords, ttl, 0);
|
||||
|
||||
// Tree-peer bloom-match selection + single encode live in the sans-IO
|
||||
// core. The core keeps the tree-only (no non-tree fallback) behavior;
|
||||
// the shell drives the sends and keeps all metrics/logging.
|
||||
let actions = {
|
||||
let rv = NodeRoutingView { node: self };
|
||||
crate::proto::lookup::plan_initiate(&request, &rv)
|
||||
};
|
||||
|
||||
let peer_count = actions.len();
|
||||
|
||||
debug!(
|
||||
request_id = request.request_id,
|
||||
target = %self.peer_display_name(target),
|
||||
ttl = ttl,
|
||||
peer_count = peer_count,
|
||||
total_peers = self.peers.len(),
|
||||
"Discovery lookup initiated"
|
||||
);
|
||||
|
||||
for action in actions {
|
||||
if let LookupAction::SendLink { peer, bytes } = action
|
||||
&& let Err(e) = self.send_encrypted_link_message(&peer, &bytes).await
|
||||
{
|
||||
debug!(
|
||||
peer = %self.peer_display_name(&peer),
|
||||
error = %e,
|
||||
"Failed to send LookupRequest to peer"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
peer_count
|
||||
}
|
||||
|
||||
/// Initiate a discovery lookup if one is not already pending for this target.
|
||||
///
|
||||
/// Checks: pending dedup, post-failure backoff (off by default), bloom
|
||||
/// filter pre-check. If all pass, sends the first attempt's LookupRequest.
|
||||
/// Subsequent attempts (with fresh request_ids) are scheduled by
|
||||
/// [`Self::check_pending_lookups`] when each attempt's per-attempt timeout
|
||||
/// expires, using the sequence in `node.lookup.attempt_timeouts_secs`.
|
||||
pub(in crate::node) async fn maybe_initiate_lookup(&mut self, dest: &NodeAddr) {
|
||||
let now_ms = Self::now_ms();
|
||||
|
||||
// Bloom filter pre-check (view read) BEFORE the core call: if no peer's
|
||||
// filter contains the target, it's not in the mesh. Reading `self.peers`
|
||||
// here keeps the `&mut self.lookup` borrow in `initiate_gate` from
|
||||
// overlapping the immutable peer-table read.
|
||||
let reachable = self.peers.values().any(|peer| peer.may_reach(dest));
|
||||
|
||||
use crate::proto::lookup::InitiateDecision;
|
||||
match crate::proto::lookup::initiate_gate(&mut self.lookup, dest, now_ms, reachable) {
|
||||
InitiateDecision::Deduplicated => {
|
||||
self.metrics().lookup.req_deduplicated.inc();
|
||||
debug!(
|
||||
target_node = %self.peer_display_name(dest),
|
||||
"Discovery lookup deduplicated, already pending"
|
||||
);
|
||||
}
|
||||
InitiateDecision::Suppressed { failures } => {
|
||||
self.metrics().lookup.req_backoff_suppressed.inc();
|
||||
debug!(
|
||||
target_node = %self.peer_display_name(dest),
|
||||
failures = failures,
|
||||
"Discovery lookup suppressed by backoff"
|
||||
);
|
||||
}
|
||||
InitiateDecision::BloomMiss => {
|
||||
self.metrics().lookup.req_bloom_miss.inc();
|
||||
debug!(
|
||||
target_node = %self.peer_display_name(dest),
|
||||
"Discovery skipped, target not in any peer bloom filter"
|
||||
);
|
||||
}
|
||||
InitiateDecision::Proceed => {
|
||||
let ttl = self.config().node.lookup.ttl;
|
||||
let sent = self.initiate_lookup(dest, ttl).await;
|
||||
|
||||
// If no tree peers had the target, fail immediately
|
||||
if sent == 0 {
|
||||
crate::proto::lookup::initiate_failed(&mut self.lookup, dest, now_ms);
|
||||
debug!(
|
||||
target_node = %self.peer_display_name(dest),
|
||||
"Discovery failed, no tree peers with bloom match"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check pending lookups for next-attempt or final timeout.
|
||||
///
|
||||
/// Called periodically from the tick handler. The lookup state machine
|
||||
/// runs through `node.lookup.attempt_timeouts_secs` (default
|
||||
/// `[1, 2, 4, 8]`): each entry is the deadline for one attempt. When the
|
||||
/// current attempt's deadline elapses:
|
||||
/// - If more entries remain: send the next attempt with a fresh
|
||||
/// `request_id`.
|
||||
/// - Otherwise: declare the destination unreachable, drop queued packets,
|
||||
/// and emit ICMPv6 destination-unreachable for each.
|
||||
pub(in crate::node) async fn check_pending_lookups(&mut self, now_ms: u64) {
|
||||
let attempt_timeouts = self.config().node.lookup.attempt_timeouts_secs.clone();
|
||||
let outcome =
|
||||
crate::proto::lookup::poll_pending(&mut self.lookup, now_ms, &attempt_timeouts);
|
||||
|
||||
for (target, attempt) in outcome.retries {
|
||||
let ttl = self.config().node.lookup.ttl;
|
||||
let sent = self.initiate_lookup(&target, ttl).await;
|
||||
if sent > 0 {
|
||||
debug!(
|
||||
target_node = %self.peer_display_name(&target),
|
||||
attempt = attempt,
|
||||
"Discovery retry sent"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (addr, failures) in outcome.timeouts {
|
||||
self.metrics().lookup.resp_timed_out.inc();
|
||||
let queued = self.pending_tun_packets.remove(&addr);
|
||||
let pkt_count = queued.as_ref().map_or(0, |p| p.len());
|
||||
info!(
|
||||
target_node = %self.peer_display_name(&addr),
|
||||
queued_packets = pkt_count,
|
||||
failures = failures,
|
||||
"Discovery lookup timed out, destination unreachable"
|
||||
);
|
||||
if let Some(packets) = queued {
|
||||
for pkt in &packets {
|
||||
self.send_icmpv6_dest_unreachable(pkt);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset discovery backoff on topology changes.
|
||||
pub(in crate::node) fn reset_lookup_backoff(&mut self) {
|
||||
let cleared = self.lookup.reset_backoff();
|
||||
if cleared > 0 {
|
||||
debug!(
|
||||
entries = cleared,
|
||||
"Resetting discovery backoff on topology change"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Min-fold our outgoing-link MTU into a LookupResponse's `path_mtu`.
|
||||
///
|
||||
/// Used at both transit-side reverse-path forward and at the target's
|
||||
/// own send_lookup_response. The link MTU we apply is the MTU of the
|
||||
/// transport+addr we'll use to deliver the response toward `next_hop`.
|
||||
/// No-op when `next_hop` is not a directly-connected peer or its
|
||||
/// transport is not registered.
|
||||
pub(in crate::node) fn apply_outgoing_link_mtu_to_response(
|
||||
&self,
|
||||
response: &mut LookupResponse,
|
||||
next_hop: &NodeAddr,
|
||||
) {
|
||||
if let Some(peer) = self.peers.get(next_hop)
|
||||
&& let Some(tid) = peer.transport_id()
|
||||
&& let Some(transport) = self.transports.get(&tid)
|
||||
{
|
||||
let link_mtu = if let Some(addr) = peer.current_addr() {
|
||||
transport.link_mtu(addr)
|
||||
} else {
|
||||
transport.mtu()
|
||||
};
|
||||
response.path_mtu = response.path_mtu.min(link_mtu);
|
||||
}
|
||||
}
|
||||
|
||||
/// Seed `path_mtu_lookup` for a directly-connected peer.
|
||||
///
|
||||
/// Called when an FMP link-layer peer is promoted to active. The seed
|
||||
/// value is the local outgoing-link MTU on the peer's transport, which
|
||||
/// is the actual link constraint for direct-link traffic. Stored only
|
||||
/// when no tighter value exists: discovery's reverse-path bottleneck
|
||||
/// or MMP `MtuExceeded` reactive learning take precedence when smaller.
|
||||
///
|
||||
/// Without this seed, configured/auto-connect peers (which establish
|
||||
/// sessions without going through the discovery Lookup flow) leave
|
||||
/// `path_mtu_lookup` empty for their FipsAddress, causing
|
||||
/// `per_flow_max_mss` to fall back to the global ceiling and the
|
||||
/// SYN-time TCP MSS clamp to over-estimate the effective path.
|
||||
pub(in crate::node) fn seed_path_mtu_for_link_peer(
|
||||
&self,
|
||||
peer_addr: &NodeAddr,
|
||||
transport_id: TransportId,
|
||||
addr: &TransportAddr,
|
||||
) {
|
||||
let Some(transport) = self.transports.get(&transport_id) else {
|
||||
debug!(
|
||||
peer = %self.peer_display_name(peer_addr),
|
||||
transport_id = %transport_id,
|
||||
"seed_path_mtu_for_link_peer: transport not registered, skipping seed"
|
||||
);
|
||||
return;
|
||||
};
|
||||
let link_mtu = transport.link_mtu(addr);
|
||||
let fips_addr = crate::FipsAddress::from_node_addr(peer_addr);
|
||||
let Ok(mut map) = self.path_mtu_lookup.write() else {
|
||||
warn!(
|
||||
peer = %self.peer_display_name(peer_addr),
|
||||
"seed_path_mtu_for_link_peer: path_mtu_lookup write lock poisoned"
|
||||
);
|
||||
return;
|
||||
};
|
||||
match map.get(&fips_addr).copied() {
|
||||
Some(existing) if existing <= link_mtu => {
|
||||
// Keep the tighter learned value; never loosen the clamp.
|
||||
debug!(
|
||||
peer = %self.peer_display_name(peer_addr),
|
||||
fips_addr = %fips_addr,
|
||||
link_mtu = link_mtu,
|
||||
existing = existing,
|
||||
"seed_path_mtu_for_link_peer: keeping tighter existing value"
|
||||
);
|
||||
}
|
||||
other => {
|
||||
map.insert(fips_addr, link_mtu);
|
||||
debug!(
|
||||
peer = %self.peer_display_name(peer_addr),
|
||||
fips_addr = %fips_addr,
|
||||
link_mtu = link_mtu,
|
||||
prior = ?other,
|
||||
map_len = map.len(),
|
||||
"seed_path_mtu_for_link_peer: wrote link MTU"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,20 +5,63 @@
|
||||
//! and teardown metric logs.
|
||||
|
||||
use crate::NodeAddr;
|
||||
use crate::mmp::MmpMode;
|
||||
use crate::mmp::MmpSessionState;
|
||||
use crate::mmp::report::{ReceiverReport, SenderReport};
|
||||
use crate::node::Node;
|
||||
use crate::node::dataplane::PeerActionCtx;
|
||||
use crate::node::reject::{MmpReject, RejectReason, TreeReject};
|
||||
use crate::protocol::{
|
||||
LinkMessageType, PathMtuNotification, SessionMessageType, SessionReceiverReport,
|
||||
SessionSenderReport,
|
||||
use crate::node::tree::sign_declaration;
|
||||
use crate::peer::machine::PeerEvent;
|
||||
use crate::proto::link::LinkMessageType;
|
||||
use crate::proto::mmp::{
|
||||
LinkReportKind, LinkReportSnapshot, MmpAction, PeerLivenessSnapshot, ReceiverReport, RrLog,
|
||||
SenderReport,
|
||||
};
|
||||
use crate::proto::stp::ParentEval;
|
||||
use crate::transport::{TransportAddr, TransportId};
|
||||
use std::time::{Duration, Instant};
|
||||
use tracing::{debug, info, trace, warn};
|
||||
|
||||
/// Emit the operator `trace!` point for a processed ReceiverReport outcome.
|
||||
///
|
||||
/// These log points used to live inside `MmpMetrics::process_receiver_report`;
|
||||
/// the sans-IO migration returns the outcome as an [`RrLog`] and re-emits it
|
||||
/// here, shell-side, preserving the original field set, content, and (relative
|
||||
/// to the surrounding handler logs) ordering. The original traces carried no
|
||||
/// peer identifier, so none is added here.
|
||||
pub(super) fn log_rr_outcome(rr: &ReceiverReport, our_timestamp_ms: u32, log: RrLog) {
|
||||
match log {
|
||||
RrLog::Stale {
|
||||
prev_highest,
|
||||
prev_packets,
|
||||
prev_bytes,
|
||||
} => trace!(
|
||||
highest_counter = rr.highest_counter,
|
||||
prev_highest_counter = prev_highest,
|
||||
cumulative_packets_recv = rr.cumulative_packets_recv,
|
||||
prev_cumulative_packets_recv = prev_packets,
|
||||
cumulative_bytes_recv = rr.cumulative_bytes_recv,
|
||||
prev_cumulative_bytes_recv = prev_bytes,
|
||||
"Ignoring stale MMP ReceiverReport"
|
||||
),
|
||||
RrLog::RttSample { rtt_ms, srtt_ms } => trace!(
|
||||
our_ts = our_timestamp_ms,
|
||||
echo = rr.timestamp_echo,
|
||||
dwell = u32::from(rr.dwell_time),
|
||||
rtt_ms = rtt_ms,
|
||||
srtt_ms = srtt_ms,
|
||||
"RTT sample from timestamp echo"
|
||||
),
|
||||
RrLog::InvalidRtt => trace!(
|
||||
our_ts = our_timestamp_ms,
|
||||
echo = rr.timestamp_echo,
|
||||
dwell = u32::from(rr.dwell_time),
|
||||
"Ignoring invalid MMP RTT sample"
|
||||
),
|
||||
RrLog::None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Format bytes/sec as human-readable throughput.
|
||||
fn format_throughput(bps: f64) -> String {
|
||||
pub(in crate::node) fn format_throughput(bps: f64) -> String {
|
||||
if bps == 0.0 {
|
||||
"n/a".to_string()
|
||||
} else if bps >= 1_000_000.0 {
|
||||
@@ -113,10 +156,12 @@ impl Node {
|
||||
|
||||
// Process the report: computes RTT from timestamp echo, updates
|
||||
// loss rate, goodput rate, jitter trend, and ETX.
|
||||
let now = Instant::now();
|
||||
let first_rtt = mmp
|
||||
.metrics
|
||||
.process_receiver_report(&rr, our_timestamp_ms, now);
|
||||
let now_ms = crate::time::mono_ms();
|
||||
let (first_rtt, rr_log) =
|
||||
mmp.metrics
|
||||
.process_receiver_report(&rr, our_timestamp_ms, now_ms);
|
||||
// Re-emit the operator trace the core used to log mid-decision.
|
||||
log_rr_outcome(&rr, our_timestamp_ms, rr_log);
|
||||
|
||||
// Feed SRTT back to sender/receiver report interval tuning
|
||||
if let Some(srtt_ms) = mmp.metrics.srtt_ms() {
|
||||
@@ -144,25 +189,43 @@ impl Node {
|
||||
// Trigger re-evaluation so the node doesn't wait for the next
|
||||
// periodic tick or TreeAnnounce.
|
||||
if first_rtt {
|
||||
let peer_costs: std::collections::HashMap<crate::NodeAddr, f64> = self
|
||||
let peer_costs: std::collections::BTreeMap<crate::NodeAddr, f64> = self
|
||||
.peers
|
||||
.iter()
|
||||
.filter(|(_, p)| p.has_srtt())
|
||||
.map(|(a, p)| (*a, p.link_cost()))
|
||||
.collect();
|
||||
if let Some(new_parent) = self.tree_state.evaluate_parent(&peer_costs) {
|
||||
// Wall-clock seconds for the escaping declaration timestamp;
|
||||
// monotonic ms for the flap-dampening / hold-down timers.
|
||||
let now_secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let mono_now_ms = crate::time::mono_ms();
|
||||
// Compute the flap-dampening / hold-down veto at the edge; a mandatory
|
||||
// switch bypasses it, a discretionary one is taken only if not suppressed.
|
||||
let switch_suppressed = self.tree_state.is_switch_suppressed(mono_now_ms);
|
||||
let new_parent = match self
|
||||
.tree_state
|
||||
.evaluate_parent(&peer_costs, &std::collections::BTreeSet::new())
|
||||
{
|
||||
ParentEval::Mandatory(p) => Some(p),
|
||||
ParentEval::Discretionary(p) if !switch_suppressed => Some(p),
|
||||
ParentEval::Discretionary(_) | ParentEval::None => None,
|
||||
};
|
||||
if let Some(new_parent) = new_parent {
|
||||
let new_seq = self.tree_state.my_declaration().sequence() + 1;
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let flap_dampened = self.tree_state.set_parent(new_parent, new_seq, timestamp);
|
||||
let flap_dampened =
|
||||
self.tree_state
|
||||
.set_parent(new_parent, new_seq, now_secs, mono_now_ms);
|
||||
self.tree_state.recompute_coords();
|
||||
// Clone identity once: sign_declaration borrows &mut tree_state while
|
||||
// the identity() accessor borrows all of &self, so an owned copy avoids
|
||||
// the split-borrow conflict on this infrequent parent-switch path.
|
||||
let our_identity = self.identity().clone();
|
||||
if let Err(e) = self.tree_state.sign_declaration(&our_identity) {
|
||||
if let Err(e) =
|
||||
sign_declaration(self.tree_state.my_declaration_mut(), &our_identity)
|
||||
{
|
||||
warn!(error = %e, "Failed to sign declaration after first-RTT parent eval");
|
||||
self.metrics()
|
||||
.tree
|
||||
@@ -172,8 +235,7 @@ impl Node {
|
||||
// Surgical invalidation — see CoordCache::invalidate_via_node doc.
|
||||
self.coord_cache
|
||||
.invalidate_via_node(our_identity.node_addr());
|
||||
self.reset_discovery_backoff();
|
||||
self.metrics().tree.parent_switched.inc();
|
||||
self.reset_lookup_backoff();
|
||||
self.metrics().tree.parent_switches.inc();
|
||||
info!(
|
||||
new_parent = %self.peer_display_name(&new_parent),
|
||||
@@ -191,10 +253,12 @@ impl Node {
|
||||
let all_peers: Vec<crate::NodeAddr> = self.peers.keys().copied().collect();
|
||||
self.bloom_state.mark_all_updates_needed(all_peers);
|
||||
} else if !self.tree_state.is_root() && self.tree_state.should_be_root() {
|
||||
self.tree_state.become_root();
|
||||
self.tree_state.become_root(now_secs);
|
||||
// Clone identity once (see the parent-switch branch above for why).
|
||||
let our_identity = self.identity().clone();
|
||||
if let Err(e) = self.tree_state.sign_declaration(&our_identity) {
|
||||
if let Err(e) =
|
||||
sign_declaration(self.tree_state.my_declaration_mut(), &our_identity)
|
||||
{
|
||||
warn!(error = %e, "Failed to sign self-root declaration after first-RTT");
|
||||
self.metrics()
|
||||
.tree
|
||||
@@ -204,8 +268,7 @@ impl Node {
|
||||
// Surgical invalidation — see CoordCache::invalidate_other_roots doc.
|
||||
self.coord_cache
|
||||
.invalidate_other_roots(our_identity.node_addr());
|
||||
self.reset_discovery_backoff();
|
||||
self.metrics().tree.parent_switched.inc();
|
||||
self.reset_lookup_backoff();
|
||||
self.metrics().tree.parent_switches.inc();
|
||||
info!(
|
||||
new_root = %self.tree_state.root(),
|
||||
@@ -223,65 +286,91 @@ impl Node {
|
||||
///
|
||||
/// Called from the tick handler. Also emits periodic operator logs.
|
||||
pub(in crate::node) async fn check_mmp_reports(&mut self) {
|
||||
let now = Instant::now();
|
||||
let now_ms = crate::time::mono_ms();
|
||||
|
||||
// Collect peers that need reports (can't borrow self mutably while iterating)
|
||||
let mut sender_reports: Vec<(NodeAddr, Vec<u8>)> = Vec::new();
|
||||
let mut receiver_reports: Vec<(NodeAddr, Vec<u8>)> = Vec::new();
|
||||
// Build one report-gating snapshot per peer, resolving every timing read
|
||||
// shell-side into a `bool`. `send_sr`/`send_rr` are `true` on the master
|
||||
// (IK) line — there is no profile negotiation here; the forward-merge to
|
||||
// `-next` wires them to `peer.send_sr()`/`peer.send_rr()` (plan spot c).
|
||||
// The snapshots own only `NodeAddr`/`MmpMode`/`bool`, so the
|
||||
// peer-iteration borrow is released before the pure decision runs and the
|
||||
// driving loop mutates the reporting state.
|
||||
let snapshots: Vec<LinkReportSnapshot> = self
|
||||
.peers
|
||||
.iter()
|
||||
.filter_map(|(node_addr, peer)| {
|
||||
let mmp = peer.mmp()?;
|
||||
Some(LinkReportSnapshot {
|
||||
peer: *node_addr,
|
||||
mode: mmp.mode(),
|
||||
send_sr: true,
|
||||
send_rr: true,
|
||||
sr_due: mmp.sender.should_send_report(now_ms),
|
||||
rr_due: mmp.receiver.should_send_report(now_ms),
|
||||
log_due: mmp.should_log(now_ms),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
for (node_addr, peer) in self.peers.iter_mut() {
|
||||
// Compute display name before taking mutable MMP borrow
|
||||
let peer_name = self
|
||||
.peer_aliases
|
||||
.get(node_addr)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| peer.identity().short_npub());
|
||||
let actions = self.mmp.plan_link_reports(&snapshots);
|
||||
|
||||
let Some(mmp) = peer.mmp_mut() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let mode = mmp.mode();
|
||||
|
||||
// Sender reports: Full mode only
|
||||
if mode == MmpMode::Full
|
||||
&& mmp.sender.should_send_report(now)
|
||||
&& let Some(sr) = mmp.sender.build_report(now)
|
||||
{
|
||||
sender_reports.push((*node_addr, sr.encode()));
|
||||
}
|
||||
|
||||
// Receiver reports: Full and Lightweight modes
|
||||
if mode != MmpMode::Minimal
|
||||
&& mmp.receiver.should_send_report(now)
|
||||
&& let Some(rr) = mmp.receiver.build_report(now)
|
||||
{
|
||||
receiver_reports.push((*node_addr, rr.encode()));
|
||||
}
|
||||
|
||||
// Periodic operator logging
|
||||
if mmp.should_log(now) {
|
||||
Self::log_mmp_metrics(&peer_name, mmp);
|
||||
mmp.mark_logged(now);
|
||||
}
|
||||
}
|
||||
|
||||
// Send collected reports
|
||||
for (node_addr, encoded) in sender_reports {
|
||||
if let Err(e) = self.send_encrypted_link_message(&node_addr, &encoded).await {
|
||||
debug!(peer = %self.peer_display_name(&node_addr), error = %e, "Failed to send SenderReport");
|
||||
}
|
||||
}
|
||||
|
||||
for (node_addr, encoded) in receiver_reports {
|
||||
if let Err(e) = self.send_encrypted_link_message(&node_addr, &encoded).await {
|
||||
debug!(peer = %self.peer_display_name(&node_addr), error = %e, "Failed to send ReceiverReport");
|
||||
// Drive the planned actions in their phase-grouped order (all logs, then
|
||||
// all SenderReports, then all ReceiverReports). Logs run first because the
|
||||
// operator log reads cumulative_packets_sent, which each report send
|
||||
// advances (send_encrypted_link_message -> sender.record_sent); the
|
||||
// pre-refactor handler logged during its collect pass, before any send.
|
||||
// `build_report` (which advances the interval state) is called only on a
|
||||
// SendLinkReport action, exactly as the pre-refactor gate did.
|
||||
for action in actions {
|
||||
match action {
|
||||
MmpAction::SendLinkReport { peer, kind } => {
|
||||
let encoded = self
|
||||
.peers
|
||||
.get_mut(&peer)
|
||||
.and_then(|p| p.mmp_mut())
|
||||
.and_then(|mmp| match kind {
|
||||
LinkReportKind::Sender => {
|
||||
mmp.sender.build_report(now_ms).map(|sr| sr.encode())
|
||||
}
|
||||
LinkReportKind::Receiver => {
|
||||
mmp.receiver.build_report(now_ms).map(|rr| rr.encode())
|
||||
}
|
||||
});
|
||||
if let Some(encoded) = encoded
|
||||
&& let Err(e) = self.send_encrypted_link_message(&peer, &encoded).await
|
||||
{
|
||||
let label = match kind {
|
||||
LinkReportKind::Sender => "Failed to send SenderReport",
|
||||
LinkReportKind::Receiver => "Failed to send ReceiverReport",
|
||||
};
|
||||
debug!(peer = %self.peer_display_name(&peer), error = %e, "{}", label);
|
||||
}
|
||||
}
|
||||
MmpAction::LogLink { peer } => {
|
||||
// Resolve the display name exactly as the pre-refactor loop
|
||||
// did (alias, else short_npub) — not `peer_display_name`,
|
||||
// which also consults the host map.
|
||||
let peer_name = self.peer_aliases.get(&peer).cloned().unwrap_or_else(|| {
|
||||
self.peers
|
||||
.get(&peer)
|
||||
.map(|p| p.identity().short_npub())
|
||||
.unwrap_or_default()
|
||||
});
|
||||
if let Some(mmp) = self.peers.get_mut(&peer).and_then(|p| p.mmp_mut()) {
|
||||
Self::log_mmp_metrics(&peer_name, mmp);
|
||||
mmp.mark_logged(now_ms);
|
||||
}
|
||||
}
|
||||
MmpAction::ReapPeer { .. }
|
||||
| MmpAction::Heartbeat { .. }
|
||||
| MmpAction::SendSessionReport { .. }
|
||||
| MmpAction::LogSession { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit periodic MMP metrics for a peer.
|
||||
fn log_mmp_metrics(peer_name: &str, mmp: &crate::mmp::MmpPeerState) {
|
||||
fn log_mmp_metrics(peer_name: &str, mmp: &crate::proto::mmp::MmpPeerState) {
|
||||
let m = &mmp.metrics;
|
||||
|
||||
let rtt_str = if m.rtt_trend.initialized() {
|
||||
@@ -309,7 +398,10 @@ impl Node {
|
||||
}
|
||||
|
||||
/// Emit a teardown log summarizing lifetime MMP metrics for a removed peer.
|
||||
pub(in crate::node) fn log_mmp_teardown(peer_name: &str, mmp: &crate::mmp::MmpPeerState) {
|
||||
pub(in crate::node) fn log_mmp_teardown(
|
||||
peer_name: &str,
|
||||
mmp: &crate::proto::mmp::MmpPeerState,
|
||||
) {
|
||||
let m = &mmp.metrics;
|
||||
let jitter_ms = mmp.receiver.jitter_us() as f64 / 1000.0;
|
||||
|
||||
@@ -334,205 +426,6 @@ impl Node {
|
||||
);
|
||||
}
|
||||
|
||||
// === Session-layer MMP ===
|
||||
|
||||
/// Check all sessions for pending MMP reports and send them.
|
||||
///
|
||||
/// Called from the tick handler. Also emits periodic session MMP logs.
|
||||
/// Uses the collect-then-send pattern to avoid borrowing conflicts.
|
||||
pub(in crate::node) async fn check_session_mmp_reports(&mut self) {
|
||||
let now = Instant::now();
|
||||
|
||||
// Collect reports to send: (dest_addr, msg_type, encoded_body)
|
||||
let mut reports: Vec<(NodeAddr, u8, Vec<u8>)> = Vec::new();
|
||||
|
||||
for (dest_addr, entry) in self.sessions.iter_mut() {
|
||||
// Compute display name before taking mutable MMP borrow
|
||||
let session_name = self
|
||||
.peer_aliases
|
||||
.get(dest_addr)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| {
|
||||
let (xonly, _) = entry.remote_pubkey().x_only_public_key();
|
||||
crate::PeerIdentity::from_pubkey(xonly).short_npub()
|
||||
});
|
||||
|
||||
let Some(mmp) = entry.mmp_mut() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let mode = mmp.mode();
|
||||
|
||||
// Sender reports: Full mode only
|
||||
if mode == MmpMode::Full
|
||||
&& mmp.sender.should_send_report(now)
|
||||
&& let Some(sr) = mmp.sender.build_report(now)
|
||||
{
|
||||
let session_sr: SessionSenderReport = SessionSenderReport::from(&sr);
|
||||
reports.push((
|
||||
*dest_addr,
|
||||
SessionMessageType::SenderReport.to_byte(),
|
||||
session_sr.encode(),
|
||||
));
|
||||
}
|
||||
|
||||
// Receiver reports: Full and Lightweight modes
|
||||
if mode != MmpMode::Minimal
|
||||
&& mmp.receiver.should_send_report(now)
|
||||
&& let Some(rr) = mmp.receiver.build_report(now)
|
||||
{
|
||||
let session_rr: SessionReceiverReport = SessionReceiverReport::from(&rr);
|
||||
reports.push((
|
||||
*dest_addr,
|
||||
SessionMessageType::ReceiverReport.to_byte(),
|
||||
session_rr.encode(),
|
||||
));
|
||||
}
|
||||
|
||||
// PathMtu notifications (all modes)
|
||||
if mmp.path_mtu.should_send_notification(now)
|
||||
&& let Some(mtu_value) = mmp.path_mtu.build_notification(now)
|
||||
{
|
||||
let notif = PathMtuNotification::new(mtu_value);
|
||||
reports.push((
|
||||
*dest_addr,
|
||||
SessionMessageType::PathMtuNotification.to_byte(),
|
||||
notif.encode(),
|
||||
));
|
||||
}
|
||||
|
||||
// Periodic operator logging
|
||||
if mmp.should_log(now) {
|
||||
Self::log_session_mmp_metrics(&session_name, mmp);
|
||||
mmp.mark_logged(now);
|
||||
}
|
||||
}
|
||||
|
||||
// Send collected reports via session-layer encryption.
|
||||
// Track per-destination success/failure for backoff and log suppression.
|
||||
let mut send_results: Vec<(NodeAddr, bool)> = Vec::new();
|
||||
for (dest_addr, msg_type, body) in reports {
|
||||
match self.send_session_msg(&dest_addr, msg_type, &body).await {
|
||||
Ok(()) => {
|
||||
send_results.push((dest_addr, true));
|
||||
}
|
||||
Err(e) => {
|
||||
// Peek at current failure count for log suppression
|
||||
let failures = self
|
||||
.sessions
|
||||
.get(&dest_addr)
|
||||
.and_then(|entry| entry.mmp())
|
||||
.map(|mmp| mmp.sender.consecutive_send_failures())
|
||||
.unwrap_or(0);
|
||||
|
||||
if failures < 3 {
|
||||
debug!(
|
||||
dest = %self.peer_display_name(&dest_addr),
|
||||
msg_type,
|
||||
error = %e,
|
||||
"Failed to send session MMP report"
|
||||
);
|
||||
} else if failures == 3 {
|
||||
debug!(
|
||||
dest = %self.peer_display_name(&dest_addr),
|
||||
"Suppressing further session MMP send failure logs"
|
||||
);
|
||||
}
|
||||
// failures > 3: silently suppressed
|
||||
|
||||
send_results.push((dest_addr, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update backoff state from send results.
|
||||
// Deduplicate: a destination counts as success if ANY report succeeded,
|
||||
// failure only if ALL reports for that destination failed.
|
||||
let mut dest_success: std::collections::HashMap<NodeAddr, bool> =
|
||||
std::collections::HashMap::new();
|
||||
for (dest, ok) in &send_results {
|
||||
let entry = dest_success.entry(*dest).or_insert(false);
|
||||
if *ok {
|
||||
*entry = true;
|
||||
}
|
||||
}
|
||||
for (dest_addr, success) in dest_success {
|
||||
if let Some(entry) = self.sessions.get_mut(&dest_addr)
|
||||
&& let Some(mmp) = entry.mmp_mut()
|
||||
{
|
||||
if success {
|
||||
let prev = mmp.sender.record_send_success();
|
||||
if prev > 3 {
|
||||
debug!(
|
||||
dest = %self.peer_display_name(&dest_addr),
|
||||
consecutive_failures = prev,
|
||||
"Resumed session MMP reporting"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
mmp.sender.record_send_failure();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit periodic session MMP metrics.
|
||||
fn log_session_mmp_metrics(session_name: &str, mmp: &MmpSessionState) {
|
||||
let m = &mmp.metrics;
|
||||
|
||||
let rtt_str = if m.rtt_trend.initialized() {
|
||||
format!("{:.1}ms", m.rtt_trend.long() / 1000.0)
|
||||
} else {
|
||||
"n/a".to_string()
|
||||
};
|
||||
let loss_str = if m.loss_trend.initialized() {
|
||||
format!("{:.1}%", m.loss_trend.long() * 100.0)
|
||||
} else {
|
||||
"n/a".to_string()
|
||||
};
|
||||
let jitter_ms = mmp.receiver.jitter_us() as f64 / 1000.0;
|
||||
|
||||
debug!(
|
||||
session = %session_name,
|
||||
rtt = %rtt_str,
|
||||
loss = %loss_str,
|
||||
jitter = format_args!("{:.1}ms", jitter_ms),
|
||||
goodput = %format_throughput(m.goodput_bps()),
|
||||
mtu = mmp.path_mtu.last_observed_mtu(),
|
||||
tx_pkts = mmp.sender.cumulative_packets_sent(),
|
||||
rx_pkts = mmp.receiver.cumulative_packets_recv(),
|
||||
"MMP session metrics"
|
||||
);
|
||||
}
|
||||
|
||||
/// Emit a teardown log summarizing lifetime session MMP metrics.
|
||||
pub(in crate::node) fn log_session_mmp_teardown(session_name: &str, mmp: &MmpSessionState) {
|
||||
let m = &mmp.metrics;
|
||||
let jitter_ms = mmp.receiver.jitter_us() as f64 / 1000.0;
|
||||
|
||||
let rtt_str = match m.srtt_ms() {
|
||||
Some(rtt) => format!("{:.1}ms", rtt),
|
||||
None => "n/a".to_string(),
|
||||
};
|
||||
let loss_str = format!("{:.1}%", m.loss_rate() * 100.0);
|
||||
|
||||
debug!(
|
||||
session = %session_name,
|
||||
rtt = %rtt_str,
|
||||
loss = %loss_str,
|
||||
jitter = format_args!("{:.1}ms", jitter_ms),
|
||||
etx = format_args!("{:.2}", m.etx),
|
||||
goodput = %format_throughput(m.goodput_bps()),
|
||||
send_mtu = mmp.path_mtu.current_mtu(),
|
||||
observed_mtu = mmp.path_mtu.last_observed_mtu(),
|
||||
tx_pkts = mmp.sender.cumulative_packets_sent(),
|
||||
tx_bytes = mmp.sender.cumulative_bytes_sent(),
|
||||
rx_pkts = mmp.receiver.cumulative_packets_recv(),
|
||||
rx_bytes = mmp.receiver.cumulative_bytes_recv(),
|
||||
"MMP session teardown"
|
||||
);
|
||||
}
|
||||
|
||||
/// Send heartbeats and remove dead peers.
|
||||
///
|
||||
/// Called from the tick handler. Sends a 1-byte heartbeat to each peer
|
||||
@@ -540,83 +433,159 @@ impl Node {
|
||||
/// hasn't sent us a frame within the link dead timeout.
|
||||
pub(in crate::node) async fn check_link_heartbeats(&mut self) {
|
||||
let now = Instant::now();
|
||||
// Monotonic ms for the MMP receiver's injected-`u64` liveness clock; the
|
||||
// Instant `now` is still used for the shell-owned heartbeat timing and
|
||||
// the session-start fallback (both `ActivePeer` Instants).
|
||||
let now_ms = crate::time::mono_ms();
|
||||
let heartbeat_interval = Duration::from_secs(self.config().node.heartbeat_interval_secs);
|
||||
let dead_timeout = Duration::from_secs(self.config().node.link_dead_timeout_secs);
|
||||
let dead_timeout_ms = dead_timeout.as_millis() as u64;
|
||||
let max_resends = self.config().node.rate_limit.handshake_max_resends;
|
||||
let heartbeat_msg = [LinkMessageType::Heartbeat.to_byte()];
|
||||
|
||||
// Collect heartbeats to send and dead peers to remove
|
||||
let mut heartbeats: Vec<NodeAddr> = Vec::new();
|
||||
let mut dead_peers: Vec<NodeAddr> = Vec::new();
|
||||
// Build one liveness snapshot per peer, resolving every clock read and
|
||||
// the rekey-suppression predicate shell-side. The snapshots own only
|
||||
// `NodeAddr`/`bool`, so the peer-iteration borrow is released before the
|
||||
// pure decision runs and the driving loop mutates the registry.
|
||||
let snapshots: Vec<PeerLivenessSnapshot> = self
|
||||
.peers
|
||||
.iter()
|
||||
.map(|(node_addr, peer)| {
|
||||
// Check liveness via the MMP receiver's last-received monotonic
|
||||
// ms. Fall back to session_start (an `ActivePeer` Instant) for
|
||||
// peers that never sent data, keeping that branch in Instant
|
||||
// space so no monotonic-ms epoch conversion is needed.
|
||||
let time_dead = if let Some(mmp) = peer.mmp() {
|
||||
match mmp.receiver.last_recv_ms() {
|
||||
Some(last_ms) => now_ms.saturating_sub(last_ms) >= dead_timeout_ms,
|
||||
None => now.duration_since(peer.session_start()) >= dead_timeout,
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
for (node_addr, peer) in self.peers.iter() {
|
||||
// Check liveness via MMP receiver last_recv_time.
|
||||
// Fall back to session_start for peers that never sent data.
|
||||
let time_dead = if let Some(mmp) = peer.mmp() {
|
||||
let reference_time = mmp
|
||||
.receiver
|
||||
.last_recv_time()
|
||||
.unwrap_or(peer.session_start());
|
||||
now.duration_since(reference_time) >= dead_timeout
|
||||
} else {
|
||||
false
|
||||
};
|
||||
// Suppress teardown while an FMP rekey is genuinely in flight
|
||||
// with budget left: a rekey-handshake link is not silent. The
|
||||
// msg1 resend cap guarantees this terminates (abandon on
|
||||
// exhaustion or cutover on completion clears
|
||||
// `rekey_in_progress`), so a truly dead link is reaped on the
|
||||
// next cycle.
|
||||
let rekey_active = peer.rekey_in_progress()
|
||||
&& peer.rekey_msg1_resend_count() < max_resends
|
||||
&& peer.rekey_msg1().is_some();
|
||||
|
||||
// Suppress teardown while an FMP rekey is genuinely in flight with
|
||||
// budget left: a rekey-handshake link is not silent. The msg1
|
||||
// resend cap guarantees this terminates (abandon on exhaustion or
|
||||
// cutover on completion clears `rekey_in_progress`), so a truly
|
||||
// dead link is reaped on the next cycle.
|
||||
let rekey_active = peer.rekey_in_progress()
|
||||
&& peer.rekey_msg1_resend_count() < max_resends
|
||||
&& peer.rekey_msg1().is_some();
|
||||
// Check if heartbeat is due.
|
||||
let heartbeat_due = match peer.last_heartbeat_sent() {
|
||||
None => true,
|
||||
Some(last) => now.duration_since(last) >= heartbeat_interval,
|
||||
};
|
||||
|
||||
let is_dead = time_dead && !rekey_active;
|
||||
if is_dead {
|
||||
dead_peers.push(*node_addr);
|
||||
continue;
|
||||
}
|
||||
PeerLivenessSnapshot {
|
||||
peer: *node_addr,
|
||||
time_dead,
|
||||
rekey_active,
|
||||
heartbeat_due,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Check if heartbeat is due
|
||||
let needs_heartbeat = match peer.last_heartbeat_sent() {
|
||||
None => true,
|
||||
Some(last) => now.duration_since(last) >= heartbeat_interval,
|
||||
};
|
||||
if needs_heartbeat {
|
||||
heartbeats.push(*node_addr);
|
||||
}
|
||||
}
|
||||
let actions = self.mmp.plan_heartbeats(&snapshots);
|
||||
|
||||
// Remove dead peers and schedule auto-reconnect
|
||||
// Wall-clock basis for reconnect scheduling, sourced once (as before).
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
|
||||
for addr in &dead_peers {
|
||||
debug!(
|
||||
peer = %self.peer_display_name(addr),
|
||||
timeout_secs = self.config().node.link_dead_timeout_secs,
|
||||
"Removing peer: link dead timeout"
|
||||
);
|
||||
self.remove_active_peer(addr);
|
||||
self.schedule_reconnect(*addr, now_ms);
|
||||
}
|
||||
|
||||
// Send heartbeats (skip peers we just removed)
|
||||
for addr in heartbeats {
|
||||
if dead_peers.contains(&addr) {
|
||||
continue;
|
||||
}
|
||||
if let Some(peer) = self.peers.get_mut(&addr) {
|
||||
peer.mark_heartbeat_sent(now);
|
||||
}
|
||||
if let Err(e) = self
|
||||
.send_encrypted_link_message(&addr, &heartbeat_msg)
|
||||
.await
|
||||
{
|
||||
trace!(peer = %self.peer_display_name(&addr), error = %e, "Failed to send heartbeat");
|
||||
// Drive the planned actions: all reaps first (each removed +
|
||||
// reconnect-scheduled), then all heartbeats (a just-reaped peer is never
|
||||
// heartbeated — the core never emits both for the same peer).
|
||||
for action in actions {
|
||||
match action {
|
||||
MmpAction::ReapPeer { peer } => {
|
||||
// Log SHELL-SIDE before routing so the reap keeps the
|
||||
// `fips::node::handlers::mmp` tracing target (no relocation into
|
||||
// the executor, no target pin needed).
|
||||
debug!(
|
||||
peer = %self.peer_display_name(&peer),
|
||||
timeout_secs = self.config().node.link_dead_timeout_secs,
|
||||
"Removing peer: link dead timeout"
|
||||
);
|
||||
self.route_link_dead(peer, now_ms).await;
|
||||
}
|
||||
MmpAction::Heartbeat { peer } => {
|
||||
if let Some(p) = self.peers.get_mut(&peer) {
|
||||
p.mark_heartbeat_sent(now);
|
||||
}
|
||||
if let Err(e) = self
|
||||
.send_encrypted_link_message(&peer, &heartbeat_msg)
|
||||
.await
|
||||
{
|
||||
trace!(peer = %self.peer_display_name(&peer), error = %e, "Failed to send heartbeat");
|
||||
}
|
||||
}
|
||||
MmpAction::SendLinkReport { .. }
|
||||
| MmpAction::LogLink { .. }
|
||||
| MmpAction::SendSessionReport { .. }
|
||||
| MmpAction::LogSession { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Route a link-dead liveness reap through the peer machine + executor.
|
||||
/// Mirrors [`route_rekey_cadence`](Node::route_rekey_cadence): the
|
||||
/// shell already decided (the tick sweep's `plan_heartbeats` batch emitted
|
||||
/// this `ReapPeer` in phase order), so the machine only CONSUMES the decision
|
||||
/// via [`PeerEvent::LinkDeadSuspected`]. The resulting executor arms
|
||||
/// (`InvalidateSendState` → `remove_active_peer`, `ReportLost` →
|
||||
/// `note_link_dead`) reproduce the pre-refactor inline reap body exactly.
|
||||
///
|
||||
/// An established peer always has a `peer_machine`. If the peer vanished
|
||||
/// between snapshot and effect, the old inline body was already a
|
||||
/// no-op, so we return; if the machine is absent (which should be impossible)
|
||||
/// we fall back to the byte-identical inline body under a `debug_assert`.
|
||||
///
|
||||
/// `now_ms` is the sweep's hoisted wall-clock ms (the same value the old reap
|
||||
/// fed `note_link_dead`); it flows to the executor `ReportLost` arm via
|
||||
/// `ambient.now_ms`.
|
||||
async fn route_link_dead(&mut self, node_addr: NodeAddr, now_ms: u64) {
|
||||
let link = match self.peers.get(&node_addr) {
|
||||
Some(peer) => peer.link_id(),
|
||||
None => return,
|
||||
};
|
||||
if !self.peer_machines.contains_key(&link) {
|
||||
debug_assert!(false, "peer machine present for every established peer");
|
||||
self.remove_active_peer(&node_addr);
|
||||
self.note_link_dead(node_addr, now_ms);
|
||||
return;
|
||||
}
|
||||
let ambient = self.link_dead_ctx(&node_addr, now_ms);
|
||||
self.advance_peer_machine(link, PeerEvent::LinkDeadSuspected, Self::now_ms(), &ambient)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Ambient shell facts for the routed liveness reap. Mirrors
|
||||
/// [`rekey_cadence_ctx`](Node::rekey_cadence_ctx). The executor reads only
|
||||
/// `verified_identity` (`InvalidateSendState` → `remove_active_peer` resolves
|
||||
/// its `NodeAddr` from it, so it must equal `node_addr`) and `now_ms`
|
||||
/// (`ReportLost` → `note_link_dead`, the wall-clock reconnect basis). The
|
||||
/// transport/index/direction fields are unused by these two arms and are
|
||||
/// populated best-effort for coherence. `now_ms` is threaded in (rather than
|
||||
/// re-read) so the value fed to `note_link_dead` is byte-identical to the old
|
||||
/// reap's hoisted wall-clock for every peer in the sweep.
|
||||
fn link_dead_ctx(&self, node_addr: &NodeAddr, now_ms: u64) -> PeerActionCtx {
|
||||
let peer = &self.peers[node_addr];
|
||||
PeerActionCtx {
|
||||
verified_identity: *peer.identity(),
|
||||
transport_id: peer.transport_id().unwrap_or_else(|| TransportId::new(0)),
|
||||
remote_addr: peer
|
||||
.current_addr()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| TransportAddr::new(Vec::new())),
|
||||
our_index: peer.our_index(),
|
||||
their_index: peer.their_index(),
|
||||
now_ms,
|
||||
is_outbound: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
//! RX event loop and message handlers.
|
||||
//! Message handlers: per-message-type behavior on `impl Node`.
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) mod connected_udp;
|
||||
pub(crate) mod discovery;
|
||||
mod dispatch;
|
||||
mod encrypted;
|
||||
mod forwarding;
|
||||
mod handshake;
|
||||
pub(crate) mod lookup;
|
||||
mod mmp;
|
||||
mod rekey;
|
||||
mod rx_loop;
|
||||
pub(in crate::node) mod session;
|
||||
mod timeout;
|
||||
|
||||
@@ -7,28 +7,28 @@
|
||||
|
||||
use crate::NodeAddr;
|
||||
use crate::node::Node;
|
||||
use crate::node::wire::build_msg1;
|
||||
use crate::node::dataplane::PeerActionCtx;
|
||||
use crate::noise::HandshakeState;
|
||||
use crate::protocol::{SessionDatagram, SessionSetup};
|
||||
use crate::peer::machine::PeerEvent;
|
||||
use crate::proto::fmp::wire::build_msg1;
|
||||
use crate::proto::fmp::{ConnAction, LifecycleView, PeerSnapshot, RekeyCfg, RekeyResendSnapshot};
|
||||
use crate::proto::fsp::{
|
||||
FspAction, RekeyMsg3ResendSnapshot, SessionSetup, SessionSnapshot, cutover_timer_elapsed,
|
||||
};
|
||||
use crate::proto::link::SessionDatagram;
|
||||
use crate::transport::{TransportAddr, TransportId};
|
||||
use tracing::{debug, trace, warn};
|
||||
|
||||
/// Keep previous session alive for this long after cutover.
|
||||
///
|
||||
/// FMP-scoped copy for `check_rekey`; the FSP session-rekey timing bounds live
|
||||
/// in `crate::proto::fsp::limits`.
|
||||
const DRAIN_WINDOW_SECS: u64 = 10;
|
||||
|
||||
/// Suppress local rekey initiation for this long after receiving
|
||||
/// a peer's rekey msg1.
|
||||
/// a peer's rekey msg1. FMP-scoped copy for `check_rekey`.
|
||||
const REKEY_DAMPENING_SECS: u64 = 30;
|
||||
|
||||
/// Liveness bound on how long the FSP rekey initiator holds the
|
||||
/// `current` + `pending` state before cutting over to the new epoch.
|
||||
///
|
||||
/// This is NOT safety-critical: overlapping-epoch trial-decrypt covers
|
||||
/// any skew between the two endpoints' cutovers. The timer only bounds
|
||||
/// how long the initiator advertises the old K-bit. An opportunistic
|
||||
/// early cutover also fires if the initiator authenticates a peer frame
|
||||
/// against its own `pending` session (the responder cut over first).
|
||||
const FSP_CUTOVER_DELAY_MS: u64 = 2000;
|
||||
|
||||
impl Node {
|
||||
/// Periodic rekey check. Called from the tick loop.
|
||||
///
|
||||
@@ -41,121 +41,225 @@ impl Node {
|
||||
return;
|
||||
}
|
||||
|
||||
let rekey_after_secs = self.config().node.rekey.after_secs;
|
||||
let rekey_after_messages = self.config().node.rekey.after_messages;
|
||||
let cfg = RekeyCfg {
|
||||
after_secs: self.config().node.rekey.after_secs,
|
||||
after_messages: self.config().node.rekey.after_messages,
|
||||
};
|
||||
|
||||
// Collect peers that need action (to avoid borrow conflicts)
|
||||
let mut peers_to_cutover: Vec<NodeAddr> = Vec::new();
|
||||
let mut peers_to_drain: Vec<NodeAddr> = Vec::new();
|
||||
let mut peers_to_rekey: Vec<NodeAddr> = Vec::new();
|
||||
|
||||
for (node_addr, peer) in &self.peers {
|
||||
if !peer.has_session() || !peer.is_healthy() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 1. Initiator-side cutover: we completed a rekey and have
|
||||
// a pending session ready. Cut over on the next tick.
|
||||
if peer.pending_new_session().is_some() && !peer.rekey_in_progress() {
|
||||
peers_to_cutover.push(*node_addr);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. Drain window expiry
|
||||
if peer.is_draining() && peer.drain_expired(DRAIN_WINDOW_SECS) {
|
||||
peers_to_drain.push(*node_addr);
|
||||
}
|
||||
|
||||
// 3. Rekey trigger
|
||||
if peer.rekey_in_progress() {
|
||||
continue;
|
||||
}
|
||||
if peer.is_rekey_dampened(REKEY_DAMPENING_SECS) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let elapsed = peer.session_established_at().elapsed().as_secs();
|
||||
let counter = peer
|
||||
.noise_session()
|
||||
.map(|s| s.current_send_counter())
|
||||
.unwrap_or(0);
|
||||
|
||||
// Apply per-session symmetric jitter to desynchronize
|
||||
// dual-initiation in symmetric-start meshes.
|
||||
let effective_after_secs =
|
||||
rekey_after_secs.saturating_add_signed(peer.rekey_jitter_secs());
|
||||
if elapsed >= effective_after_secs || counter >= rekey_after_messages {
|
||||
peers_to_rekey.push(*node_addr);
|
||||
// The shell snapshots each healthy peer's rekey ages/flags (every clock
|
||||
// read resolved here); the core decides cutover/drain/trigger with no
|
||||
// clock, phase-grouped to preserve the pre-refactor execution order.
|
||||
// The batch `poll_rekey` + snapshots STAY SHELL-SIDE and BYTE-UNCHANGED:
|
||||
// the cross-peer phase-grouping (all Cutover → all Drain →
|
||||
// all InitiateRekey) governs the shared `index_allocator` free-then-alloc
|
||||
// SEQUENCE that appears on the wire. The machine must NOT re-poll; it
|
||||
// CONSUMES each decided `ConnAction` in the same order the batch returned.
|
||||
let snapshots = self.rekey_peers();
|
||||
for action in self.fmp.poll_rekey(snapshots, &cfg) {
|
||||
match action {
|
||||
// Initiator cutover: route the decided action through the peer
|
||||
// machine + executor. The executor's `SwapSendState` arm
|
||||
// reproduces the pre-refactor cutover body EXACTLY.
|
||||
ConnAction::Cutover { peer: node_addr } => {
|
||||
self.route_rekey_cadence(node_addr, ConnAction::Cutover { peer: node_addr })
|
||||
.await;
|
||||
}
|
||||
// Drain completion: route through the machine + executor. The
|
||||
// executor's `CompleteDrain` arm reads the REAL previous index
|
||||
// from `complete_drain()` and frees it at the same point the old
|
||||
// inline body did (index-order preserving).
|
||||
ConnAction::Drain { peer: node_addr } => {
|
||||
self.route_rekey_cadence(node_addr, ConnAction::Drain { peer: node_addr })
|
||||
.await;
|
||||
}
|
||||
// Initiate a new rekey: STAYS INLINE (the Noise msg1 build +
|
||||
// index allocation are a shell-side leaf, byte-unchanged). Feed
|
||||
// the machine a `RekeyInitiated` observation afterward so its
|
||||
// control state stays coherent for the next tick's Cutover/Drain.
|
||||
ConnAction::InitiateRekey { peer: node_addr } => {
|
||||
self.initiate_rekey(&node_addr).await;
|
||||
self.observe_rekey_initiated(&node_addr);
|
||||
}
|
||||
#[allow(unreachable_patterns)]
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execute cutover for initiator side
|
||||
for node_addr in peers_to_cutover {
|
||||
let did_cutover = if let Some(peer) = self.peers.get_mut(&node_addr) {
|
||||
if let Some(_old_our_index) = peer.cutover_to_new_session() {
|
||||
// New index was pre-registered in peers_by_index
|
||||
// during msg2 handling (handshake.rs).
|
||||
debug_assert!(
|
||||
peer.transport_id().is_some()
|
||||
&& peer.our_index().is_some()
|
||||
&& self.peers_by_index.contains_key(&(
|
||||
peer.transport_id().unwrap(),
|
||||
peer.our_index().unwrap().as_u32()
|
||||
)),
|
||||
"peers_by_index should contain pre-registered new index after cutover"
|
||||
);
|
||||
debug!(
|
||||
peer = %self.peer_display_name(&node_addr),
|
||||
"Rekey cutover complete (initiator), K-bit flipped"
|
||||
);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
/// Route a cadence-decided `Cutover`/`Drain` `ConnAction` through the peer
|
||||
/// machine + executor. The shell already decided (batch `poll_rekey`);
|
||||
/// the machine consumes via [`PeerEvent::RekeyConsume`] WITHOUT re-polling,
|
||||
/// preserving the phase order. The `SwapSendState`/`CompleteDrain` executor
|
||||
/// arms reproduce the pre-refactor inline effect bodies exactly.
|
||||
///
|
||||
/// An established peer always has a `peer_machine`. If the peer vanished
|
||||
/// between snapshot and effect, the old inline body was a no-op, so we do
|
||||
/// nothing; if the machine is absent (which should be impossible) we fall
|
||||
/// back to the byte-identical inline body under a `debug_assert`.
|
||||
async fn route_rekey_cadence(&mut self, node_addr: NodeAddr, action: ConnAction) {
|
||||
let link = match self.peers.get(&node_addr) {
|
||||
Some(peer) => peer.link_id(),
|
||||
None => return,
|
||||
};
|
||||
if !self.peer_machines.contains_key(&link) {
|
||||
debug_assert!(
|
||||
false,
|
||||
"peer machine present for every established rekey peer"
|
||||
);
|
||||
match action {
|
||||
ConnAction::Cutover { peer } => self.cutover_peer_inline(&peer),
|
||||
ConnAction::Drain { peer } => self.drain_peer_inline(&peer),
|
||||
_ => {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let ambient = self.rekey_cadence_ctx(&node_addr);
|
||||
self.advance_peer_machine(
|
||||
link,
|
||||
PeerEvent::RekeyConsume { action },
|
||||
ambient.now_ms,
|
||||
&ambient,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Feed the machine the `RekeyInitiated` observation after the inline
|
||||
/// `initiate_rekey`. The obs emits no action, so there is no executor
|
||||
/// pass — a bare `step` keeps the machine's control state coherent.
|
||||
fn observe_rekey_initiated(&mut self, node_addr: &NodeAddr) {
|
||||
let link = match self.peers.get(node_addr) {
|
||||
Some(peer) => peer.link_id(),
|
||||
None => return,
|
||||
};
|
||||
if let Some(machine) = self.peer_machines.get_mut(&link) {
|
||||
let acts = machine.step(
|
||||
PeerEvent::RekeyInitiated,
|
||||
Self::now_ms(),
|
||||
&mut self.index_allocator,
|
||||
);
|
||||
debug_assert!(acts.is_empty(), "RekeyInitiated is a pure observation");
|
||||
} else {
|
||||
debug_assert!(
|
||||
false,
|
||||
"peer machine present for every established rekey peer"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ambient shell facts for the routed cadence Cutover/Drain step. Only
|
||||
/// `verified_identity` is read by the `SwapSendState`/`CompleteDrain`
|
||||
/// executor arms — `SwapSendState` resolves its `NodeAddr` from it (so it must
|
||||
/// equal `node_addr`), and `CompleteDrain` carries its peer in the action
|
||||
/// payload. The transport/index/direction fields are unused by these two arms
|
||||
/// (they matter only to `PromoteToActive`, never emitted on this path) and are
|
||||
/// populated best-effort for coherence.
|
||||
fn rekey_cadence_ctx(&self, node_addr: &NodeAddr) -> PeerActionCtx {
|
||||
let peer = &self.peers[node_addr];
|
||||
PeerActionCtx {
|
||||
verified_identity: *peer.identity(),
|
||||
transport_id: peer.transport_id().unwrap_or_else(|| TransportId::new(0)),
|
||||
remote_addr: peer
|
||||
.current_addr()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| TransportAddr::new(Vec::new())),
|
||||
our_index: peer.our_index(),
|
||||
their_index: peer.their_index(),
|
||||
now_ms: Self::now_ms(),
|
||||
is_outbound: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Pre-refactor initiator cutover body, retained as the release fallback for
|
||||
/// the (should-be-impossible) missing-machine case. Byte-identical to the old
|
||||
/// inline `ConnAction::Cutover` arm and to the executor's `SwapSendState` arm.
|
||||
fn cutover_peer_inline(&mut self, node_addr: &NodeAddr) {
|
||||
let did_cutover = if let Some(peer) = self.peers.get_mut(node_addr) {
|
||||
if let Some(_old_our_index) = peer.cutover_to_new_session() {
|
||||
// New index was pre-registered in peers_by_index during msg2
|
||||
// handling (handshake.rs).
|
||||
debug_assert!(
|
||||
peer.transport_id().is_some()
|
||||
&& peer.our_index().is_some()
|
||||
&& self.peers_by_index.contains_key(&(
|
||||
peer.transport_id().unwrap(),
|
||||
peer.our_index().unwrap().as_u32()
|
||||
)),
|
||||
"peers_by_index should contain pre-registered new index after cutover"
|
||||
);
|
||||
debug!(
|
||||
peer = %self.peer_display_name(node_addr),
|
||||
"Rekey cutover complete (initiator), K-bit flipped"
|
||||
);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
// Re-register the new session with the decrypt worker — the
|
||||
// cache_key (transport_id, our_index) just changed, so the
|
||||
// old worker entry is stale and every packet on the new
|
||||
// session would miss the worker's HashMap lookup.
|
||||
#[cfg(unix)]
|
||||
if did_cutover {
|
||||
self.register_decrypt_worker_session(&node_addr);
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = did_cutover;
|
||||
} else {
|
||||
false
|
||||
};
|
||||
// Re-register the new session with the decrypt worker — the cache_key
|
||||
// (transport_id, our_index) just changed, so the old worker entry is
|
||||
// stale and every packet on the new session would miss the lookup.
|
||||
#[cfg(unix)]
|
||||
if did_cutover {
|
||||
self.register_decrypt_worker_session(node_addr);
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = did_cutover;
|
||||
}
|
||||
|
||||
// Execute drain completion
|
||||
for node_addr in peers_to_drain {
|
||||
// Extract the old index and transport_id under the peer
|
||||
// borrow, then drop the borrow so the cache_key cleanup
|
||||
// below can take &mut self for unregister_decrypt_worker_session.
|
||||
let drained = self
|
||||
.peers
|
||||
.get_mut(&node_addr)
|
||||
.and_then(|peer| peer.complete_drain().map(|idx| (idx, peer.transport_id())));
|
||||
if let Some((old_our_index, transport_id)) = drained {
|
||||
if let Some(tid) = transport_id {
|
||||
let cache_key = (tid, old_our_index.as_u32());
|
||||
self.peers_by_index.remove(&cache_key);
|
||||
#[cfg(unix)]
|
||||
self.unregister_decrypt_worker_session(cache_key);
|
||||
}
|
||||
let _ = self.index_allocator.free(old_our_index);
|
||||
trace!(
|
||||
peer = %self.peer_display_name(&node_addr),
|
||||
old_index = %old_our_index,
|
||||
"Drain complete, previous session erased"
|
||||
);
|
||||
/// Pre-refactor drain-completion body, retained as the release fallback for
|
||||
/// the (should-be-impossible) missing-machine case. Byte-identical to the old
|
||||
/// inline `ConnAction::Drain` arm and to the executor's `CompleteDrain` arm.
|
||||
fn drain_peer_inline(&mut self, node_addr: &NodeAddr) {
|
||||
// Extract the old index and transport_id under the peer borrow, then drop
|
||||
// the borrow so the cache_key cleanup below can take &mut self for
|
||||
// unregister_decrypt_worker_session.
|
||||
let drained = self
|
||||
.peers
|
||||
.get_mut(node_addr)
|
||||
.and_then(|peer| peer.complete_drain().map(|idx| (idx, peer.transport_id())));
|
||||
if let Some((old_our_index, transport_id)) = drained {
|
||||
if let Some(tid) = transport_id {
|
||||
let cache_key = (tid, old_our_index.as_u32());
|
||||
self.peers_by_index.remove(&cache_key);
|
||||
#[cfg(unix)]
|
||||
self.unregister_decrypt_worker_session(cache_key);
|
||||
}
|
||||
let _ = self.index_allocator.free(old_our_index);
|
||||
trace!(
|
||||
peer = %self.peer_display_name(node_addr),
|
||||
old_index = %old_our_index,
|
||||
"Drain complete, previous session erased"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Initiate new rekeys
|
||||
for node_addr in peers_to_rekey {
|
||||
self.initiate_rekey(&node_addr).await;
|
||||
}
|
||||
/// Snapshot every healthy peer with a session for the rekey decision,
|
||||
/// pre-computing its monotonic ages and timer predicates so the pure core
|
||||
/// applies the thresholds without reading a clock (see [`PeerSnapshot`]).
|
||||
///
|
||||
/// Lives here, beside the drain/dampening constants and the FSP analog, so
|
||||
/// the forward-merge onto `next` reconciles rekey timing in one place.
|
||||
pub(in crate::node) fn rekey_peer_snapshots(&self) -> Vec<PeerSnapshot> {
|
||||
self.peers
|
||||
.iter()
|
||||
.filter(|(_, peer)| peer.has_session() && peer.is_healthy())
|
||||
.map(|(node_addr, peer)| PeerSnapshot {
|
||||
addr: *node_addr,
|
||||
has_pending: peer.pending_new_session().is_some(),
|
||||
rekey_in_progress: peer.rekey_in_progress(),
|
||||
is_draining: peer.is_draining(),
|
||||
drain_expired: peer.drain_expired(DRAIN_WINDOW_SECS),
|
||||
is_dampened: peer.is_rekey_dampened(REKEY_DAMPENING_SECS),
|
||||
elapsed_secs: peer.session_established_at().elapsed().as_secs(),
|
||||
counter: peer
|
||||
.noise_session()
|
||||
.map(|s| s.current_send_counter())
|
||||
.unwrap_or(0),
|
||||
jitter_secs: peer.rekey_jitter_secs(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Initiate an outbound rekey to a peer.
|
||||
@@ -260,60 +364,74 @@ impl Node {
|
||||
let backoff = self.config().node.rate_limit.handshake_resend_backoff;
|
||||
let max_resends = self.config().node.rate_limit.handshake_max_resends;
|
||||
|
||||
// Collect peers needing action
|
||||
let mut to_resend: Vec<(NodeAddr, Vec<u8>)> = Vec::new();
|
||||
let mut to_abandon: Vec<NodeAddr> = Vec::new();
|
||||
// The shell snapshots each in-flight rekey (resend-due predicate
|
||||
// resolved here); the core classifies abandon-vs-resend and computes
|
||||
// the backoff, abandons first.
|
||||
let candidates = self.rekey_resend_candidates(now_ms);
|
||||
for action in
|
||||
self.fmp
|
||||
.poll_rekey_resends(candidates, now_ms, interval_ms, backoff, max_resends)
|
||||
{
|
||||
match action {
|
||||
// Abandon rekey cycles that exhausted their retransmission budget.
|
||||
ConnAction::AbandonRekey { peer: node_addr } => {
|
||||
if let Some(peer) = self.peers.get_mut(&node_addr) {
|
||||
peer.abandon_rekey();
|
||||
}
|
||||
debug!(
|
||||
peer = %self.peer_display_name(&node_addr),
|
||||
"FMP rekey aborted: msg1 unconfirmed after max retransmissions, abandoning cycle"
|
||||
);
|
||||
}
|
||||
ConnAction::ResendRekeyMsg1 {
|
||||
peer: node_addr,
|
||||
bytes,
|
||||
next_resend_at_ms,
|
||||
} => {
|
||||
let (transport_id, remote_addr) = match self.peers.get(&node_addr) {
|
||||
Some(p) => match (p.transport_id(), p.current_addr()) {
|
||||
(Some(tid), Some(addr)) => (tid, addr.clone()),
|
||||
_ => continue,
|
||||
},
|
||||
None => continue,
|
||||
};
|
||||
|
||||
for (node_addr, peer) in &self.peers {
|
||||
if !peer.rekey_in_progress() || peer.rekey_msg1().is_none() {
|
||||
continue;
|
||||
}
|
||||
if peer.rekey_msg1_resend_count() >= max_resends {
|
||||
to_abandon.push(*node_addr);
|
||||
continue;
|
||||
}
|
||||
if peer.needs_msg1_resend(now_ms) {
|
||||
to_resend.push((*node_addr, peer.rekey_msg1().unwrap().to_vec()));
|
||||
let sent = if let Some(transport) = self.transports.get(&transport_id) {
|
||||
transport.send(&remote_addr, &bytes).await.is_ok()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if sent && let Some(peer) = self.peers.get_mut(&node_addr) {
|
||||
peer.record_rekey_msg1_resend(next_resend_at_ms);
|
||||
let count = peer.rekey_msg1_resend_count();
|
||||
trace!(
|
||||
peer = %self.peer_display_name(&node_addr),
|
||||
resend = count,
|
||||
"Resent rekey msg1"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[allow(unreachable_patterns)]
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Abandon rekey cycles that exhausted their retransmission budget.
|
||||
for node_addr in to_abandon {
|
||||
if let Some(peer) = self.peers.get_mut(&node_addr) {
|
||||
peer.abandon_rekey();
|
||||
}
|
||||
debug!(
|
||||
peer = %self.peer_display_name(&node_addr),
|
||||
"FMP rekey aborted: msg1 unconfirmed after max retransmissions, abandoning cycle"
|
||||
);
|
||||
}
|
||||
|
||||
for (node_addr, msg1_bytes) in to_resend {
|
||||
let (transport_id, remote_addr) = match self.peers.get(&node_addr) {
|
||||
Some(p) => match (p.transport_id(), p.current_addr()) {
|
||||
(Some(tid), Some(addr)) => (tid, addr.clone()),
|
||||
_ => continue,
|
||||
},
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let sent = if let Some(transport) = self.transports.get(&transport_id) {
|
||||
transport.send(&remote_addr, &msg1_bytes).await.is_ok()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if sent && let Some(peer) = self.peers.get_mut(&node_addr) {
|
||||
let count = peer.rekey_msg1_resend_count() + 1;
|
||||
let next = now_ms + (interval_ms as f64 * backoff.powi(count as i32)) as u64;
|
||||
peer.record_rekey_msg1_resend(next);
|
||||
trace!(
|
||||
peer = %self.peer_display_name(&node_addr),
|
||||
resend = count,
|
||||
"Resent rekey msg1"
|
||||
);
|
||||
}
|
||||
}
|
||||
/// Snapshot every peer with a rekey handshake in flight (and a stored
|
||||
/// msg1) for the retransmission decision, pre-evaluating the resend-due
|
||||
/// predicate against `now_ms` so the core reads no clock.
|
||||
pub(in crate::node) fn rekey_resend_snapshots(&self, now_ms: u64) -> Vec<RekeyResendSnapshot> {
|
||||
self.peers
|
||||
.iter()
|
||||
.filter(|(_, peer)| peer.rekey_in_progress() && peer.rekey_msg1().is_some())
|
||||
.map(|(node_addr, peer)| RekeyResendSnapshot {
|
||||
peer: *node_addr,
|
||||
resend_count: peer.rekey_msg1_resend_count(),
|
||||
needs_resend: peer.needs_msg1_resend(now_ms),
|
||||
msg1: peer.rekey_msg1().unwrap().to_vec(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Retransmit FSP rekey msg3 until the responder is confirmed on the
|
||||
@@ -352,66 +470,77 @@ impl Node {
|
||||
let ttl = self.config().node.session.default_ttl;
|
||||
let my_addr = *self.node_addr();
|
||||
|
||||
// Collect rekey initiators whose msg3 retransmission is due.
|
||||
let mut to_resend: Vec<(NodeAddr, Vec<u8>)> = Vec::new();
|
||||
let mut to_abandon: Vec<NodeAddr> = Vec::new();
|
||||
|
||||
for (node_addr, entry) in &self.sessions {
|
||||
// Only the rekey initiator retains a msg3 payload.
|
||||
let payload = match entry.rekey_msg3_payload() {
|
||||
Some(p) => p,
|
||||
None => continue,
|
||||
};
|
||||
if entry.rekey_msg3_next_resend_ms() == 0 || now_ms < entry.rekey_msg3_next_resend_ms()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if entry.rekey_msg3_resend_count() >= max_resends {
|
||||
to_abandon.push(*node_addr);
|
||||
continue;
|
||||
}
|
||||
to_resend.push((*node_addr, payload.to_vec()));
|
||||
}
|
||||
|
||||
// Abandon rekey cycles that exhausted their retransmission budget.
|
||||
for node_addr in to_abandon {
|
||||
if let Some(entry) = self.sessions.get_mut(&node_addr) {
|
||||
entry.abandon_rekey();
|
||||
}
|
||||
debug!(
|
||||
peer = %self.peer_display_name(&node_addr),
|
||||
"FSP rekey aborted: msg3 unconfirmed after max retransmissions, abandoning cycle"
|
||||
);
|
||||
}
|
||||
|
||||
// Retransmit msg3 for cycles still within budget.
|
||||
for (node_addr, payload) in to_resend {
|
||||
let mut datagram = SessionDatagram::new(my_addr, node_addr, payload).with_ttl(ttl);
|
||||
let sent = match self.send_session_datagram(&mut datagram).await {
|
||||
Ok(_) => true,
|
||||
Err(e) => {
|
||||
// The shell snapshots each session retaining a msg3 payload (resend-due
|
||||
// predicate resolved here); the core classifies abandon-vs-resend,
|
||||
// abandons first.
|
||||
let candidates = self.rekey_msg3_resend_snapshots(now_ms);
|
||||
for action in self.fsp.poll_rekey_msg3_resends(candidates, max_resends) {
|
||||
match action {
|
||||
FspAction::AbandonRekey { addr } => {
|
||||
if let Some(entry) = self.sessions.get_mut(&addr) {
|
||||
entry.abandon_rekey();
|
||||
}
|
||||
debug!(
|
||||
peer = %self.peer_display_name(&node_addr),
|
||||
error = %e,
|
||||
"FSP rekey msg3 retransmission failed"
|
||||
peer = %self.peer_display_name(&addr),
|
||||
"FSP rekey aborted: msg3 unconfirmed after max retransmissions, abandoning cycle"
|
||||
);
|
||||
false
|
||||
}
|
||||
};
|
||||
FspAction::ResendSessionMsg3 { addr } => {
|
||||
let payload = match self
|
||||
.sessions
|
||||
.get(&addr)
|
||||
.and_then(|e| e.rekey_msg3_payload())
|
||||
{
|
||||
Some(p) => p.to_vec(),
|
||||
None => continue,
|
||||
};
|
||||
let mut datagram = SessionDatagram::new(my_addr, addr, payload).with_ttl(ttl);
|
||||
let sent = match self.send_session_datagram(&mut datagram).await {
|
||||
Ok(_) => true,
|
||||
Err(e) => {
|
||||
debug!(
|
||||
peer = %self.peer_display_name(&addr),
|
||||
error = %e,
|
||||
"FSP rekey msg3 retransmission failed"
|
||||
);
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if sent && let Some(entry) = self.sessions.get_mut(&node_addr) {
|
||||
let count = entry.rekey_msg3_resend_count() + 1;
|
||||
let next = now_ms + (interval_ms as f64 * backoff.powi(count as i32)) as u64;
|
||||
entry.record_rekey_msg3_resend(next);
|
||||
trace!(
|
||||
peer = %self.peer_display_name(&node_addr),
|
||||
resend = count,
|
||||
"Resent FSP rekey msg3"
|
||||
);
|
||||
if sent && let Some(entry) = self.sessions.get_mut(&addr) {
|
||||
let count = entry.rekey_msg3_resend_count() + 1;
|
||||
let next =
|
||||
now_ms + (interval_ms as f64 * backoff.powi(count as i32)) as u64;
|
||||
entry.record_rekey_msg3_resend(next);
|
||||
trace!(
|
||||
peer = %self.peer_display_name(&addr),
|
||||
resend = count,
|
||||
"Resent FSP rekey msg3"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[allow(unreachable_patterns)]
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot every session retaining a rekey-msg3 payload for the
|
||||
/// retransmission decision, pre-evaluating the resend-due predicate against
|
||||
/// `now_ms` so the core reads no clock.
|
||||
fn rekey_msg3_resend_snapshots(&self, now_ms: u64) -> Vec<RekeyMsg3ResendSnapshot> {
|
||||
self.sessions
|
||||
.iter()
|
||||
.filter(|(_, entry)| entry.rekey_msg3_payload().is_some())
|
||||
.map(|(node_addr, entry)| RekeyMsg3ResendSnapshot {
|
||||
addr: *node_addr,
|
||||
resend_count: entry.rekey_msg3_resend_count(),
|
||||
resend_due: entry.rekey_msg3_next_resend_ms() != 0
|
||||
&& now_ms >= entry.rekey_msg3_next_resend_ms(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Periodic session (FSP) rekey check. Called from the tick loop.
|
||||
///
|
||||
/// For each established session:
|
||||
@@ -429,100 +558,71 @@ impl Node {
|
||||
return;
|
||||
}
|
||||
|
||||
let rekey_after_secs = self.config().node.rekey.after_secs;
|
||||
let rekey_after_messages = self.config().node.rekey.after_messages;
|
||||
let cfg = crate::proto::fsp::RekeyCfg {
|
||||
after_secs: self.config().node.rekey.after_secs,
|
||||
after_messages: self.config().node.rekey.after_messages,
|
||||
};
|
||||
let now_ms = Self::now_ms();
|
||||
let drain_ms = DRAIN_WINDOW_SECS * 1000;
|
||||
let dampening_ms = REKEY_DAMPENING_SECS * 1000;
|
||||
|
||||
let mut sessions_to_cutover: Vec<NodeAddr> = Vec::new();
|
||||
let mut sessions_to_drain: Vec<NodeAddr> = Vec::new();
|
||||
let mut sessions_to_rekey: Vec<NodeAddr> = Vec::new();
|
||||
|
||||
for (node_addr, entry) in &self.sessions {
|
||||
if !entry.is_established() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 1. Initiator-side cutover (option A): completed rekey,
|
||||
// pending session ready, liveness timer elapsed. This is
|
||||
// an unconditional timer, NOT gated on responder progress —
|
||||
// overlapping-epoch trial-decrypt covers the cutover skew,
|
||||
// so flipping the K-bit here is always safe. An
|
||||
// opportunistic early cutover also happens in
|
||||
// `handle_encrypted_session_msg` if the initiator
|
||||
// authenticates a peer frame against its own `pending`.
|
||||
if entry.pending_new_session().is_some()
|
||||
&& !entry.has_rekey_in_progress()
|
||||
&& entry.is_rekey_initiator()
|
||||
&& now_ms.saturating_sub(entry.rekey_completed_ms()) >= FSP_CUTOVER_DELAY_MS
|
||||
{
|
||||
sessions_to_cutover.push(*node_addr);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. Drain window expiry
|
||||
if entry.is_draining() && entry.drain_expired(now_ms, drain_ms) {
|
||||
sessions_to_drain.push(*node_addr);
|
||||
}
|
||||
|
||||
// 3. Rekey trigger
|
||||
if entry.has_rekey_in_progress() {
|
||||
continue;
|
||||
}
|
||||
if entry.pending_new_session().is_some() {
|
||||
continue; // Pending session present, awaiting cutover
|
||||
}
|
||||
if entry.rekey_msg3_payload().is_some() {
|
||||
// Initiator already cut over on its liveness timer but is
|
||||
// still retransmitting msg3 to a responder not yet
|
||||
// confirmed on the new epoch. Don't start another rekey
|
||||
// until the current cycle's msg3 is delivered or abandoned.
|
||||
continue;
|
||||
}
|
||||
if entry.is_rekey_dampened(now_ms, dampening_ms) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let elapsed_secs = now_ms.saturating_sub(entry.session_start_ms()) / 1000;
|
||||
let counter = entry.send_counter();
|
||||
|
||||
// Apply per-session symmetric jitter to desynchronize
|
||||
// dual-initiation in symmetric-start meshes.
|
||||
let effective_after_secs =
|
||||
rekey_after_secs.saturating_add_signed(entry.rekey_jitter_secs());
|
||||
if elapsed_secs >= effective_after_secs || counter >= rekey_after_messages {
|
||||
sessions_to_rekey.push(*node_addr);
|
||||
// The shell snapshots each established session's rekey ages/flags
|
||||
// (every clock read resolved here); the core decides
|
||||
// cutover/drain/trigger with no clock, phase-grouped to preserve the
|
||||
// pre-refactor execution order.
|
||||
let snapshots = self.session_rekey_snapshots(now_ms);
|
||||
for action in self.fsp.poll_rekey(snapshots, &cfg) {
|
||||
match action {
|
||||
FspAction::CutOver { addr } => {
|
||||
if let Some(entry) = self.sessions.get_mut(&addr)
|
||||
&& entry.cutover_to_new_session(now_ms)
|
||||
{
|
||||
debug!(
|
||||
peer = %self.peer_display_name(&addr),
|
||||
"FSP rekey cutover complete (initiator), K-bit flipped"
|
||||
);
|
||||
}
|
||||
}
|
||||
FspAction::CompleteDrain { addr } => {
|
||||
if let Some(entry) = self.sessions.get_mut(&addr) {
|
||||
entry.complete_drain();
|
||||
trace!(
|
||||
peer = %self.peer_display_name(&addr),
|
||||
"FSP drain complete, previous session erased"
|
||||
);
|
||||
}
|
||||
}
|
||||
FspAction::InitiateRekey { addr } => {
|
||||
self.initiate_session_rekey(&addr).await;
|
||||
}
|
||||
#[allow(unreachable_patterns)]
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execute cutover for initiator side
|
||||
for node_addr in sessions_to_cutover {
|
||||
if let Some(entry) = self.sessions.get_mut(&node_addr)
|
||||
&& entry.cutover_to_new_session(now_ms)
|
||||
{
|
||||
debug!(
|
||||
peer = %self.peer_display_name(&node_addr),
|
||||
"FSP rekey cutover complete (initiator), K-bit flipped"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Execute drain completion
|
||||
for node_addr in sessions_to_drain {
|
||||
if let Some(entry) = self.sessions.get_mut(&node_addr) {
|
||||
entry.complete_drain();
|
||||
trace!(
|
||||
peer = %self.peer_display_name(&node_addr),
|
||||
"FSP drain complete, previous session erased"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Initiate new rekeys
|
||||
for node_addr in sessions_to_rekey {
|
||||
self.initiate_session_rekey(&node_addr).await;
|
||||
}
|
||||
/// Snapshot every established session for the FSP rekey decision,
|
||||
/// pre-computing its monotonic age and timer predicates so the pure core
|
||||
/// applies the thresholds without reading a clock (see [`SessionSnapshot`]).
|
||||
fn session_rekey_snapshots(&self, now_ms: u64) -> Vec<SessionSnapshot> {
|
||||
let drain_ms = crate::proto::fsp::limits::DRAIN_WINDOW_SECS * 1000;
|
||||
let dampening_ms = crate::proto::fsp::limits::REKEY_DAMPENING_SECS * 1000;
|
||||
self.sessions
|
||||
.iter()
|
||||
.filter(|(_, entry)| entry.is_established())
|
||||
.map(|(node_addr, entry)| SessionSnapshot {
|
||||
addr: *node_addr,
|
||||
has_pending: entry.pending_new_session().is_some(),
|
||||
rekey_in_progress: entry.has_rekey_in_progress(),
|
||||
is_rekey_initiator: entry.is_rekey_initiator(),
|
||||
cutover_timer_elapsed: cutover_timer_elapsed(now_ms, entry.rekey_completed_ms()),
|
||||
is_draining: entry.is_draining(),
|
||||
drain_expired: entry.drain_expired(now_ms, drain_ms),
|
||||
has_rekey_msg3_payload: entry.rekey_msg3_payload().is_some(),
|
||||
is_dampened: entry.is_rekey_dampened(now_ms, dampening_ms),
|
||||
elapsed_secs: now_ms.saturating_sub(entry.session_start_ms()) / 1000,
|
||||
counter: entry.send_counter(),
|
||||
jitter_secs: entry.rekey_jitter_secs(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Initiate an FSP session rekey.
|
||||
|
||||
@@ -6,34 +6,39 @@
|
||||
//! encrypted data, and error signals (CoordsRequired, PathBroken).
|
||||
|
||||
use crate::NodeAddr;
|
||||
use crate::mmp::report::ReceiverReport;
|
||||
use crate::mmp::{MAX_SESSION_REPORT_INTERVAL_MS, MIN_SESSION_REPORT_INTERVAL_MS};
|
||||
use crate::node::handlers::mmp::format_throughput;
|
||||
use crate::node::reject::{RejectReason, SessionReject};
|
||||
use crate::node::session::{EndToEndState, EpochSlot, SessionEntry};
|
||||
use crate::node::session_wire::{
|
||||
FSP_COMMON_PREFIX_SIZE, FSP_FLAG_CP, FSP_FLAG_K, FSP_HEADER_SIZE, FSP_PHASE_ESTABLISHED,
|
||||
FSP_PHASE_MSG1, FSP_PHASE_MSG2, FSP_PHASE_MSG3, FSP_PORT_HEADER_SIZE, FSP_PORT_IPV6_SHIM,
|
||||
FspCommonPrefix, FspEncryptedHeader, build_fsp_header, fsp_prepend_inner_header,
|
||||
fsp_strip_inner_header, parse_encrypted_coords,
|
||||
};
|
||||
#[cfg(unix)]
|
||||
use crate::node::wire::{
|
||||
ESTABLISHED_HEADER_SIZE, FLAG_KEY_EPOCH, FLAG_SP, build_established_header,
|
||||
};
|
||||
use crate::node::{Node, NodeError};
|
||||
use crate::noise::{
|
||||
HandshakeState, XK_HANDSHAKE_MSG1_SIZE, XK_HANDSHAKE_MSG2_SIZE, XK_HANDSHAKE_MSG3_SIZE,
|
||||
};
|
||||
#[cfg(unix)]
|
||||
use crate::protocol::LinkMessageType;
|
||||
#[cfg(unix)]
|
||||
use crate::protocol::SESSION_DATAGRAM_HEADER_SIZE;
|
||||
use crate::protocol::{
|
||||
CoordsRequired, FspInnerFlags, MtuExceeded, PathBroken, PathMtuNotification, SessionAck,
|
||||
SessionDatagram, SessionMessageType, SessionMsg3, SessionReceiverReport, SessionSenderReport,
|
||||
SessionSetup,
|
||||
use crate::proto::fmp::wire::{
|
||||
ESTABLISHED_HEADER_SIZE, FLAG_KEY_EPOCH, FLAG_SP, build_established_header,
|
||||
};
|
||||
use crate::protocol::{coords_wire_size, encode_coords};
|
||||
use crate::proto::fsp::wire::{
|
||||
FSP_COMMON_PREFIX_SIZE, FSP_FLAG_CP, FSP_FLAG_K, FSP_HEADER_SIZE, FSP_PHASE_ESTABLISHED,
|
||||
FSP_PHASE_MSG1, FSP_PHASE_MSG2, FSP_PHASE_MSG3, FSP_PORT_HEADER_SIZE, FSP_PORT_IPV6_SHIM,
|
||||
FspCommonPrefix, FspEncryptedHeader, build_fsp_header, fsp_prepend_inner_header,
|
||||
fsp_strip_inner_header, parse_encrypted_coords,
|
||||
};
|
||||
use crate::proto::fsp::{
|
||||
DecryptSlot, EpochReaction, FspAction, FspInnerFlags, SessionAck, SessionMessageType,
|
||||
SessionMsg3, SessionSetup, mark_ipv6_ecn_ce,
|
||||
};
|
||||
#[cfg(unix)]
|
||||
use crate::proto::link::LinkMessageType;
|
||||
#[cfg(unix)]
|
||||
use crate::proto::link::SESSION_DATAGRAM_HEADER_SIZE;
|
||||
use crate::proto::link::SessionDatagram;
|
||||
use crate::proto::mmp::{
|
||||
BackoffUpdate, MmpAction, MmpSessionState, PathMtuNotification, ReceiverReport, SendResult,
|
||||
SessionReceiverReport, SessionReportKind, SessionReportSnapshot, SessionSenderReport,
|
||||
};
|
||||
use crate::proto::mmp::{MAX_SESSION_REPORT_INTERVAL_MS, MIN_SESSION_REPORT_INTERVAL_MS};
|
||||
use crate::proto::routing::{CoordsRequired, MtuExceeded, PathBroken, RoutingSignalType};
|
||||
use crate::proto::stp::{coords_wire_size, encode_coords};
|
||||
#[cfg(unix)]
|
||||
use crate::transport::TransportHandle;
|
||||
use crate::upper::icmp::FIPS_OVERHEAD;
|
||||
@@ -51,8 +56,8 @@ struct PipelinedSend<'a> {
|
||||
timestamp: u32,
|
||||
fsp_flags: u8,
|
||||
inner_plaintext: &'a [u8],
|
||||
my_coords: Option<&'a crate::tree::TreeCoordinate>,
|
||||
dest_coords: Option<&'a crate::tree::TreeCoordinate>,
|
||||
my_coords: Option<&'a crate::proto::stp::TreeCoordinate>,
|
||||
dest_coords: Option<&'a crate::proto::stp::TreeCoordinate>,
|
||||
}
|
||||
|
||||
impl Node {
|
||||
@@ -104,14 +109,14 @@ impl Node {
|
||||
}
|
||||
let error_type = inner[0];
|
||||
let error_body = &inner[1..];
|
||||
match SessionMessageType::from_byte(error_type) {
|
||||
Some(SessionMessageType::CoordsRequired) => {
|
||||
match RoutingSignalType::from_byte(error_type) {
|
||||
Some(RoutingSignalType::CoordsRequired) => {
|
||||
self.handle_coords_required(error_body).await;
|
||||
}
|
||||
Some(SessionMessageType::PathBroken) => {
|
||||
Some(RoutingSignalType::PathBroken) => {
|
||||
self.handle_path_broken(error_body).await;
|
||||
}
|
||||
Some(SessionMessageType::MtuExceeded) => {
|
||||
Some(RoutingSignalType::MtuExceeded) => {
|
||||
self.handle_mtu_exceeded(error_body).await;
|
||||
}
|
||||
_ => {
|
||||
@@ -166,11 +171,14 @@ impl Node {
|
||||
match parse_encrypted_coords(coord_data) {
|
||||
Ok((src_coords, dest_coords, bytes_consumed)) => {
|
||||
let now_ms = Self::now_ms();
|
||||
if let Some(coords) = src_coords {
|
||||
self.coord_cache.insert(*src_addr, coords, now_ms);
|
||||
}
|
||||
if let Some(coords) = dest_coords {
|
||||
self.coord_cache.insert(*self.node_addr(), coords, now_ms);
|
||||
let my_addr = *self.node_addr();
|
||||
for action in
|
||||
self.fsp
|
||||
.plan_cache_coords(*src_addr, my_addr, src_coords, dest_coords)
|
||||
{
|
||||
if let FspAction::CacheCoords { addr, coords } = action {
|
||||
self.coord_cache.insert(addr, coords, now_ms);
|
||||
}
|
||||
}
|
||||
ciphertext_offset += bytes_consumed;
|
||||
}
|
||||
@@ -248,44 +256,55 @@ impl Node {
|
||||
}
|
||||
};
|
||||
|
||||
// React to the epoch the frame decrypted against.
|
||||
match slot {
|
||||
EpochSlot::Pending => {
|
||||
// A frame that authenticates against `pending` is itself
|
||||
// the cutover signal — proof the peer derived the new
|
||||
// session and moved to it. Promote now: current →
|
||||
// previous, pending → current, flip the K-bit. The
|
||||
// header K-bit is no longer the gating event; the
|
||||
// authenticated decrypt is.
|
||||
// React to the epoch the frame decrypted against. The shell opened
|
||||
// the frame; the core classifies the post-decrypt reaction over the
|
||||
// plain-data slot + session flags, and the shell applies the
|
||||
// `SessionEntry` mutation.
|
||||
let decrypt_slot = match slot {
|
||||
EpochSlot::Current => DecryptSlot::Current,
|
||||
EpochSlot::Pending => DecryptSlot::Pending,
|
||||
EpochSlot::Previous => DecryptSlot::Previous,
|
||||
};
|
||||
match self.fsp.classify_epoch(
|
||||
decrypt_slot,
|
||||
entry.rekey_msg3_payload().is_some(),
|
||||
entry.pending_new_session().is_some(),
|
||||
) {
|
||||
EpochReaction::PromoteConfirming => {
|
||||
// A frame that authenticates against `pending` is itself the
|
||||
// cutover signal — proof the peer derived the new session and
|
||||
// moved to it. The peer received msg3, so confirm it on the new
|
||||
// epoch (stop retransmitting) before `handle_peer_kbit_flip`
|
||||
// consumes the pending session, then promote.
|
||||
info!(
|
||||
peer = %self.peer_display_name(src_addr),
|
||||
"Peer FSP new-epoch frame authenticated, FSP rekey cutover complete, promoting new session"
|
||||
);
|
||||
// The peer derived the new session, so it received msg3:
|
||||
// confirm it on the new epoch and stop retransmitting.
|
||||
// `handle_peer_kbit_flip` consumes the pending session,
|
||||
// so confirm first.
|
||||
if entry.rekey_msg3_payload().is_some() {
|
||||
entry.confirm_peer_new_epoch();
|
||||
}
|
||||
entry.confirm_peer_new_epoch();
|
||||
entry.handle_peer_kbit_flip(now_ms);
|
||||
}
|
||||
EpochSlot::Current => {
|
||||
// If we still retain a msg3 retransmission payload but no
|
||||
// longer hold a `pending` session, we are the rekey
|
||||
// initiator that already cut over on its own timer:
|
||||
// `current` is now the new epoch, so a frame decrypting
|
||||
// against it confirms the responder reached the new
|
||||
// epoch. Stop retransmitting msg3.
|
||||
if entry.rekey_msg3_payload().is_some() && entry.pending_new_session().is_none() {
|
||||
entry.confirm_peer_new_epoch();
|
||||
}
|
||||
EpochReaction::Promote => {
|
||||
// Promote now: current → previous, pending → current, flip the
|
||||
// K-bit. The header K-bit is only a hint; the authenticated
|
||||
// decrypt is the gating event.
|
||||
info!(
|
||||
peer = %self.peer_display_name(src_addr),
|
||||
"Peer FSP new-epoch frame authenticated, FSP rekey cutover complete, promoting new session"
|
||||
);
|
||||
entry.handle_peer_kbit_flip(now_ms);
|
||||
}
|
||||
EpochSlot::Previous => {
|
||||
// The peer is still on the old epoch. `fsp_trial_decrypt`
|
||||
// already refreshed the drain deadline so the `previous`
|
||||
// slot is not retired while the peer keeps using it —
|
||||
// no further state change here, just deliver.
|
||||
EpochReaction::ConfirmResponder => {
|
||||
// We are the rekey initiator that already cut over on its own
|
||||
// timer: `current` is now the new epoch, so a frame decrypting
|
||||
// against it confirms the responder reached it. Stop
|
||||
// retransmitting msg3.
|
||||
entry.confirm_peer_new_epoch();
|
||||
}
|
||||
EpochReaction::None => {
|
||||
// Steady-state `current`, or an old-epoch `previous` straggler:
|
||||
// `fsp_trial_decrypt` already refreshed the drain deadline so
|
||||
// the `previous` slot is not retired while the peer keeps using
|
||||
// it — no further state change, just deliver.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,16 +324,16 @@ impl Node {
|
||||
if let Some(entry) = self.sessions.get_mut(src_addr)
|
||||
&& let Some(mmp) = entry.mmp_mut()
|
||||
{
|
||||
let now = std::time::Instant::now();
|
||||
let now_ms = crate::time::mono_ms();
|
||||
mmp.receiver
|
||||
.record_recv(header.counter, timestamp, plaintext.len(), ce_flag, now);
|
||||
.record_recv(header.counter, timestamp, plaintext.len(), ce_flag, now_ms);
|
||||
// Spin bit: advance state machine for correct TX reflection.
|
||||
// RTT samples not fed into SRTT — timestamp-echo provides
|
||||
// accurate RTT; spin bit includes variable inter-frame delays.
|
||||
let inner_flags = FspInnerFlags::from_byte(inner_flags_byte);
|
||||
let _spin_rtt = mmp
|
||||
.spin_bit
|
||||
.rx_observe(inner_flags.spin_bit, header.counter, now);
|
||||
.rx_observe(inner_flags.spin_bit, header.counter, now_ms);
|
||||
}
|
||||
|
||||
// Feed path_mtu from datagram envelope to MMP path MTU tracking.
|
||||
@@ -355,7 +374,7 @@ impl Node {
|
||||
mark_ipv6_ecn_ce(&mut packet);
|
||||
self.metrics().congestion.ce_received.inc();
|
||||
}
|
||||
if let Some(tun_tx) = &self.tun_tx {
|
||||
if let Some(tun_tx) = &self.supervisor.tun_tx {
|
||||
if let Err(e) = tun_tx.send(packet) {
|
||||
debug!(error = %e, "Failed to deliver decompressed IPv6 packet to TUN");
|
||||
}
|
||||
@@ -444,7 +463,7 @@ impl Node {
|
||||
if let Some(existing) = self.sessions.get(src_addr) {
|
||||
if existing.is_initiating() {
|
||||
// Simultaneous initiation: smaller NodeAddr wins as initiator
|
||||
if self.identity().node_addr() < src_addr {
|
||||
if crate::proto::fsp::initiation_winner(self.identity().node_addr(), src_addr) {
|
||||
// We win — drop their setup, they'll process ours
|
||||
debug!(
|
||||
src = %self.peer_display_name(src_addr),
|
||||
@@ -482,7 +501,10 @@ impl Node {
|
||||
// simultaneously. Apply tie-breaker — smaller NodeAddr
|
||||
// wins as initiator (same as initial session setup).
|
||||
if rekey_in_progress {
|
||||
if self.identity().node_addr() < src_addr {
|
||||
if crate::proto::fsp::initiation_winner(
|
||||
self.identity().node_addr(),
|
||||
src_addr,
|
||||
) {
|
||||
// We win as initiator — drop their msg1.
|
||||
debug!(
|
||||
src = %self.peer_display_name(src_addr),
|
||||
@@ -919,6 +941,221 @@ impl Node {
|
||||
|
||||
// === Session-layer MMP report handlers ===
|
||||
|
||||
/// Check all sessions for pending MMP reports and send them.
|
||||
///
|
||||
/// Called from the tick handler. Also emits periodic session MMP logs.
|
||||
/// Uses the collect-then-send pattern to avoid borrowing conflicts.
|
||||
pub(in crate::node) async fn check_session_mmp_reports(&mut self) {
|
||||
let now_ms = crate::time::mono_ms();
|
||||
|
||||
// Build one report-gating snapshot per session, resolving every timing
|
||||
// read shell-side into a `bool`. The snapshots own only
|
||||
// `NodeAddr`/`MmpMode`/`bool`, so the session-iteration borrow is released
|
||||
// before the pure decision runs and the driving loop mutates the
|
||||
// reporting state / performs the sends.
|
||||
let snapshots: Vec<SessionReportSnapshot> = self
|
||||
.sessions
|
||||
.iter()
|
||||
.filter_map(|(dest_addr, entry)| {
|
||||
let mmp = entry.mmp()?;
|
||||
Some(SessionReportSnapshot {
|
||||
dest: *dest_addr,
|
||||
mode: mmp.mode(),
|
||||
sr_due: mmp.sender.should_send_report(now_ms),
|
||||
rr_due: mmp.receiver.should_send_report(now_ms),
|
||||
mtu_due: mmp.path_mtu.should_send_notification(now_ms),
|
||||
log_due: mmp.should_log(now_ms),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let actions = self.mmp.plan_session_reports(&snapshots);
|
||||
|
||||
// Drive the planned actions in phase-grouped order (all logs, then the
|
||||
// sends in per-session SR/RR/MTU order). Logs run first because the
|
||||
// session operator log reads cumulative_packets_sent, which each send
|
||||
// advances (send_session_msg -> sender.record_sent); the pre-refactor
|
||||
// handler logged during its collect pass, before any send. Each build
|
||||
// (`build_report`/`build_notification`, which advance interval/
|
||||
// notification state) runs only on its SendSessionReport action, exactly
|
||||
// as the pre-refactor collect pass did. Per-destination success/failure
|
||||
// is collected for the backoff dedup + failure-log suppression.
|
||||
let mut send_results: Vec<SendResult> = Vec::new();
|
||||
for action in actions {
|
||||
match action {
|
||||
MmpAction::LogSession { dest } => {
|
||||
// Resolve the display name exactly as the pre-refactor loop
|
||||
// did (alias, else short_npub from the session's remote key).
|
||||
let session_name = self.peer_aliases.get(&dest).cloned().unwrap_or_else(|| {
|
||||
self.sessions
|
||||
.get(&dest)
|
||||
.map(|entry| {
|
||||
let (xonly, _) = entry.remote_pubkey().x_only_public_key();
|
||||
crate::PeerIdentity::from_pubkey(xonly).short_npub()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
});
|
||||
if let Some(mmp) = self.sessions.get_mut(&dest).and_then(|e| e.mmp_mut()) {
|
||||
Self::log_session_mmp_metrics(&session_name, mmp);
|
||||
mmp.mark_logged(now_ms);
|
||||
}
|
||||
}
|
||||
MmpAction::SendSessionReport { dest, kind } => {
|
||||
let built = self
|
||||
.sessions
|
||||
.get_mut(&dest)
|
||||
.and_then(|entry| entry.mmp_mut())
|
||||
.and_then(|mmp| match kind {
|
||||
SessionReportKind::Sender => {
|
||||
mmp.sender.build_report(now_ms).map(|sr| {
|
||||
(
|
||||
SessionMessageType::SenderReport.to_byte(),
|
||||
SessionSenderReport::from(&sr).encode(),
|
||||
)
|
||||
})
|
||||
}
|
||||
SessionReportKind::Receiver => {
|
||||
mmp.receiver.build_report(now_ms).map(|rr| {
|
||||
(
|
||||
SessionMessageType::ReceiverReport.to_byte(),
|
||||
SessionReceiverReport::from(&rr).encode(),
|
||||
)
|
||||
})
|
||||
}
|
||||
SessionReportKind::PathMtu => {
|
||||
mmp.path_mtu.build_notification(now_ms).map(|mtu_value| {
|
||||
(
|
||||
SessionMessageType::PathMtuNotification.to_byte(),
|
||||
PathMtuNotification::new(mtu_value).encode(),
|
||||
)
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
let Some((msg_type, body)) = built else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match self.send_session_msg(&dest, msg_type, &body).await {
|
||||
Ok(()) => send_results.push(SendResult { dest, ok: true }),
|
||||
Err(e) => {
|
||||
// Peek at current failure count for log suppression
|
||||
// (unchanged by the backoff apply, which runs later).
|
||||
let failures = self
|
||||
.sessions
|
||||
.get(&dest)
|
||||
.and_then(|entry| entry.mmp())
|
||||
.map(|mmp| mmp.sender.consecutive_send_failures())
|
||||
.unwrap_or(0);
|
||||
|
||||
if failures < 3 {
|
||||
debug!(
|
||||
dest = %self.peer_display_name(&dest),
|
||||
msg_type,
|
||||
error = %e,
|
||||
"Failed to send session MMP report"
|
||||
);
|
||||
} else if failures == 3 {
|
||||
debug!(
|
||||
dest = %self.peer_display_name(&dest),
|
||||
"Suppressing further session MMP send failure logs"
|
||||
);
|
||||
}
|
||||
// failures > 3: silently suppressed
|
||||
|
||||
send_results.push(SendResult { dest, ok: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
MmpAction::ReapPeer { .. }
|
||||
| MmpAction::Heartbeat { .. }
|
||||
| MmpAction::SendLinkReport { .. }
|
||||
| MmpAction::LogLink { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate send results per destination (any-ok -> success, all-fail
|
||||
// -> failure) and apply the backoff state transition for each dest.
|
||||
for update in self.mmp.plan_backoff(&send_results) {
|
||||
match update {
|
||||
BackoffUpdate::Success { dest } => {
|
||||
if let Some(mmp) = self.sessions.get_mut(&dest).and_then(|e| e.mmp_mut()) {
|
||||
let prev = mmp.sender.record_send_success();
|
||||
if prev > 3 {
|
||||
debug!(
|
||||
dest = %self.peer_display_name(&dest),
|
||||
consecutive_failures = prev,
|
||||
"Resumed session MMP reporting"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
BackoffUpdate::Failure { dest } => {
|
||||
if let Some(mmp) = self.sessions.get_mut(&dest).and_then(|e| e.mmp_mut()) {
|
||||
mmp.sender.record_send_failure();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit periodic session MMP metrics.
|
||||
fn log_session_mmp_metrics(session_name: &str, mmp: &MmpSessionState) {
|
||||
let m = &mmp.metrics;
|
||||
|
||||
let rtt_str = if m.rtt_trend.initialized() {
|
||||
format!("{:.1}ms", m.rtt_trend.long() / 1000.0)
|
||||
} else {
|
||||
"n/a".to_string()
|
||||
};
|
||||
let loss_str = if m.loss_trend.initialized() {
|
||||
format!("{:.1}%", m.loss_trend.long() * 100.0)
|
||||
} else {
|
||||
"n/a".to_string()
|
||||
};
|
||||
let jitter_ms = mmp.receiver.jitter_us() as f64 / 1000.0;
|
||||
|
||||
debug!(
|
||||
session = %session_name,
|
||||
rtt = %rtt_str,
|
||||
loss = %loss_str,
|
||||
jitter = format_args!("{:.1}ms", jitter_ms),
|
||||
goodput = %format_throughput(m.goodput_bps()),
|
||||
mtu = mmp.path_mtu.last_observed_mtu(),
|
||||
tx_pkts = mmp.sender.cumulative_packets_sent(),
|
||||
rx_pkts = mmp.receiver.cumulative_packets_recv(),
|
||||
"MMP session metrics"
|
||||
);
|
||||
}
|
||||
|
||||
/// Emit a teardown log summarizing lifetime session MMP metrics.
|
||||
pub(in crate::node) fn log_session_mmp_teardown(session_name: &str, mmp: &MmpSessionState) {
|
||||
let m = &mmp.metrics;
|
||||
let jitter_ms = mmp.receiver.jitter_us() as f64 / 1000.0;
|
||||
|
||||
let rtt_str = match m.srtt_ms() {
|
||||
Some(rtt) => format!("{:.1}ms", rtt),
|
||||
None => "n/a".to_string(),
|
||||
};
|
||||
let loss_str = format!("{:.1}%", m.loss_rate() * 100.0);
|
||||
|
||||
debug!(
|
||||
session = %session_name,
|
||||
rtt = %rtt_str,
|
||||
loss = %loss_str,
|
||||
jitter = format_args!("{:.1}ms", jitter_ms),
|
||||
etx = format_args!("{:.2}", m.etx),
|
||||
goodput = %format_throughput(m.goodput_bps()),
|
||||
send_mtu = mmp.path_mtu.current_mtu(),
|
||||
observed_mtu = mmp.path_mtu.last_observed_mtu(),
|
||||
tx_pkts = mmp.sender.cumulative_packets_sent(),
|
||||
tx_bytes = mmp.sender.cumulative_bytes_sent(),
|
||||
rx_pkts = mmp.receiver.cumulative_packets_recv(),
|
||||
rx_bytes = mmp.receiver.cumulative_bytes_recv(),
|
||||
"MMP session teardown"
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle an incoming session-layer SenderReport (msg_type 0x11).
|
||||
///
|
||||
/// Informational only — the peer is telling us about what they sent.
|
||||
@@ -974,9 +1211,11 @@ impl Node {
|
||||
return;
|
||||
};
|
||||
|
||||
let now = std::time::Instant::now();
|
||||
mmp.metrics
|
||||
.process_receiver_report(&rr, our_timestamp_ms, now);
|
||||
let (_first_rtt, rr_log) =
|
||||
mmp.metrics
|
||||
.process_receiver_report(&rr, our_timestamp_ms, crate::time::mono_ms());
|
||||
// Re-emit the operator trace the core used to log mid-decision.
|
||||
super::mmp::log_rr_outcome(&rr, our_timestamp_ms, rr_log);
|
||||
|
||||
// Feed SRTT back to sender/receiver report interval tuning (session-layer bounds)
|
||||
if let Some(srtt_ms) = mmp.metrics.srtt_ms() {
|
||||
@@ -1042,8 +1281,9 @@ impl Node {
|
||||
};
|
||||
|
||||
let old_mtu = mmp.path_mtu.current_mtu();
|
||||
let now = std::time::Instant::now();
|
||||
let changed = mmp.path_mtu.apply_notification(notif.path_mtu, now);
|
||||
let changed = mmp
|
||||
.path_mtu
|
||||
.apply_notification(notif.path_mtu, crate::time::mono_ms());
|
||||
let new_mtu = mmp.path_mtu.current_mtu();
|
||||
|
||||
if !changed {
|
||||
@@ -1065,28 +1305,34 @@ impl Node {
|
||||
// tighter of existing-or-new — never loosen the clamp.
|
||||
let fips_addr = crate::FipsAddress::from_node_addr(src_addr);
|
||||
match self.path_mtu_lookup.write() {
|
||||
Ok(mut map) => match map.get(&fips_addr).copied() {
|
||||
Some(existing) if existing <= new_mtu => {
|
||||
Ok(mut map) => {
|
||||
// Read existing, decide, and apply the write under one guard so
|
||||
// the keep-tighter update stays atomic.
|
||||
let prior = map.get(&fips_addr).copied();
|
||||
let actions = self.fsp.plan_path_mtu_tighten(fips_addr, prior, new_mtu);
|
||||
if actions.is_empty() {
|
||||
debug!(
|
||||
dest = %peer_name,
|
||||
fips_addr = %fips_addr,
|
||||
new_mtu,
|
||||
existing,
|
||||
existing = prior.unwrap_or(new_mtu),
|
||||
"PathMtuNotification: keeping tighter existing path_mtu_lookup value"
|
||||
);
|
||||
}
|
||||
other => {
|
||||
map.insert(fips_addr, new_mtu);
|
||||
debug!(
|
||||
dest = %peer_name,
|
||||
fips_addr = %fips_addr,
|
||||
new_mtu,
|
||||
prior = ?other,
|
||||
map_len = map.len(),
|
||||
"PathMtuNotification: tightened path_mtu_lookup"
|
||||
);
|
||||
for action in actions {
|
||||
if let FspAction::TightenPathMtuLookup { fips_addr, mtu } = action {
|
||||
map.insert(fips_addr, mtu);
|
||||
debug!(
|
||||
dest = %peer_name,
|
||||
fips_addr = %fips_addr,
|
||||
new_mtu,
|
||||
prior = ?prior,
|
||||
map_len = map.len(),
|
||||
"PathMtuNotification: tightened path_mtu_lookup"
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
dest = %peer_name,
|
||||
@@ -1125,7 +1371,7 @@ impl Node {
|
||||
// Send standalone CoordsWarmup immediately (rate-limited)
|
||||
if self
|
||||
.coords_response_rate_limiter
|
||||
.should_send(&msg.dest_addr)
|
||||
.should_send(&msg.dest_addr, Self::now_ms())
|
||||
{
|
||||
if let Some(entry) = self.sessions.get(&msg.dest_addr)
|
||||
&& entry.is_established()
|
||||
@@ -1141,12 +1387,19 @@ impl Node {
|
||||
|
||||
// Only trigger discovery if we have the target's identity cached —
|
||||
// otherwise we can't verify the LookupResponse proof.
|
||||
if self.has_cached_identity(&msg.dest_addr) {
|
||||
self.maybe_initiate_lookup(&msg.dest_addr).await;
|
||||
} else {
|
||||
let has_cached_identity = self.has_cached_identity(&msg.dest_addr);
|
||||
let actions = self
|
||||
.fsp
|
||||
.plan_coords_required_lookup(msg.dest_addr, has_cached_identity);
|
||||
if actions.is_empty() {
|
||||
debug!(dest = %msg.dest_addr,
|
||||
"Skipping discovery after CoordsRequired: no cached identity for target");
|
||||
}
|
||||
for action in actions {
|
||||
if let FspAction::InitiateLookup { dest } = action {
|
||||
self.maybe_initiate_lookup(&dest).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset coords warmup counter so the next N packets also include
|
||||
// COORDS_PRESENT, re-warming transit caches along the path.
|
||||
@@ -1186,7 +1439,7 @@ impl Node {
|
||||
// Send standalone CoordsWarmup immediately (rate-limited)
|
||||
if self
|
||||
.coords_response_rate_limiter
|
||||
.should_send(&msg.dest_addr)
|
||||
.should_send(&msg.dest_addr, Self::now_ms())
|
||||
{
|
||||
if let Some(entry) = self.sessions.get(&msg.dest_addr)
|
||||
&& entry.is_established()
|
||||
@@ -1200,16 +1453,26 @@ impl Node {
|
||||
"PathBroken response rate-limited, skipping standalone CoordsWarmup");
|
||||
}
|
||||
|
||||
// Invalidate stale cached coordinates
|
||||
self.coord_cache.remove(&msg.dest_addr);
|
||||
|
||||
// Trigger re-discovery to get fresh coordinates, but only if we have
|
||||
// the target's identity cached — otherwise we can't verify the
|
||||
// LookupResponse proof. This avoids a race when the XK responder
|
||||
// receives PathBroken before msg3 completes (identity unknown).
|
||||
if self.has_cached_identity(&msg.dest_addr) {
|
||||
self.maybe_initiate_lookup(&msg.dest_addr).await;
|
||||
} else {
|
||||
// Invalidate stale cached coordinates, then (only if the target's
|
||||
// identity is cached — else the LookupResponse proof cannot be verified,
|
||||
// e.g. when the XK responder receives PathBroken before msg3 completes)
|
||||
// trigger re-discovery. The core emits invalidate-then-lookup in order.
|
||||
let has_cached_identity = self.has_cached_identity(&msg.dest_addr);
|
||||
let actions = self
|
||||
.fsp
|
||||
.plan_path_broken(msg.dest_addr, has_cached_identity);
|
||||
for action in actions {
|
||||
match action {
|
||||
FspAction::InvalidateCoords { addr } => {
|
||||
self.coord_cache.remove(&addr);
|
||||
}
|
||||
FspAction::InitiateLookup { dest } => {
|
||||
self.maybe_initiate_lookup(&dest).await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !has_cached_identity {
|
||||
debug!(dest = %msg.dest_addr,
|
||||
"Skipping discovery after PathBroken: no cached identity for target");
|
||||
}
|
||||
@@ -1256,8 +1519,10 @@ impl Node {
|
||||
&& let Some(mmp) = entry.mmp_mut()
|
||||
{
|
||||
let old_mtu = mmp.path_mtu.current_mtu();
|
||||
let now = std::time::Instant::now();
|
||||
if mmp.path_mtu.apply_notification(msg.mtu, now) {
|
||||
if mmp
|
||||
.path_mtu
|
||||
.apply_notification(msg.mtu, crate::time::mono_ms())
|
||||
{
|
||||
let new_mtu = mmp.path_mtu.current_mtu();
|
||||
info!(
|
||||
dest = %peer_name,
|
||||
@@ -1277,28 +1542,34 @@ impl Node {
|
||||
// tighter of existing-or-new — never loosen the clamp.
|
||||
let fips_addr = crate::FipsAddress::from_node_addr(&msg.dest_addr);
|
||||
match self.path_mtu_lookup.write() {
|
||||
Ok(mut map) => match map.get(&fips_addr).copied() {
|
||||
Some(existing) if existing <= msg.mtu => {
|
||||
Ok(mut map) => {
|
||||
// Read existing, decide, and apply the write under one guard so
|
||||
// the keep-tighter update stays atomic.
|
||||
let prior = map.get(&fips_addr).copied();
|
||||
let actions = self.fsp.plan_path_mtu_tighten(fips_addr, prior, msg.mtu);
|
||||
if actions.is_empty() {
|
||||
debug!(
|
||||
dest = %peer_name,
|
||||
fips_addr = %fips_addr,
|
||||
bottleneck_mtu = msg.mtu,
|
||||
existing,
|
||||
existing = prior.unwrap_or(msg.mtu),
|
||||
"Reactive MtuExceeded: keeping tighter existing path_mtu_lookup value"
|
||||
);
|
||||
}
|
||||
other => {
|
||||
map.insert(fips_addr, msg.mtu);
|
||||
debug!(
|
||||
dest = %peer_name,
|
||||
fips_addr = %fips_addr,
|
||||
bottleneck_mtu = msg.mtu,
|
||||
prior = ?other,
|
||||
map_len = map.len(),
|
||||
"Reactive MtuExceeded: tightened path_mtu_lookup"
|
||||
);
|
||||
for action in actions {
|
||||
if let FspAction::TightenPathMtuLookup { fips_addr, mtu } = action {
|
||||
map.insert(fips_addr, mtu);
|
||||
debug!(
|
||||
dest = %peer_name,
|
||||
fips_addr = %fips_addr,
|
||||
bottleneck_mtu = msg.mtu,
|
||||
prior = ?prior,
|
||||
map_len = map.len(),
|
||||
"Reactive MtuExceeded: tightened path_mtu_lookup"
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
dest = %peer_name,
|
||||
@@ -1561,7 +1832,7 @@ impl Node {
|
||||
send: PipelinedSend<'_>,
|
||||
) -> Result<bool, NodeError> {
|
||||
let dest_addr = send.dest_addr;
|
||||
let Some(workers) = self.encrypt_workers.as_ref().cloned() else {
|
||||
let Some(workers) = self.supervisor.encrypt_workers.as_ref().cloned() else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
@@ -2065,7 +2336,10 @@ impl Node {
|
||||
/// Returns our own coordinates as a fallback (the SessionSetup will
|
||||
/// carry src_coords for return path routing; empty dest_coords
|
||||
/// would fail wire encoding since TreeCoordinate requires ≥1 entry).
|
||||
pub(in crate::node) fn get_dest_coords(&self, dest: &NodeAddr) -> crate::tree::TreeCoordinate {
|
||||
pub(in crate::node) fn get_dest_coords(
|
||||
&self,
|
||||
dest: &NodeAddr,
|
||||
) -> crate::proto::stp::TreeCoordinate {
|
||||
let now_ms = Self::now_ms();
|
||||
if let Some(coords) = self.coord_cache.get(dest, now_ms) {
|
||||
return coords.clone();
|
||||
@@ -2174,7 +2448,7 @@ impl Node {
|
||||
let our_ipv6 = FipsAddress::from_node_addr(self.node_addr()).to_ipv6();
|
||||
if let Some(response) =
|
||||
build_dest_unreachable(original_packet, DestUnreachableCode::NoRoute, our_ipv6)
|
||||
&& let Some(tun_tx) = &self.tun_tx
|
||||
&& let Some(tun_tx) = &self.supervisor.tun_tx
|
||||
{
|
||||
let _ = tun_tx.send(response);
|
||||
}
|
||||
@@ -2209,7 +2483,7 @@ impl Node {
|
||||
// causes a PMTUD blackhole when both src and ICMP-src are local.
|
||||
let dest_addr = Ipv6Addr::from(<[u8; 16]>::try_from(&original_packet[24..40]).unwrap());
|
||||
if let Some(response) = build_packet_too_big(original_packet, mtu, dest_addr)
|
||||
&& let Some(tun_tx) = &self.tun_tx
|
||||
&& let Some(tun_tx) = &self.supervisor.tun_tx
|
||||
{
|
||||
debug!(
|
||||
original_src = %src_addr,
|
||||
@@ -2234,10 +2508,7 @@ impl Node {
|
||||
|
||||
let per_dest = self.config().node.session.pending_packets_per_dest;
|
||||
let queue = self.pending_tun_packets.entry(dest_addr).or_default();
|
||||
if queue.len() >= per_dest {
|
||||
queue.pop_front(); // Drop oldest
|
||||
}
|
||||
queue.push_back(packet);
|
||||
crate::proto::fsp::push_bounded_pending(queue, packet, per_dest);
|
||||
}
|
||||
|
||||
/// Flush pending packets for a destination whose session just reached Established.
|
||||
@@ -2288,31 +2559,3 @@ impl Node {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark ECN-CE in an IPv6 packet's Traffic Class field.
|
||||
///
|
||||
/// IPv6 Traffic Class occupies bits across bytes 0 and 1:
|
||||
/// byte[0] bits[3:0] = TC[7:4]
|
||||
/// byte[1] bits[7:4] = TC[3:0]
|
||||
/// ECN is TC[1:0]. Only marks CE (0b11) if the packet is ECN-capable
|
||||
/// (ECT(0) or ECT(1)). Packets with ECN=0b00 (Not-ECT) are never marked
|
||||
/// per RFC 3168.
|
||||
///
|
||||
/// No checksum update needed: IPv6 has no header checksum, and the Traffic
|
||||
/// Class field is not part of the TCP/UDP pseudo-header.
|
||||
pub(in crate::node) fn mark_ipv6_ecn_ce(packet: &mut [u8]) {
|
||||
if packet.len() < 2 {
|
||||
return;
|
||||
}
|
||||
// Extract 8-bit Traffic Class from IPv6 header bytes 0-1
|
||||
let tc = ((packet[0] & 0x0F) << 4) | (packet[1] >> 4);
|
||||
let ecn = tc & 0x03;
|
||||
// Only mark CE on ECN-capable packets (ECT(0)=0b10 or ECT(1)=0b01)
|
||||
if ecn == 0 {
|
||||
return;
|
||||
}
|
||||
// Set both ECN bits to 1 (CE = 0b11)
|
||||
let new_tc = tc | 0x03;
|
||||
packet[0] = (packet[0] & 0xF0) | (new_tc >> 4);
|
||||
packet[1] = (new_tc << 4) | (packet[1] & 0x0F);
|
||||
}
|
||||
|
||||
@@ -2,58 +2,108 @@
|
||||
//! and handshake message resend scheduling.
|
||||
|
||||
use crate::node::Node;
|
||||
use crate::peer::HandshakeState;
|
||||
use crate::peer::machine::TimerKind;
|
||||
use crate::proto::fmp::{
|
||||
ConnAction, ConnSnapshot, LifecycleView, PeerSnapshot, RekeyResendSnapshot,
|
||||
};
|
||||
use crate::transport::LinkId;
|
||||
use tracing::{debug, info};
|
||||
|
||||
impl LifecycleView for Node {
|
||||
fn stale_connections(&self, now_ms: u64, timeout_ms: u64) -> Vec<ConnSnapshot> {
|
||||
// `is_failed()` legs are always reaped here (~1s), as before. The
|
||||
// idle-timeout is reaped here ONLY for legs whose timeout is not already
|
||||
// driven by a machine `HandshakeTimeout` timer — i.e. inbound legs (IK
|
||||
// inbound arms none). Outbound legs with an armed timer are reaped by
|
||||
// `drive_handshake_timeouts`, so excluding them here avoids a double
|
||||
// reap.
|
||||
self.peer_machines
|
||||
.iter()
|
||||
.filter(|(_, machine)| machine.leg().is_some())
|
||||
.filter(|(link_id, machine)| {
|
||||
machine.is_failed()
|
||||
|| (machine.conn_is_timed_out(now_ms, timeout_ms)
|
||||
&& !self.peer_timers.get(*link_id).is_some_and(|timers| {
|
||||
timers.contains_key(&TimerKind::HandshakeTimeout)
|
||||
}))
|
||||
})
|
||||
.map(|(link_id, machine)| ConnSnapshot {
|
||||
link: *link_id,
|
||||
is_outbound: machine.conn_is_outbound(),
|
||||
retry_addr: machine.conn_expected_identity().map(|id| *id.node_addr()),
|
||||
resend_count: 0,
|
||||
msg1: Vec::new(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn rekey_peers(&self) -> Vec<PeerSnapshot> {
|
||||
// The snapshot builder lives in `rekey` beside its drain/dampening
|
||||
// constants; the read-seam unifies here.
|
||||
self.rekey_peer_snapshots()
|
||||
}
|
||||
|
||||
fn rekey_resend_candidates(&self, now_ms: u64) -> Vec<RekeyResendSnapshot> {
|
||||
self.rekey_resend_snapshots(now_ms)
|
||||
}
|
||||
}
|
||||
|
||||
impl Node {
|
||||
/// Check for timed-out handshake connections and clean them up.
|
||||
///
|
||||
/// Called periodically by the RX event loop. Removes connections that have
|
||||
/// been idle longer than the configured handshake timeout or are in Failed state.
|
||||
///
|
||||
/// The stale/failed predicate and every registry mutation stay shell-side;
|
||||
/// the retry-then-teardown choreography is the pure
|
||||
/// [`Fmp::poll_timeouts`](crate::proto::fmp::Fmp::poll_timeouts) decision.
|
||||
pub(in crate::node) fn check_timeouts(&mut self) {
|
||||
if self.connections.is_empty() {
|
||||
if self.connection_count() == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let now_ms = Self::now_ms();
|
||||
let timeout_ms = self.config().node.rate_limit.handshake_timeout_secs * 1000;
|
||||
|
||||
let stale: Vec<LinkId> = self
|
||||
.connections
|
||||
.iter()
|
||||
.filter(|(_, conn)| conn.is_timed_out(now_ms, timeout_ms) || conn.is_failed())
|
||||
.map(|(link_id, _)| *link_id)
|
||||
.collect();
|
||||
|
||||
for link_id in stale {
|
||||
// Log and schedule retry before cleanup (need connection state)
|
||||
if let Some(conn) = self.connections.get(&link_id) {
|
||||
let direction = conn.direction();
|
||||
let idle_ms = conn.idle_time(now_ms);
|
||||
if conn.is_failed() {
|
||||
debug!(
|
||||
link_id = %link_id,
|
||||
direction = %direction,
|
||||
"Failed handshake connection cleaned up"
|
||||
);
|
||||
} else {
|
||||
debug!(
|
||||
link_id = %link_id,
|
||||
direction = %direction,
|
||||
idle_secs = idle_ms / 1000,
|
||||
"Stale handshake connection timed out"
|
||||
);
|
||||
}
|
||||
|
||||
// Schedule retry for failed outbound auto-connect peers
|
||||
if conn.is_outbound()
|
||||
&& let Some(identity) = conn.expected_identity()
|
||||
{
|
||||
self.schedule_retry(*identity.node_addr(), now_ms);
|
||||
let stale = self.stale_connections(now_ms, timeout_ms);
|
||||
for action in self.fmp.poll_timeouts(stale) {
|
||||
match action {
|
||||
ConnAction::ScheduleRetry { peer } => self.note_handshake_timeout(peer, now_ms),
|
||||
ConnAction::Teardown { link } => {
|
||||
// Log before cleanup (needs live connection state). The
|
||||
// failure signal is now read from the control machine; the
|
||||
// leg still carries direction/idle for the log fields.
|
||||
let is_failed = self
|
||||
.peer_machines
|
||||
.get(&link)
|
||||
.is_some_and(|machine| machine.is_failed());
|
||||
if let Some(machine) = self
|
||||
.peer_machines
|
||||
.get(&link)
|
||||
.filter(|machine| machine.leg().is_some())
|
||||
{
|
||||
let direction = machine.conn_direction();
|
||||
if is_failed {
|
||||
debug!(
|
||||
link_id = %link,
|
||||
direction = %direction,
|
||||
"Failed handshake connection cleaned up"
|
||||
);
|
||||
} else {
|
||||
debug!(
|
||||
link_id = %link,
|
||||
direction = %direction,
|
||||
idle_secs =
|
||||
now_ms.saturating_sub(self.connection_last_activity(link)) / 1000,
|
||||
"Stale handshake connection timed out"
|
||||
);
|
||||
}
|
||||
}
|
||||
self.cleanup_stale_connection(link, now_ms);
|
||||
}
|
||||
#[allow(unreachable_patterns)]
|
||||
_ => {}
|
||||
}
|
||||
self.cleanup_stale_connection(link_id, now_ms);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,15 +113,32 @@ impl Node {
|
||||
/// the link and address mapping. Does not log — callers provide context-appropriate
|
||||
/// log messages.
|
||||
pub(in crate::node) fn cleanup_stale_connection(&mut self, link_id: LinkId, _now_ms: u64) {
|
||||
let conn = match self.connections.remove(&link_id) {
|
||||
// Take the connection off its machine BEFORE disposing the machine
|
||||
// (the machine owns it), keeping it readable for the index/link
|
||||
// cleanup below. The machine shares the connection's `link_id` and
|
||||
// lifetime; dropping it here means a reaped handshake leg leaves no
|
||||
// dangling machine. A no-op for promoted peers — `promote_connection`
|
||||
// already consumed their connection, so this reaper never runs for
|
||||
// them.
|
||||
let _detached_leg = match self
|
||||
.peer_machines
|
||||
.get_mut(&link_id)
|
||||
.and_then(|machine| machine.take_leg())
|
||||
{
|
||||
Some(c) => c,
|
||||
None => return,
|
||||
};
|
||||
let transport_id = conn.transport_id();
|
||||
// Read the transport ID and session index off the surviving carrier
|
||||
// before disposing the machine (the leg no longer projects them).
|
||||
let (transport_id, our_index) = match self.peer_machines.get(&link_id) {
|
||||
Some(machine) => (machine.conn_transport_id(), machine.our_index()),
|
||||
None => (None, None),
|
||||
};
|
||||
self.remove_peer_machine(link_id);
|
||||
|
||||
// Free session index and pending_outbound if allocated
|
||||
if let Some(idx) = conn.our_index() {
|
||||
if let Some(tid) = conn.transport_id() {
|
||||
if let Some(idx) = our_index {
|
||||
if let Some(tid) = transport_id {
|
||||
self.pending_outbound.remove(&(tid, idx.as_u32()));
|
||||
}
|
||||
let _ = self.index_allocator.free(idx);
|
||||
@@ -84,53 +151,196 @@ impl Node {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resend handshake messages for pending connections.
|
||||
/// Act on the per-peer machine timers this tick.
|
||||
///
|
||||
/// For outbound connections in SentMsg1 state, resends the stored msg1
|
||||
/// with exponential backoff. Called periodically from the RX event loop.
|
||||
pub(in crate::node) async fn resend_pending_handshakes(&mut self, now_ms: u64) {
|
||||
if self.connections.is_empty() {
|
||||
/// The sans-IO machine arms `SetTimer`/`CancelTimer` actions into
|
||||
/// [`peer_timers`](Node::peer_timers); this is the shell driver that acts on
|
||||
/// them: timeout reaps idle-timed-out outbound legs, retransmit resends the
|
||||
/// due msg1s. Handshake-TIMEOUT is driven before handshake-RETRANSMIT so a
|
||||
/// timed-out leg is reaped rather than resent on the same tick. The
|
||||
/// rekey/liveness kinds keep their own shell drivers, so only the two
|
||||
/// handshake kinds are driven here.
|
||||
pub(in crate::node) async fn drive_peer_timers(&mut self, now_ms: u64) {
|
||||
if self.peer_timers.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.drive_handshake_timeouts(now_ms);
|
||||
self.drive_handshake_retransmits(now_ms).await;
|
||||
}
|
||||
|
||||
/// Reap the outbound legs whose machine `HandshakeTimeout` timer marks them
|
||||
/// as machine-timeout-owned and which have idle-timed-out this tick.
|
||||
///
|
||||
/// The timer's PRESENCE selects the leg (only OUTBOUND legs arm one — IK
|
||||
/// inbound arms none); the reap THRESHOLD is the shell `is_timed_out(now,
|
||||
/// config)` predicate, NOT the timer's stored deadline. This matters because
|
||||
/// the machine arms the timer from a hardcoded constant at dial, which is not
|
||||
/// authoritative for an operator-tuned `handshake_timeout_secs` — reading the
|
||||
/// threshold from config each tick keeps the reap neutral for any config, and
|
||||
/// off the `last_activity` clock exactly as the old `check_timeouts` did. A
|
||||
/// timed-out leg is reaped by the old Teardown path: the outbound retry
|
||||
/// reflex, then `cleanup_stale_connection` (which drops the machine + timers).
|
||||
///
|
||||
/// `check_timeouts` keeps reaping everything else — `is_failed()` legs and the
|
||||
/// idle-timeout of legs without a machine timer (inbound legs).
|
||||
fn drive_handshake_timeouts(&mut self, now_ms: u64) {
|
||||
let timeout_ms = self.config().node.rate_limit.handshake_timeout_secs * 1000;
|
||||
let timer_links: Vec<LinkId> = self
|
||||
.peer_timers
|
||||
.iter()
|
||||
.filter(|(_, timers)| timers.contains_key(&TimerKind::HandshakeTimeout))
|
||||
.map(|(link, _)| *link)
|
||||
.collect();
|
||||
for link in timer_links {
|
||||
// The idle-timeout threshold reads the survivor carrier's
|
||||
// last-activity; presence of a pending handshake is what decides
|
||||
// between reaping and dropping an orphan timer.
|
||||
let timed_out = self
|
||||
.peer_machines
|
||||
.get(&link)
|
||||
.is_some_and(|machine| machine.conn_is_timed_out(now_ms, timeout_ms));
|
||||
let (reap, retry_peer) = match self.has_pending_leg(&link) {
|
||||
true if timed_out => {
|
||||
let retry_peer = if self
|
||||
.peer_machines
|
||||
.get(&link)
|
||||
.is_some_and(|machine| machine.conn_is_outbound())
|
||||
{
|
||||
self.peer_machines
|
||||
.get(&link)
|
||||
.and_then(|machine| machine.conn_expected_identity())
|
||||
.map(|id| *id.node_addr())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(true, retry_peer)
|
||||
}
|
||||
// Not yet idle-timed-out: leave the timer for a later tick.
|
||||
true => (false, None),
|
||||
false => {
|
||||
// Orphan timer (connection already reaped elsewhere) — drop it.
|
||||
if let Some(timers) = self.peer_timers.get_mut(&link) {
|
||||
timers.remove(&TimerKind::HandshakeTimeout);
|
||||
}
|
||||
(false, None)
|
||||
}
|
||||
};
|
||||
if reap {
|
||||
if let Some(peer) = retry_peer {
|
||||
self.note_handshake_timeout(peer, now_ms);
|
||||
}
|
||||
debug!(link_id = %link, "Handshake connection timed out");
|
||||
self.cleanup_stale_connection(link, now_ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire due handshake-retransmit timers: resend the stored msg1.
|
||||
///
|
||||
/// The pre-fold `resend_pending_handshakes` logic, re-homed: the *due* signal
|
||||
/// is the machine-armed timer (not the connection's `next_resend_at_ms`), and
|
||||
/// the resend counter lives on the machine (the operator-visible count reads
|
||||
/// from there). The wire bytes and transport target still come from the shell
|
||||
/// connection, the pure core computes the backoff schedule, and — matching the
|
||||
/// old shell exactly — the count and reschedule advance only on a successful
|
||||
/// send; a failed send neither advances the count nor marks the connection
|
||||
/// failed, it just retries next tick.
|
||||
async fn drive_handshake_retransmits(&mut self, now_ms: u64) {
|
||||
let max_resends = self.config().node.rate_limit.handshake_max_resends;
|
||||
let interval_ms = self.config().node.rate_limit.handshake_resend_interval_ms;
|
||||
let backoff = self.config().node.rate_limit.handshake_resend_backoff;
|
||||
|
||||
// Collect resend candidates: outbound, in SentMsg1, with stored msg1,
|
||||
// under max resends, and past the scheduled time.
|
||||
let candidates: Vec<(LinkId, Vec<u8>)> = self
|
||||
.connections
|
||||
// Collect due retransmit timers (kind-filtered).
|
||||
let due: Vec<LinkId> = self
|
||||
.peer_timers
|
||||
.iter()
|
||||
.filter(|(_, conn)| {
|
||||
conn.is_outbound()
|
||||
&& conn.handshake_state() == HandshakeState::SentMsg1
|
||||
&& conn.resend_count() < max_resends
|
||||
&& conn.next_resend_at_ms() > 0
|
||||
&& now_ms >= conn.next_resend_at_ms()
|
||||
})
|
||||
.filter_map(|(link_id, conn)| {
|
||||
conn.handshake_msg1().map(|msg1| (*link_id, msg1.to_vec()))
|
||||
.filter(|(_, timers)| {
|
||||
timers
|
||||
.get(&TimerKind::HandshakeRetransmit)
|
||||
.is_some_and(|&at_ms| now_ms >= at_ms)
|
||||
})
|
||||
.map(|(link, _)| *link)
|
||||
.collect();
|
||||
if due.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
for (link_id, msg1_bytes) in candidates {
|
||||
// Get transport and address info from the connection
|
||||
let (transport_id, remote_addr) = match self.connections.get(&link_id) {
|
||||
Some(conn) => match (conn.transport_id(), conn.source_addr()) {
|
||||
(Some(tid), Some(addr)) => (tid, addr.clone()),
|
||||
_ => continue,
|
||||
},
|
||||
// Classify each due link against the machine + connection. A timer whose
|
||||
// machine has left `SentMsg1` (promoted/gone) or has hit the resend cap
|
||||
// is dropped — no more resends, exactly as the old shell stopped
|
||||
// selecting a capped/settled connection; the handshake-timeout reaper
|
||||
// takes it from there.
|
||||
let mut candidates: Vec<ConnSnapshot> = Vec::new();
|
||||
let mut drop_timers: Vec<LinkId> = Vec::new();
|
||||
for link in due {
|
||||
let armed = match self.peer_machines.get(&link) {
|
||||
Some(machine)
|
||||
if machine.is_handshaking_sent_msg1()
|
||||
&& machine.resend_count() < max_resends =>
|
||||
{
|
||||
machine.resend_count()
|
||||
}
|
||||
Some(_) => {
|
||||
drop_timers.push(link);
|
||||
continue;
|
||||
}
|
||||
None => {
|
||||
drop_timers.push(link);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match self
|
||||
.peer_machines
|
||||
.get(&link)
|
||||
.and_then(|machine| machine.conn_handshake_msg1())
|
||||
{
|
||||
// Armed but the stored wire isn't there yet — leave the timer and
|
||||
// retry next tick (matches the old candidate filter skipping it).
|
||||
None => continue,
|
||||
Some(msg1) => candidates.push(ConnSnapshot {
|
||||
link,
|
||||
is_outbound: true,
|
||||
retry_addr: None,
|
||||
resend_count: armed,
|
||||
msg1: msg1.to_vec(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
for link in drop_timers {
|
||||
if let Some(timers) = self.peer_timers.get_mut(&link) {
|
||||
timers.remove(&TimerKind::HandshakeRetransmit);
|
||||
}
|
||||
}
|
||||
|
||||
for action in self
|
||||
.fmp
|
||||
.poll_resends(candidates, now_ms, interval_ms, backoff)
|
||||
{
|
||||
let ConnAction::ResendMsg1 {
|
||||
link,
|
||||
bytes,
|
||||
next_resend_at_ms,
|
||||
} = action
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let (transport_id, remote_addr) = match self.peer_machines.get(&link) {
|
||||
Some(machine) if machine.leg().is_some() => {
|
||||
match (machine.conn_transport_id(), machine.conn_source_addr()) {
|
||||
(Some(tid), Some(addr)) => (tid, addr.clone()),
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
// Send the stored msg1
|
||||
let sent = if let Some(transport) = self.transports.get(&transport_id) {
|
||||
match transport.send(&remote_addr, &msg1_bytes).await {
|
||||
match transport.send(&remote_addr, &bytes).await {
|
||||
Ok(_) => true,
|
||||
Err(e) => {
|
||||
debug!(
|
||||
link_id = %link_id,
|
||||
link_id = %link,
|
||||
error = %e,
|
||||
"Handshake msg1 resend failed"
|
||||
);
|
||||
@@ -141,15 +351,26 @@ impl Node {
|
||||
false
|
||||
};
|
||||
|
||||
if sent && let Some(conn) = self.connections.get_mut(&link_id) {
|
||||
let count = conn.resend_count() + 1;
|
||||
let next = now_ms + (interval_ms as f64 * backoff.powi(count as i32)) as u64;
|
||||
conn.record_resend(next);
|
||||
debug!(
|
||||
link_id = %link_id,
|
||||
resend = count,
|
||||
"Resent handshake msg1"
|
||||
);
|
||||
if sent {
|
||||
if let Some(machine) = self.peer_machines.get_mut(&link) {
|
||||
machine.record_resend(next_resend_at_ms);
|
||||
debug!(
|
||||
link_id = %link,
|
||||
resend = machine.resend_count(),
|
||||
"Resent handshake msg1"
|
||||
);
|
||||
}
|
||||
self.peer_timers
|
||||
.entry(link)
|
||||
.or_default()
|
||||
.insert(TimerKind::HandshakeRetransmit, next_resend_at_ms);
|
||||
} else {
|
||||
// Failed send: keep retrying at the tick cadence (the old shell
|
||||
// left next_resend_at_ms unchanged so the connection stayed due).
|
||||
self.peer_timers
|
||||
.entry(link)
|
||||
.or_default()
|
||||
.insert(TimerKind::HandshakeRetransmit, now_ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,7 +425,7 @@ impl Node {
|
||||
.collect();
|
||||
|
||||
for (dest_addr, payload) in candidates {
|
||||
use crate::protocol::SessionDatagram;
|
||||
use crate::proto::link::SessionDatagram;
|
||||
|
||||
let mut datagram = SessionDatagram::new(my_addr, dest_addr, payload).with_ttl(ttl);
|
||||
let sent = match self.send_session_datagram(&mut datagram).await {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
3316
src/node/lifecycle/mod.rs
Normal file
3316
src/node/lifecycle/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
1364
src/node/lifecycle/supervisor.rs
Normal file
1364
src/node/lifecycle/supervisor.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -15,8 +15,8 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use crate::node::reject::{BloomReject, DiscoveryReject, ForwardingReject, TreeReject};
|
||||
use crate::node::stats::{
|
||||
BloomStatsSnapshot, CongestionStatsSnapshot, DiscoveryStatsSnapshot, ErrorSignalStatsSnapshot,
|
||||
ForwardingStatsSnapshot, TreeStatsSnapshot,
|
||||
BloomStatsSnapshot, CongestionStatsSnapshot, ErrorSignalStatsSnapshot, ForwardingStatsSnapshot,
|
||||
LookupStatsSnapshot, TreeStatsSnapshot,
|
||||
};
|
||||
|
||||
/// An atomic counter.
|
||||
@@ -90,43 +90,10 @@ pub struct ForwardingMetrics {
|
||||
}
|
||||
|
||||
/// Route class of a transit-forwarded packet, classified from tree
|
||||
/// coordinates at the forwarding decision point. The six variants
|
||||
/// partition `forwarded_packets` exactly.
|
||||
///
|
||||
/// Two variants are up-and-over forwards (destination not in the chosen
|
||||
/// peer's subtree); they differ in whether they depend on a child
|
||||
/// advertising cross-link reach *upward* to its parent:
|
||||
/// - `TreeDownCross`: the chosen peer is our tree descendant, but the
|
||||
/// destination is *not* in that child's subtree. The forward only fired
|
||||
/// because the child advertised cross-link reach upward to us, beyond its
|
||||
/// own subtree. If children advertised only their subtree upward, this
|
||||
/// forward would route up instead, so its count measures how much
|
||||
/// forwarding depends on the upward cross-link advertisement — the
|
||||
/// dive-to-tree-child cut-through.
|
||||
/// - `CrosslinkAscend`: the chosen peer is lateral (neither ancestor nor
|
||||
/// descendant) and the destination is not in its subtree. This is a node
|
||||
/// using its *own* cross-link, learned via the peer's split-horizon
|
||||
/// advertisement to its neighbors, so it does not depend on any upward
|
||||
/// advertisement. Tracked alongside `TreeDownCross` as the lateral
|
||||
/// up-and-over contrast.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RouteClass {
|
||||
/// Chosen peer is our ancestor (tree-up).
|
||||
TreeUp,
|
||||
/// Chosen peer is our descendant and dest is in its subtree (canonical
|
||||
/// tree-down).
|
||||
TreeDown,
|
||||
/// Chosen peer is our descendant but dest is *not* in its subtree: the
|
||||
/// dive-to-tree-child cut-through enabled by upward cross-link
|
||||
/// advertisement.
|
||||
TreeDownCross,
|
||||
/// Chosen peer is lateral and dest is in its subtree (subtree entry).
|
||||
CrosslinkDescend,
|
||||
/// Chosen peer is lateral and dest is not in its subtree (up-and-over).
|
||||
CrosslinkAscend,
|
||||
/// Chosen peer is the destination itself (degenerate direct hop).
|
||||
DirectPeer,
|
||||
}
|
||||
/// coordinates at the forwarding decision point. Defined by the sans-IO
|
||||
/// routing core and re-exported here for the forwarding-metrics counters
|
||||
/// ([`ForwardingMetrics::record_route_class`]).
|
||||
pub(crate) use crate::proto::routing::RouteClass;
|
||||
|
||||
impl ForwardingMetrics {
|
||||
/// Record a received packet of `bytes` payload (packets and bytes).
|
||||
@@ -235,7 +202,7 @@ impl ForwardingMetrics {
|
||||
|
||||
/// Discovery metric counters.
|
||||
#[derive(Default)]
|
||||
pub struct DiscoveryMetrics {
|
||||
pub struct LookupMetrics {
|
||||
pub req_received: Padded<Counter>,
|
||||
pub req_decode_error: Counter,
|
||||
pub req_duplicate: Counter,
|
||||
@@ -260,7 +227,7 @@ pub struct DiscoveryMetrics {
|
||||
pub resp_timed_out: Counter,
|
||||
}
|
||||
|
||||
impl DiscoveryMetrics {
|
||||
impl LookupMetrics {
|
||||
/// Mirror of `DiscoveryStats::record_reject`: route a typed discovery
|
||||
/// rejection to its counter.
|
||||
#[inline]
|
||||
@@ -278,8 +245,8 @@ impl DiscoveryMetrics {
|
||||
}
|
||||
|
||||
/// Sample every counter into a serializable snapshot.
|
||||
pub fn snapshot(&self) -> DiscoveryStatsSnapshot {
|
||||
DiscoveryStatsSnapshot {
|
||||
pub fn snapshot(&self) -> LookupStatsSnapshot {
|
||||
LookupStatsSnapshot {
|
||||
req_received: self.req_received.get(),
|
||||
req_decode_error: self.req_decode_error.get(),
|
||||
req_duplicate: self.req_duplicate.get(),
|
||||
@@ -317,7 +284,6 @@ pub struct TreeMetrics {
|
||||
pub stale: Counter,
|
||||
pub ancestry_invalid: Counter,
|
||||
pub accepted: Counter,
|
||||
pub parent_switched: Counter,
|
||||
pub loop_detected: Counter,
|
||||
pub ancestry_changed: Counter,
|
||||
pub sent: Counter,
|
||||
@@ -351,7 +317,6 @@ impl TreeMetrics {
|
||||
stale: self.stale.get(),
|
||||
ancestry_invalid: self.ancestry_invalid.get(),
|
||||
accepted: self.accepted.get(),
|
||||
parent_switched: self.parent_switched.get(),
|
||||
loop_detected: self.loop_detected.get(),
|
||||
ancestry_changed: self.ancestry_changed.get(),
|
||||
sent: self.sent.get(),
|
||||
@@ -462,7 +427,7 @@ impl ErrorMetrics {
|
||||
#[derive(Default)]
|
||||
pub struct MetricsRegistry {
|
||||
pub forwarding: ForwardingMetrics,
|
||||
pub discovery: DiscoveryMetrics,
|
||||
pub lookup: LookupMetrics,
|
||||
pub tree: TreeMetrics,
|
||||
pub bloom: BloomMetrics,
|
||||
pub congestion: CongestionMetrics,
|
||||
@@ -490,7 +455,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn discovery_record_reject_routes_to_field() {
|
||||
let m = DiscoveryMetrics::default();
|
||||
let m = LookupMetrics::default();
|
||||
m.record_reject(DiscoveryReject::ReqDuplicate);
|
||||
m.record_reject(DiscoveryReject::ReqDuplicate);
|
||||
m.record_reject(DiscoveryReject::RespNoRoute);
|
||||
@@ -501,7 +466,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn discovery_direct_counters_increment() {
|
||||
let m = DiscoveryMetrics::default();
|
||||
let m = LookupMetrics::default();
|
||||
m.req_received.inc();
|
||||
m.req_forwarded.inc();
|
||||
m.req_forwarded.inc();
|
||||
@@ -534,9 +499,9 @@ mod tests {
|
||||
fn registry_subcounters_are_independent() {
|
||||
let r = MetricsRegistry::new();
|
||||
r.forwarding.record_received(10);
|
||||
r.discovery.req_received.inc();
|
||||
r.lookup.req_received.inc();
|
||||
assert_eq!(r.forwarding.received_packets.get(), 1);
|
||||
assert_eq!(r.forwarding.received_bytes.get(), 10);
|
||||
assert_eq!(r.discovery.req_received.get(), 1);
|
||||
assert_eq!(r.lookup.req_received.get(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
1034
src/node/mod.rs
1034
src/node/mod.rs
File diff suppressed because it is too large
Load Diff
148
src/node/peering/driver.rs
Normal file
148
src/node/peering/driver.rs
Normal file
@@ -0,0 +1,148 @@
|
||||
//! Thin async driver for the peering reconciler.
|
||||
//!
|
||||
//! These `impl Node` methods are the I/O edge of the sans-IO
|
||||
//! [`super::reconcile::PeeringReconciler`]: they snapshot the live dataplane
|
||||
//! maps into the reconciler's plain-data inputs, invoke the pure core, and
|
||||
//! perform the dial / advert-refetch I/O each [`PeeringAction`] names. They also
|
||||
//! host the two gate-guarded reflex wrappers every peer-loss call site routes
|
||||
//! through, so drain suppression and the connected-guard live in one place.
|
||||
//!
|
||||
//! The `Policy` / `Observed` / `Budget` builders these methods consume live in
|
||||
//! [`crate::node::lifecycle`] next to the surviving budget helpers and limit
|
||||
//! constants they wrap.
|
||||
|
||||
use crate::identity::NodeAddr;
|
||||
use crate::node::{Node, NodeError};
|
||||
use tracing::warn;
|
||||
|
||||
use super::reconcile::{DiscoveryPools, Gate, PeeringAction};
|
||||
|
||||
impl Node {
|
||||
/// Reflex: an outbound handshake timed out (replaces the old
|
||||
/// `Node::schedule_retry` call sites).
|
||||
///
|
||||
/// Replicates `schedule_retry`'s connected-guard — the pure core cannot
|
||||
/// observe the peers map, so the driver drops the event when the peer is
|
||||
/// already connected — then feeds the gate-guarded reconciler reflex with
|
||||
/// the gate derived from the live published state.
|
||||
pub(in crate::node) fn note_handshake_timeout(&mut self, node_addr: NodeAddr, now_ms: u64) {
|
||||
if self.peers.contains_key(&node_addr) {
|
||||
return;
|
||||
}
|
||||
let policy =
|
||||
self.build_peering_policy(self.config().auto_connect_peers().cloned().collect());
|
||||
let gate = Gate::from_state(self.supervisor.state);
|
||||
let _ = self
|
||||
.peering
|
||||
.reconciler
|
||||
.on_handshake_timeout(node_addr, now_ms, &policy, gate);
|
||||
}
|
||||
|
||||
/// Reflex: a link went dead / a peer was lost (replaces the old
|
||||
/// `Node::schedule_reconnect` call sites).
|
||||
///
|
||||
/// No connected-guard — the peer is already gone by the time a link-dead /
|
||||
/// disconnect event fires (`schedule_reconnect` had none). The gate is
|
||||
/// derived from the live published state so a drain self-suppresses the
|
||||
/// reconnect.
|
||||
pub(in crate::node) fn note_link_dead(&mut self, node_addr: NodeAddr, now_ms: u64) {
|
||||
let policy =
|
||||
self.build_peering_policy(self.config().auto_connect_peers().cloned().collect());
|
||||
let gate = Gate::from_state(self.supervisor.state);
|
||||
let _ = self
|
||||
.peering
|
||||
.reconciler
|
||||
.on_link_dead(node_addr, now_ms, &policy, gate);
|
||||
}
|
||||
|
||||
/// Process pending retries whose time has arrived (replaces the old
|
||||
/// `Node::process_pending_retries` body).
|
||||
///
|
||||
/// The pure retry-dial phase owns the decision — drop expired entries, refuse
|
||||
/// to grow when admission binds, dial the first `retry_per_tick` due entries
|
||||
/// (bumping their `retry_after_ms` past the handshake window). This driver
|
||||
/// performs the advert-refetch + dial I/O each emitted `Connect` names, and
|
||||
/// on an immediate dial error feeds the `on_handshake_timeout` reflex so the
|
||||
/// optimistic re-fire suppression is overwritten by proper backoff. During a
|
||||
/// drain the gate is `Suspended`, so the reconcile clears the schedule and
|
||||
/// emits nothing.
|
||||
pub(in crate::node) async fn process_pending_retries(&mut self, now_ms: u64) {
|
||||
if self.peering.reconciler.retry_pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Retry-dial cadence slot: empty config floor and empty discovery
|
||||
// pools, so only the retry-dial phase acts.
|
||||
let policy = self.build_peering_policy(Vec::new());
|
||||
let observed = self.observe_peering();
|
||||
let budget = self.build_peering_budget();
|
||||
let gate = Gate::from_state(self.supervisor.state);
|
||||
let actions = self.peering.reconciler.reconcile(
|
||||
&policy,
|
||||
&observed,
|
||||
&budget,
|
||||
&DiscoveryPools::default(),
|
||||
now_ms,
|
||||
gate,
|
||||
);
|
||||
|
||||
for action in actions {
|
||||
let PeeringAction::Connect(candidate) = action else {
|
||||
continue;
|
||||
};
|
||||
let Some(identity) = candidate.identity else {
|
||||
continue;
|
||||
};
|
||||
let node_addr = *identity.node_addr();
|
||||
let Some(peer_config) = self
|
||||
.peering
|
||||
.reconciler
|
||||
.retry_pending
|
||||
.get(&node_addr)
|
||||
.map(|state| state.peer_config.clone())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Refresh the peer's overlay advert before retrying. The cache is
|
||||
// read-only on hit, so a retry without a refetch dials the same
|
||||
// cached endpoint — and the most common reason a peer landed in the
|
||||
// retry schedule is that endpoint just stopped working (NAT rebind,
|
||||
// port change, peer restart). Cheap (one Filter fetch, bounded by
|
||||
// the retry backoff cadence).
|
||||
if let Some(bootstrap) = self.supervisor.nostr_rendezvous.engine_arc() {
|
||||
let _ = bootstrap
|
||||
.refetch_advert_for_stale_check(&peer_config.npub)
|
||||
.await;
|
||||
}
|
||||
|
||||
match self.initiate_peer_connection(&peer_config).await {
|
||||
// The core already pushed `retry_after_ms` past the handshake
|
||||
// window; a successful promotion clears the entry, a later
|
||||
// timeout re-fires the reflex with proper backoff.
|
||||
Ok(()) => {}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
peer = %self.peer_display_name(&node_addr),
|
||||
error = %e,
|
||||
"Retry connection initiation failed"
|
||||
);
|
||||
// No-transport failures usually mean the cached overlay
|
||||
// advert is stale; force a re-fetch so the next tick picks up
|
||||
// fresh endpoints.
|
||||
if matches!(e, NodeError::NoTransportForType(_))
|
||||
&& let Some(bootstrap) = self.supervisor.nostr_rendezvous.engine_arc()
|
||||
{
|
||||
let npub = peer_config.npub.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = bootstrap.refetch_advert_for_stale_check(&npub).await;
|
||||
});
|
||||
}
|
||||
// Immediate failure counts as an attempt: overwrite the
|
||||
// optimistic re-fire suppression with backoff.
|
||||
self.note_handshake_timeout(node_addr, now_ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
14
src/node/peering/mod.rs
Normal file
14
src/node/peering/mod.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
//! Peering homeostasis — the desired-state controller for the node's peer set.
|
||||
//!
|
||||
//! This module is the home for the peering-reconciler concept: config defines a
|
||||
//! desired peer set; the reconciler converges the observed set toward it
|
||||
//! (auto-connect floor, overlay pool, transport-neighbor growth) under the
|
||||
//! `node.limits` ceiling. Startup and steady-state are the same loop.
|
||||
//!
|
||||
//! The cross-attempt retry schedule (`retry.rs`) lives here because a fresh
|
||||
//! connection is created per re-dial, so the escalating backoff count must
|
||||
//! persist in the reconciler, not per-connection.
|
||||
|
||||
pub(in crate::node) mod driver;
|
||||
pub(in crate::node) mod reconcile;
|
||||
pub(in crate::node) mod retry;
|
||||
1447
src/node/peering/reconcile.rs
Normal file
1447
src/node/peering/reconcile.rs
Normal file
File diff suppressed because it is too large
Load Diff
49
src/node/peering/retry.rs
Normal file
49
src/node/peering/retry.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
//! Cross-attempt retry state for auto-connect peers.
|
||||
//!
|
||||
//! [`RetryState`] is the durable per-peer schedule entry the peering reconciler
|
||||
//! owns (it lives in [`crate::node::peering::reconcile::PeeringReconciler`], not
|
||||
//! on a per-connection object, because a fresh connection is created per re-dial
|
||||
//! so the escalating backoff count must persist across attempts). The decision
|
||||
//! logic that reads and mutates it — the retry-dial phase and the
|
||||
//! `on_handshake_timeout` / `on_link_dead` reflexes — lives in the sans-IO
|
||||
//! reconciler core; the driver wrappers that feed it (retry-dial I/O, the
|
||||
//! gate-guarded reflex call sites) live in [`super::driver`].
|
||||
|
||||
use crate::config::PeerConfig;
|
||||
|
||||
/// Per-tick cap on retry-dial connection attempts (ceiling).
|
||||
pub(in crate::node) const MAX_RETRY_CONNECTIONS_PER_TICK: usize = 16;
|
||||
|
||||
/// Tracks retry state for a peer across connection attempts.
|
||||
pub struct RetryState {
|
||||
/// The peer config to use for initiating retries.
|
||||
pub peer_config: PeerConfig,
|
||||
|
||||
/// Number of retries attempted so far.
|
||||
pub retry_count: u32,
|
||||
|
||||
/// Timestamp (Unix ms) when the next retry should be attempted.
|
||||
pub retry_after_ms: u64,
|
||||
|
||||
/// Whether this is an auto-reconnect (unlimited retries, ignores max_retries).
|
||||
pub reconnect: bool,
|
||||
|
||||
/// Optional absolute expiry for this retry entry (Unix ms).
|
||||
///
|
||||
/// When set, retries are dropped after this point even if reconnect logic
|
||||
/// would otherwise continue.
|
||||
pub expires_at_ms: Option<u64>,
|
||||
}
|
||||
|
||||
impl RetryState {
|
||||
/// Create a new retry state for a peer.
|
||||
pub fn new(peer_config: PeerConfig) -> Self {
|
||||
Self {
|
||||
peer_config,
|
||||
retry_count: 0,
|
||||
retry_after_ms: 0,
|
||||
reconnect: false,
|
||||
expires_at_ms: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -222,7 +222,7 @@ pub enum MmpReject {
|
||||
/// Forwarding-path rejection reasons.
|
||||
///
|
||||
/// Each variant corresponds to a silent-rejection path in
|
||||
/// `src/node/handlers/forwarding.rs::handle_session_datagram`. Matching
|
||||
/// `src/node/dataplane/forwarding.rs::handle_session_datagram`. Matching
|
||||
/// `ForwardingStats` counters already track packets and bytes for each
|
||||
/// outcome; `record_reject` mirrors the packet-count side of the bump
|
||||
/// for parity with the other rejection clusters.
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
//! file. There is nothing to poll. (Its read side could adopt the same
|
||||
//! lock-free `ArcSwap` shape in the future, but that is an optimization, not
|
||||
//! a reload.)
|
||||
//! - `nostr_discovery` is an async spawned subsystem, not a snapshot of disk
|
||||
//! - `nostr_rendezvous` is an async spawned subsystem, not a snapshot of disk
|
||||
//! state.
|
||||
//!
|
||||
//! Both [`HostMapReloadable`] and the peer ACL reloader currently stat
|
||||
|
||||
@@ -1,417 +0,0 @@
|
||||
//! Connection retry logic for auto-connect peers.
|
||||
//!
|
||||
//! When an outbound handshake fails (timeout or send error), the node can
|
||||
//! automatically retry with exponential backoff. Retry state lives on Node
|
||||
//! (not PeerConnection) because each retry creates a fresh connection.
|
||||
|
||||
use super::{Node, NodeError};
|
||||
use crate::PeerIdentity;
|
||||
use crate::config::PeerConfig;
|
||||
use crate::identity::NodeAddr;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
// MAX_BACKOFF_MS is now derived from config: node.retry.max_backoff_secs * 1000
|
||||
const MAX_RETRY_CONNECTIONS_PER_TICK: usize = 16;
|
||||
|
||||
/// Tracks retry state for a peer across connection attempts.
|
||||
pub struct RetryState {
|
||||
/// The peer config to use for initiating retries.
|
||||
pub peer_config: PeerConfig,
|
||||
|
||||
/// Number of retries attempted so far.
|
||||
pub retry_count: u32,
|
||||
|
||||
/// Timestamp (Unix ms) when the next retry should be attempted.
|
||||
pub retry_after_ms: u64,
|
||||
|
||||
/// Whether this is an auto-reconnect (unlimited retries, ignores max_retries).
|
||||
pub reconnect: bool,
|
||||
|
||||
/// Optional absolute expiry for this retry entry (Unix ms).
|
||||
///
|
||||
/// When set, retries are dropped after this point even if reconnect logic
|
||||
/// would otherwise continue.
|
||||
pub expires_at_ms: Option<u64>,
|
||||
}
|
||||
|
||||
impl RetryState {
|
||||
/// Create a new retry state for a peer.
|
||||
pub fn new(peer_config: PeerConfig) -> Self {
|
||||
Self {
|
||||
peer_config,
|
||||
retry_count: 0,
|
||||
retry_after_ms: 0,
|
||||
reconnect: false,
|
||||
expires_at_ms: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate the backoff delay in milliseconds for the current retry count.
|
||||
///
|
||||
/// Uses exponential backoff: `base_interval_ms * 2^retry_count`,
|
||||
/// capped at `MAX_BACKOFF_MS`.
|
||||
pub fn backoff_ms(&self, base_interval_ms: u64, max_backoff_ms: u64) -> u64 {
|
||||
let multiplier = 1u64.checked_shl(self.retry_count).unwrap_or(u64::MAX);
|
||||
base_interval_ms
|
||||
.saturating_mul(multiplier)
|
||||
.min(max_backoff_ms)
|
||||
}
|
||||
}
|
||||
|
||||
impl Node {
|
||||
/// Schedule a retry for a failed outbound connection, if applicable.
|
||||
///
|
||||
/// Only schedules if the peer is an auto-connect peer and max retries
|
||||
/// have not been exhausted (unless `reconnect` is true, which retries
|
||||
/// indefinitely). Does nothing if the peer is already connected or has
|
||||
/// a connection in progress.
|
||||
pub(super) fn schedule_retry(&mut self, node_addr: NodeAddr, now_ms: u64) {
|
||||
let retry_cfg = &self.config().node.retry;
|
||||
let max_retries = retry_cfg.max_retries;
|
||||
if max_retries == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't retry if peer is already connected
|
||||
if self.peers.contains_key(&node_addr) {
|
||||
return;
|
||||
}
|
||||
|
||||
let base_interval_ms = retry_cfg.base_interval_secs * 1000;
|
||||
let max_backoff_ms = retry_cfg.max_backoff_secs * 1000;
|
||||
let peer_name = self.peer_display_name(&node_addr);
|
||||
|
||||
if let Some(state) = self.retry_pending.get_mut(&node_addr) {
|
||||
// Already tracking — increment
|
||||
state.retry_count += 1;
|
||||
if !state.reconnect && state.retry_count > max_retries {
|
||||
info!(
|
||||
peer = %peer_name,
|
||||
attempts = state.retry_count,
|
||||
"Max retries exhausted, giving up on peer"
|
||||
);
|
||||
self.retry_pending.remove(&node_addr);
|
||||
return;
|
||||
}
|
||||
let delay = state.backoff_ms(base_interval_ms, max_backoff_ms);
|
||||
state.retry_after_ms = now_ms + delay;
|
||||
debug!(
|
||||
peer = %peer_name,
|
||||
retry = state.retry_count,
|
||||
reconnect = state.reconnect,
|
||||
delay_secs = delay / 1000,
|
||||
"Scheduling connection retry"
|
||||
);
|
||||
} else {
|
||||
// First failure — find the matching PeerConfig
|
||||
let peer_config = self
|
||||
.config()
|
||||
.auto_connect_peers()
|
||||
.find(|pc| {
|
||||
PeerIdentity::from_npub(&pc.npub)
|
||||
.map(|id| *id.node_addr() == node_addr)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.cloned();
|
||||
|
||||
if let Some(pc) = peer_config {
|
||||
let mut state = RetryState::new(pc);
|
||||
state.retry_count = 1;
|
||||
state.reconnect = true;
|
||||
let delay = state.backoff_ms(base_interval_ms, max_backoff_ms);
|
||||
state.retry_after_ms = now_ms + delay;
|
||||
debug!(
|
||||
peer = %self.peer_display_name(&node_addr),
|
||||
delay_secs = delay / 1000,
|
||||
"First connection attempt failed, scheduling retry"
|
||||
);
|
||||
self.retry_pending.insert(node_addr, state);
|
||||
}
|
||||
// If not found in auto_connect_peers, no retry (one-shot connection)
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedule auto-reconnect for a peer removed by MMP dead timeout.
|
||||
///
|
||||
/// Looks up the peer in auto-connect config and checks `auto_reconnect`.
|
||||
/// If enabled, feeds the peer into the retry system with unlimited retries.
|
||||
///
|
||||
/// If a retry entry already exists (e.g. from a previous failed handshake
|
||||
/// attempt during an earlier reconnect cycle), the existing retry count is
|
||||
/// preserved and incremented rather than reset to zero. This ensures
|
||||
/// exponential backoff accumulates across repeated link-dead events instead
|
||||
/// of resetting to the base interval on every peer removal.
|
||||
pub(super) fn schedule_reconnect(&mut self, node_addr: NodeAddr, now_ms: u64) {
|
||||
// Find peer in auto-connect config
|
||||
let peer_config = self
|
||||
.config()
|
||||
.auto_connect_peers()
|
||||
.find(|pc| {
|
||||
PeerIdentity::from_npub(&pc.npub)
|
||||
.map(|id| *id.node_addr() == node_addr)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.cloned();
|
||||
|
||||
let Some(pc) = peer_config else {
|
||||
return; // Not an auto-connect peer, no reconnect
|
||||
};
|
||||
|
||||
if !pc.auto_reconnect {
|
||||
debug!(
|
||||
peer = %self.peer_display_name(&node_addr),
|
||||
"Auto-reconnect disabled for peer, skipping"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let base_interval_ms = self.config().node.retry.base_interval_secs * 1000;
|
||||
let max_backoff_ms = self.config().node.retry.max_backoff_secs * 1000;
|
||||
let peer_name = self.peer_display_name(&node_addr);
|
||||
|
||||
// If we already have accumulated backoff from previous failed attempts,
|
||||
// preserve and bump it rather than resetting to zero. This prevents the
|
||||
// exponential backoff from being discarded on each link-dead cycle.
|
||||
if let Some(state) = self.retry_pending.get_mut(&node_addr) {
|
||||
state.reconnect = true;
|
||||
state.retry_count += 1;
|
||||
let delay = state.backoff_ms(base_interval_ms, max_backoff_ms);
|
||||
state.retry_after_ms = now_ms + delay;
|
||||
debug!(
|
||||
peer = %peer_name,
|
||||
retry = state.retry_count,
|
||||
delay_secs = delay / 1000,
|
||||
"Scheduling auto-reconnect after link-dead removal (backoff preserved)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut state = RetryState::new(pc);
|
||||
state.reconnect = true;
|
||||
let delay = state.backoff_ms(base_interval_ms, max_backoff_ms);
|
||||
state.retry_after_ms = now_ms + delay;
|
||||
|
||||
debug!(
|
||||
peer = %peer_name,
|
||||
delay_secs = delay / 1000,
|
||||
"Scheduling auto-reconnect after link-dead removal"
|
||||
);
|
||||
|
||||
self.retry_pending.insert(node_addr, state);
|
||||
}
|
||||
|
||||
/// Process pending retries whose time has arrived.
|
||||
///
|
||||
/// For each due retry, initiates a fresh connection attempt. The retry
|
||||
/// entry stays in `retry_pending` until the connection succeeds (cleared
|
||||
/// in `promote_connection`) or max retries are exhausted (cleared in
|
||||
/// `schedule_retry`).
|
||||
pub(super) async fn process_pending_retries(&mut self, now_ms: u64) {
|
||||
if self.retry_pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let expired: Vec<NodeAddr> = self
|
||||
.retry_pending
|
||||
.iter()
|
||||
.filter_map(|(addr, state)| {
|
||||
state
|
||||
.expires_at_ms
|
||||
.filter(|expires_at_ms| now_ms >= *expires_at_ms)
|
||||
.map(|_| *addr)
|
||||
})
|
||||
.collect();
|
||||
for node_addr in expired {
|
||||
self.retry_pending.remove(&node_addr);
|
||||
info!(
|
||||
peer = %self.peer_display_name(&node_addr),
|
||||
"Retry window expired, dropping pending retry state"
|
||||
);
|
||||
}
|
||||
if self.retry_pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if !self.outbound_admission_check() {
|
||||
debug!(
|
||||
peers = self.peers.len(),
|
||||
max_peers = self.max_peers(),
|
||||
retry_pending = self.retry_pending.len(),
|
||||
"Suppressing auto-reconnect retries: at capacity"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect retries that are due
|
||||
let due: Vec<NodeAddr> = self
|
||||
.retry_pending
|
||||
.iter()
|
||||
.filter(|(_, state)| now_ms >= state.retry_after_ms)
|
||||
.map(|(addr, _)| *addr)
|
||||
.collect();
|
||||
let deferred = due.len().saturating_sub(MAX_RETRY_CONNECTIONS_PER_TICK);
|
||||
if deferred > 0 {
|
||||
debug!(
|
||||
due = due.len(),
|
||||
processing = MAX_RETRY_CONNECTIONS_PER_TICK,
|
||||
deferred,
|
||||
"Retry processing budget exhausted; deferring remaining peers"
|
||||
);
|
||||
}
|
||||
|
||||
for node_addr in due.into_iter().take(MAX_RETRY_CONNECTIONS_PER_TICK) {
|
||||
// Peer may have connected inbound while we waited
|
||||
if self.peers.contains_key(&node_addr) {
|
||||
self.retry_pending.remove(&node_addr);
|
||||
continue;
|
||||
}
|
||||
|
||||
let state = match self.retry_pending.get(&node_addr) {
|
||||
Some(s) => s,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
debug!(
|
||||
peer = %self.peer_display_name(&node_addr),
|
||||
retry = state.retry_count,
|
||||
"Attempting connection retry"
|
||||
);
|
||||
|
||||
let peer_config = state.peer_config.clone();
|
||||
|
||||
// Refresh the peer's overlay advert before retrying. The cache is
|
||||
// read-only on hit (see fetch_advert), so every retry without a
|
||||
// refetch dials the same cached endpoint — and the most common
|
||||
// reason a peer ended up in retry_pending is that the cached
|
||||
// endpoint just stopped working (NAT rebind, port change, peer
|
||||
// restart on a different port). Without this refresh the retry
|
||||
// loop dials the same dead address forever.
|
||||
//
|
||||
// refetch_advert_for_stale_check uses the relay's advert as
|
||||
// ground truth: replaces the cache if there's a newer one,
|
||||
// evicts if the relay has nothing, otherwise leaves it. Cheap
|
||||
// (one Filter fetch with 2s timeout) and bounded by the retry
|
||||
// backoff cadence.
|
||||
if let Some(bootstrap) = self.nostr_discovery.clone() {
|
||||
let _ = bootstrap
|
||||
.refetch_advert_for_stale_check(&peer_config.npub)
|
||||
.await;
|
||||
}
|
||||
|
||||
match self.initiate_peer_connection(&peer_config).await {
|
||||
Ok(()) => {
|
||||
// Push retry_after_ms past the handshake timeout window so
|
||||
// we don't re-fire on the next tick. If the handshake
|
||||
// succeeds, promote_connection() clears retry_pending. If
|
||||
// it times out, check_timeouts() calls schedule_retry()
|
||||
// which bumps the counter and applies proper backoff.
|
||||
let hs_timeout_ms = self.config().node.rate_limit.handshake_timeout_secs * 1000;
|
||||
if let Some(state) = self.retry_pending.get_mut(&node_addr) {
|
||||
state.retry_after_ms = now_ms + hs_timeout_ms;
|
||||
}
|
||||
debug!(
|
||||
peer = %self.peer_display_name(&node_addr),
|
||||
"Retry connection initiated, suppressing re-fire for {}s",
|
||||
self.config().node.rate_limit.handshake_timeout_secs,
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
peer = %self.peer_display_name(&node_addr),
|
||||
error = %e,
|
||||
"Retry connection initiation failed"
|
||||
);
|
||||
// No-transport failures usually mean the cached overlay
|
||||
// advert is stale (peer rebound NAT, switched relay, etc.).
|
||||
// The advert cache is read-only inside fetch_advert, so
|
||||
// every retry returns the same dead address until the
|
||||
// entry expires. Force a re-fetch so the next retry tick
|
||||
// picks up fresh endpoints.
|
||||
if matches!(e, NodeError::NoTransportForType(_))
|
||||
&& let Some(bootstrap) = self.nostr_discovery.clone()
|
||||
{
|
||||
let npub = peer_config.npub.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = bootstrap.refetch_advert_for_stale_check(&npub).await;
|
||||
});
|
||||
}
|
||||
// Immediate failure counts as an attempt — schedule next retry
|
||||
// (reconnect flag is preserved on existing retry_pending entry)
|
||||
self.schedule_retry(node_addr, now_ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::PeerConfig;
|
||||
|
||||
const TEST_MAX_BACKOFF_MS: u64 = 300_000;
|
||||
|
||||
#[test]
|
||||
fn test_backoff_exponential() {
|
||||
let state = RetryState {
|
||||
peer_config: PeerConfig::default(),
|
||||
retry_count: 0,
|
||||
retry_after_ms: 0,
|
||||
reconnect: false,
|
||||
expires_at_ms: None,
|
||||
};
|
||||
// base = 5000ms
|
||||
assert_eq!(state.backoff_ms(5000, TEST_MAX_BACKOFF_MS), 5000); // 5s * 2^0
|
||||
|
||||
let state = RetryState {
|
||||
retry_count: 1,
|
||||
..state
|
||||
};
|
||||
assert_eq!(state.backoff_ms(5000, TEST_MAX_BACKOFF_MS), 10_000); // 5s * 2^1
|
||||
|
||||
let state = RetryState {
|
||||
retry_count: 2,
|
||||
..state
|
||||
};
|
||||
assert_eq!(state.backoff_ms(5000, TEST_MAX_BACKOFF_MS), 20_000); // 5s * 2^2
|
||||
|
||||
let state = RetryState {
|
||||
retry_count: 3,
|
||||
..state
|
||||
};
|
||||
assert_eq!(state.backoff_ms(5000, TEST_MAX_BACKOFF_MS), 40_000); // 5s * 2^3
|
||||
|
||||
let state = RetryState {
|
||||
retry_count: 4,
|
||||
..state
|
||||
};
|
||||
assert_eq!(state.backoff_ms(5000, TEST_MAX_BACKOFF_MS), 80_000); // 5s * 2^4
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backoff_cap() {
|
||||
let state = RetryState {
|
||||
peer_config: PeerConfig::default(),
|
||||
retry_count: 20, // 2^20 * 5000 would be huge
|
||||
retry_after_ms: 0,
|
||||
reconnect: false,
|
||||
expires_at_ms: None,
|
||||
};
|
||||
assert_eq!(
|
||||
state.backoff_ms(5000, TEST_MAX_BACKOFF_MS),
|
||||
TEST_MAX_BACKOFF_MS
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backoff_zero_base() {
|
||||
let state = RetryState {
|
||||
peer_config: PeerConfig::default(),
|
||||
retry_count: 3,
|
||||
retry_after_ms: 0,
|
||||
reconnect: false,
|
||||
expires_at_ms: None,
|
||||
};
|
||||
assert_eq!(state.backoff_ms(0, TEST_MAX_BACKOFF_MS), 0);
|
||||
}
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
//! Routing error signal rate limiting.
|
||||
//!
|
||||
//! Prevents routing error floods (CoordsRequired / PathBroken) by
|
||||
//! rate-limiting error signals per destination address at transit nodes.
|
||||
|
||||
use crate::NodeAddr;
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Rate limiter for routing error signals (CoordsRequired / PathBroken).
|
||||
///
|
||||
/// Tracks the last time a routing error was sent for each destination
|
||||
/// address and enforces a minimum interval to prevent floods.
|
||||
pub struct RoutingErrorRateLimiter {
|
||||
/// Maps destination NodeAddr to the last time we sent an error about it.
|
||||
last_sent: HashMap<NodeAddr, Instant>,
|
||||
/// Minimum interval between error signals for the same destination.
|
||||
min_interval: Duration,
|
||||
/// Maximum age of entries before cleanup.
|
||||
max_age: Duration,
|
||||
}
|
||||
|
||||
impl RoutingErrorRateLimiter {
|
||||
/// Create a new rate limiter.
|
||||
///
|
||||
/// Default: max 10 errors/sec per destination (100ms interval).
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
last_sent: HashMap::new(),
|
||||
min_interval: Duration::from_millis(100),
|
||||
max_age: Duration::from_secs(10),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a rate limiter with a custom minimum interval.
|
||||
pub fn with_interval(min_interval: Duration) -> Self {
|
||||
Self {
|
||||
last_sent: HashMap::new(),
|
||||
min_interval,
|
||||
max_age: Duration::from_secs(10),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if we should send a routing error for this destination.
|
||||
///
|
||||
/// Returns true if enough time has passed since the last error for
|
||||
/// this destination, or if this is the first error. Updates internal
|
||||
/// state when returning true.
|
||||
pub fn should_send(&mut self, dest_addr: &NodeAddr) -> bool {
|
||||
let now = Instant::now();
|
||||
|
||||
if let Some(&last) = self.last_sent.get(dest_addr)
|
||||
&& now.duration_since(last) < self.min_interval
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
self.last_sent.insert(*dest_addr, now);
|
||||
self.cleanup(now);
|
||||
true
|
||||
}
|
||||
|
||||
/// Remove entries older than max_age.
|
||||
fn cleanup(&mut self, now: Instant) {
|
||||
self.last_sent
|
||||
.retain(|_, &mut last| now.duration_since(last) < self.max_age);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn len(&self) -> usize {
|
||||
self.last_sent.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RoutingErrorRateLimiter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::thread;
|
||||
|
||||
fn addr(val: u8) -> NodeAddr {
|
||||
let mut bytes = [0u8; 16];
|
||||
bytes[0] = val;
|
||||
NodeAddr::from_bytes(bytes)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_first_send_allowed() {
|
||||
let mut limiter = RoutingErrorRateLimiter::new();
|
||||
assert!(limiter.should_send(&addr(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rapid_sends_rate_limited() {
|
||||
let mut limiter = RoutingErrorRateLimiter::new();
|
||||
assert!(limiter.should_send(&addr(1)));
|
||||
assert!(!limiter.should_send(&addr(1)));
|
||||
assert!(!limiter.should_send(&addr(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_destinations_independent() {
|
||||
let mut limiter = RoutingErrorRateLimiter::new();
|
||||
assert!(limiter.should_send(&addr(1)));
|
||||
assert!(limiter.should_send(&addr(2)));
|
||||
assert!(!limiter.should_send(&addr(1)));
|
||||
assert!(!limiter.should_send(&addr(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_send_allowed_after_interval() {
|
||||
let mut limiter = RoutingErrorRateLimiter::new();
|
||||
assert!(limiter.should_send(&addr(1)));
|
||||
|
||||
thread::sleep(Duration::from_millis(110));
|
||||
|
||||
assert!(limiter.should_send(&addr(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cleanup_removes_old_entries() {
|
||||
let mut limiter = RoutingErrorRateLimiter::new();
|
||||
assert!(limiter.should_send(&addr(1)));
|
||||
assert!(limiter.should_send(&addr(2)));
|
||||
assert_eq!(limiter.len(), 2);
|
||||
|
||||
let future = Instant::now() + Duration::from_secs(11);
|
||||
limiter.cleanup(future);
|
||||
assert_eq!(limiter.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cleanup_preserves_recent_entries() {
|
||||
let mut limiter = RoutingErrorRateLimiter::new();
|
||||
assert!(limiter.should_send(&addr(1)));
|
||||
assert_eq!(limiter.len(), 1);
|
||||
|
||||
limiter.cleanup(Instant::now());
|
||||
assert_eq!(limiter.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_interval_custom_rate() {
|
||||
let mut limiter = RoutingErrorRateLimiter::with_interval(Duration::from_millis(500));
|
||||
assert!(limiter.should_send(&addr(1)));
|
||||
assert!(!limiter.should_send(&addr(1)));
|
||||
|
||||
// Still rate-limited after 200ms (would pass with default 100ms)
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
assert!(!limiter.should_send(&addr(1)));
|
||||
|
||||
// Allowed after 500ms total
|
||||
thread::sleep(Duration::from_millis(350));
|
||||
assert!(limiter.should_send(&addr(1)));
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,11 @@
|
||||
//! (SessionSetup/SessionAck/SessionMsg3) carried inside SessionDatagram
|
||||
//! envelopes through the mesh.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::NodeAddr;
|
||||
use crate::config::SessionMmpConfig;
|
||||
use crate::mmp::MmpSessionState;
|
||||
use crate::node::REKEY_JITTER_SECS;
|
||||
use crate::noise::{HandshakeState, NoiseSession};
|
||||
use crate::proto::mmp::MmpSessionState;
|
||||
use rand::RngExt;
|
||||
use secp256k1::PublicKey;
|
||||
|
||||
@@ -338,7 +336,12 @@ impl SessionEntry {
|
||||
|
||||
/// Initialize session-layer MMP state (called on Established transition).
|
||||
pub(crate) fn init_mmp(&mut self, config: &SessionMmpConfig) {
|
||||
self.mmp = Some(MmpSessionState::new(config, self.is_initiator));
|
||||
self.mmp = Some(MmpSessionState::new(
|
||||
config.mode,
|
||||
config.log_interval_secs,
|
||||
config.owd_window_size,
|
||||
self.is_initiator,
|
||||
));
|
||||
}
|
||||
|
||||
// === Traffic Counters ===
|
||||
@@ -665,9 +668,9 @@ impl SessionEntry {
|
||||
self.rekey_jitter_secs = draw_rekey_jitter();
|
||||
|
||||
// Reset MMP counters to avoid metric discontinuity
|
||||
let now = Instant::now();
|
||||
let now_ms = crate::time::mono_ms();
|
||||
if let Some(mmp) = &mut self.mmp {
|
||||
mmp.reset_for_rekey(now);
|
||||
mmp.reset_for_rekey(now_ms);
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -798,8 +801,8 @@ impl SessionEntry {
|
||||
#[cfg(test)]
|
||||
mod overlapping_epoch_tests {
|
||||
use super::*;
|
||||
use crate::node::session_wire::{FSP_FLAG_K, build_fsp_header};
|
||||
use crate::noise::HandshakeState;
|
||||
use crate::proto::fsp::wire::{FSP_FLAG_K, build_fsp_header};
|
||||
use secp256k1::{Keypair, Secp256k1, SecretKey};
|
||||
|
||||
/// Deterministic keypair from a single seed byte.
|
||||
@@ -1262,4 +1265,155 @@ mod overlapping_epoch_tests {
|
||||
"window must expire on the plain wall-clock timer when peer is off the old epoch"
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Rekey-policy characterization (pins `check_session_rekey`'s decision
|
||||
// boundaries before the `Fsp::poll_rekey` hoist — these thresholds have no
|
||||
// other test module).
|
||||
// ========================================================================
|
||||
|
||||
/// The initiator liveness-cutover delay used by `check_session_rekey`
|
||||
/// (`FSP_CUTOVER_DELAY_MS`). Mirrored here as the characterization anchor.
|
||||
const CUTOVER_DELAY_MS: u64 = 2000;
|
||||
|
||||
/// Build an established entry that has completed a rekey as initiator and
|
||||
/// holds a pending session awaiting the K-bit cutover.
|
||||
fn entry_pending_cutover(rekey_completed_ms: u64) -> SessionEntry {
|
||||
let (_cur_send, cur_recv) = xk_pair(1, 2);
|
||||
let (_new_send, new_recv) = xk_pair(3, 4);
|
||||
let mut entry = entry_with_current(cur_recv);
|
||||
// Mark ourselves the rekey initiator, then land the completed session
|
||||
// as pending (clears rekey_state, so has_rekey_in_progress() == false).
|
||||
entry.set_rekey_state(HandshakeState::new_xk_responder(keypair(7)), true);
|
||||
entry.set_pending_session(new_recv);
|
||||
entry.set_rekey_completed_ms(rekey_completed_ms);
|
||||
entry
|
||||
}
|
||||
|
||||
// The initiator-side cutover predicate: pending session present, no rekey
|
||||
// in progress, we are the initiator, and the liveness timer has elapsed.
|
||||
#[test]
|
||||
fn rekey_cutover_predicate_boundary() {
|
||||
let completed = 1_000u64;
|
||||
let entry = entry_pending_cutover(completed);
|
||||
|
||||
assert!(entry.pending_new_session().is_some());
|
||||
assert!(!entry.has_rekey_in_progress());
|
||||
assert!(entry.is_rekey_initiator());
|
||||
|
||||
// Not yet eligible one ms before the delay elapses.
|
||||
let just_before = completed + CUTOVER_DELAY_MS - 1;
|
||||
assert!(
|
||||
just_before.saturating_sub(entry.rekey_completed_ms()) < CUTOVER_DELAY_MS,
|
||||
"cutover must not fire before the liveness delay"
|
||||
);
|
||||
// Eligible exactly at the delay.
|
||||
let at = completed + CUTOVER_DELAY_MS;
|
||||
assert!(
|
||||
at.saturating_sub(entry.rekey_completed_ms()) >= CUTOVER_DELAY_MS,
|
||||
"cutover fires once the liveness delay has elapsed"
|
||||
);
|
||||
}
|
||||
|
||||
// Rekey-trigger threshold: elapsed time (with symmetric jitter applied)
|
||||
// OR the send counter crossing its configured bound.
|
||||
#[test]
|
||||
fn rekey_trigger_threshold_arithmetic() {
|
||||
let after_secs = 100u64;
|
||||
let after_messages = 1_000u64;
|
||||
|
||||
// Jitter is always within [-REKEY_JITTER_SECS, +REKEY_JITTER_SECS].
|
||||
let (_s, recv) = xk_pair(1, 2);
|
||||
let entry = entry_with_current(recv);
|
||||
let jitter = entry.rekey_jitter_secs();
|
||||
assert!(
|
||||
jitter.abs() <= REKEY_JITTER_SECS,
|
||||
"jitter within configured bound"
|
||||
);
|
||||
|
||||
// Effective time threshold applies the symmetric jitter.
|
||||
let effective_after = after_secs.saturating_add_signed(jitter);
|
||||
|
||||
// Reproduce the policy's OR predicate directly.
|
||||
let triggers =
|
||||
|elapsed: u64, counter: u64| elapsed >= effective_after || counter >= after_messages;
|
||||
|
||||
// Time arm: fires at/after the effective threshold, not below it.
|
||||
assert!(!triggers(effective_after - 1, 0), "below time threshold");
|
||||
assert!(triggers(effective_after, 0), "at time threshold");
|
||||
// Counter arm: fires independently of elapsed time.
|
||||
assert!(!triggers(0, after_messages - 1), "below counter threshold");
|
||||
assert!(triggers(0, after_messages), "at counter threshold");
|
||||
}
|
||||
|
||||
// Dampening boundary: within `dampening_ms` of the peer's rekey msg1, local
|
||||
// initiation is suppressed; at/after the window it is not.
|
||||
#[test]
|
||||
fn rekey_dampening_boundary() {
|
||||
let (_s, recv) = xk_pair(1, 2);
|
||||
let mut entry = entry_with_current(recv);
|
||||
const DAMP_MS: u64 = 30_000;
|
||||
|
||||
// No peer rekey recorded → never dampened.
|
||||
assert!(!entry.is_rekey_dampened(50_000, DAMP_MS));
|
||||
|
||||
entry.record_peer_rekey(10_000);
|
||||
assert!(
|
||||
entry.is_rekey_dampened(10_000 + DAMP_MS - 1, DAMP_MS),
|
||||
"dampened within the window"
|
||||
);
|
||||
assert!(
|
||||
!entry.is_rekey_dampened(10_000 + DAMP_MS, DAMP_MS),
|
||||
"not dampened once the window has elapsed"
|
||||
);
|
||||
}
|
||||
|
||||
// Epoch-reaction: a frame authenticating against `pending` while a msg3
|
||||
// retransmission is retained confirms the peer on the new epoch (clears the
|
||||
// msg3 payload) and then promotes.
|
||||
#[test]
|
||||
fn epoch_reaction_pending_confirms_then_promotes() {
|
||||
let (mut p_send, p_recv) = xk_pair(3, 4);
|
||||
let (_cur_send, cur_recv) = xk_pair(1, 2);
|
||||
let mut entry = entry_with_current(cur_recv);
|
||||
let k_before = entry.current_k_bit();
|
||||
entry.set_pending_session(p_recv);
|
||||
entry.set_rekey_msg3_payload(vec![0xAB; 8], 5_000);
|
||||
assert!(entry.rekey_msg3_payload().is_some());
|
||||
|
||||
let (ct, counter, hdr) = seal(&mut p_send, b"new-epoch", !k_before);
|
||||
let (_pt, slot) = entry
|
||||
.fsp_trial_decrypt(&ct, counter, &hdr, !k_before, 2_000)
|
||||
.expect("pending frame decrypts");
|
||||
assert_eq!(slot, EpochSlot::Pending);
|
||||
|
||||
// Reaction order: confirm (while pending still held) then promote.
|
||||
entry.confirm_peer_new_epoch();
|
||||
assert!(entry.rekey_msg3_payload().is_none());
|
||||
entry.handle_peer_kbit_flip(2_000);
|
||||
assert!(entry.pending_new_session().is_none());
|
||||
assert_ne!(entry.current_k_bit(), k_before);
|
||||
}
|
||||
|
||||
// Epoch-reaction: as the initiator that already cut over on its own timer
|
||||
// (msg3 retained, no pending), a frame authenticating against `current`
|
||||
// confirms the responder reached the new epoch.
|
||||
#[test]
|
||||
fn epoch_reaction_current_confirms_responder() {
|
||||
let (mut cur_send, cur_recv) = xk_pair(1, 2);
|
||||
let mut entry = entry_with_current(cur_recv);
|
||||
entry.set_rekey_msg3_payload(vec![0xCD; 8], 5_000);
|
||||
assert!(entry.pending_new_session().is_none());
|
||||
assert!(entry.rekey_msg3_payload().is_some());
|
||||
|
||||
let (ct, counter, hdr) = seal(&mut cur_send, b"steady", false);
|
||||
let (_pt, slot) = entry
|
||||
.fsp_trial_decrypt(&ct, counter, &hdr, false, 2_000)
|
||||
.expect("current frame decrypts");
|
||||
assert_eq!(slot, EpochSlot::Current);
|
||||
|
||||
// The Current-with-retained-msg3-and-no-pending arm confirms.
|
||||
entry.confirm_peer_new_epoch();
|
||||
assert!(entry.rekey_msg3_payload().is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,618 +0,0 @@
|
||||
//! FSP Wire Format Parsing and Serialization
|
||||
//!
|
||||
//! Defines the FIPS session-layer wire format (FSP) for packet dispatch.
|
||||
//! All FSP messages begin with a 4-byte common prefix followed by phase-specific
|
||||
//! fields. Encrypted messages use a 12-byte cleartext header as AAD for AEAD,
|
||||
//! and a 6-byte encrypted inner header containing timestamps and message type.
|
||||
//!
|
||||
//! ## Common Prefix (4 bytes)
|
||||
//!
|
||||
//! ```text
|
||||
//! [ver+phase:1][flags:1][payload_len:2 LE]
|
||||
//! ```
|
||||
//!
|
||||
//! ## DataPacket Port Multiplexing
|
||||
//!
|
||||
//! DataPacket (msg_type 0x10) payloads inside the AEAD envelope carry a 4-byte
|
||||
//! port header for service dispatch:
|
||||
//!
|
||||
//! ```text
|
||||
//! [src_port:2 LE][dst_port:2 LE][service payload...]
|
||||
//! ```
|
||||
//!
|
||||
//! Port 256 (0x100) = IPv6 shim with header compression.
|
||||
//!
|
||||
//! ## Message Classes
|
||||
//!
|
||||
//! | Phase | U Flag | Type | Description |
|
||||
//! |-------|--------|------------------|-----------------------------------|
|
||||
//! | 0x0 | 0 | Encrypted | Post-handshake encrypted data |
|
||||
//! | 0x0 | 1 | Plaintext error | CoordsRequired, PathBroken |
|
||||
//! | 0x1 | - | Handshake msg1 | SessionSetup (Noise XK msg1) |
|
||||
//! | 0x2 | - | Handshake msg2 | SessionAck (Noise XK msg2) |
|
||||
//! | 0x3 | - | Handshake msg3 | SessionMsg3 (Noise XK msg3) |
|
||||
|
||||
use crate::protocol::{ProtocolError, decode_optional_coords};
|
||||
use crate::tree::TreeCoordinate;
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
/// FSP protocol version (4 high bits of byte 0).
|
||||
pub const FSP_VERSION: u8 = 0;
|
||||
|
||||
/// Phase value for established (encrypted or plaintext error) messages.
|
||||
pub const FSP_PHASE_ESTABLISHED: u8 = 0x0;
|
||||
|
||||
/// Phase value for SessionSetup (Noise IK message 1).
|
||||
pub const FSP_PHASE_MSG1: u8 = 0x1;
|
||||
|
||||
/// Phase value for SessionAck (Noise handshake message 2).
|
||||
pub const FSP_PHASE_MSG2: u8 = 0x2;
|
||||
|
||||
/// Phase value for XK message 3 (initiator's encrypted static).
|
||||
pub const FSP_PHASE_MSG3: u8 = 0x3;
|
||||
|
||||
/// Size of the common packet prefix (all FSP message types).
|
||||
pub const FSP_COMMON_PREFIX_SIZE: usize = 4;
|
||||
|
||||
/// Size of the full encrypted message header (prefix + counter).
|
||||
pub const FSP_HEADER_SIZE: usize = 12;
|
||||
|
||||
/// Size of the encrypted inner header (timestamp + msg_type + inner_flags).
|
||||
pub const FSP_INNER_HEADER_SIZE: usize = 6;
|
||||
|
||||
/// AEAD authentication tag size (ChaCha20-Poly1305).
|
||||
const TAG_SIZE: usize = 16;
|
||||
|
||||
/// Minimum size for an encrypted FSP message: header + tag (no plaintext).
|
||||
pub const FSP_ENCRYPTED_MIN_SIZE: usize = FSP_HEADER_SIZE + TAG_SIZE; // 28 bytes
|
||||
|
||||
// FSP DataPacket port header constants.
|
||||
|
||||
/// Size of the FSP DataPacket port header (src_port + dst_port).
|
||||
pub const FSP_PORT_HEADER_SIZE: usize = 4;
|
||||
|
||||
/// FSP port: IPv6 shim service.
|
||||
pub const FSP_PORT_IPV6_SHIM: u16 = 256;
|
||||
|
||||
// Cleartext flag bit constants (byte 1 of common prefix, phase 0x0 only).
|
||||
|
||||
/// Coords Present — source and destination coordinates follow the header.
|
||||
pub const FSP_FLAG_CP: u8 = 0x01;
|
||||
|
||||
/// Key Epoch — selects active key during rekeying.
|
||||
#[allow(dead_code)]
|
||||
pub const FSP_FLAG_K: u8 = 0x02;
|
||||
|
||||
/// Unencrypted — payload is plaintext (error signals).
|
||||
pub const FSP_FLAG_U: u8 = 0x04;
|
||||
|
||||
// Inner flag bit constants (byte 5 of decrypted inner header).
|
||||
|
||||
/// Spin bit for end-to-end RTT measurement (inside AEAD).
|
||||
#[allow(dead_code)]
|
||||
pub const FSP_INNER_FLAG_SP: u8 = 0x01;
|
||||
|
||||
// ============================================================================
|
||||
// Common Prefix
|
||||
// ============================================================================
|
||||
|
||||
/// Parsed FSP common packet prefix (first 4 bytes of every FSP message).
|
||||
///
|
||||
/// Wire format:
|
||||
/// ```text
|
||||
/// [ver(4bits)+phase(4bits)][flags:1][payload_len:2 LE]
|
||||
/// ```
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FspCommonPrefix {
|
||||
/// Protocol version (high nibble of byte 0).
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub version: u8,
|
||||
/// Session lifecycle phase (low nibble of byte 0).
|
||||
pub phase: u8,
|
||||
/// Per-message signal flags.
|
||||
pub flags: u8,
|
||||
/// Length of payload following the phase-specific header.
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub payload_len: u16,
|
||||
}
|
||||
|
||||
impl FspCommonPrefix {
|
||||
/// Parse a common prefix from the first 4 bytes of FSP message data.
|
||||
pub fn parse(data: &[u8]) -> Option<Self> {
|
||||
if data.len() < FSP_COMMON_PREFIX_SIZE {
|
||||
return None;
|
||||
}
|
||||
|
||||
let version = data[0] >> 4;
|
||||
let phase = data[0] & 0x0F;
|
||||
let flags = data[1];
|
||||
let payload_len = u16::from_le_bytes([data[2], data[3]]);
|
||||
|
||||
Some(Self {
|
||||
version,
|
||||
phase,
|
||||
flags,
|
||||
payload_len,
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if the Unencrypted flag is set.
|
||||
pub fn is_unencrypted(&self) -> bool {
|
||||
self.flags & FSP_FLAG_U != 0
|
||||
}
|
||||
|
||||
/// Check if the Coords Present flag is set.
|
||||
pub fn has_coords(&self) -> bool {
|
||||
self.flags & FSP_FLAG_CP != 0
|
||||
}
|
||||
|
||||
/// Encode the ver+phase byte.
|
||||
fn ver_phase_byte(version: u8, phase: u8) -> u8 {
|
||||
(version << 4) | (phase & 0x0F)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Encrypted Message Header
|
||||
// ============================================================================
|
||||
|
||||
/// Parsed FSP encrypted message header (phase 0x0, U flag clear).
|
||||
///
|
||||
/// Wire format (12 bytes):
|
||||
/// ```text
|
||||
/// [ver+phase:1][flags:1][payload_len:2 LE][counter:8 LE]
|
||||
/// ```
|
||||
///
|
||||
/// The full 12-byte header is used as AAD for the AEAD construction.
|
||||
/// No receiver_idx — unlike FMP, FSP is end-to-end (dispatched by src_addr
|
||||
/// from the SessionDatagram envelope, not by index).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FspEncryptedHeader {
|
||||
/// Per-message flags (CP, K).
|
||||
pub flags: u8,
|
||||
/// Length of encrypted payload (excluding AEAD tag).
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub payload_len: u16,
|
||||
/// Monotonic counter used as AEAD nonce.
|
||||
pub counter: u64,
|
||||
/// Raw 12-byte header for use as AEAD AAD.
|
||||
pub header_bytes: [u8; FSP_HEADER_SIZE],
|
||||
}
|
||||
|
||||
impl FspEncryptedHeader {
|
||||
/// Parse an encrypted message header from FSP message data.
|
||||
///
|
||||
/// Returns None if the data is too short or has wrong version/phase,
|
||||
/// or if the U flag is set (plaintext messages use a different path).
|
||||
pub fn parse(data: &[u8]) -> Option<Self> {
|
||||
if data.len() < FSP_ENCRYPTED_MIN_SIZE {
|
||||
return None;
|
||||
}
|
||||
|
||||
let version = data[0] >> 4;
|
||||
let phase = data[0] & 0x0F;
|
||||
|
||||
if version != FSP_VERSION || phase != FSP_PHASE_ESTABLISHED {
|
||||
return None;
|
||||
}
|
||||
|
||||
let flags = data[1];
|
||||
|
||||
// U flag means plaintext — not an encrypted message
|
||||
if flags & FSP_FLAG_U != 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let payload_len = u16::from_le_bytes([data[2], data[3]]);
|
||||
let counter = u64::from_le_bytes([
|
||||
data[4], data[5], data[6], data[7], data[8], data[9], data[10], data[11],
|
||||
]);
|
||||
|
||||
let mut header_bytes = [0u8; FSP_HEADER_SIZE];
|
||||
header_bytes.copy_from_slice(&data[..FSP_HEADER_SIZE]);
|
||||
|
||||
Some(Self {
|
||||
flags,
|
||||
payload_len,
|
||||
counter,
|
||||
header_bytes,
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if the Coords Present flag is set.
|
||||
pub fn has_coords(&self) -> bool {
|
||||
self.flags & FSP_FLAG_CP != 0
|
||||
}
|
||||
|
||||
/// Offset where ciphertext (or coords if CP) begins in the original data.
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub fn data_offset(&self) -> usize {
|
||||
FSP_HEADER_SIZE
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Serialization Helpers
|
||||
// ============================================================================
|
||||
|
||||
/// Build the 12-byte cleartext header for an encrypted FSP message.
|
||||
///
|
||||
/// Returns the header bytes for use as AEAD AAD.
|
||||
pub fn build_fsp_header(counter: u64, flags: u8, payload_len: u16) -> [u8; FSP_HEADER_SIZE] {
|
||||
let mut header = [0u8; FSP_HEADER_SIZE];
|
||||
header[0] = FspCommonPrefix::ver_phase_byte(FSP_VERSION, FSP_PHASE_ESTABLISHED);
|
||||
header[1] = flags;
|
||||
header[2..4].copy_from_slice(&payload_len.to_le_bytes());
|
||||
header[4..12].copy_from_slice(&counter.to_le_bytes());
|
||||
header
|
||||
}
|
||||
|
||||
/// Assemble a wire-format encrypted FSP message.
|
||||
///
|
||||
/// Format: `[header:12][ciphertext+tag]`
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub fn build_fsp_encrypted(header: &[u8; FSP_HEADER_SIZE], ciphertext: &[u8]) -> Vec<u8> {
|
||||
let mut packet = Vec::with_capacity(FSP_HEADER_SIZE + ciphertext.len());
|
||||
packet.extend_from_slice(header);
|
||||
packet.extend_from_slice(ciphertext);
|
||||
packet
|
||||
}
|
||||
|
||||
/// Build a 4-byte common prefix for a handshake message.
|
||||
///
|
||||
/// `phase` should be `FSP_PHASE_MSG1`, `FSP_PHASE_MSG2`, or `FSP_PHASE_MSG3`.
|
||||
/// Flags are zero during handshake.
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub fn build_fsp_handshake_prefix(phase: u8, payload_len: u16) -> [u8; FSP_COMMON_PREFIX_SIZE] {
|
||||
let mut prefix = [0u8; FSP_COMMON_PREFIX_SIZE];
|
||||
prefix[0] = FspCommonPrefix::ver_phase_byte(FSP_VERSION, phase);
|
||||
prefix[1] = 0x00; // flags must be zero during handshake
|
||||
prefix[2..4].copy_from_slice(&payload_len.to_le_bytes());
|
||||
prefix
|
||||
}
|
||||
|
||||
/// Build a 4-byte common prefix for a plaintext error signal.
|
||||
///
|
||||
/// Sets phase 0x0 and U flag.
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub fn build_fsp_error_prefix(payload_len: u16) -> [u8; FSP_COMMON_PREFIX_SIZE] {
|
||||
let mut prefix = [0u8; FSP_COMMON_PREFIX_SIZE];
|
||||
prefix[0] = FspCommonPrefix::ver_phase_byte(FSP_VERSION, FSP_PHASE_ESTABLISHED);
|
||||
prefix[1] = FSP_FLAG_U;
|
||||
prefix[2..4].copy_from_slice(&payload_len.to_le_bytes());
|
||||
prefix
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Inner Header Helpers
|
||||
// ============================================================================
|
||||
|
||||
/// Prepend the 6-byte FSP inner header to a message payload.
|
||||
///
|
||||
/// Inner header: `[timestamp:4 LE][msg_type:1][inner_flags:1]`
|
||||
///
|
||||
/// The caller provides the message-type-specific payload (e.g., application
|
||||
/// data for msg_type 0x10, report fields for SenderReport). This function
|
||||
/// prepends the inner header.
|
||||
pub fn fsp_prepend_inner_header(
|
||||
timestamp_ms: u32,
|
||||
msg_type: u8,
|
||||
inner_flags: u8,
|
||||
payload: &[u8],
|
||||
) -> Vec<u8> {
|
||||
let mut buf = Vec::with_capacity(FSP_INNER_HEADER_SIZE + payload.len());
|
||||
buf.extend_from_slice(×tamp_ms.to_le_bytes());
|
||||
buf.push(msg_type);
|
||||
buf.push(inner_flags);
|
||||
buf.extend_from_slice(payload);
|
||||
buf
|
||||
}
|
||||
|
||||
/// Strip the 6-byte FSP inner header from a decrypted payload.
|
||||
///
|
||||
/// Returns `(timestamp, msg_type, inner_flags, &rest)` or None if too short.
|
||||
pub fn fsp_strip_inner_header(plaintext: &[u8]) -> Option<(u32, u8, u8, &[u8])> {
|
||||
if plaintext.len() < FSP_INNER_HEADER_SIZE {
|
||||
return None;
|
||||
}
|
||||
let timestamp = u32::from_le_bytes([plaintext[0], plaintext[1], plaintext[2], plaintext[3]]);
|
||||
let msg_type = plaintext[4];
|
||||
let inner_flags = plaintext[5];
|
||||
Some((
|
||||
timestamp,
|
||||
msg_type,
|
||||
inner_flags,
|
||||
&plaintext[FSP_INNER_HEADER_SIZE..],
|
||||
))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Coordinate Parsing (for transit nodes and receive path)
|
||||
// ============================================================================
|
||||
|
||||
/// Parse source and destination coordinates from the cleartext section
|
||||
/// of an encrypted FSP message when the CP flag is set.
|
||||
///
|
||||
/// Coordinates appear between the 12-byte header and the ciphertext:
|
||||
/// `[src_coords_count:2 LE][src_coords:16×n][dest_coords_count:2 LE][dest_coords:16×m]`
|
||||
///
|
||||
/// Returns `(src_coords, dest_coords, bytes_consumed)`.
|
||||
pub fn parse_encrypted_coords(
|
||||
data: &[u8],
|
||||
) -> Result<(Option<TreeCoordinate>, Option<TreeCoordinate>, usize), ProtocolError> {
|
||||
let (src_coords, src_consumed) = decode_optional_coords(data)?;
|
||||
let (dest_coords, dest_consumed) = decode_optional_coords(&data[src_consumed..])?;
|
||||
Ok((src_coords, dest_coords, src_consumed + dest_consumed))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ===== Size Constant Tests =====
|
||||
|
||||
#[test]
|
||||
fn test_wire_sizes() {
|
||||
assert_eq!(FSP_COMMON_PREFIX_SIZE, 4);
|
||||
assert_eq!(FSP_HEADER_SIZE, 12);
|
||||
assert_eq!(FSP_INNER_HEADER_SIZE, 6);
|
||||
assert_eq!(FSP_ENCRYPTED_MIN_SIZE, 28); // 12 + 16
|
||||
}
|
||||
|
||||
// ===== Common Prefix Tests =====
|
||||
|
||||
#[test]
|
||||
fn test_common_prefix_parse_established() {
|
||||
let data = [0x00, 0x01, 0x40, 0x00]; // ver=0, phase=0, flags=CP, payload_len=64
|
||||
let prefix = FspCommonPrefix::parse(&data).unwrap();
|
||||
assert_eq!(prefix.version, 0);
|
||||
assert_eq!(prefix.phase, FSP_PHASE_ESTABLISHED);
|
||||
assert_eq!(prefix.flags, FSP_FLAG_CP);
|
||||
assert_eq!(prefix.payload_len, 64);
|
||||
assert!(prefix.has_coords());
|
||||
assert!(!prefix.is_unencrypted());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_common_prefix_parse_handshake() {
|
||||
let data = [0x01, 0x00, 0x50, 0x00]; // ver=0, phase=1, flags=0, payload_len=80
|
||||
let prefix = FspCommonPrefix::parse(&data).unwrap();
|
||||
assert_eq!(prefix.version, 0);
|
||||
assert_eq!(prefix.phase, FSP_PHASE_MSG1);
|
||||
assert_eq!(prefix.flags, 0);
|
||||
assert_eq!(prefix.payload_len, 80);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_common_prefix_parse_error_signal() {
|
||||
let data = [0x00, FSP_FLAG_U, 0x22, 0x00]; // ver=0, phase=0, U flag, payload_len=34
|
||||
let prefix = FspCommonPrefix::parse(&data).unwrap();
|
||||
assert_eq!(prefix.phase, FSP_PHASE_ESTABLISHED);
|
||||
assert!(prefix.is_unencrypted());
|
||||
assert_eq!(prefix.payload_len, 34);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_common_prefix_too_short() {
|
||||
assert!(FspCommonPrefix::parse(&[0, 0, 0]).is_none());
|
||||
}
|
||||
|
||||
// ===== Encrypted Header Tests =====
|
||||
|
||||
#[test]
|
||||
fn test_encrypted_header_parse() {
|
||||
let counter = 42u64;
|
||||
let flags = FSP_FLAG_CP;
|
||||
let payload_len = 100u16;
|
||||
let header = build_fsp_header(counter, flags, payload_len);
|
||||
|
||||
// Build a minimal packet: header + 16 bytes of fake ciphertext (tag)
|
||||
let mut packet = Vec::from(header);
|
||||
packet.extend_from_slice(&[0xaa; TAG_SIZE]);
|
||||
|
||||
let parsed = FspEncryptedHeader::parse(&packet).unwrap();
|
||||
assert_eq!(parsed.counter, 42);
|
||||
assert_eq!(parsed.flags, FSP_FLAG_CP);
|
||||
assert_eq!(parsed.payload_len, 100);
|
||||
assert!(parsed.has_coords());
|
||||
assert_eq!(parsed.header_bytes, header);
|
||||
assert_eq!(parsed.data_offset(), FSP_HEADER_SIZE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypted_header_too_short() {
|
||||
let packet = vec![0x00; FSP_ENCRYPTED_MIN_SIZE - 1];
|
||||
assert!(FspEncryptedHeader::parse(&packet).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypted_header_wrong_phase() {
|
||||
let mut packet = vec![0x00; FSP_ENCRYPTED_MIN_SIZE];
|
||||
packet[0] = 0x01; // phase 1 (msg1), not established
|
||||
assert!(FspEncryptedHeader::parse(&packet).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypted_header_wrong_version() {
|
||||
let mut packet = vec![0x00; FSP_ENCRYPTED_MIN_SIZE];
|
||||
packet[0] = 0x10; // version 1, phase 0
|
||||
assert!(FspEncryptedHeader::parse(&packet).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypted_header_u_flag_rejected() {
|
||||
let mut packet = vec![0x00; FSP_ENCRYPTED_MIN_SIZE];
|
||||
packet[1] = FSP_FLAG_U; // U flag set → not encrypted
|
||||
assert!(FspEncryptedHeader::parse(&packet).is_none());
|
||||
}
|
||||
|
||||
// ===== Build Header Tests =====
|
||||
|
||||
#[test]
|
||||
fn test_build_fsp_header() {
|
||||
let header = build_fsp_header(1000, FSP_FLAG_CP, 200);
|
||||
assert_eq!(header[0], 0x00); // ver=0, phase=0
|
||||
assert_eq!(header[1], FSP_FLAG_CP);
|
||||
assert_eq!(u16::from_le_bytes([header[2], header[3]]), 200);
|
||||
assert_eq!(
|
||||
u64::from_le_bytes([
|
||||
header[4], header[5], header[6], header[7], header[8], header[9], header[10],
|
||||
header[11],
|
||||
]),
|
||||
1000
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_fsp_encrypted() {
|
||||
let header = build_fsp_header(0, 0, 10);
|
||||
let ciphertext = vec![0xCC; 26]; // 10 payload + 16 tag
|
||||
let packet = build_fsp_encrypted(&header, &ciphertext);
|
||||
assert_eq!(packet.len(), FSP_HEADER_SIZE + 26);
|
||||
assert_eq!(&packet[..FSP_HEADER_SIZE], &header);
|
||||
assert_eq!(&packet[FSP_HEADER_SIZE..], &ciphertext[..]);
|
||||
}
|
||||
|
||||
// ===== Handshake Prefix Tests =====
|
||||
|
||||
#[test]
|
||||
fn test_build_fsp_handshake_prefix_msg1() {
|
||||
let prefix = build_fsp_handshake_prefix(FSP_PHASE_MSG1, 100);
|
||||
assert_eq!(prefix[0], 0x01); // ver=0, phase=1
|
||||
assert_eq!(prefix[1], 0x00); // flags zero
|
||||
assert_eq!(u16::from_le_bytes([prefix[2], prefix[3]]), 100);
|
||||
|
||||
let parsed = FspCommonPrefix::parse(&prefix).unwrap();
|
||||
assert_eq!(parsed.phase, FSP_PHASE_MSG1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_fsp_handshake_prefix_msg2() {
|
||||
let prefix = build_fsp_handshake_prefix(FSP_PHASE_MSG2, 50);
|
||||
assert_eq!(prefix[0], 0x02); // ver=0, phase=2
|
||||
assert_eq!(prefix[1], 0x00);
|
||||
assert_eq!(u16::from_le_bytes([prefix[2], prefix[3]]), 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_fsp_handshake_prefix_msg3() {
|
||||
let prefix = build_fsp_handshake_prefix(FSP_PHASE_MSG3, 73);
|
||||
assert_eq!(prefix[0], 0x03); // ver=0, phase=3
|
||||
assert_eq!(prefix[1], 0x00); // flags zero
|
||||
assert_eq!(u16::from_le_bytes([prefix[2], prefix[3]]), 73);
|
||||
|
||||
let parsed = FspCommonPrefix::parse(&prefix).unwrap();
|
||||
assert_eq!(parsed.phase, FSP_PHASE_MSG3);
|
||||
}
|
||||
|
||||
// ===== Error Prefix Tests =====
|
||||
|
||||
#[test]
|
||||
fn test_build_fsp_error_prefix() {
|
||||
let prefix = build_fsp_error_prefix(34);
|
||||
assert_eq!(prefix[0], 0x00); // ver=0, phase=0
|
||||
assert_eq!(prefix[1], FSP_FLAG_U);
|
||||
assert_eq!(u16::from_le_bytes([prefix[2], prefix[3]]), 34);
|
||||
|
||||
let parsed = FspCommonPrefix::parse(&prefix).unwrap();
|
||||
assert!(parsed.is_unencrypted());
|
||||
assert_eq!(parsed.phase, FSP_PHASE_ESTABLISHED);
|
||||
}
|
||||
|
||||
// ===== Inner Header Tests =====
|
||||
|
||||
#[test]
|
||||
fn test_inner_header_prepend_strip() {
|
||||
let timestamp: u32 = 12345;
|
||||
let msg_type: u8 = 0x10;
|
||||
let inner_flags: u8 = 0x01; // SP bit
|
||||
let payload = vec![0xAA, 0xBB, 0xCC];
|
||||
|
||||
let with_header = fsp_prepend_inner_header(timestamp, msg_type, inner_flags, &payload);
|
||||
assert_eq!(with_header.len(), FSP_INNER_HEADER_SIZE + 3);
|
||||
|
||||
let (ts, mt, flags, rest) = fsp_strip_inner_header(&with_header).unwrap();
|
||||
assert_eq!(ts, 12345);
|
||||
assert_eq!(mt, 0x10);
|
||||
assert_eq!(flags, 0x01);
|
||||
assert_eq!(rest, &payload[..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inner_header_empty_payload() {
|
||||
let with_header = fsp_prepend_inner_header(0, 0x13, 0, &[]);
|
||||
assert_eq!(with_header.len(), FSP_INNER_HEADER_SIZE);
|
||||
|
||||
let (ts, mt, flags, rest) = fsp_strip_inner_header(&with_header).unwrap();
|
||||
assert_eq!(ts, 0);
|
||||
assert_eq!(mt, 0x13);
|
||||
assert_eq!(flags, 0);
|
||||
assert!(rest.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inner_header_too_short() {
|
||||
assert!(fsp_strip_inner_header(&[0, 0, 0, 0, 0]).is_none()); // needs 6 bytes
|
||||
assert!(fsp_strip_inner_header(&[]).is_none());
|
||||
}
|
||||
|
||||
// ===== Flag Constants Tests =====
|
||||
|
||||
#[test]
|
||||
fn test_flag_bits_distinct() {
|
||||
// Cleartext flags don't overlap
|
||||
assert_eq!(FSP_FLAG_CP & FSP_FLAG_K, 0);
|
||||
assert_eq!(FSP_FLAG_CP & FSP_FLAG_U, 0);
|
||||
assert_eq!(FSP_FLAG_K & FSP_FLAG_U, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_header_roundtrip() {
|
||||
let counter = 0xDEADBEEF_12345678u64;
|
||||
let flags = FSP_FLAG_CP | FSP_FLAG_K;
|
||||
let payload_len = 1234u16;
|
||||
|
||||
let header = build_fsp_header(counter, flags, payload_len);
|
||||
let ciphertext = vec![0xFF; payload_len as usize + TAG_SIZE];
|
||||
let packet = build_fsp_encrypted(&header, &ciphertext);
|
||||
|
||||
let parsed = FspEncryptedHeader::parse(&packet).unwrap();
|
||||
assert_eq!(parsed.counter, counter);
|
||||
assert_eq!(parsed.flags, flags);
|
||||
assert_eq!(parsed.payload_len, payload_len);
|
||||
assert!(parsed.has_coords());
|
||||
assert_eq!(parsed.header_bytes, header);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_message_types_through_prefix() {
|
||||
// Encrypted (phase 0, no U)
|
||||
let prefix = FspCommonPrefix::parse(&[0x00, 0x00, 0x10, 0x00]).unwrap();
|
||||
assert_eq!(prefix.phase, 0);
|
||||
assert!(!prefix.is_unencrypted());
|
||||
|
||||
// Error signal (phase 0, U set)
|
||||
let prefix = FspCommonPrefix::parse(&[0x00, FSP_FLAG_U, 0x22, 0x00]).unwrap();
|
||||
assert_eq!(prefix.phase, 0);
|
||||
assert!(prefix.is_unencrypted());
|
||||
|
||||
// SessionSetup (phase 1)
|
||||
let prefix = FspCommonPrefix::parse(&[0x01, 0x00, 0x50, 0x00]).unwrap();
|
||||
assert_eq!(prefix.phase, 1);
|
||||
|
||||
// SessionAck (phase 2)
|
||||
let prefix = FspCommonPrefix::parse(&[0x02, 0x00, 0x21, 0x00]).unwrap();
|
||||
assert_eq!(prefix.phase, 2);
|
||||
|
||||
// SessionMsg3 (phase 3)
|
||||
let prefix = FspCommonPrefix::parse(&[0x03, 0x00, 0x49, 0x00]).unwrap();
|
||||
assert_eq!(prefix.phase, 3);
|
||||
}
|
||||
}
|
||||
@@ -238,7 +238,7 @@ pub struct ForwardingStatsSnapshot {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct DiscoveryStatsSnapshot {
|
||||
pub struct LookupStatsSnapshot {
|
||||
pub req_received: u64,
|
||||
pub req_decode_error: u64,
|
||||
pub req_duplicate: u64,
|
||||
@@ -273,7 +273,6 @@ pub struct TreeStatsSnapshot {
|
||||
pub stale: u64,
|
||||
pub ancestry_invalid: u64,
|
||||
pub accepted: u64,
|
||||
pub parent_switched: u64,
|
||||
pub loop_detected: u64,
|
||||
pub ancestry_changed: u64,
|
||||
pub sent: u64,
|
||||
|
||||
@@ -2,7 +2,7 @@ use super::*;
|
||||
use crate::ReceivedPacket;
|
||||
use crate::node::acl::PeerAclReloader;
|
||||
use crate::node::reloadable::HostMapReloadable;
|
||||
use crate::node::wire::{build_msg1, build_msg2};
|
||||
use crate::proto::fmp::wire::{build_msg1, build_msg2};
|
||||
use crate::upper::hosts::HostMap;
|
||||
use crate::utils::index::SessionIndex;
|
||||
use std::path::PathBuf;
|
||||
@@ -56,7 +56,7 @@ async fn test_inbound_msg1_denied_by_acl() {
|
||||
node_b.reload_peer_acl().await;
|
||||
|
||||
let peer_b_identity = PeerIdentity::from_pubkey_full(node_b.identity().pubkey_full());
|
||||
let mut conn_a = PeerConnection::outbound(LinkId::new(1), peer_b_identity, 1000);
|
||||
let mut conn_a = outbound_leg(LinkId::new(1), peer_b_identity, 1000);
|
||||
let noise_msg1 = conn_a
|
||||
.start_handshake(node_a.identity().keypair(), node_a.startup_epoch(), 1000)
|
||||
.unwrap();
|
||||
@@ -84,14 +84,23 @@ async fn test_outbound_msg2_denied_after_acl_reload() {
|
||||
let peer_b_identity = PeerIdentity::from_pubkey_full(node_b.identity().pubkey_full());
|
||||
|
||||
let link_id_a = node_a.allocate_link_id();
|
||||
let mut conn_a = PeerConnection::outbound(link_id_a, peer_b_identity, 1000);
|
||||
let our_index_a = node_a.index_allocator.allocate().unwrap();
|
||||
let noise_msg1 = conn_a
|
||||
.start_handshake(node_a.identity().keypair(), node_a.startup_epoch(), 1000)
|
||||
node_a
|
||||
.seed_handshake_machine(
|
||||
HandshakeSeed::outbound(link_id_a, peer_b_identity, 1000)
|
||||
.with_our_index(our_index_a)
|
||||
.with_transport_id(transport_id)
|
||||
.with_source_addr(remote_addr.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
let keypair_a = node_a.identity().keypair();
|
||||
let epoch_a = node_a.startup_epoch();
|
||||
let noise_msg1 = node_a
|
||||
.peer_machines
|
||||
.get_mut(&link_id_a)
|
||||
.unwrap()
|
||||
.start_handshake(keypair_a, epoch_a, 1000)
|
||||
.unwrap();
|
||||
conn_a.set_our_index(our_index_a);
|
||||
conn_a.set_transport_id(transport_id);
|
||||
conn_a.set_source_addr(remote_addr.clone());
|
||||
|
||||
let link_a = Link::connectionless(
|
||||
link_id_a,
|
||||
@@ -104,12 +113,11 @@ async fn test_outbound_msg2_denied_after_acl_reload() {
|
||||
node_a
|
||||
.addr_to_link
|
||||
.insert((transport_id, remote_addr.clone()), link_id_a);
|
||||
node_a.connections.insert(link_id_a, conn_a);
|
||||
node_a
|
||||
.pending_outbound
|
||||
.insert((transport_id, our_index_a.as_u32()), link_id_a);
|
||||
|
||||
let mut conn_b = PeerConnection::inbound(LinkId::new(2), 1000);
|
||||
let mut conn_b = inbound_leg(LinkId::new(2), 1000);
|
||||
let responder_epoch = [0x11; 8];
|
||||
let noise_msg2 = conn_b
|
||||
.receive_handshake_init(
|
||||
@@ -178,3 +186,49 @@ async fn test_outbound_connect_not_denied_by_allowlist_miss() {
|
||||
|
||||
assert!(!matches!(result, Err(NodeError::AccessDenied(_))));
|
||||
}
|
||||
|
||||
/// The ACL-rejected arm of the same property the Noise-failure arm pins in
|
||||
/// `unit.rs`: a msg1 that is admitted by the crypto but denied by the ACL
|
||||
/// still leaves nothing behind. The control machine is built above the crypto
|
||||
/// so it can drive the handshake, but it stays a local until a promote tail
|
||||
/// inserts it, so a denial drops it.
|
||||
#[tokio::test]
|
||||
async fn test_acl_rejected_msg1_leaves_no_registry_trace() {
|
||||
let (dir, mut node_b) = make_acl_node();
|
||||
let node_a = make_node();
|
||||
|
||||
std::fs::write(deny_path(&dir), format!("{}\n", node_a.npub())).unwrap();
|
||||
node_b.reload_peer_acl().await;
|
||||
|
||||
let peer_b_identity = PeerIdentity::from_pubkey_full(node_b.identity().pubkey_full());
|
||||
let mut conn_a = outbound_leg(LinkId::new(1), peer_b_identity, 1000);
|
||||
let noise_msg1 = conn_a
|
||||
.start_handshake(node_a.identity().keypair(), node_a.startup_epoch(), 1000)
|
||||
.unwrap();
|
||||
let wire_msg1 = build_msg1(SessionIndex::new(7), &noise_msg1);
|
||||
let packet = ReceivedPacket::with_timestamp(
|
||||
TransportId::new(1),
|
||||
TransportAddr::from_string("127.0.0.1:5000"),
|
||||
wire_msg1,
|
||||
1000,
|
||||
);
|
||||
|
||||
node_b.handle_msg1(packet).await;
|
||||
|
||||
assert!(
|
||||
node_b.peer_machines.is_empty(),
|
||||
"an ACL-denied msg1 must leave no control machine behind"
|
||||
);
|
||||
assert_eq!(node_b.connection_count(), 0);
|
||||
assert_eq!(node_b.peer_count(), 0);
|
||||
assert_eq!(node_b.link_count(), 0);
|
||||
assert!(
|
||||
node_b.peers_by_index.is_empty(),
|
||||
"an ACL-denied msg1 must allocate no session index"
|
||||
);
|
||||
assert_eq!(
|
||||
node_b.stats().handshake.bad_state,
|
||||
1,
|
||||
"the denial is attributed to the handshake state-machine counter"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -455,9 +455,9 @@ async fn test_bloom_filter_split_horizon() {
|
||||
/// counted once, not the double-count fingerprint.
|
||||
#[test]
|
||||
fn compute_mesh_size_counts_each_peer_filter_once() {
|
||||
use crate::bloom::BloomFilter;
|
||||
use crate::peer::ActivePeer;
|
||||
use crate::tree::ParentDeclaration;
|
||||
use crate::proto::bloom::BloomFilter;
|
||||
use crate::proto::stp::ParentDeclaration;
|
||||
|
||||
let mut node = make_node();
|
||||
let my_addr = *node.tree_state().my_node_addr();
|
||||
@@ -498,8 +498,8 @@ fn compute_mesh_size_counts_each_peer_filter_once() {
|
||||
|
||||
// Seed parent ancestry first so recompute_coords can extend it and
|
||||
// flip is_root() to false; child ancestry is for completeness.
|
||||
let parent_ancestry = crate::tree::TreeCoordinate::root_with_meta(parent_addr, 1, 1);
|
||||
let child_ancestry = crate::tree::TreeCoordinate::root_with_meta(child_addr, 1, 1);
|
||||
let parent_ancestry = crate::proto::stp::TreeCoordinate::root_with_meta(parent_addr, 1, 1);
|
||||
let child_ancestry = crate::proto::stp::TreeCoordinate::root_with_meta(child_addr, 1, 1);
|
||||
// Inject the stale-cache scenario: peer_declaration(P) still names
|
||||
// US (M) as P's parent (the pre-switch advert that the cache hasn't
|
||||
// refreshed yet). Q is a legitimate child also naming M as parent.
|
||||
@@ -513,7 +513,7 @@ fn compute_mesh_size_counts_each_peer_filter_once() {
|
||||
.update_peer(child_decl, child_ancestry);
|
||||
|
||||
// Switch our parent to P and recompute coords so root flips off self.
|
||||
node.tree_state_mut().set_parent(parent_addr, 2, 1);
|
||||
node.tree_state_mut().set_parent(parent_addr, 2, 1, 1);
|
||||
node.tree_state_mut().recompute_coords();
|
||||
assert!(
|
||||
!node.tree_state().is_root(),
|
||||
@@ -550,8 +550,8 @@ fn compute_mesh_size_counts_each_peer_filter_once() {
|
||||
/// `estimated_mesh_size` carries.
|
||||
#[test]
|
||||
fn compute_mesh_size_unions_overlapping_filters() {
|
||||
use crate::bloom::BloomFilter;
|
||||
use crate::peer::ActivePeer;
|
||||
use crate::proto::bloom::BloomFilter;
|
||||
|
||||
let mut node = make_node();
|
||||
|
||||
@@ -632,8 +632,8 @@ fn compute_mesh_size_unions_overlapping_filters() {
|
||||
/// removes the parent, and asserts the estimate does not collapse.
|
||||
#[test]
|
||||
fn compute_mesh_size_stable_across_parent_drop_with_cross_link() {
|
||||
use crate::bloom::BloomFilter;
|
||||
use crate::peer::ActivePeer;
|
||||
use crate::proto::bloom::BloomFilter;
|
||||
|
||||
let mut node = make_node();
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
//! bloom.rs.
|
||||
|
||||
use super::*;
|
||||
use crate::bloom::{BloomFilter, DEFAULT_FILTER_SIZE_BITS, DEFAULT_HASH_COUNT};
|
||||
use crate::peer::ActivePeer;
|
||||
use crate::protocol::FilterAnnounce;
|
||||
use crate::proto::bloom::FilterAnnounce;
|
||||
use crate::proto::bloom::{BloomFilter, DEFAULT_FILTER_SIZE_BITS, DEFAULT_HASH_COUNT};
|
||||
|
||||
/// Inject a synthetic active peer into the node with a known NodeAddr.
|
||||
/// Returns the peer's NodeAddr.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use super::*;
|
||||
use crate::EstablishedTraversal;
|
||||
use crate::config::{TransportInstances, UdpConfig};
|
||||
use crate::node::wire::{PHASE_MSG1, PHASE_MSG2};
|
||||
use crate::proto::fmp::wire::{PHASE_MSG1, PHASE_MSG2};
|
||||
use crate::transport::udp::UdpTransport;
|
||||
use crate::utils::index::IndexAllocator;
|
||||
use std::collections::HashMap;
|
||||
@@ -24,17 +24,17 @@ async fn test_adopted_udp_traversal_completes_handshake() {
|
||||
let (packet_tx_a, packet_rx_a) = packet_channel(64);
|
||||
let (packet_tx_b, packet_rx_b) = packet_channel(64);
|
||||
|
||||
node_a.packet_tx = Some(packet_tx_a.clone());
|
||||
node_a.supervisor.packet_tx = Some(packet_tx_a.clone());
|
||||
node_a.packet_rx = Some(packet_rx_a);
|
||||
node_a.state = NodeState::Running;
|
||||
node_a.supervisor.state = NodeState::Running;
|
||||
|
||||
let mut transport_b = UdpTransport::new(transport_id_b, None, udp_config, packet_tx_b.clone());
|
||||
transport_b.start_async().await.unwrap();
|
||||
|
||||
let addr_b = transport_b.local_addr().unwrap();
|
||||
node_b.packet_tx = Some(packet_tx_b.clone());
|
||||
node_b.supervisor.packet_tx = Some(packet_tx_b.clone());
|
||||
node_b.packet_rx = Some(packet_rx_b);
|
||||
node_b.state = NodeState::Running;
|
||||
node_b.supervisor.state = NodeState::Running;
|
||||
node_b
|
||||
.transports
|
||||
.insert(transport_id_b, TransportHandle::Udp(transport_b));
|
||||
@@ -91,9 +91,9 @@ async fn test_adopted_udp_traversal_completes_handshake() {
|
||||
async fn test_failed_adopted_traversal_cleans_up_transport() {
|
||||
let mut node = make_node();
|
||||
let (packet_tx, packet_rx) = packet_channel(64);
|
||||
node.packet_tx = Some(packet_tx);
|
||||
node.supervisor.packet_tx = Some(packet_tx);
|
||||
node.packet_rx = Some(packet_rx);
|
||||
node.state = NodeState::Running;
|
||||
node.supervisor.state = NodeState::Running;
|
||||
node.index_allocator = IndexAllocator::with_max_attempts(0);
|
||||
|
||||
let peer = make_node();
|
||||
@@ -121,15 +121,14 @@ async fn test_failed_adopted_traversal_cleans_up_transport() {
|
||||
async fn test_adopted_traversal_skips_already_connected_peer() {
|
||||
let mut node = make_node();
|
||||
let (packet_tx, packet_rx) = packet_channel(64);
|
||||
node.packet_tx = Some(packet_tx);
|
||||
node.supervisor.packet_tx = Some(packet_tx);
|
||||
node.packet_rx = Some(packet_rx);
|
||||
node.state = NodeState::Running;
|
||||
node.supervisor.state = NodeState::Running;
|
||||
|
||||
let transport_id = TransportId::new(1);
|
||||
let link_id = LinkId::new(1);
|
||||
let (conn, peer_identity) = make_completed_connection(&mut node, link_id, transport_id, 1_000);
|
||||
let peer_identity = seed_completed_connection(&mut node, link_id, transport_id, 1_000);
|
||||
let peer_node_addr = *peer_identity.node_addr();
|
||||
node.add_connection(conn).unwrap();
|
||||
node.promote_connection(link_id, peer_identity, 2_000)
|
||||
.unwrap();
|
||||
|
||||
@@ -180,17 +179,17 @@ async fn test_third_peer_can_handshake_via_adopted_transport_socket() {
|
||||
let (packet_tx_b, packet_rx_b) = packet_channel(64);
|
||||
let (packet_tx_c, packet_rx_c) = packet_channel(64);
|
||||
|
||||
node_a.packet_tx = Some(packet_tx_a.clone());
|
||||
node_a.supervisor.packet_tx = Some(packet_tx_a.clone());
|
||||
node_a.packet_rx = Some(packet_rx_a);
|
||||
node_a.state = NodeState::Running;
|
||||
node_a.supervisor.state = NodeState::Running;
|
||||
|
||||
node_b.packet_tx = Some(packet_tx_b.clone());
|
||||
node_b.supervisor.packet_tx = Some(packet_tx_b.clone());
|
||||
node_b.packet_rx = Some(packet_rx_b);
|
||||
node_b.state = NodeState::Running;
|
||||
node_b.supervisor.state = NodeState::Running;
|
||||
|
||||
node_c.packet_tx = Some(packet_tx_c.clone());
|
||||
node_c.supervisor.packet_tx = Some(packet_tx_c.clone());
|
||||
node_c.packet_rx = Some(packet_rx_c);
|
||||
node_c.state = NodeState::Running;
|
||||
node_c.supervisor.state = NodeState::Running;
|
||||
|
||||
let mut transport_a = UdpTransport::new(transport_id_a, None, udp_config.clone(), packet_tx_a);
|
||||
transport_a.start_async().await.unwrap();
|
||||
@@ -300,9 +299,9 @@ async fn test_adopted_udp_inherits_mtu_from_single_primary_config() {
|
||||
let mut node = make_node_with(config);
|
||||
|
||||
let (packet_tx, packet_rx) = packet_channel(64);
|
||||
node.packet_tx = Some(packet_tx);
|
||||
node.supervisor.packet_tx = Some(packet_tx);
|
||||
node.packet_rx = Some(packet_rx);
|
||||
node.state = NodeState::Running;
|
||||
node.supervisor.state = NodeState::Running;
|
||||
|
||||
let peer = make_node();
|
||||
let adopted_socket = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
@@ -350,9 +349,9 @@ async fn test_adopted_udp_inherits_mtu_from_named_primary_config() {
|
||||
let mut node = make_node_with(config);
|
||||
|
||||
let (packet_tx, packet_rx) = packet_channel(64);
|
||||
node.packet_tx = Some(packet_tx);
|
||||
node.supervisor.packet_tx = Some(packet_tx);
|
||||
node.packet_rx = Some(packet_rx);
|
||||
node.state = NodeState::Running;
|
||||
node.supervisor.state = NodeState::Running;
|
||||
|
||||
let peer = make_node();
|
||||
let adopted_socket = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Tests for the consecutive-decrypt-failure threshold force-removal path.
|
||||
//!
|
||||
//! Covers `Node::handle_decrypt_failure` (in `node/handlers/encrypted.rs`),
|
||||
//! Covers `Node::handle_decrypt_failure` (in `node/dataplane/encrypted.rs`),
|
||||
//! which increments `ActivePeer::increment_decrypt_failures` on each AEAD
|
||||
//! verification failure and force-removes the peer once
|
||||
//! `DECRYPT_FAILURE_THRESHOLD` consecutive failures are observed. The
|
||||
@@ -18,7 +18,7 @@ use super::*;
|
||||
/// the full `peers_by_index` cleanup path (not just the bare `peers` table).
|
||||
#[test]
|
||||
fn test_decrypt_failure_threshold_removes_peer() {
|
||||
// Threshold constant in node/handlers/encrypted.rs (kept in sync with
|
||||
// Threshold constant in node/dataplane/encrypted.rs (kept in sync with
|
||||
// production code; see DECRYPT_FAILURE_THRESHOLD).
|
||||
const THRESHOLD: u32 = 20;
|
||||
|
||||
@@ -28,10 +28,9 @@ fn test_decrypt_failure_threshold_removes_peer() {
|
||||
|
||||
// Build a fully-promoted active peer with our_index/transport_id set
|
||||
// so peers_by_index is populated by promote_connection.
|
||||
let (conn, identity) = make_completed_connection(&mut node, link_id, transport_id, 1_000);
|
||||
let identity = seed_completed_connection(&mut node, link_id, transport_id, 1_000);
|
||||
let node_addr = *identity.node_addr();
|
||||
|
||||
node.add_connection(conn).unwrap();
|
||||
node.promote_connection(link_id, identity, 2_000).unwrap();
|
||||
|
||||
// Sanity: peer is registered and indexed.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
use super::spanning_tree::*;
|
||||
use super::*;
|
||||
use crate::protocol::{Disconnect, DisconnectReason};
|
||||
use crate::proto::fmp::{Disconnect, DisconnectReason};
|
||||
|
||||
/// 3-node chain: middle node disconnects one peer.
|
||||
///
|
||||
@@ -295,7 +295,7 @@ async fn test_disconnect_clears_session() {
|
||||
);
|
||||
|
||||
// Node 0 sends Disconnect to node 1.
|
||||
let disconnect = crate::protocol::Disconnect::new(DisconnectReason::Shutdown);
|
||||
let disconnect = crate::proto::fmp::Disconnect::new(DisconnectReason::Shutdown);
|
||||
nodes[0]
|
||||
.node
|
||||
.send_encrypted_link_message(&node1_addr, &disconnect.encode())
|
||||
|
||||
@@ -5,9 +5,8 @@
|
||||
//! response routing.
|
||||
|
||||
use super::*;
|
||||
use crate::node::RecentRequest;
|
||||
use crate::protocol::{LookupRequest, LookupResponse};
|
||||
use crate::tree::TreeCoordinate;
|
||||
use crate::proto::lookup::{LookupRequest, LookupResponse, RecentRequest};
|
||||
use crate::proto::stp::TreeCoordinate;
|
||||
use spanning_tree::{
|
||||
cleanup_nodes, generate_random_edges, lock_large_network_test, process_available_packets,
|
||||
run_tree_test, verify_tree_convergence,
|
||||
@@ -23,7 +22,7 @@ async fn test_request_decode_error() {
|
||||
let from = make_node_addr(0xAA);
|
||||
// Too-short payload: should log error and return without panic
|
||||
node.handle_lookup_request(&from, &[0x00; 5]).await;
|
||||
assert!(node.recent_requests.is_empty());
|
||||
assert!(node.lookup.recent_requests.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -39,11 +38,11 @@ async fn test_request_dedup() {
|
||||
|
||||
// First request: accepted
|
||||
node.handle_lookup_request(&from, payload).await;
|
||||
assert_eq!(node.recent_requests.len(), 1);
|
||||
assert_eq!(node.lookup.recent_requests.len(), 1);
|
||||
|
||||
// Duplicate request: dropped
|
||||
node.handle_lookup_request(&from, payload).await;
|
||||
assert_eq!(node.recent_requests.len(), 1);
|
||||
assert_eq!(node.lookup.recent_requests.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -61,7 +60,7 @@ async fn test_request_target_is_self() {
|
||||
// Should succeed without panic (response send will fail silently
|
||||
// since we have no peers to route toward origin)
|
||||
node.handle_lookup_request(&from, payload).await;
|
||||
assert!(node.recent_requests.contains_key(&777));
|
||||
assert!(node.lookup.recent_requests.contains_key(&777));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -77,7 +76,7 @@ async fn test_request_ttl_zero_not_forwarded() {
|
||||
|
||||
node.handle_lookup_request(&from, payload).await;
|
||||
// Request recorded, but not forwarded (TTL=0, and no peers anyway)
|
||||
assert!(node.recent_requests.contains_key(&666));
|
||||
assert!(node.lookup.recent_requests.contains_key(&666));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -115,7 +114,7 @@ async fn test_response_originator_caches_route() {
|
||||
let payload = &response.encode()[1..]; // skip msg_type
|
||||
|
||||
// No entry in recent_requests for 555 → we're the originator
|
||||
assert!(!node.recent_requests.contains_key(&555));
|
||||
assert!(!node.lookup.recent_requests.contains_key(&555));
|
||||
|
||||
node.handle_lookup_response(&from, payload).await;
|
||||
|
||||
@@ -149,7 +148,8 @@ async fn test_response_transit_needs_recent_request() {
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as u64;
|
||||
node.recent_requests
|
||||
node.lookup
|
||||
.recent_requests
|
||||
.insert(444, RecentRequest::new(make_node_addr(0xDD), now_ms));
|
||||
|
||||
// Handle response — should try to reverse-path forward to 0xDD
|
||||
@@ -319,14 +319,16 @@ async fn test_recent_request_expiry() {
|
||||
.as_millis() as u64;
|
||||
|
||||
// Insert an old request (11 seconds ago)
|
||||
node.recent_requests
|
||||
node.lookup
|
||||
.recent_requests
|
||||
.insert(123, RecentRequest::new(make_node_addr(1), now_ms - 11_000));
|
||||
|
||||
// Insert a recent request
|
||||
node.recent_requests
|
||||
node.lookup
|
||||
.recent_requests
|
||||
.insert(456, RecentRequest::new(make_node_addr(2), now_ms));
|
||||
|
||||
assert_eq!(node.recent_requests.len(), 2);
|
||||
assert_eq!(node.lookup.recent_requests.len(), 2);
|
||||
|
||||
// Trigger purge via a new lookup request
|
||||
let target = make_node_addr(0xBB);
|
||||
@@ -338,9 +340,9 @@ async fn test_recent_request_expiry() {
|
||||
.await;
|
||||
|
||||
// Old entry (123) should be purged, recent entry (456) and new entry (789) kept
|
||||
assert!(!node.recent_requests.contains_key(&123));
|
||||
assert!(node.recent_requests.contains_key(&456));
|
||||
assert!(node.recent_requests.contains_key(&789));
|
||||
assert!(!node.lookup.recent_requests.contains_key(&123));
|
||||
assert!(node.lookup.recent_requests.contains_key(&456));
|
||||
assert!(node.lookup.recent_requests.contains_key(&789));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -379,7 +381,7 @@ async fn test_request_forwarding_two_node() {
|
||||
|
||||
// Node1 should have recorded the request
|
||||
assert!(
|
||||
nodes[1].node.recent_requests.contains_key(&42),
|
||||
nodes[1].node.lookup.recent_requests.contains_key(&42),
|
||||
"Node 1 should have recorded the forwarded request"
|
||||
);
|
||||
|
||||
@@ -447,13 +449,13 @@ async fn test_request_three_node_chain() {
|
||||
|
||||
// Node1 should have been a transit node (has the request_id in recent_requests)
|
||||
assert!(
|
||||
!nodes[1].node.recent_requests.is_empty(),
|
||||
!nodes[1].node.lookup.recent_requests.is_empty(),
|
||||
"Node 1 should have recorded the forwarded request"
|
||||
);
|
||||
|
||||
// Node2 should have received the request (it's the target)
|
||||
assert!(
|
||||
!nodes[2].node.recent_requests.is_empty(),
|
||||
!nodes[2].node.lookup.recent_requests.is_empty(),
|
||||
"Node 2 should have received the request"
|
||||
);
|
||||
|
||||
@@ -502,7 +504,7 @@ async fn test_request_dedup_convergent_paths() {
|
||||
|
||||
// Node2 (the target) must have received the request
|
||||
assert!(
|
||||
nodes[2].node.recent_requests.contains_key(&300),
|
||||
nodes[2].node.lookup.recent_requests.contains_key(&300),
|
||||
"Node 2 (target) should have received the request"
|
||||
);
|
||||
|
||||
@@ -912,6 +914,45 @@ async fn test_originator_stores_path_mtu_in_cache() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_originator_lookup_response_keeps_tighter_path_mtu_lookup() {
|
||||
// Regression: a LookupResponse carrying a looser (larger) path_mtu must
|
||||
// NOT clobber a tighter (smaller) value already in path_mtu_lookup that a
|
||||
// reactive MtuExceeded or PathMtuNotification learned. Cross-carrier
|
||||
// keep-tighter: the clamp must never loosen.
|
||||
let mut node = make_node();
|
||||
let from = make_node_addr(0xAA);
|
||||
|
||||
let target_identity = Identity::generate();
|
||||
let target = *target_identity.node_addr();
|
||||
let root = make_node_addr(0xF0);
|
||||
let coords = TreeCoordinate::from_addrs(vec![target, root]).unwrap();
|
||||
|
||||
node.register_identity(target, target_identity.pubkey_full());
|
||||
|
||||
// Pre-seed a tighter value, as if a reactive signal already narrowed it.
|
||||
let target_fips = crate::FipsAddress::from_node_addr(&target);
|
||||
node.path_mtu_lookup_insert(target_fips, 1280);
|
||||
|
||||
let proof_data = LookupResponse::proof_bytes(800, &target, &coords);
|
||||
let proof = target_identity.sign(&proof_data);
|
||||
|
||||
let mut response = LookupResponse::new(800, target, coords.clone(), proof);
|
||||
// Looser discovery estimate that must be rejected in favor of the tighter
|
||||
// existing entry.
|
||||
response.path_mtu = 1500;
|
||||
|
||||
let payload = &response.encode()[1..];
|
||||
|
||||
node.handle_lookup_response(&from, payload).await;
|
||||
|
||||
assert_eq!(
|
||||
node.path_mtu_lookup_get(&target_fips),
|
||||
Some(1280),
|
||||
"LookupResponse must not loosen a tighter existing path_mtu_lookup value"
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Open-Discovery Sweep — cache-injection unit test
|
||||
// ============================================================================
|
||||
@@ -919,28 +960,28 @@ async fn test_originator_stores_path_mtu_in_cache() {
|
||||
/// Pin the iterate-filter-queue contract of `run_open_discovery_sweep`.
|
||||
///
|
||||
/// Builds a `Node` with `nostr.policy = Open` and an empty peer list,
|
||||
/// then injects three cached adverts into a test `NostrDiscovery` and
|
||||
/// then injects three cached adverts into a test `NostrRendezvous` and
|
||||
/// asserts the sweep:
|
||||
/// - queues a retry for an eligible (unknown, not-self) advert,
|
||||
/// - skips the advert whose author is our own node identity, and
|
||||
/// - skips the advert whose author is an already-connected peer.
|
||||
///
|
||||
/// Uses `NostrDiscovery::new_for_test()` and `insert_advert_for_test()`
|
||||
/// Uses `NostrRendezvous::new_for_test()` and `insert_advert_for_test()`
|
||||
/// (both `#[cfg(test)]`-gated test escape hatches in
|
||||
/// `src/discovery/nostr/runtime.rs`) to populate the cache without
|
||||
/// requiring live relay subscriptions.
|
||||
#[tokio::test]
|
||||
async fn test_open_discovery_sweep_queues_eligible_skips_filtered() {
|
||||
use crate::config::NostrDiscoveryPolicy;
|
||||
use crate::discovery::nostr::{NostrDiscovery, OverlayEndpointAdvert, OverlayTransportKind};
|
||||
use crate::config::NostrRendezvousPolicy;
|
||||
use crate::nostr::{NostrRendezvous, OverlayEndpointAdvert, OverlayTransportKind};
|
||||
use crate::peer::ActivePeer;
|
||||
use crate::transport::LinkId;
|
||||
use std::sync::Arc;
|
||||
|
||||
// Build node with open-discovery enabled.
|
||||
let mut config = crate::Config::new();
|
||||
config.node.discovery.nostr.enabled = true;
|
||||
config.node.discovery.nostr.policy = NostrDiscoveryPolicy::Open;
|
||||
config.node.rendezvous.nostr.enabled = true;
|
||||
config.node.rendezvous.nostr.policy = NostrRendezvousPolicy::Open;
|
||||
let mut node = crate::Node::new(config).unwrap();
|
||||
|
||||
// Identity of an already-connected peer; insert into node.peers
|
||||
@@ -963,8 +1004,8 @@ async fn test_open_discovery_sweep_queues_eligible_skips_filtered() {
|
||||
let self_npub = crate::encode_npub(&node.identity().pubkey());
|
||||
let self_node_addr = *node.identity().node_addr();
|
||||
|
||||
// Build a NostrDiscovery test instance and inject the three adverts.
|
||||
let bootstrap = Arc::new(NostrDiscovery::new_for_test());
|
||||
// Build a NostrRendezvous test instance and inject the three adverts.
|
||||
let bootstrap = Arc::new(NostrRendezvous::new_for_test());
|
||||
let endpoint = OverlayEndpointAdvert {
|
||||
transport: OverlayTransportKind::Udp,
|
||||
addr: "203.0.113.7:2121".to_string(),
|
||||
@@ -975,36 +1016,57 @@ async fn test_open_discovery_sweep_queues_eligible_skips_filtered() {
|
||||
.unwrap_or(0);
|
||||
for npub in [&eligible_npub, &connected_npub, &self_npub] {
|
||||
let advert =
|
||||
NostrDiscovery::cached_advert_for_test(npub.clone(), endpoint.clone(), now_secs);
|
||||
NostrRendezvous::cached_advert_for_test(npub.clone(), endpoint.clone(), now_secs);
|
||||
bootstrap.insert_advert_for_test(npub.clone(), advert).await;
|
||||
}
|
||||
|
||||
// The sweep now runs through the gate-checked reconciler overlay layer,
|
||||
// which is inert unless the node is Running/Degraded. In production the
|
||||
// sweep fires only from the rx_loop tick (which spins after `start()`
|
||||
// returns Running), so drive the node into `Running` to reflect that.
|
||||
node.supervisor.state = crate::node::NodeState::Running;
|
||||
|
||||
// Run the sweep.
|
||||
node.run_open_discovery_sweep(&bootstrap, Some(3_600), "test")
|
||||
.await;
|
||||
node.run_open_discovery_sweep(&bootstrap, Some(3_600)).await;
|
||||
|
||||
// Eligible peer was queued.
|
||||
assert!(
|
||||
node.retry_pending.contains_key(&eligible_node_addr),
|
||||
node.peering
|
||||
.reconciler
|
||||
.retry_pending
|
||||
.contains_key(&eligible_node_addr),
|
||||
"eligible advert should be queued for retry"
|
||||
);
|
||||
let queued = node.retry_pending.get(&eligible_node_addr).unwrap();
|
||||
let queued = node
|
||||
.peering
|
||||
.reconciler
|
||||
.retry_pending
|
||||
.get(&eligible_node_addr)
|
||||
.unwrap();
|
||||
assert_eq!(queued.peer_config.npub, eligible_npub);
|
||||
|
||||
// Connected-peer skip filter held.
|
||||
assert!(
|
||||
!node.retry_pending.contains_key(&connected_node_addr),
|
||||
!node
|
||||
.peering
|
||||
.reconciler
|
||||
.retry_pending
|
||||
.contains_key(&connected_node_addr),
|
||||
"advert for already-connected peer must not be queued"
|
||||
);
|
||||
|
||||
// Self skip filter held.
|
||||
assert!(
|
||||
!node.retry_pending.contains_key(&self_node_addr),
|
||||
!node
|
||||
.peering
|
||||
.reconciler
|
||||
.retry_pending
|
||||
.contains_key(&self_node_addr),
|
||||
"advert authored by own node must not be queued"
|
||||
);
|
||||
|
||||
// Exactly one queued entry from the three injected adverts.
|
||||
assert_eq!(node.retry_pending.len(), 1);
|
||||
assert_eq!(node.peering.reconciler.retry_pending.len(), 1);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -1014,17 +1076,17 @@ async fn test_open_discovery_sweep_queues_eligible_skips_filtered() {
|
||||
/// Pin the per-attempt timeout sequence in `check_pending_lookups`.
|
||||
///
|
||||
/// Drives the state machine deterministically through the default
|
||||
/// `node.discovery.attempt_timeouts_secs = [1, 2, 4, 8]` sequence.
|
||||
/// `node.lookup.attempt_timeouts_secs = [1, 2, 4, 8]` sequence.
|
||||
/// Asserts:
|
||||
/// 1. **Sequence timing** — retries fire at the cumulative deadlines
|
||||
/// (t=1100ms, 3100ms, 7100ms) and unreachable at t=15100ms.
|
||||
/// 2. **Fresh `initiate_lookup` per attempt** — `req_initiated` counter
|
||||
/// increments by exactly one on each retry. The actual `request_id`
|
||||
/// is generated by `LookupRequest::generate(...)` via `rand::random()`
|
||||
/// inside `initiate_lookup` and is not stored on the originator
|
||||
/// side, so per-attempt freshness is verified indirectly: each
|
||||
/// `req_initiated` increment corresponds to one fresh
|
||||
/// `LookupRequest::generate` call.
|
||||
/// is drawn via `rand::rng().random()` at the shell inside
|
||||
/// `initiate_lookup` and passed to `LookupRequest::new(...)`; it is
|
||||
/// not stored on the originator side, so per-attempt freshness is
|
||||
/// verified indirectly: each `req_initiated` increment corresponds
|
||||
/// to one fresh `initiate_lookup` call.
|
||||
/// 3. **Final-timeout state transitions** — `pending_lookups` entry is
|
||||
/// removed, `discovery.resp_timed_out` counter ticks, queued packet
|
||||
/// is drained, and an ICMPv6 Destination Unreachable frame is
|
||||
@@ -1036,9 +1098,9 @@ async fn test_open_discovery_sweep_queues_eligible_skips_filtered() {
|
||||
/// that `initiate_lookup` ran fresh on each attempt.
|
||||
#[tokio::test]
|
||||
async fn test_check_pending_lookups_default_sequence_unreachable() {
|
||||
use crate::bloom::BloomFilter;
|
||||
use crate::node::handlers::discovery::PendingLookup;
|
||||
use crate::peer::ActivePeer;
|
||||
use crate::proto::bloom::BloomFilter;
|
||||
use crate::proto::lookup::PendingLookup;
|
||||
use crate::transport::LinkId;
|
||||
use std::sync::mpsc;
|
||||
|
||||
@@ -1047,14 +1109,14 @@ async fn test_check_pending_lookups_default_sequence_unreachable() {
|
||||
// Default attempt_timeouts_secs is [1, 2, 4, 8]. Confirm so the test
|
||||
// cannot silently drift if the default changes.
|
||||
assert_eq!(
|
||||
node.config().node.discovery.attempt_timeouts_secs,
|
||||
node.config().node.lookup.attempt_timeouts_secs,
|
||||
vec![1, 2, 4, 8],
|
||||
"test pins the [1,2,4,8] default; update the test if the default changes"
|
||||
);
|
||||
|
||||
// Inject a TUN sender so `send_icmpv6_dest_unreachable` is observable.
|
||||
let (tun_tx, tun_rx) = mpsc::channel::<Vec<u8>>();
|
||||
node.tun_tx = Some(tun_tx);
|
||||
node.supervisor.tun_tx = Some(tun_tx);
|
||||
|
||||
// Build a target identity (the unreachable destination).
|
||||
let target_identity = Identity::generate();
|
||||
@@ -1080,7 +1142,7 @@ async fn test_check_pending_lookups_default_sequence_unreachable() {
|
||||
// as its parent. `is_tree_peer` checks both directions — the child
|
||||
// direction (peer.parent_id == self.node_addr) is what we exercise.
|
||||
let our_addr = *node.node_addr();
|
||||
let peer_decl = crate::tree::ParentDeclaration::new(peer_addr, our_addr, 1, 0);
|
||||
let peer_decl = crate::proto::stp::ParentDeclaration::new(peer_addr, our_addr, 1, 0);
|
||||
let peer_coords = TreeCoordinate::from_addrs(vec![peer_addr, our_addr]).unwrap();
|
||||
node.tree_state_mut().update_peer(peer_decl, peer_coords);
|
||||
assert!(node.is_tree_peer(&peer_addr), "peer must be a tree peer");
|
||||
@@ -1106,16 +1168,18 @@ async fn test_check_pending_lookups_default_sequence_unreachable() {
|
||||
// Inject a PendingLookup directly: attempt=1, last_sent_ms=0. This
|
||||
// mirrors the post-condition of a successful `maybe_initiate_lookup`
|
||||
// at t=0 without depending on wall-clock-derived `Self::now_ms()`.
|
||||
node.pending_lookups
|
||||
node.lookup
|
||||
.pending_lookups
|
||||
.insert(target_addr, PendingLookup::new(0));
|
||||
|
||||
let baseline_initiated = node.metrics().discovery.req_initiated.get();
|
||||
let baseline_timed_out = node.metrics().discovery.resp_timed_out.get();
|
||||
let baseline_initiated = node.metrics().lookup.req_initiated.get();
|
||||
let baseline_timed_out = node.metrics().lookup.resp_timed_out.get();
|
||||
|
||||
// --- t = 1100ms: first retry deadline (1*1000) ---
|
||||
node.check_pending_lookups(1100).await;
|
||||
{
|
||||
let entry = node
|
||||
.lookup
|
||||
.pending_lookups
|
||||
.get(&target_addr)
|
||||
.expect("still pending");
|
||||
@@ -1123,7 +1187,7 @@ async fn test_check_pending_lookups_default_sequence_unreachable() {
|
||||
assert_eq!(entry.last_sent_ms, 1100);
|
||||
}
|
||||
assert_eq!(
|
||||
node.metrics().discovery.req_initiated.get(),
|
||||
node.metrics().lookup.req_initiated.get(),
|
||||
baseline_initiated + 1,
|
||||
"retry #1 must invoke initiate_lookup exactly once"
|
||||
);
|
||||
@@ -1132,6 +1196,7 @@ async fn test_check_pending_lookups_default_sequence_unreachable() {
|
||||
node.check_pending_lookups(3100).await;
|
||||
{
|
||||
let entry = node
|
||||
.lookup
|
||||
.pending_lookups
|
||||
.get(&target_addr)
|
||||
.expect("still pending");
|
||||
@@ -1139,7 +1204,7 @@ async fn test_check_pending_lookups_default_sequence_unreachable() {
|
||||
assert_eq!(entry.last_sent_ms, 3100);
|
||||
}
|
||||
assert_eq!(
|
||||
node.metrics().discovery.req_initiated.get(),
|
||||
node.metrics().lookup.req_initiated.get(),
|
||||
baseline_initiated + 2,
|
||||
"retry #2 must invoke initiate_lookup exactly once more"
|
||||
);
|
||||
@@ -1148,6 +1213,7 @@ async fn test_check_pending_lookups_default_sequence_unreachable() {
|
||||
node.check_pending_lookups(7100).await;
|
||||
{
|
||||
let entry = node
|
||||
.lookup
|
||||
.pending_lookups
|
||||
.get(&target_addr)
|
||||
.expect("still pending");
|
||||
@@ -1155,7 +1221,7 @@ async fn test_check_pending_lookups_default_sequence_unreachable() {
|
||||
assert_eq!(entry.last_sent_ms, 7100);
|
||||
}
|
||||
assert_eq!(
|
||||
node.metrics().discovery.req_initiated.get(),
|
||||
node.metrics().lookup.req_initiated.get(),
|
||||
baseline_initiated + 3,
|
||||
"retry #3 must invoke initiate_lookup exactly once more"
|
||||
);
|
||||
@@ -1163,16 +1229,16 @@ async fn test_check_pending_lookups_default_sequence_unreachable() {
|
||||
// --- Just-before-final: at t=15099ms the 8s window is not yet reached ---
|
||||
node.check_pending_lookups(15_099).await;
|
||||
assert!(
|
||||
node.pending_lookups.contains_key(&target_addr),
|
||||
node.lookup.pending_lookups.contains_key(&target_addr),
|
||||
"8s window not yet expired: pending_lookup must persist"
|
||||
);
|
||||
assert_eq!(
|
||||
node.metrics().discovery.req_initiated.get(),
|
||||
node.metrics().lookup.req_initiated.get(),
|
||||
baseline_initiated + 3,
|
||||
"no new attempt before final deadline"
|
||||
);
|
||||
assert_eq!(
|
||||
node.metrics().discovery.resp_timed_out.get(),
|
||||
node.metrics().lookup.resp_timed_out.get(),
|
||||
baseline_timed_out,
|
||||
"no timeout before final deadline"
|
||||
);
|
||||
@@ -1186,18 +1252,18 @@ async fn test_check_pending_lookups_default_sequence_unreachable() {
|
||||
|
||||
// Pending lookup is dropped.
|
||||
assert!(
|
||||
!node.pending_lookups.contains_key(&target_addr),
|
||||
!node.lookup.pending_lookups.contains_key(&target_addr),
|
||||
"final timeout must remove the pending_lookups entry"
|
||||
);
|
||||
// resp_timed_out counter ticked.
|
||||
assert_eq!(
|
||||
node.metrics().discovery.resp_timed_out.get(),
|
||||
node.metrics().lookup.resp_timed_out.get(),
|
||||
baseline_timed_out + 1,
|
||||
"final timeout must increment discovery.resp_timed_out"
|
||||
);
|
||||
// No additional initiate_lookup on the timeout step.
|
||||
assert_eq!(
|
||||
node.metrics().discovery.req_initiated.get(),
|
||||
node.metrics().lookup.req_initiated.get(),
|
||||
baseline_initiated + 3,
|
||||
"the final-timeout step must NOT call initiate_lookup"
|
||||
);
|
||||
|
||||
1034
src/node/tests/establish_chartests.rs
Normal file
1034
src/node/tests/establish_chartests.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -83,7 +83,7 @@ async fn make_test_node_ethernet(interface: &str) -> TestNode {
|
||||
|
||||
let config = EthernetConfig {
|
||||
interface: interface.to_string(),
|
||||
discovery: Some(false),
|
||||
listen: Some(false),
|
||||
announce: Some(false),
|
||||
accept_connections: Some(true),
|
||||
..Default::default()
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
//! multi-hop forwarding through live node topologies.
|
||||
|
||||
use super::*;
|
||||
use crate::node::session_wire::{FSP_FLAG_CP, build_fsp_header};
|
||||
use crate::protocol::{SessionAck, SessionDatagram, SessionSetup, encode_coords};
|
||||
use crate::tree::TreeCoordinate;
|
||||
use crate::proto::fsp::wire::{FSP_FLAG_CP, build_fsp_header};
|
||||
use crate::proto::fsp::{SessionAck, SessionSetup};
|
||||
use crate::proto::link::SessionDatagram;
|
||||
use crate::proto::stp::TreeCoordinate;
|
||||
use crate::proto::stp::encode_coords;
|
||||
use spanning_tree::{
|
||||
TestNode, cleanup_nodes, process_available_packets, run_tree_test, verify_tree_convergence,
|
||||
};
|
||||
@@ -591,7 +593,7 @@ async fn test_forwarding_with_cache_warming_enables_routing() {
|
||||
// ============================================================================
|
||||
|
||||
use crate::node::TransportDropState;
|
||||
use crate::node::handlers::session::mark_ipv6_ecn_ce;
|
||||
use crate::proto::fsp::mark_ipv6_ecn_ce;
|
||||
use crate::transport::TransportId;
|
||||
|
||||
/// Build a minimal IPv6 header (40 bytes) with specified ECN bits.
|
||||
|
||||
@@ -5,7 +5,7 @@ use super::*;
|
||||
#[tokio::test]
|
||||
async fn test_two_node_handshake_udp() {
|
||||
use crate::config::UdpConfig;
|
||||
use crate::node::wire::{
|
||||
use crate::proto::fmp::wire::{
|
||||
build_encrypted, build_established_header, build_msg1, prepend_inner_header,
|
||||
};
|
||||
use crate::transport::udp::UdpTransport;
|
||||
@@ -53,19 +53,28 @@ async fn test_two_node_handshake_udp() {
|
||||
let peer_b_node_addr = *peer_b_identity.node_addr();
|
||||
|
||||
let link_id_a = node_a.allocate_link_id();
|
||||
let mut conn_a = PeerConnection::outbound(link_id_a, peer_b_identity, 1000);
|
||||
|
||||
// Allocate session index for A's outbound
|
||||
let our_index_a = node_a.index_allocator.allocate().unwrap();
|
||||
|
||||
node_a
|
||||
.seed_handshake_machine(
|
||||
HandshakeSeed::outbound(link_id_a, peer_b_identity, 1000)
|
||||
.with_our_index(our_index_a)
|
||||
.with_transport_id(transport_id_a)
|
||||
.with_source_addr(remote_addr_b.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Start handshake (generates Noise IK msg1)
|
||||
let our_keypair_a = node_a.identity().keypair();
|
||||
let noise_msg1 = conn_a
|
||||
.start_handshake(our_keypair_a, node_a.startup_epoch(), 1000)
|
||||
let startup_epoch_a = node_a.startup_epoch();
|
||||
let noise_msg1 = node_a
|
||||
.peer_machines
|
||||
.get_mut(&link_id_a)
|
||||
.unwrap()
|
||||
.start_handshake(our_keypair_a, startup_epoch_a, 1000)
|
||||
.unwrap();
|
||||
conn_a.set_our_index(our_index_a);
|
||||
conn_a.set_transport_id(transport_id_a);
|
||||
conn_a.set_source_addr(remote_addr_b.clone());
|
||||
|
||||
// Build wire msg1 and track in node state
|
||||
let wire_msg1 = build_msg1(our_index_a, &noise_msg1);
|
||||
@@ -78,7 +87,6 @@ async fn test_two_node_handshake_udp() {
|
||||
Duration::from_millis(100),
|
||||
);
|
||||
node_a.links.insert(link_id_a, link_a);
|
||||
node_a.connections.insert(link_id_a, conn_a);
|
||||
node_a
|
||||
.pending_outbound
|
||||
.insert((transport_id_a, our_index_a.as_u32()), link_id_a);
|
||||
@@ -243,7 +251,7 @@ async fn test_two_node_handshake_udp() {
|
||||
#[tokio::test]
|
||||
async fn test_run_rx_loop_handshake() {
|
||||
use crate::config::UdpConfig;
|
||||
use crate::node::wire::build_msg1;
|
||||
use crate::proto::fmp::wire::build_msg1;
|
||||
use crate::transport::udp::UdpTransport;
|
||||
use tokio::time::Duration;
|
||||
|
||||
@@ -285,8 +293,8 @@ async fn test_run_rx_loop_handshake() {
|
||||
node_b.packet_rx = Some(packet_rx_b);
|
||||
|
||||
// Set node state to Running (transports need to be operational)
|
||||
node_a.state = NodeState::Running;
|
||||
node_b.state = NodeState::Running;
|
||||
node_a.supervisor.state = NodeState::Running;
|
||||
node_b.supervisor.state = NodeState::Running;
|
||||
|
||||
// === Phase 1: Node A initiates handshake to Node B ===
|
||||
|
||||
@@ -294,16 +302,24 @@ async fn test_run_rx_loop_handshake() {
|
||||
let peer_b_node_addr = *peer_b_identity.node_addr();
|
||||
|
||||
let link_id_a = node_a.allocate_link_id();
|
||||
let mut conn_a = PeerConnection::outbound(link_id_a, peer_b_identity, 1000);
|
||||
|
||||
let our_index_a = node_a.index_allocator.allocate().unwrap();
|
||||
let our_keypair_a = node_a.identity().keypair();
|
||||
let noise_msg1 = conn_a
|
||||
.start_handshake(our_keypair_a, node_a.startup_epoch(), 1000)
|
||||
node_a
|
||||
.seed_handshake_machine(
|
||||
HandshakeSeed::outbound(link_id_a, peer_b_identity, 1000)
|
||||
.with_our_index(our_index_a)
|
||||
.with_transport_id(transport_id_a)
|
||||
.with_source_addr(remote_addr_b.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
let our_keypair_a = node_a.identity().keypair();
|
||||
let startup_epoch_a = node_a.startup_epoch();
|
||||
let noise_msg1 = node_a
|
||||
.peer_machines
|
||||
.get_mut(&link_id_a)
|
||||
.unwrap()
|
||||
.start_handshake(our_keypair_a, startup_epoch_a, 1000)
|
||||
.unwrap();
|
||||
conn_a.set_our_index(our_index_a);
|
||||
conn_a.set_transport_id(transport_id_a);
|
||||
conn_a.set_source_addr(remote_addr_b.clone());
|
||||
|
||||
let wire_msg1 = build_msg1(our_index_a, &noise_msg1);
|
||||
|
||||
@@ -315,7 +331,6 @@ async fn test_run_rx_loop_handshake() {
|
||||
Duration::from_millis(100),
|
||||
);
|
||||
node_a.links.insert(link_id_a, link_a);
|
||||
node_a.connections.insert(link_id_a, conn_a);
|
||||
node_a
|
||||
.pending_outbound
|
||||
.insert((transport_id_a, our_index_a.as_u32()), link_id_a);
|
||||
@@ -434,7 +449,7 @@ async fn test_run_rx_loop_handshake() {
|
||||
#[tokio::test]
|
||||
async fn test_cross_connection_both_initiate() {
|
||||
use crate::config::UdpConfig;
|
||||
use crate::node::wire::build_msg1;
|
||||
use crate::proto::fmp::wire::build_msg1;
|
||||
use crate::transport::udp::UdpTransport;
|
||||
use tokio::time::{Duration, timeout};
|
||||
|
||||
@@ -483,15 +498,23 @@ async fn test_cross_connection_both_initiate() {
|
||||
|
||||
// Node A initiates to Node B
|
||||
let link_id_a_out = node_a.allocate_link_id();
|
||||
let mut conn_a = PeerConnection::outbound(link_id_a_out, peer_b_identity, 1000);
|
||||
let our_index_a = node_a.index_allocator.allocate().unwrap();
|
||||
let our_keypair_a = node_a.identity().keypair();
|
||||
let noise_msg1_a = conn_a
|
||||
.start_handshake(our_keypair_a, node_a.startup_epoch(), 1000)
|
||||
node_a
|
||||
.seed_handshake_machine(
|
||||
HandshakeSeed::outbound(link_id_a_out, peer_b_identity, 1000)
|
||||
.with_our_index(our_index_a)
|
||||
.with_transport_id(transport_id_a)
|
||||
.with_source_addr(remote_addr_b.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
let our_keypair_a = node_a.identity().keypair();
|
||||
let startup_epoch_a = node_a.startup_epoch();
|
||||
let noise_msg1_a = node_a
|
||||
.peer_machines
|
||||
.get_mut(&link_id_a_out)
|
||||
.unwrap()
|
||||
.start_handshake(our_keypair_a, startup_epoch_a, 1000)
|
||||
.unwrap();
|
||||
conn_a.set_our_index(our_index_a);
|
||||
conn_a.set_transport_id(transport_id_a);
|
||||
conn_a.set_source_addr(remote_addr_b.clone());
|
||||
|
||||
let wire_msg1_a = build_msg1(our_index_a, &noise_msg1_a);
|
||||
|
||||
@@ -506,22 +529,29 @@ async fn test_cross_connection_both_initiate() {
|
||||
node_a
|
||||
.addr_to_link
|
||||
.insert((transport_id_a, remote_addr_b.clone()), link_id_a_out);
|
||||
node_a.connections.insert(link_id_a_out, conn_a);
|
||||
node_a
|
||||
.pending_outbound
|
||||
.insert((transport_id_a, our_index_a.as_u32()), link_id_a_out);
|
||||
|
||||
// Node B initiates to Node A
|
||||
let link_id_b_out = node_b.allocate_link_id();
|
||||
let mut conn_b = PeerConnection::outbound(link_id_b_out, peer_a_identity, 1000);
|
||||
let our_index_b = node_b.index_allocator.allocate().unwrap();
|
||||
let our_keypair_b = node_b.identity().keypair();
|
||||
let noise_msg1_b = conn_b
|
||||
.start_handshake(our_keypair_b, node_b.startup_epoch(), 1000)
|
||||
node_b
|
||||
.seed_handshake_machine(
|
||||
HandshakeSeed::outbound(link_id_b_out, peer_a_identity, 1000)
|
||||
.with_our_index(our_index_b)
|
||||
.with_transport_id(transport_id_b)
|
||||
.with_source_addr(remote_addr_a.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
let our_keypair_b = node_b.identity().keypair();
|
||||
let startup_epoch_b = node_b.startup_epoch();
|
||||
let noise_msg1_b = node_b
|
||||
.peer_machines
|
||||
.get_mut(&link_id_b_out)
|
||||
.unwrap()
|
||||
.start_handshake(our_keypair_b, startup_epoch_b, 1000)
|
||||
.unwrap();
|
||||
conn_b.set_our_index(our_index_b);
|
||||
conn_b.set_transport_id(transport_id_b);
|
||||
conn_b.set_source_addr(remote_addr_a.clone());
|
||||
|
||||
let wire_msg1_b = build_msg1(our_index_b, &noise_msg1_b);
|
||||
|
||||
@@ -536,7 +566,6 @@ async fn test_cross_connection_both_initiate() {
|
||||
node_b
|
||||
.addr_to_link
|
||||
.insert((transport_id_b, remote_addr_a.clone()), link_id_b_out);
|
||||
node_b.connections.insert(link_id_b_out, conn_b);
|
||||
node_b
|
||||
.pending_outbound
|
||||
.insert((transport_id_b, our_index_b.as_u32()), link_id_b_out);
|
||||
@@ -662,17 +691,24 @@ async fn test_stale_connection_cleanup() {
|
||||
// Create outbound connection with a timestamp far in the past
|
||||
let past_time_ms = 1000; // A very early timestamp
|
||||
let link_id = node.allocate_link_id();
|
||||
let mut conn = PeerConnection::outbound(link_id, peer_identity, past_time_ms);
|
||||
|
||||
// Allocate session index and set transport info
|
||||
let our_index = node.index_allocator.allocate().unwrap();
|
||||
node.seed_handshake_machine(
|
||||
HandshakeSeed::outbound(link_id, peer_identity, past_time_ms)
|
||||
.with_our_index(our_index)
|
||||
.with_transport_id(transport_id)
|
||||
.with_source_addr(remote_addr.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
let our_keypair = node.identity().keypair();
|
||||
let _noise_msg1 = conn
|
||||
.start_handshake(our_keypair, node.startup_epoch(), past_time_ms)
|
||||
let startup_epoch = node.startup_epoch();
|
||||
let _noise_msg1 = node
|
||||
.peer_machines
|
||||
.get_mut(&link_id)
|
||||
.unwrap()
|
||||
.start_handshake(our_keypair, startup_epoch, past_time_ms)
|
||||
.unwrap();
|
||||
conn.set_our_index(our_index);
|
||||
conn.set_transport_id(transport_id);
|
||||
conn.set_source_addr(remote_addr.clone());
|
||||
|
||||
// Set up all the state that initiate_peer_connection would create
|
||||
let link = Link::connectionless(
|
||||
@@ -685,7 +721,6 @@ async fn test_stale_connection_cleanup() {
|
||||
node.links.insert(link_id, link);
|
||||
node.addr_to_link
|
||||
.insert((transport_id, remote_addr.clone()), link_id);
|
||||
node.connections.insert(link_id, conn);
|
||||
node.pending_outbound
|
||||
.insert((transport_id, our_index.as_u32()), link_id);
|
||||
|
||||
@@ -741,17 +776,23 @@ async fn test_failed_connection_cleanup() {
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
let link_id = node.allocate_link_id();
|
||||
let mut conn = PeerConnection::outbound(link_id, peer_identity, now_ms);
|
||||
|
||||
let our_index = node.index_allocator.allocate().unwrap();
|
||||
node.seed_handshake_machine(
|
||||
HandshakeSeed::outbound(link_id, peer_identity, now_ms)
|
||||
.with_our_index(our_index)
|
||||
.with_transport_id(transport_id)
|
||||
.with_source_addr(remote_addr.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
let our_keypair = node.identity().keypair();
|
||||
let _noise_msg1 = conn
|
||||
.start_handshake(our_keypair, node.startup_epoch(), now_ms)
|
||||
let startup_epoch = node.startup_epoch();
|
||||
let _noise_msg1 = node
|
||||
.peer_machines
|
||||
.get_mut(&link_id)
|
||||
.unwrap()
|
||||
.start_handshake(our_keypair, startup_epoch, now_ms)
|
||||
.unwrap();
|
||||
conn.set_our_index(our_index);
|
||||
conn.set_transport_id(transport_id);
|
||||
conn.set_source_addr(remote_addr.clone());
|
||||
conn.mark_failed(); // Simulate send failure
|
||||
|
||||
let link = Link::connectionless(
|
||||
link_id,
|
||||
@@ -763,10 +804,27 @@ async fn test_failed_connection_cleanup() {
|
||||
node.links.insert(link_id, link);
|
||||
node.addr_to_link
|
||||
.insert((transport_id, remote_addr.clone()), link_id);
|
||||
node.connections.insert(link_id, conn);
|
||||
node.pending_outbound
|
||||
.insert((transport_id, our_index.as_u32()), link_id);
|
||||
|
||||
// Simulate a stored-handshake send failure through the control machine —
|
||||
// the failure carrier the stale-connection sweep now reads (the leg no
|
||||
// longer carries a failed phase of its own).
|
||||
{
|
||||
let machine = node
|
||||
.peer_machines
|
||||
.get_mut(&link_id)
|
||||
.expect("machine seeded by the handshake seeder");
|
||||
let alloc = &mut node.index_allocator;
|
||||
let actions = machine.step(
|
||||
crate::peer::machine::PeerEvent::HandshakeSendFailed,
|
||||
now_ms,
|
||||
alloc,
|
||||
);
|
||||
assert!(actions.is_empty());
|
||||
assert!(machine.is_failed());
|
||||
}
|
||||
|
||||
assert_eq!(node.connection_count(), 1);
|
||||
|
||||
// Failed connections should be cleaned up immediately regardless of age
|
||||
@@ -788,7 +846,7 @@ async fn test_failed_connection_cleanup() {
|
||||
/// Test that msg1 bytes are stored on connection for resend.
|
||||
#[tokio::test]
|
||||
async fn test_msg1_stored_for_resend() {
|
||||
use crate::node::wire::build_msg1;
|
||||
use crate::proto::fmp::wire::build_msg1;
|
||||
|
||||
let mut node = make_node();
|
||||
let transport_id = TransportId::new(1);
|
||||
@@ -801,26 +859,24 @@ async fn test_msg1_stored_for_resend() {
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
let link_id = node.allocate_link_id();
|
||||
let mut conn = PeerConnection::outbound(link_id, peer_identity, now_ms);
|
||||
let mut conn = outbound_leg(link_id, peer_identity, now_ms);
|
||||
|
||||
let our_index = node.index_allocator.allocate().unwrap();
|
||||
let our_keypair = node.identity().keypair();
|
||||
let noise_msg1 = conn
|
||||
.start_handshake(our_keypair, node.startup_epoch(), now_ms)
|
||||
.unwrap();
|
||||
conn.set_our_index(our_index);
|
||||
conn.set_transport_id(transport_id);
|
||||
conn.set_source_addr(remote_addr.clone());
|
||||
conn.set_conn_our_index(our_index);
|
||||
conn.set_conn_transport_id(transport_id);
|
||||
conn.set_conn_source_addr(remote_addr.clone());
|
||||
|
||||
// Build wire msg1 and store it (as initiate_peer_connection does)
|
||||
let wire_msg1 = build_msg1(our_index, &noise_msg1);
|
||||
let resend_interval = node.config().node.rate_limit.handshake_resend_interval_ms;
|
||||
conn.set_handshake_msg1(wire_msg1.clone(), now_ms + resend_interval);
|
||||
conn.set_conn_handshake_msg1(wire_msg1.clone(), now_ms + resend_interval);
|
||||
|
||||
// Verify stored msg1 matches what was built
|
||||
assert_eq!(conn.handshake_msg1().unwrap(), &wire_msg1);
|
||||
assert_eq!(conn.resend_count(), 0);
|
||||
assert!(conn.next_resend_at_ms() > now_ms);
|
||||
assert_eq!(conn.conn_handshake_msg1().unwrap(), &wire_msg1);
|
||||
}
|
||||
|
||||
/// Test that resend scheduling respects max_resends and backoff.
|
||||
@@ -834,20 +890,17 @@ async fn test_resend_scheduling() {
|
||||
|
||||
let now_ms = 100_000u64; // Use a fixed time for predictable testing
|
||||
let link_id = node.allocate_link_id();
|
||||
let mut conn = PeerConnection::outbound(link_id, peer_identity, now_ms);
|
||||
let mut conn = outbound_leg(link_id, peer_identity, now_ms);
|
||||
|
||||
let our_index = node.index_allocator.allocate().unwrap();
|
||||
let our_keypair = node.identity().keypair();
|
||||
let noise_msg1 = conn
|
||||
.start_handshake(our_keypair, node.startup_epoch(), now_ms)
|
||||
.unwrap();
|
||||
conn.set_our_index(our_index);
|
||||
conn.set_transport_id(transport_id);
|
||||
conn.set_source_addr(remote_addr.clone());
|
||||
conn.set_conn_source_addr(remote_addr.clone());
|
||||
|
||||
// Store msg1 with first resend at now + 1000ms
|
||||
let wire_msg1 = crate::node::wire::build_msg1(our_index, &noise_msg1);
|
||||
conn.set_handshake_msg1(wire_msg1, now_ms + 1000);
|
||||
let wire_msg1 = crate::proto::fmp::wire::build_msg1(our_index, &noise_msg1);
|
||||
|
||||
let link = Link::connectionless(
|
||||
link_id,
|
||||
@@ -858,73 +911,152 @@ async fn test_resend_scheduling() {
|
||||
);
|
||||
node.links.insert(link_id, link);
|
||||
node.addr_to_link
|
||||
.insert((transport_id, remote_addr), link_id);
|
||||
.insert((transport_id, remote_addr.clone()), link_id);
|
||||
node.pending_outbound
|
||||
.insert((transport_id, our_index.as_u32()), link_id);
|
||||
node.connections.insert(link_id, conn);
|
||||
|
||||
// Before resend time: nothing should happen (no transport = can't send,
|
||||
// but the filter should exclude it because now < next_resend_at)
|
||||
node.resend_pending_handshakes(now_ms + 500).await;
|
||||
let conn = node.connections.get(&link_id).unwrap();
|
||||
assert_eq!(conn.resend_count(), 0, "No resend before scheduled time");
|
||||
// The msg1-resend counter and its due timer live on the per-peer machine,
|
||||
// which also carries the pending connection. Dial it to `SentMsg1`
|
||||
// (connectionless: no connect step) and arm its retransmit timer at
|
||||
// now + 1000ms, mirroring what a real dial arms.
|
||||
let mut machine =
|
||||
crate::peer::machine::PeerMachine::new_outbound(link_id, peer_identity, now_ms);
|
||||
let _ = machine.step(
|
||||
crate::peer::machine::PeerEvent::Dial {
|
||||
transport_id,
|
||||
remote_addr: remote_addr.clone(),
|
||||
peer_identity,
|
||||
connection_oriented: false,
|
||||
},
|
||||
now_ms,
|
||||
&mut node.index_allocator,
|
||||
);
|
||||
// The msg1 wire lives on the machine's carrier (the retransmit driver's
|
||||
// resend source), mirroring `prepare_outbound_msg1`.
|
||||
machine.set_conn_handshake_msg1(wire_msg1, now_ms + 1000);
|
||||
machine.set_conn_our_index(our_index);
|
||||
machine.set_conn_transport_id(transport_id);
|
||||
machine.set_leg(conn.take_leg().unwrap());
|
||||
node.peer_machines.insert(link_id, machine);
|
||||
node.peer_timers.entry(link_id).or_default().insert(
|
||||
crate::peer::machine::TimerKind::HandshakeRetransmit,
|
||||
now_ms + 1000,
|
||||
);
|
||||
|
||||
// At resend time: would resend if transport existed. Without transport,
|
||||
// the send fails silently and resend_count stays at 0.
|
||||
// This tests the filtering logic — the connection IS a candidate.
|
||||
node.resend_pending_handshakes(now_ms + 1000).await;
|
||||
// No transport registered, so send fails — count stays 0.
|
||||
// That's the expected behavior (transport absence is a transient condition).
|
||||
let conn = node.connections.get(&link_id).unwrap();
|
||||
// Before the scheduled time the timer isn't due, so nothing fires.
|
||||
node.drive_peer_timers(now_ms + 500).await;
|
||||
assert_eq!(
|
||||
conn.resend_count(),
|
||||
node.connection_resend_count(link_id),
|
||||
0,
|
||||
"No transport means no resend recorded"
|
||||
"No resend before scheduled time"
|
||||
);
|
||||
|
||||
// At the scheduled time the timer is due, but no transport is registered so
|
||||
// the send fails. Record-on-success: the count does NOT advance (and the
|
||||
// connection is not marked failed) — a failed resend just retries next tick.
|
||||
node.drive_peer_timers(now_ms + 1000).await;
|
||||
assert_eq!(
|
||||
node.connection_resend_count(link_id),
|
||||
0,
|
||||
"Failed send records no resend"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test that msg2 is stored on PeerConnection for responder resend.
|
||||
#[test]
|
||||
fn test_msg2_stored_on_connection() {
|
||||
let mut conn = PeerConnection::inbound(LinkId::new(1), 1000);
|
||||
/// Test that the timer driver reaps an outbound leg whose machine
|
||||
/// `HandshakeTimeout` timer has come due (the timeout fold). The reap re-checks
|
||||
/// the shell `is_timed_out` predicate, then tears the connection down exactly as
|
||||
/// the old `check_timeouts` Teardown path did.
|
||||
#[tokio::test]
|
||||
async fn test_handshake_timeout_drive() {
|
||||
let mut node = make_node();
|
||||
let transport_id = TransportId::new(1);
|
||||
let peer_identity = make_peer_identity();
|
||||
let remote_addr = TransportAddr::from_string("10.0.0.2:2121");
|
||||
|
||||
assert!(conn.handshake_msg2().is_none());
|
||||
let dial_ms = 1000u64;
|
||||
let link_id = node.allocate_link_id();
|
||||
let mut conn = outbound_leg(link_id, peer_identity, dial_ms);
|
||||
let our_index = node.index_allocator.allocate().unwrap();
|
||||
let our_keypair = node.identity().keypair();
|
||||
let _ = conn
|
||||
.start_handshake(our_keypair, node.startup_epoch(), dial_ms)
|
||||
.unwrap();
|
||||
conn.set_conn_source_addr(remote_addr.clone());
|
||||
|
||||
let msg2_bytes = vec![0x01, 0x02, 0x03, 0x04];
|
||||
conn.set_handshake_msg2(msg2_bytes.clone());
|
||||
let link = Link::connectionless(
|
||||
link_id,
|
||||
transport_id,
|
||||
remote_addr.clone(),
|
||||
LinkDirection::Outbound,
|
||||
Duration::from_millis(100),
|
||||
);
|
||||
node.links.insert(link_id, link);
|
||||
node.addr_to_link
|
||||
.insert((transport_id, remote_addr.clone()), link_id);
|
||||
node.pending_outbound
|
||||
.insert((transport_id, our_index.as_u32()), link_id);
|
||||
|
||||
assert_eq!(conn.handshake_msg2().unwrap(), &msg2_bytes);
|
||||
// Machine in SentMsg1, carrying the pending connection, with a
|
||||
// HandshakeTimeout timer armed at dial + 30s.
|
||||
let mut machine =
|
||||
crate::peer::machine::PeerMachine::new_outbound(link_id, peer_identity, dial_ms);
|
||||
let _ = machine.step(
|
||||
crate::peer::machine::PeerEvent::Dial {
|
||||
transport_id,
|
||||
remote_addr: remote_addr.clone(),
|
||||
peer_identity,
|
||||
connection_oriented: false,
|
||||
},
|
||||
dial_ms,
|
||||
&mut node.index_allocator,
|
||||
);
|
||||
machine.set_conn_our_index(our_index);
|
||||
machine.set_conn_transport_id(transport_id);
|
||||
machine.set_leg(conn.take_leg().unwrap());
|
||||
node.peer_machines.insert(link_id, machine);
|
||||
node.peer_timers.entry(link_id).or_default().insert(
|
||||
crate::peer::machine::TimerKind::HandshakeTimeout,
|
||||
dial_ms + 30_000,
|
||||
);
|
||||
|
||||
assert_eq!(node.connection_count(), 1);
|
||||
|
||||
// Well past dial + 30s: the timer is due and the leg is idle-timed-out.
|
||||
node.drive_peer_timers(dial_ms + 100_000).await;
|
||||
|
||||
assert_eq!(
|
||||
node.connection_count(),
|
||||
0,
|
||||
"Timed-out leg reaped by the timer drive"
|
||||
);
|
||||
assert_eq!(node.index_allocator.count(), 0, "Session index freed");
|
||||
assert!(
|
||||
!node.peer_machines.contains_key(&link_id),
|
||||
"Control machine dropped with the reaped connection"
|
||||
);
|
||||
assert!(
|
||||
!node.peer_timers.contains_key(&link_id),
|
||||
"Timer store dropped with the reaped connection"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test that resend_count and next_resend_at_ms track correctly.
|
||||
/// Test that msg2 is stored on the control machine's carrier for responder resend.
|
||||
#[test]
|
||||
fn test_resend_count_tracking() {
|
||||
let peer_identity = make_peer_identity();
|
||||
let mut conn = PeerConnection::outbound(LinkId::new(1), peer_identity, 1000);
|
||||
fn test_msg2_stored_on_connection() {
|
||||
let mut machine = crate::peer::machine::PeerMachine::new_inbound(LinkId::new(1), 1000);
|
||||
|
||||
assert_eq!(conn.resend_count(), 0);
|
||||
assert_eq!(conn.next_resend_at_ms(), 0);
|
||||
assert!(machine.conn_handshake_msg2().is_none());
|
||||
|
||||
// Simulate storing msg1 and scheduling first resend
|
||||
conn.set_handshake_msg1(vec![0x01], 2000);
|
||||
assert_eq!(conn.resend_count(), 0);
|
||||
assert_eq!(conn.next_resend_at_ms(), 2000);
|
||||
let msg2_bytes = vec![0x01, 0x02, 0x03, 0x04];
|
||||
machine.set_conn_handshake_msg2(msg2_bytes.clone());
|
||||
|
||||
// Record first resend
|
||||
conn.record_resend(4000); // next at 4000 (2s backoff)
|
||||
assert_eq!(conn.resend_count(), 1);
|
||||
assert_eq!(conn.next_resend_at_ms(), 4000);
|
||||
|
||||
// Record second resend
|
||||
conn.record_resend(8000); // next at 8000 (4s backoff)
|
||||
assert_eq!(conn.resend_count(), 2);
|
||||
assert_eq!(conn.next_resend_at_ms(), 8000);
|
||||
assert_eq!(machine.conn_handshake_msg2().unwrap(), &msg2_bytes);
|
||||
}
|
||||
|
||||
/// Test that duplicate msg2 is silently dropped when pending_outbound is already cleared.
|
||||
#[tokio::test]
|
||||
async fn test_duplicate_msg2_dropped() {
|
||||
use crate::node::wire::build_msg2;
|
||||
use crate::proto::fmp::wire::build_msg2;
|
||||
use crate::transport::ReceivedPacket;
|
||||
|
||||
let mut node = make_node();
|
||||
@@ -1072,7 +1204,7 @@ async fn test_should_admit_msg1_admits_rekey_when_udp_accept_off() {
|
||||
///
|
||||
/// The carve-out predicate must also consult peer state by source
|
||||
/// address: `current_addr()` is updated from inbound encrypted-frame
|
||||
/// source addrs (`handlers/encrypted.rs`), so an established peer can
|
||||
/// source addrs (`dataplane/encrypted.rs`), so an established peer can
|
||||
/// be matched even when the addr_to_link key is hostname-form and the
|
||||
/// incoming addr is numeric.
|
||||
#[tokio::test]
|
||||
|
||||
439
src/node/tests/mmp_chartests.rs
Normal file
439
src/node/tests/mmp_chartests.rs
Normal file
@@ -0,0 +1,439 @@
|
||||
//! Characterization tests for the three under-tested MMP tick handlers.
|
||||
//!
|
||||
//! These lock in the *current* observable behavior of the MMP fan-out and
|
||||
//! first-RTT paths so a later behavior-neutral sans-IO extraction has an
|
||||
//! equality oracle. The `check_link_heartbeats` handler already has a good
|
||||
//! oracle (`heartbeat.rs` + `tcp.rs`) and is not re-covered here; this file
|
||||
//! targets the three paths with no direct handler tests:
|
||||
//!
|
||||
//! * `check_mmp_reports` — link-layer mode/flag fan-out gating
|
||||
//! * `check_session_mmp_reports` — session mode + PathMtu gating + backoff dedup
|
||||
//! * `handle_receiver_report` — the first-RTT tree re-evaluation branch
|
||||
//!
|
||||
//! Assertions capture what the code does today, surprising or not.
|
||||
//!
|
||||
//! Report-generation is probed through the reused `proto/mmp/` primitives
|
||||
//! (`should_send_report` / `should_send_notification`): after a handler tick,
|
||||
//! a *consumed* interval reads as "not due" (the report was built) while an
|
||||
//! *ungated* interval still reads as "due" (the report was suppressed by the
|
||||
//! mode/flag gate). This survives the later refactor because those primitives
|
||||
//! stay in `proto/mmp/` unchanged.
|
||||
//!
|
||||
//! Two `#[cfg(test)]` production seams are used, both on `ActivePeer`:
|
||||
//! * `test_init_mmp(mode)` — attach link MMP with a chosen mode to a
|
||||
//! bare (sessionless) peer, so mode gating is exercisable.
|
||||
//! * `test_backdate_session_start` — age `session_elapsed_ms()` so a crafted
|
||||
//! ReceiverReport yields a positive RTT sample (first-RTT trigger).
|
||||
//!
|
||||
//! Neither changes any decision logic or threshold.
|
||||
|
||||
use super::*;
|
||||
use crate::config::SessionMmpConfig;
|
||||
use crate::node::session::{EndToEndState, SessionEntry};
|
||||
use crate::noise::HandshakeState;
|
||||
use crate::peer::ActivePeer;
|
||||
use crate::proto::mmp::{MmpMode, ReceiverReport};
|
||||
use crate::proto::stp::{ParentDeclaration, TreeCoordinate};
|
||||
|
||||
// ===========================================================================
|
||||
// Helpers
|
||||
// ===========================================================================
|
||||
|
||||
/// Insert a bare (sessionless) peer carrying link-layer MMP state in `mode`.
|
||||
/// Returns the peer's NodeAddr.
|
||||
fn insert_link_peer(node: &mut Node, mode: MmpMode) -> NodeAddr {
|
||||
let identity = make_peer_identity();
|
||||
let addr = *identity.node_addr();
|
||||
let mut peer = ActivePeer::new(identity, LinkId::new(1), 0);
|
||||
peer.test_init_mmp(mode);
|
||||
node.peers.insert(addr, peer);
|
||||
addr
|
||||
}
|
||||
|
||||
/// Arm both sender and receiver link-MMP intervals so a report would be built.
|
||||
fn arm_link_mmp(node: &mut Node, addr: &NodeAddr) {
|
||||
let mmp = node.get_peer_mut(addr).unwrap().mmp_mut().unwrap();
|
||||
mmp.sender.record_sent(1, 100, 500);
|
||||
mmp.receiver
|
||||
.record_recv(1, 100, 500, false, crate::time::mono_ms());
|
||||
}
|
||||
|
||||
/// Complete an in-memory Noise IK handshake, returning the initiator session.
|
||||
fn make_noise_session(
|
||||
our_identity: &crate::Identity,
|
||||
remote_identity: &crate::Identity,
|
||||
) -> crate::noise::NoiseSession {
|
||||
let mut initiator =
|
||||
HandshakeState::new_initiator(our_identity.keypair(), remote_identity.pubkey_full());
|
||||
let mut responder = HandshakeState::new_responder(remote_identity.keypair());
|
||||
|
||||
let mut init_epoch = [0u8; 8];
|
||||
rand::Rng::fill_bytes(&mut rand::rng(), &mut init_epoch);
|
||||
initiator.set_local_epoch(init_epoch);
|
||||
let mut resp_epoch = [0u8; 8];
|
||||
rand::Rng::fill_bytes(&mut rand::rng(), &mut resp_epoch);
|
||||
responder.set_local_epoch(resp_epoch);
|
||||
|
||||
let msg1 = initiator.write_message_1().unwrap();
|
||||
responder.read_message_1(&msg1).unwrap();
|
||||
let msg2 = responder.write_message_2().unwrap();
|
||||
initiator.read_message_2(&msg2).unwrap();
|
||||
|
||||
initiator.into_session().unwrap()
|
||||
}
|
||||
|
||||
/// Insert an Established session carrying session-layer MMP state in `mode`.
|
||||
/// Returns the destination NodeAddr.
|
||||
fn insert_session(node: &mut Node, mode: MmpMode) -> NodeAddr {
|
||||
let remote = crate::Identity::generate();
|
||||
let remote_addr = *remote.node_addr();
|
||||
let session = make_noise_session(node.identity(), &remote);
|
||||
let mut entry = SessionEntry::new(
|
||||
remote_addr,
|
||||
remote.pubkey_full(),
|
||||
EndToEndState::Established(session),
|
||||
1000,
|
||||
true,
|
||||
);
|
||||
let cfg = SessionMmpConfig {
|
||||
mode,
|
||||
..SessionMmpConfig::default()
|
||||
};
|
||||
entry.init_mmp(&cfg);
|
||||
node.sessions.insert(remote_addr, entry);
|
||||
remote_addr
|
||||
}
|
||||
|
||||
/// Arm both sender and receiver session-MMP intervals.
|
||||
fn arm_session_mmp(node: &mut Node, addr: &NodeAddr) {
|
||||
let mmp = node.sessions.get_mut(addr).unwrap().mmp_mut().unwrap();
|
||||
mmp.sender.record_sent(1, 100, 500);
|
||||
mmp.receiver
|
||||
.record_recv(1, 100, 500, false, crate::time::mono_ms());
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// check_mmp_reports — link-layer mode fan-out
|
||||
// ===========================================================================
|
||||
|
||||
/// Full mode: both a SenderReport and a ReceiverReport are generated (both
|
||||
/// intervals consumed).
|
||||
#[tokio::test]
|
||||
async fn mmp_full_mode_builds_sender_and_receiver_reports() {
|
||||
let mut node = make_node();
|
||||
let addr = insert_link_peer(&mut node, MmpMode::Full);
|
||||
arm_link_mmp(&mut node, &addr);
|
||||
|
||||
node.check_mmp_reports().await;
|
||||
|
||||
let mmp = node.get_peer(&addr).unwrap().mmp().unwrap();
|
||||
let now = crate::time::mono_ms();
|
||||
assert!(
|
||||
!mmp.sender.should_send_report(now),
|
||||
"Full mode consumes the sender interval (SenderReport built)"
|
||||
);
|
||||
assert!(
|
||||
!mmp.receiver.should_send_report(now),
|
||||
"Full mode consumes the receiver interval (ReceiverReport built)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Lightweight mode: only a ReceiverReport is generated; the sender interval
|
||||
/// is left intact (no SenderReport in Lightweight).
|
||||
#[tokio::test]
|
||||
async fn mmp_lightweight_mode_builds_receiver_report_only() {
|
||||
let mut node = make_node();
|
||||
let addr = insert_link_peer(&mut node, MmpMode::Lightweight);
|
||||
arm_link_mmp(&mut node, &addr);
|
||||
|
||||
node.check_mmp_reports().await;
|
||||
|
||||
let mmp = node.get_peer(&addr).unwrap().mmp().unwrap();
|
||||
let now = crate::time::mono_ms();
|
||||
assert!(
|
||||
mmp.sender.should_send_report(now),
|
||||
"Lightweight mode suppresses the SenderReport (sender interval intact)"
|
||||
);
|
||||
assert!(
|
||||
!mmp.receiver.should_send_report(now),
|
||||
"Lightweight mode still builds the ReceiverReport (receiver interval consumed)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Minimal mode: neither report is generated; both intervals stay intact.
|
||||
#[tokio::test]
|
||||
async fn mmp_minimal_mode_builds_nothing() {
|
||||
let mut node = make_node();
|
||||
let addr = insert_link_peer(&mut node, MmpMode::Minimal);
|
||||
arm_link_mmp(&mut node, &addr);
|
||||
|
||||
node.check_mmp_reports().await;
|
||||
|
||||
let mmp = node.get_peer(&addr).unwrap().mmp().unwrap();
|
||||
let now = crate::time::mono_ms();
|
||||
assert!(
|
||||
mmp.sender.should_send_report(now),
|
||||
"Minimal mode suppresses the SenderReport"
|
||||
);
|
||||
assert!(
|
||||
mmp.receiver.should_send_report(now),
|
||||
"Minimal mode suppresses the ReceiverReport"
|
||||
);
|
||||
}
|
||||
|
||||
/// Periodic operator logging fires once per interval: a fresh peer is due for
|
||||
/// a log, and after one tick the log is marked (not due again within the
|
||||
/// interval).
|
||||
#[tokio::test]
|
||||
async fn mmp_should_log_marks_logged_once_per_interval() {
|
||||
let mut node = make_node();
|
||||
let addr = insert_link_peer(&mut node, MmpMode::Full);
|
||||
|
||||
assert!(
|
||||
node.get_peer(&addr)
|
||||
.unwrap()
|
||||
.mmp()
|
||||
.unwrap()
|
||||
.should_log(crate::time::mono_ms()),
|
||||
"a freshly created peer is due for its first operator log"
|
||||
);
|
||||
|
||||
node.check_mmp_reports().await;
|
||||
|
||||
assert!(
|
||||
!node
|
||||
.get_peer(&addr)
|
||||
.unwrap()
|
||||
.mmp()
|
||||
.unwrap()
|
||||
.should_log(crate::time::mono_ms()),
|
||||
"after one tick the log is marked and not due again within the interval"
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// check_session_mmp_reports — session mode + PathMtu gating + backoff dedup
|
||||
// ===========================================================================
|
||||
|
||||
/// Full mode session: both SenderReport and ReceiverReport are generated
|
||||
/// (both intervals consumed) even though the send has no route and fails.
|
||||
#[tokio::test]
|
||||
async fn session_full_mode_builds_sender_and_receiver_reports() {
|
||||
let mut node = make_node();
|
||||
let addr = insert_session(&mut node, MmpMode::Full);
|
||||
arm_session_mmp(&mut node, &addr);
|
||||
|
||||
node.check_session_mmp_reports().await;
|
||||
|
||||
let mmp = node.get_session(&addr).unwrap().mmp().unwrap();
|
||||
let now = crate::time::mono_ms();
|
||||
assert!(
|
||||
!mmp.sender.should_send_report(now),
|
||||
"Full session consumes the sender interval"
|
||||
);
|
||||
assert!(
|
||||
!mmp.receiver.should_send_report(now),
|
||||
"Full session consumes the receiver interval"
|
||||
);
|
||||
}
|
||||
|
||||
/// PathMtu notifications gate on all modes: in Minimal mode neither report is
|
||||
/// built, yet a PathMtuNotification is still generated when an MTU has been
|
||||
/// observed.
|
||||
#[tokio::test]
|
||||
async fn session_minimal_mode_still_sends_path_mtu() {
|
||||
let mut node = make_node();
|
||||
let addr = insert_session(&mut node, MmpMode::Minimal);
|
||||
arm_session_mmp(&mut node, &addr);
|
||||
// Observe an MTU so a notification becomes due (all modes).
|
||||
node.sessions
|
||||
.get_mut(&addr)
|
||||
.unwrap()
|
||||
.mmp_mut()
|
||||
.unwrap()
|
||||
.path_mtu
|
||||
.observe_incoming_mtu(1200);
|
||||
|
||||
let now_before = crate::time::mono_ms();
|
||||
assert!(
|
||||
node.get_session(&addr)
|
||||
.unwrap()
|
||||
.mmp()
|
||||
.unwrap()
|
||||
.path_mtu
|
||||
.should_send_notification(now_before),
|
||||
"precondition: a PathMtuNotification is due after observing an MTU"
|
||||
);
|
||||
|
||||
node.check_session_mmp_reports().await;
|
||||
|
||||
let mmp = node.get_session(&addr).unwrap().mmp().unwrap();
|
||||
let now = crate::time::mono_ms();
|
||||
assert!(
|
||||
mmp.sender.should_send_report(now),
|
||||
"Minimal mode suppresses the session SenderReport"
|
||||
);
|
||||
assert!(
|
||||
mmp.receiver.should_send_report(now),
|
||||
"Minimal mode suppresses the session ReceiverReport"
|
||||
);
|
||||
assert!(
|
||||
!mmp.path_mtu.should_send_notification(now),
|
||||
"PathMtuNotification is generated in Minimal mode (gate is mode-independent)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Backoff dedup, all-fail side: a Full-mode session generates two reports
|
||||
/// (SR + RR) to one destination; with no route both sends fail. The
|
||||
/// per-destination dedup collapses the two failures into exactly ONE
|
||||
/// `record_send_failure` (consecutive count advances by 1, not 2).
|
||||
#[tokio::test]
|
||||
async fn session_backoff_all_reports_fail_records_single_failure() {
|
||||
let mut node = make_node();
|
||||
let addr = insert_session(&mut node, MmpMode::Full);
|
||||
arm_session_mmp(&mut node, &addr);
|
||||
|
||||
assert_eq!(
|
||||
node.get_session(&addr)
|
||||
.unwrap()
|
||||
.mmp()
|
||||
.unwrap()
|
||||
.sender
|
||||
.consecutive_send_failures(),
|
||||
0,
|
||||
"precondition: no prior send failures"
|
||||
);
|
||||
|
||||
node.check_session_mmp_reports().await;
|
||||
|
||||
assert_eq!(
|
||||
node.get_session(&addr)
|
||||
.unwrap()
|
||||
.mmp()
|
||||
.unwrap()
|
||||
.sender
|
||||
.consecutive_send_failures(),
|
||||
1,
|
||||
"two failed reports to one dest dedup to a single record_send_failure"
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// handle_receiver_report — first-RTT tree re-evaluation branch
|
||||
// ===========================================================================
|
||||
|
||||
/// Build a peer (NodeAddr strictly smaller than the node's own) that carries
|
||||
/// link MMP but no RTT yet, and register it in the tree as a self-root with
|
||||
/// that smaller address. This makes it a mandatory parent-switch target once
|
||||
/// it becomes eligible. Returns the peer's NodeAddr.
|
||||
fn setup_smaller_root_peer(node: &mut Node) -> NodeAddr {
|
||||
let my_addr = *node.node_addr();
|
||||
let (identity, addr) = loop {
|
||||
let id = make_peer_identity();
|
||||
let a = *id.node_addr();
|
||||
if a < my_addr {
|
||||
break (id, a);
|
||||
}
|
||||
};
|
||||
let mut peer = ActivePeer::new(identity, LinkId::new(1), 0);
|
||||
peer.test_init_mmp(MmpMode::Full);
|
||||
// Age the session so a crafted ReceiverReport yields a positive RTT.
|
||||
peer.test_backdate_session_start(std::time::Duration::from_secs(10));
|
||||
node.peers.insert(addr, peer);
|
||||
|
||||
// Register the peer as a self-root in the tree at its (smaller) address.
|
||||
node.tree_state_mut().update_peer(
|
||||
ParentDeclaration::self_root(addr, 1, 0),
|
||||
TreeCoordinate::root(addr),
|
||||
);
|
||||
addr
|
||||
}
|
||||
|
||||
/// Craft a ReceiverReport whose timestamp echo yields a valid first RTT
|
||||
/// sample. `highest`/`pkts`/`bytes` advance the cumulative counters so a
|
||||
/// second report is not dropped as stale/duplicate.
|
||||
fn craft_rr_payload(highest: u64, pkts: u64, bytes: u64) -> Vec<u8> {
|
||||
let rr = ReceiverReport {
|
||||
highest_counter: highest,
|
||||
cumulative_packets_recv: pkts,
|
||||
cumulative_bytes_recv: bytes,
|
||||
timestamp_echo: 1000,
|
||||
dwell_time: 0,
|
||||
max_burst_loss: 0,
|
||||
mean_burst_loss: 0,
|
||||
jitter: 0,
|
||||
ecn_ce_count: 0,
|
||||
owd_trend: 0,
|
||||
burst_loss_count: 0,
|
||||
cumulative_reorder_count: 0,
|
||||
interval_packets_recv: pkts as u32,
|
||||
interval_bytes_recv: bytes as u32,
|
||||
};
|
||||
// handle_receiver_report receives the body with the msg_type byte stripped.
|
||||
rr.encode()[1..].to_vec()
|
||||
}
|
||||
|
||||
/// A first RTT sample flips the peer eligible for parent selection AND fires
|
||||
/// the shell-resident tree branch: the node (initially self-root) adopts the
|
||||
/// smaller-addressed peer as its new root.
|
||||
#[tokio::test]
|
||||
async fn first_rtt_flips_peer_eligible_and_triggers_tree_reeval() {
|
||||
let mut node = make_node();
|
||||
let addr = setup_smaller_root_peer(&mut node);
|
||||
|
||||
assert!(
|
||||
node.tree_state().is_root(),
|
||||
"precondition: node starts as its own root"
|
||||
);
|
||||
assert!(
|
||||
!node.get_peer(&addr).unwrap().has_srtt(),
|
||||
"precondition: peer has no RTT measurement yet"
|
||||
);
|
||||
let switches_before = node.metrics().tree.parent_switches.get();
|
||||
|
||||
node.handle_receiver_report(&addr, &craft_rr_payload(10, 5, 500))
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
node.get_peer(&addr).unwrap().has_srtt(),
|
||||
"first RTT sample makes the peer eligible for parent selection"
|
||||
);
|
||||
assert!(
|
||||
!node.tree_state().is_root(),
|
||||
"the first-RTT tree branch fired: node adopted a parent"
|
||||
);
|
||||
assert_eq!(
|
||||
node.tree_state().root(),
|
||||
&addr,
|
||||
"node switched its root to the smaller-addressed peer"
|
||||
);
|
||||
assert!(
|
||||
node.metrics().tree.parent_switches.get() > switches_before,
|
||||
"the parent-switch was recorded in the tree metrics"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression guard: a *second* ReceiverReport (RTT already initialized, so
|
||||
/// `first_rtt` is false) does NOT re-enter the tree branch — no further parent
|
||||
/// switch is recorded.
|
||||
#[tokio::test]
|
||||
async fn non_first_receiver_report_does_not_retrigger_tree() {
|
||||
let mut node = make_node();
|
||||
let addr = setup_smaller_root_peer(&mut node);
|
||||
|
||||
// First report: fires the branch (established by the test above).
|
||||
node.handle_receiver_report(&addr, &craft_rr_payload(10, 5, 500))
|
||||
.await;
|
||||
let switches_after_first = node.metrics().tree.parent_switches.get();
|
||||
assert!(node.get_peer(&addr).unwrap().has_srtt());
|
||||
|
||||
// Second report with advanced counters: first_rtt is now false.
|
||||
node.handle_receiver_report(&addr, &craft_rr_payload(20, 10, 1000))
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
node.metrics().tree.parent_switches.get(),
|
||||
switches_after_first,
|
||||
"a non-first ReceiverReport does not re-enter the first-RTT tree branch"
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::*;
|
||||
use crate::PeerIdentity;
|
||||
use crate::peer::machine::HandshakeCrypto;
|
||||
use crate::transport::{LinkDirection, ReceivedPacket, TransportAddr, packet_channel};
|
||||
use crate::utils::index::SessionIndex;
|
||||
use std::time::Duration;
|
||||
@@ -13,11 +14,13 @@ mod bootstrap;
|
||||
mod decrypt_failure;
|
||||
mod disconnect;
|
||||
mod discovery;
|
||||
mod establish_chartests;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod ethernet;
|
||||
mod forwarding;
|
||||
mod handshake;
|
||||
mod heartbeat;
|
||||
mod mmp_chartests;
|
||||
mod routing;
|
||||
mod session;
|
||||
mod spanning_tree;
|
||||
@@ -28,6 +31,25 @@ pub(super) fn make_node() -> Node {
|
||||
make_node_with(Config::new())
|
||||
}
|
||||
|
||||
/// A test node that reaches `Full` health on `start()`.
|
||||
///
|
||||
/// A default [`make_node`] configures no transports, so its `start()` now
|
||||
/// resolves to `NodeState::Failed` (zero transports up) and
|
||||
/// returns `NoOperationalTransports`. Lifecycle-state tests that need a running
|
||||
/// node build one with a single loopback UDP transport (ephemeral port) as the
|
||||
/// sole configured child — DNS disabled — so bring-up has exactly one
|
||||
/// configured child and it comes up (`Full`). Mirrors the udp config in
|
||||
/// `test_node_start_does_not_wait_for_nostr_relay_startup`.
|
||||
pub(super) fn make_healthy_node() -> Node {
|
||||
let mut config = Config::new();
|
||||
config.transports.udp = crate::config::TransportInstances::Single(crate::config::UdpConfig {
|
||||
bind_addr: Some("127.0.0.1:0".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
config.dns.enabled = false;
|
||||
make_node_with(config)
|
||||
}
|
||||
|
||||
/// Build a test node from an explicit `Config`. Immutable state lives solely in
|
||||
/// the shared `NodeContext`, built once at construction — there is no
|
||||
/// post-construction field to poke, so set limits/config on the `Config` here.
|
||||
@@ -63,31 +85,87 @@ pub(super) fn make_peer_identity() -> PeerIdentity {
|
||||
PeerIdentity::from_pubkey(identity.pubkey())
|
||||
}
|
||||
|
||||
/// Create a PeerConnection with a completed Noise IK handshake.
|
||||
/// A control machine carrying a fresh outbound connection, for tests that
|
||||
/// drive one end of a handshake without a whole node behind it. The machine
|
||||
/// owns the handshake operations, so it is what the crypto runs on.
|
||||
pub(super) fn outbound_leg(
|
||||
link_id: LinkId,
|
||||
expected_identity: PeerIdentity,
|
||||
current_time_ms: u64,
|
||||
) -> PeerMachine {
|
||||
let mut machine = PeerMachine::new_outbound(link_id, expected_identity, current_time_ms);
|
||||
machine.set_leg(HandshakeCrypto::new());
|
||||
machine
|
||||
}
|
||||
|
||||
/// The responder twin of [`outbound_leg`].
|
||||
pub(super) fn inbound_leg(link_id: LinkId, current_time_ms: u64) -> PeerMachine {
|
||||
let mut machine = PeerMachine::new_inbound(link_id, current_time_ms);
|
||||
machine.set_leg(HandshakeCrypto::new());
|
||||
machine
|
||||
}
|
||||
|
||||
/// Seed a control machine whose leg carries a completed Noise IK handshake.
|
||||
///
|
||||
/// Returns (connection, peer_identity) where the connection is outbound,
|
||||
/// in Complete state, with session, indices, and transport info set.
|
||||
pub(super) fn make_completed_connection(
|
||||
/// Returns the peer identity. The leg is outbound, in Complete state, with
|
||||
/// session, indices, and transport info set, and is installed on the node
|
||||
/// through [`Node::seed_handshake_machine`].
|
||||
pub(super) fn seed_completed_connection(
|
||||
node: &mut Node,
|
||||
link_id: LinkId,
|
||||
transport_id: TransportId,
|
||||
current_time_ms: u64,
|
||||
) -> (PeerConnection, PeerIdentity) {
|
||||
) -> PeerIdentity {
|
||||
let our_index = node.index_allocator.allocate().unwrap();
|
||||
seed_completed_connection_with(node, link_id, current_time_ms, |seed| {
|
||||
seed.with_our_index(our_index)
|
||||
.with_their_index(SessionIndex::new(42))
|
||||
.with_transport_id(transport_id)
|
||||
.with_source_addr(TransportAddr::from_string("127.0.0.1:5000"))
|
||||
})
|
||||
}
|
||||
|
||||
/// [`seed_completed_connection`] with the seed left to the caller, for tests
|
||||
/// that need a leg deliberately missing one of the fields promotion requires.
|
||||
///
|
||||
/// The Noise exchange runs on the already-seeded leg, where it used to run
|
||||
/// before the leg was handed over. That reordering is neutral, but not
|
||||
/// because the handshake leaves the seeded fields alone —
|
||||
/// `receive_handshake_init` does write `expected_identity`. It is neutral
|
||||
/// because the only read of `expected_identity` is guarded by `is_outbound`:
|
||||
/// an inbound leg takes the `new_inbound` arm whether or not the identity has
|
||||
/// been learned, and an outbound leg never runs that method. The remaining
|
||||
/// reads (`link_id`, `started_at`, `is_outbound`, `their_index`,
|
||||
/// `transport_id`) are genuinely untouched by the handshake.
|
||||
pub(super) fn seed_completed_connection_with(
|
||||
node: &mut Node,
|
||||
link_id: LinkId,
|
||||
current_time_ms: u64,
|
||||
shape: impl FnOnce(HandshakeSeed) -> HandshakeSeed,
|
||||
) -> PeerIdentity {
|
||||
let peer_identity_full = Identity::generate();
|
||||
// Must use from_pubkey_full to preserve parity for ECDH
|
||||
let peer_identity = PeerIdentity::from_pubkey_full(peer_identity_full.pubkey_full());
|
||||
|
||||
// Create outbound connection
|
||||
let mut conn = PeerConnection::outbound(link_id, peer_identity, current_time_ms);
|
||||
node.seed_handshake_machine(shape(HandshakeSeed::outbound(
|
||||
link_id,
|
||||
peer_identity,
|
||||
current_time_ms,
|
||||
)))
|
||||
.unwrap();
|
||||
|
||||
// Run initiator side of handshake
|
||||
let our_keypair = node.identity().keypair();
|
||||
let msg1 = conn
|
||||
.start_handshake(our_keypair, node.startup_epoch(), current_time_ms)
|
||||
let startup_epoch = node.startup_epoch();
|
||||
let msg1 = node
|
||||
.peer_machines
|
||||
.get_mut(&link_id)
|
||||
.unwrap()
|
||||
.start_handshake(our_keypair, startup_epoch, current_time_ms)
|
||||
.unwrap();
|
||||
|
||||
// Run responder side to generate msg2
|
||||
let mut resp_conn = PeerConnection::inbound(LinkId::new(999), current_time_ms);
|
||||
let mut resp_conn = inbound_leg(LinkId::new(999), current_time_ms);
|
||||
let peer_keypair = peer_identity_full.keypair();
|
||||
let mut resp_epoch = [0u8; 8];
|
||||
rand::Rng::fill_bytes(&mut rand::rng(), &mut resp_epoch);
|
||||
@@ -96,14 +174,11 @@ pub(super) fn make_completed_connection(
|
||||
.unwrap();
|
||||
|
||||
// Complete initiator handshake
|
||||
conn.complete_handshake(&msg2, current_time_ms).unwrap();
|
||||
node.peer_machines
|
||||
.get_mut(&link_id)
|
||||
.unwrap()
|
||||
.complete_handshake(&msg2, current_time_ms)
|
||||
.unwrap();
|
||||
|
||||
// Set indices and transport info
|
||||
let our_index = node.index_allocator.allocate().unwrap();
|
||||
conn.set_our_index(our_index);
|
||||
conn.set_their_index(SessionIndex::new(42));
|
||||
conn.set_transport_id(transport_id);
|
||||
conn.set_source_addr(TransportAddr::from_string("127.0.0.1:5000"));
|
||||
|
||||
(conn, peer_identity)
|
||||
peer_identity
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user