23 Commits
Author SHA1 Message Date
Johnathan Corgan d72e619c51 Release v0.2.1
Bump Cargo.toml version 0.2.1-dev -> 0.2.1 and resync Cargo.lock,
move the CHANGELOG [Unreleased] block under [0.2.1] - 2026-05-11,
update the README status badge and prose to v0.2.1, and add the
release notes at docs/releases/release-notes-v0.2.1.md with a
mirrored copy at the repo root as RELEASE-NOTES.md.
2026-05-11 14:34:05 +00:00
Johnathan Corgan de8db82614 Make tree ancestry acceptance test deterministic
The test used Identity::generate() for the signing node while pinning
the fixed root to node_addr byte[0]=0x01. About 2/256 random identities
have byte[0] <= 0x01, which made the generated node_addr the path
minimum and triggered AncestryRootNotMinimum. Regenerate the identity
until its node_addr is numerically larger than the fixed parent and
root, so the test matches the preconditions it asserts.
2026-04-22 01:17:04 +00:00
Johnathan Corgan db4b32110c Validate bloom filter fill ratio on FilterAnnounce ingress
A malformed FilterAnnounce whose fill ratio produces an implausibly
high false-positive rate is mostly useless for routing and, once
merged into our outgoing filter via bitwise OR, propagates the
saturated state to tree peers one hop per announce tick. A saturated
filter also made estimated_count() return f64::INFINITY, which
compute_mesh_size summed into its cached estimate.

handle_filter_announce now rejects inbound FilterAnnounce whose
derived FPR exceeds `node.bloom.max_inbound_fpr` (new config field,
default 0.05 ≈ fill 0.549 at k=5). Rejection is silent on the wire,
logs at WARN, and increments a new `bloom.fill_exceeded` counter. The
peer's prior stored filter and filter_sequence are left unchanged so
a single rejected announce does not wipe the peer's existing
contribution to aggregation.

After a successful outgoing FilterAnnounce send, a rate-limited WARN
fires if our own filter's FPR exceeds the same cap, surfacing
aggregation drift. Limited to once per 60 seconds via a new
Node.last_self_warn field.

BloomFilter::estimated_count() now takes max_fpr and returns
Option<f64>. Returns None for saturated filters (regardless of cap)
or when the filter's FPR exceeds max_fpr. Callers updated: debug
logs render None as "—", the Debug impl uses f64::INFINITY as "no
cap" and prints "saturated" instead of inf, control-socket JSON
emits null, and compute_mesh_size propagates None into the already-
Option<u64> estimated_mesh_size field.
2026-04-21 19:28:23 +00:00
Sats And SportsandJohnathan Corgan b36966be3a Tighten TreeAnnounce validation to match spanning tree specification
Adds TreeAnnounce::validate_semantics() called from handle_tree_announce
before any tree-state mutation. Enforces that the ancestry accompanying
a parent declaration conforms to the spanning tree rules:

- first ancestry entry matches the signed sender
- is_root declarations carry a single-entry ancestry
- non-root declarations include the signed parent as the second entry
- the advertised root is the minimum node_addr in the ancestry

Non-conforming announcements are rejected with a warn log and no state
change. Adds unit tests for each rejected shape plus an integration
test covering the full receive path in a two-node tree.

Co-authored-by: Johnathan Corgan <johnathan@corganlabs.com>
2026-04-15 16:55:48 +00:00
Johnathan Corgan 7e002a3883 Update CHANGELOG for pending 0.2.1 bug fixes
Captures three fixes landed on maint since 0.2.0 that were not yet
recorded: the bloom-filter greedy-tree fallback fix, auto-connect
reconnect on graceful disconnect (#60), and fipsctl mesh-address
rejection (#61).
2026-04-13 05:49:37 +00:00
Johnathan Corgan d16acf8cea Reject FIPS mesh addresses in fipsctl connect
When a user passes an fd00::/8 address as the endpoint for a udp,
tcp, or ethernet transport, the CLI previously echoed success while
the daemon silently failed the bind with EAFNOSUPPORT. Mesh ULAs are
destinations inside the mesh, not reachable transport endpoints.

fipsctl now validates the address up front and prints a clear error
with examples, exiting 1 before the control socket call. Other
transports (tor) are not inspected since they legitimately accept
non-IP endpoints.

Covered by inline tests for bare/bracketed/with-port ULA syntaxes,
non-ULA IPv6, IPv4, hostnames, and the transport filter.

Fixes #61.
2026-04-13 05:44:23 +00:00
Johnathan Corgan 42834b8008 Fix auto-connect reconnect on graceful peer disconnect
handle_disconnect() called remove_active_peer without scheduling a
reconnect, orphaning auto-connect peers on a clean upstream shutdown.
Mirror the pattern from the other three peer-removal paths (link-dead,
decrypt failure, peer restart) which all schedule reconnect after
removal.

Adds test_disconnect_schedules_reconnect regression test that verifies
handle_disconnect populates retry_pending for an auto-connect peer.
Visibility of handle_disconnect bumped to pub(in crate::node) for
direct unit-test access.

Fixes #60.
2026-04-13 05:34:51 +00:00
Johnathan Corgan 0b81f15369 Add rustfmt to pinned toolchain components
CI format check was failing because the pinned toolchain
rust-toolchain.toml overrides the `dtolnay/rust-toolchain@stable +
rustfmt` action installation — rustup installs rustfmt onto the
stable channel, but when cargo fmt runs inside the repo, rustup
honors the 1.94.1 pin and does an on-demand install that pulls only
rustc/cargo/rust-std.

Declaring components in rust-toolchain.toml ensures the on-demand
install of the pinned toolchain includes rustfmt.
2026-04-12 08:33:38 +00:00
Johnathan Corgan 19cb776216 Bump pinned toolchain to 1.94.1
The rustfmt component is no longer available for 1.94.0 on GitHub
Actions runners, causing the format check CI job to fail.
2026-04-10 16:43:45 +00:00
Johnathan Corgan 68bdcb2c75 Add cargo fmt --check to CI and local CI 2026-04-10 08:45:18 +00:00
Johnathan Corgan 48b1617497 Add .git-blame-ignore-revs for bulk reformat commit 2026-04-10 08:27:23 +00:00
Johnathan Corgan 13c0b70dc3 Add rustfmt formatting policy and reformat codebase
Add rustfmt.toml with stable defaults and apply cargo fmt to all
source files. This establishes a consistent formatting baseline
for CI enforcement.
2026-04-10 08:27:07 +00:00
Johnathan Corgan a859da7748 Fix bloom filter routing blocking greedy tree fallback
When bloom filter candidates existed but none were strictly closer to
the destination in tree-coordinate distance, find_next_hop returned
None without trying greedy tree routing. This caused NoRoute failures
in topologies where the tree parent was closer but not a bloom
candidate. Fall through to tree routing when no bloom candidate makes
progress.
2026-04-05 12:23:24 +00:00
Johnathan Corgan 15628e5b41 Improve ping test convergence by checking all nodes
Wait for all nodes to reach their expected peer counts instead of
only checking a single node. This prevents false failures on slower
CI runners where remote nodes (especially node E in mesh/chain
topologies) take longer to establish all links.
2026-04-01 11:54:58 +00:00
Johnathan Corgan f6f2bea792 Update changelog with backported fixes and packaging workflows 2026-03-31 19:52:56 +00:00
sandwichandJohnathan Corgan 0ff9139b64 ci: add AUR publish workflow for tagged releases
- Publish fips PKGBUILD to AUR on stable v* tag push
- Skip pre-release tags (containing '-')
- Uses KSXGitHub/github-actions-deploy-aur, continue-on-error
- Requires AUR_SSH_PRIVATE_KEY and AUR_EMAIL secrets
2026-03-31 19:52:25 +00:00
joandJohnathan Corgan 7d33f1f2c9 ci: add Linux packaging workflow and target-aware build scripts
- Add package-linux.yml: builds tarball and .deb for x86_64 and aarch64
  on v* tag push, uploads artifacts to GitHub release with checksums
- Make build-tarball.sh target-aware: --target, --version, --arch, --no-build
- Make build-deb.sh target-aware: --target, --version, --no-build
- Configurable strip binary via STRIP env var
2026-03-31 19:52:25 +00:00
Johnathan Corgan 97fc29eb82 Add ip6 routing policy rule to protect fd00::/8 from interception
Tailscale (and potentially other routing software) installs a default
IPv6 route in an auxiliary routing table with a policy rule that runs
before the main table. This silently diverts fd00::/8 FIPS traffic
away from the fips0 TUN device.

Add an ip6 rule (priority 5265) during TUN setup that directs fd00::/8
to the main routing table, ensuring the fips0 route is always used.
2026-03-31 19:39:27 +00:00
Origami74andJohnathan Corgan 8c4455cc1c Fix OpenWrt ipk build: exclude BLE feature that requires D-Bus
The ble feature (bluer crate) pulls in libdbus-sys which cannot
cross-compile with cargo-zigbuild. Disable default features and
explicitly enable only tui for the OpenWrt package build.
2026-03-31 19:39:27 +00:00
Johnathan Corgan 7f33e5f867 Fix lookup-request.svg: remove stale visited bloom filter
The visited_bits field (hash_cnt + 256 bytes) was removed from the
LookupRequest wire format in the discovery-rework (bloom-guided tree
routing replaced the visited filter). Update the diagram to match the
current 46 + 16n byte format.
2026-03-25 03:07:46 +00:00
Johnathan Corgan bce5619b74 Fix control socket path detection for non-group users (#30)
Check /run/fips/ directory existence instead of the socket file inside
it. Users not in the fips group can stat the directory but not traverse
it, so the socket file check silently returned false and fell back to
$XDG_RUNTIME_DIR with a misleading "No such file" error.
2026-03-23 13:49:52 +00:00
Johnathan Corgan 9757877c0a Update README and changelog for v0.2.0 release 2026-03-23 03:21:44 +00:00
Johnathan Corgan 94884876b8 Bump version to 0.2.1-dev 2026-03-23 03:21:32 +00:00
123 changed files with 5120 additions and 2308 deletions
+2
View File
@@ -0,0 +1,2 @@
# rustfmt bulk reformat (maint)
13c0b70dc3111cf94fef217b0f8b5fdbe469d3eb
+36
View File
@@ -0,0 +1,36 @@
name: AUR Publish
on:
push:
tags:
- 'v*'
jobs:
aur-publish-fips:
name: Publish fips to AUR
runs-on: ubuntu-latest
continue-on-error: true
if: "!contains(github.ref_name, '-')"
steps:
- uses: actions/checkout@v4
- name: Update pkgver in PKGBUILD
run: |
VERSION="${GITHUB_REF_NAME#v}"
sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/aur/PKGBUILD
- name: Publish to AUR
uses: KSXGitHub/github-actions-deploy-aur@v4.1.1
with:
pkgname: fips
pkgbuild: packaging/aur/PKGBUILD
updpkgsums: true
assets: |
packaging/aur/fips.sysusers
packaging/aur/fips.tmpfiles
packaging/aur/fips.install
commit_username: ${{ github.repository_owner }}
commit_email: ${{ secrets.AUR_EMAIL }}
ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
commit_message: "Update to ${{ github.ref_name }}"
+10
View File
@@ -27,6 +27,16 @@ env:
# separate ci-compat.yml workflow so their failures don't mark this run red.
# ─────────────────────────────────────────────────────────────────────────────
jobs:
fmt:
name: Format check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- run: cargo fmt --check
build:
name: Build (${{ matrix.os }})
runs-on: ${{ matrix.os }}
+196
View File
@@ -0,0 +1,196 @@
name: Linux Package
on:
push:
tags:
- "v*"
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
jobs:
determine-versioning:
runs-on: ubuntu-latest
outputs:
linux_package_version: ${{ steps.linux_version.outputs.linux_package_version }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Derive Linux package version
id: linux_version
shell: bash
run: |
: ${GITHUB_OUTPUT:=/tmp/github_output}
BASE_VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
VERSION="${GITHUB_REF_NAME#v}"
else
BRANCH=$(echo "$GITHUB_REF_NAME" | sed 's|[^A-Za-z0-9]|.|g; s/\.\.+/./g; s/^\.//; s/\.$//')
HEIGHT=$(git rev-list --count HEAD)
HASH=$(git rev-parse --short HEAD)
if [[ -z "$BRANCH" ]]; then
BRANCH="ref"
fi
VERSION="${BASE_VERSION}+${BRANCH}.${HEIGHT}.${HASH}"
fi
echo "linux_package_version=${VERSION}" >> "$GITHUB_OUTPUT"
build:
name: Build Linux artifacts (${{ matrix.artifact_arch }})
runs-on: ${{ matrix.os }}
needs: determine-versioning
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
artifact_arch: x86_64
deb_arch: amd64
- os: ubuntu-24.04-arm
artifact_arch: aarch64
deb_arch: arm64
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set SOURCE_DATE_EPOCH from git
run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV"
- name: Install system dependencies
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libdbus-1-dev llvm
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Cache Cargo registry + build
if: ${{ env.ACT != 'true' }}
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: linux-release-${{ runner.os }}-${{ matrix.artifact_arch }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
linux-release-${{ runner.os }}-${{ matrix.artifact_arch }}-
- name: Install cargo-deb
run: cargo install cargo-deb --version 3.6.3 --locked
- name: Build release binaries
run: cargo build --release
- name: Build systemd tarball
env:
STRIP: llvm-strip
run: |
packaging/systemd/build-tarball.sh \
--version "${{ needs.determine-versioning.outputs.linux_package_version }}" \
--arch "${{ matrix.artifact_arch }}" \
--no-build
- name: Build Debian package
run: |
packaging/debian/build-deb.sh \
--version "${{ needs.determine-versioning.outputs.linux_package_version }}" \
--no-build
- name: Resolve Linux asset paths
id: linux-assets
shell: bash
run: |
: ${GITHUB_OUTPUT:=/tmp/github_output}
TARBALL="deploy/fips-${{ needs.determine-versioning.outputs.linux_package_version }}-linux-${{ matrix.artifact_arch }}.tar.gz"
if [[ ! -f "$TARBALL" ]]; then
echo "Missing tarball: $TARBALL" >&2
exit 1
fi
DEB_FILE=$(find deploy -maxdepth 1 -type f -name "fips_*_${{ matrix.deb_arch }}.deb" | sort | head -n 1)
if [[ -z "$DEB_FILE" ]]; then
echo "Missing Debian package for ${{ matrix.deb_arch }}" >&2
exit 1
fi
echo "tarball=$TARBALL" >> "$GITHUB_OUTPUT"
echo "deb=$DEB_FILE" >> "$GITHUB_OUTPUT"
- name: SHA-256 hashes
run: |
echo "==> Linux release assets:"
sha256sum \
"${{ steps.linux-assets.outputs.tarball }}" \
"${{ steps.linux-assets.outputs.deb }}"
- name: Upload artifact (GitHub only)
if: ${{ env.ACT != 'true' }}
uses: actions/upload-artifact@v4
with:
name: fips_${{ needs.determine-versioning.outputs.linux_package_version }}_${{ matrix.artifact_arch }}_linux
path: |
${{ steps.linux-assets.outputs.tarball }}
${{ steps.linux-assets.outputs.deb }}
retention-days: 30
- name: Build Summary
run: |
echo "Build Summary for linux/${{ matrix.artifact_arch }}:"
echo " Tarball: ${{ steps.linux-assets.outputs.tarball }}"
echo " Debian: ${{ steps.linux-assets.outputs.deb }}"
release:
name: Publish Linux assets to GitHub Release
runs-on: ubuntu-latest
needs: build
if: startsWith(github.ref, 'refs/tags/')
permissions:
contents: write
steps:
- name: Download Linux artifacts
uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: Generate Linux release checksums
run: |
cd dist
find . -maxdepth 1 -type f \( -name '*.deb' -o -name '*.tar.gz' \) -printf '%P\n' \
| LC_ALL=C sort \
| xargs sha256sum \
> checksums-linux.txt
- name: Wait for tag release
env:
GH_TOKEN: ${{ github.token }}
run: |
for attempt in $(seq 1 20); do
if gh release view "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then
exit 0
fi
echo "Release ${GITHUB_REF_NAME} not available yet; waiting..."
sleep 15
done
echo "Timed out waiting for release ${GITHUB_REF_NAME}" >&2
exit 1
- name: Upload Linux assets
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release upload "${GITHUB_REF_NAME}" \
dist/*.deb \
dist/*.tar.gz \
dist/checksums-linux.txt \
--clobber \
--repo "${GITHUB_REPOSITORY}"
+65
View File
@@ -5,6 +5,71 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.2.1] - 2026-05-11
### Added
- Linux release artifact workflow: builds x86_64 and aarch64 tarballs
and `.deb` packages on `v*` tag push, with SHA-256 checksums
- AUR publish workflow for tagged stable releases
### Changed
- Validate bloom filter fill ratio on FilterAnnounce ingress.
Inbound FilterAnnounce messages whose derived false-positive
rate exceeds `node.bloom.max_inbound_fpr` (new config field,
default 0.05) are rejected silently on the wire, logged at WARN,
and counted in a new `bloom.fill_exceeded` counter. A
rate-limited WARN also fires if our own outgoing filter's FPR
exceeds the cap. `BloomFilter::estimated_count` now takes
`max_fpr` and returns `Option<f64>`, returning `None` for
saturated filters; this propagates through `compute_mesh_size`
into `estimated_mesh_size` (already `Option<u64>`)
### Fixed
- Control socket path detection in fipsctl and fipstop now checks for
the `/run/fips/` directory instead of the socket file inside it, so
users not yet in the `fips` group get a clear "Permission denied"
error instead of a misleading "No such file" fallback to
`$XDG_RUNTIME_DIR` ([#30](https://github.com/jmcorgan/fips/issues/30),
reported by [@Sebastix](https://github.com/Sebastix))
- OpenWrt ipk build excluded BLE feature that requires D-Bus, which is
unavailable on OpenWrt targets
- IPv6 routing policy rule added at TUN setup to protect `fd00::/8`
from interception by Tailscale's table 52 default route
- Bloom filter routing no longer swallows traffic when no bloom
candidate is strictly closer than the current node. `find_next_hop`
now falls through to greedy tree routing in that case instead of
returning `NoRoute`, which previously caused dropped packets in
topologies where the tree parent was closer but not a bloom
candidate
- Auto-connect peers now reconnect after a graceful `Disconnect`
notification from the remote side. `handle_disconnect` previously
removed the peer without scheduling a reconnect, orphaning the
entry on a clean upstream shutdown; the other removal paths
(link-dead, decrypt failure, peer restart) already scheduled
reconnect ([#60](https://github.com/jmcorgan/fips/issues/60),
reported by [@SwapMarket](https://github.com/SwapMarket))
- `fipsctl connect` now rejects FIPS mesh (`fd00::/8`) addresses for
`udp`, `tcp`, and `ethernet` transports with a clear error message
instead of echoing success while the daemon silently failed the
bind with `EAFNOSUPPORT`
([#61](https://github.com/jmcorgan/fips/issues/61),
reported by [@SwapMarket](https://github.com/SwapMarket))
- Tighten TreeAnnounce ancestry validation to match the spanning
tree specification. The receive path now verifies that the
ancestry is structurally consistent with the signed parent
declaration before mutating tree state.
- Make the tree ancestry acceptance unit test deterministic.
`test_tree_announce_validate_semantics_accepts_valid_non_root`
generated a random signing identity while pinning the fixed root
to `node_addr[0] = 0x01`; about 2 in 256 random identities were
numerically smaller than the claimed root, triggering
`AncestryRootNotMinimum`. The test now regenerates the identity
until its `node_addr` is strictly larger than both the fixed
parent and root.
## [0.2.0] - 2026-03-22
### Added
Generated
+1 -1
View File
@@ -783,7 +783,7 @@ checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5"
[[package]]
name = "fips"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"bech32",
"chacha20poly1305",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "fips"
version = "0.2.0"
version = "0.2.1"
edition = "2024"
description = "A distributed, decentralized network routing protocol for mesh nodes connecting over arbitrary transports"
license = "MIT"
+20 -20
View File
@@ -3,15 +3,13 @@
![banner](docs/logos/fips_banner.png)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![Rust](https://img.shields.io/badge/rust-1.85%2B-orange.svg)](https://www.rust-lang.org/)
[![Status](https://img.shields.io/badge/status-alpha%20(0.1.0)-yellow.svg)](#status--roadmap)
[![Status](https://img.shields.io/badge/status-v0.2.1-green.svg)](#status--roadmap)
A distributed, decentralized network routing protocol for mesh nodes
connecting over arbitrary transports.
> **Status: Alpha (0.1.0)**
>
> FIPS is under active development. The protocol and APIs are not stable.
> Expect breaking changes. See [Status & Roadmap](#status--roadmap) below.
> FIPS is under active development. The protocol and APIs are not yet stable.
> See [Status & Roadmap](#status--roadmap) below.
## Overview
@@ -38,22 +36,22 @@ endpoints.
## Features
- **Self-organizing mesh routing** — spanning tree coordinates and bloom
filter candidate selection, no global routing tables
- **Multi-transport** — UDP, TCP, and Ethernet today; designed for
Bluetooth, serial, radio, and Tor
- **Noise encryption** — hop-by-hop link encryption plus independent
end-to-end session encryption, with periodic rekey for forward secrecy
- **Self-organizing mesh routing** — spanning tree coordinates with bloom
filter guided discovery, no global routing tables
- **Multi-transport** — UDP, TCP, Ethernet, and Tor today; designed for
Bluetooth, serial, and radio
- **Noise encryption** — hop-by-hop link encryption (IK) plus independent
end-to-end session encryption (XK), with periodic rekey for forward secrecy
- **Nostr-native identity** — secp256k1 keypairs as node addresses, no
registration or central authority
- **IPv6 adaptation** — TUN interface maps npubs to fd00::/8 addresses for
unmodified IP applications; static hostname mapping (`/etc/fips/hosts`)
- **Metrics Measurement Protocol** — per-link RTT, loss, jitter, and goodput
measurement
measurement with mesh size estimation
- **ECN congestion signaling** — hop-by-hop CE flag relay with RFC 3168 IPv6
marking, transport kernel drop detection
- **Operator visibility** — `fipsctl` CLI and `fipstop` TUI dashboard for
runtime inspection of peers, links, sessions, tree state, and metrics
runtime inspection and runtime peer management
- **Zero configuration** — sensible defaults; a node can start with no config
file, though peer addresses are needed to join a network
@@ -232,7 +230,7 @@ a layered protocol specification. Start with
## Project Structure
```
```text
src/ Rust source (library + fips/fipsctl/fipstop binaries)
packaging/ Debian, systemd tarball, and shared packaging files
docs/design/ Protocol design specifications
@@ -241,29 +239,31 @@ testing/ Docker-based integration test harnesses
## Status & Roadmap
FIPS is at **v0.1.0 (alpha)**. The core protocol works end-to-end over
UDP, TCP, Ethernet, and Tor but has not been tested beyond small meshes.
FIPS is at **v0.2.1**. The core protocol works end-to-end over UDP, TCP,
Ethernet, and Tor with a small live mesh of deployed nodes.
### What works today
- Spanning tree construction with greedy coordinate routing
- Bloom filter discovery for finding nodes without global state
- Bloom filter guided discovery (no flooding, single-path with retry)
- Noise IK (link layer) and Noise XK (session layer) encryption
- Periodic Noise rekey with forward secrecy (FMP + FSP)
- Periodic Noise rekey with hitless cutover for forward secrecy (FMP + FSP)
- Persistent node identity with key file management
- IPv6 TUN adapter with DNS resolution of `.fips` names
- Static hostname mapping (`/etc/fips/hosts`) with auto-reload
- Per-link metrics (RTT, loss, jitter, goodput) and mesh size estimation
- ECN congestion signaling (hop-by-hop CE relay, IPv6 CE marking, kernel drop detection)
- UDP, TCP, Ethernet, and Tor transports (SOCKS5 outbound + directory-mode onion service inbound)
- Runtime inspection via `fipsctl` and `fipstop`
- Runtime inspection and peer management via `fipsctl` and `fipstop`
- Reproducible builds with toolchain pinning and SOURCE_DATE_EPOCH
- Debian and systemd tarball packaging
- Docker-based integration and chaos testing
### Near-term priorities
- Peer discovery via Nostr relays (bootstrap without static peer lists)
- Native API for FIPS-aware applications (npub:port addressing)
- Additional transports (Bluetooth)
- Improved routing resilience under churn
- Security audit of cryptographic protocols
### Longer-term
+141
View File
@@ -0,0 +1,141 @@
# FIPS v0.2.1
**Released**: 2026-05-11
v0.2.1 is a maintenance release on the v0.2.x line. No new features
and no wire-format changes; operators running v0.2.0 can upgrade in
place. The release rolls up bug fixes and operational hardening for
issues surfaced in v0.2.0 deployments, plus a bloom-filter fill-ratio
validation that protects mesh-size estimates from saturated-filter
inputs.
## At a glance
- 22 commits since v0.2.0, 5 committers plus 2 issue reporters.
- All changes are backwards-compatible with v0.2.0 on the wire.
- Bloom filter fill-ratio validation hardens the FilterAnnounce
ingress path.
- TreeAnnounce ancestry validation tightened to match the
spanning-tree specification.
- Signed-tarball + `.deb` artifact workflow added for tagged
releases; AUR auto-publish on stable tags.
## Behavior changes worth flagging
- **Bloom filter fill-ratio validation** runs on every inbound
`FilterAnnounce`. Filters whose derived false-positive rate exceeds
`node.bloom.max_inbound_fpr` (new config field, default `0.05`) are
rejected silently on the wire, logged at WARN, and counted in a
new `bloom.fill_exceeded` counter. A rate-limited WARN also fires
when the local outgoing filter exceeds the cap.
`BloomFilter::estimated_count` now takes `max_fpr` and returns
`Option<f64>`, returning `None` for saturated filters; this
propagates through `compute_mesh_size` into `estimated_mesh_size`.
- **TreeAnnounce ancestry validation** is now run before tree-state
mutation, enforcing ancestry-self-match, root-single-entry,
parent-second-entry, and root-is-minimum-NodeAddr. Non-conforming
announces are rejected with a WARN. Mixed v0.2.0 / v0.2.1 meshes
may produce WARN log lines on the v0.2.1 side until all peers
upgrade; behavior is correct, log noise only.
## Notable bug fixes
- **Control socket path detection** in `fipsctl` and `fipstop` now
checks for the `/run/fips/` directory instead of the socket file
inside it. Users not yet in the `fips` group get a clear
"Permission denied" error instead of a misleading "No such file"
fallback to `$XDG_RUNTIME_DIR`
([#30](https://github.com/jmcorgan/fips/issues/30), reported by
[@Sebastix](https://github.com/Sebastix)).
- **`fd00::/8` routing protected from Tailscale interception.** The
daemon installs an IPv6 routing-policy rule
(`ip -6 rule to fd00::/8 lookup main priority 5265`) at TUN setup,
so Tailscale's table 52 default route can no longer divert mesh
traffic.
- **Bloom filter routing greedy-tree fallback.** `find_next_hop` no
longer returns `NoRoute` when the bloom candidate set is non-empty
but no candidate is strictly closer than the current node; it
falls through to greedy tree routing instead. Previously, this
caused dropped packets in topologies where the tree parent was
closer but not a bloom candidate.
- **Auto-connect peers reconnect after a graceful Disconnect.**
Previously, a clean upstream shutdown left the auto-connect peer
orphaned; only the link-dead, decrypt-fail, and peer-restart paths
scheduled a reconnect
([#60](https://github.com/jmcorgan/fips/issues/60), reported by
[@SwapMarket](https://github.com/SwapMarket)).
- **`fipsctl connect` rejects FIPS mesh addresses** (`fd00::/8`) for
`udp`, `tcp`, and `ethernet` transports with a clear error message
instead of echoing success while the daemon silently failed the
bind with `EAFNOSUPPORT`
([#61](https://github.com/jmcorgan/fips/issues/61), reported by
[@SwapMarket](https://github.com/SwapMarket)).
- **OpenWrt ipk** cross-compiles cleanly again after excluding the
BLE feature that requires D-Bus, which is unavailable on OpenWrt
targets.
## Packaging
- **Linux release artifact workflow** builds x86_64 and aarch64
tarballs and `.deb` packages on `v*` tag push, with SHA-256
checksums, and publishes them to the GitHub release page.
- **AUR publish workflow** auto-publishes the `fips` PKGBUILD on
stable `v*` tags.
## Upgrade notes
Operator-actionable items when moving from v0.2.0 to v0.2.1:
- **Bloom filter fill-ratio cap (default 0.05).** Inbound
`FilterAnnounce` messages whose derived FPR exceeds the cap are
rejected silently on the wire. Operators with unusually saturated
filters in the field may want to confirm that the default applies
cleanly to their deployment; check the new `bloom.fill_exceeded`
counter if mesh-size estimates drift after upgrade.
- **TreeAnnounce ancestry tightening.** Mixed v0.2.0 / v0.2.1 meshes
may produce WARN log lines on the v0.2.1 side until all peers
upgrade. Behavior is correct, log noise only.
## Getting v0.2.1
- **Linux x86_64 / aarch64**: `.deb` and tarball at the
[v0.2.1 release page](https://github.com/jmcorgan/fips/releases/tag/v0.2.1).
- **Arch Linux**: `fips` from the AUR.
- **OpenWrt**: `.ipk` at the v0.2.1 release page.
- **From source**: `cargo build --release` from a checkout of the
v0.2.1 tag.
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 or bug reports to this
release.
**Code and packaging**:
- [@jcorgan](https://github.com/jmcorgan): release shepherd, bloom
fill-ratio validation, auto-connect reconnect fix, `fipsctl`
mesh-address rejection, control-socket path detection,
Tailscale-vs-`fd00::/8` routing policy, bloom routing greedy
fallback, rustfmt baseline.
- [@Origami74](https://github.com/Origami74): OpenWrt ipk
BLE-feature build fix.
- [@jodobear](https://github.com/jodobear): Linux release-artifact
workflow and target-aware build scripts.
- [@dskvr](https://github.com/dskvr): AUR publish workflow.
- [@SatsAndSports](https://github.com/SatsAndSports): TreeAnnounce
semantic validation.
**Issue reports that drove fixes in this release**:
- [@Sebastix](https://github.com/Sebastix): `fipsctl` / `fipstop`
control-socket path detection
([#30](https://github.com/jmcorgan/fips/issues/30)).
- [@SwapMarket](https://github.com/SwapMarket): auto-connect
reconnect after graceful disconnect
([#60](https://github.com/jmcorgan/fips/issues/60)) and
`fipsctl` mesh-address rejection
([#61](https://github.com/jmcorgan/fips/issues/61)).
+4 -13
View File
@@ -1,9 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 580 478" font-family="monospace" font-size="13">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 580 430" font-family="monospace" font-size="13">
<!-- Background -->
<rect width="580" height="478" fill="#1a1a2e" rx="4"/>
<rect width="580" height="430" fill="#1a1a2e" rx="4"/>
<!-- Title -->
<text x="310" y="26" fill="#e0e0e0" text-anchor="middle" font-size="14" font-weight="bold">LookupRequest (0x30) — 303 + 16n bytes</text>
<text x="310" y="26" fill="#e0e0e0" text-anchor="middle" font-size="14" font-weight="bold">LookupRequest (0x30) — 46 + 16n bytes</text>
<!-- Row 0 (03): msg_type + request_id starts -->
<text x="50" y="62" fill="#666" font-size="10" text-anchor="end">03</text>
@@ -58,15 +58,6 @@
<rect x="55" y="348" width="520" height="40" fill="#1f1f3a" stroke="#4ad9d9" stroke-width="1" stroke-dasharray="4,3" rx="3"/>
<text x="315" y="372" fill="#8af8f8" text-anchor="middle" font-size="11">origin_coords × n (16 bytes each — NodeAddr only)</text>
<!-- Row 7: visited bloom -->
<text x="50" y="414" fill="#666" font-size="10" text-anchor="end">+1+256</text>
<rect x="55" y="394" width="130" height="40" fill="#3d3d2d" stroke="#a0a04a" stroke-width="1.5" rx="3"/>
<text x="120" y="418" fill="#d8d88a" text-anchor="middle" font-size="10">hash_cnt</text>
<rect x="185" y="394" width="390" height="40" fill="#1f1f3a" stroke="#a0a04a" stroke-width="1" stroke-dasharray="4,3" rx="3"/>
<text x="380" y="418" fill="#d8d88a" text-anchor="middle" font-size="11">visited_bits (256 bytes)</text>
<!-- Total -->
<text x="310" y="462" fill="#777" font-size="10" text-anchor="middle">total: 303 + (n × 16) bytes, where n = origin depth + 1</text>
<text x="310" y="414" fill="#777" font-size="10" text-anchor="middle">total: 46 + (n × 16) bytes, where n = origin depth + 1</text>
</svg>

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

+141
View File
@@ -0,0 +1,141 @@
# FIPS v0.2.1
**Released**: 2026-05-11
v0.2.1 is a maintenance release on the v0.2.x line. No new features
and no wire-format changes; operators running v0.2.0 can upgrade in
place. The release rolls up bug fixes and operational hardening for
issues surfaced in v0.2.0 deployments, plus a bloom-filter fill-ratio
validation that protects mesh-size estimates from saturated-filter
inputs.
## At a glance
- 22 commits since v0.2.0, 5 committers plus 2 issue reporters.
- All changes are backwards-compatible with v0.2.0 on the wire.
- Bloom filter fill-ratio validation hardens the FilterAnnounce
ingress path.
- TreeAnnounce ancestry validation tightened to match the
spanning-tree specification.
- Signed-tarball + `.deb` artifact workflow added for tagged
releases; AUR auto-publish on stable tags.
## Behavior changes worth flagging
- **Bloom filter fill-ratio validation** runs on every inbound
`FilterAnnounce`. Filters whose derived false-positive rate exceeds
`node.bloom.max_inbound_fpr` (new config field, default `0.05`) are
rejected silently on the wire, logged at WARN, and counted in a
new `bloom.fill_exceeded` counter. A rate-limited WARN also fires
when the local outgoing filter exceeds the cap.
`BloomFilter::estimated_count` now takes `max_fpr` and returns
`Option<f64>`, returning `None` for saturated filters; this
propagates through `compute_mesh_size` into `estimated_mesh_size`.
- **TreeAnnounce ancestry validation** is now run before tree-state
mutation, enforcing ancestry-self-match, root-single-entry,
parent-second-entry, and root-is-minimum-NodeAddr. Non-conforming
announces are rejected with a WARN. Mixed v0.2.0 / v0.2.1 meshes
may produce WARN log lines on the v0.2.1 side until all peers
upgrade; behavior is correct, log noise only.
## Notable bug fixes
- **Control socket path detection** in `fipsctl` and `fipstop` now
checks for the `/run/fips/` directory instead of the socket file
inside it. Users not yet in the `fips` group get a clear
"Permission denied" error instead of a misleading "No such file"
fallback to `$XDG_RUNTIME_DIR`
([#30](https://github.com/jmcorgan/fips/issues/30), reported by
[@Sebastix](https://github.com/Sebastix)).
- **`fd00::/8` routing protected from Tailscale interception.** The
daemon installs an IPv6 routing-policy rule
(`ip -6 rule to fd00::/8 lookup main priority 5265`) at TUN setup,
so Tailscale's table 52 default route can no longer divert mesh
traffic.
- **Bloom filter routing greedy-tree fallback.** `find_next_hop` no
longer returns `NoRoute` when the bloom candidate set is non-empty
but no candidate is strictly closer than the current node; it
falls through to greedy tree routing instead. Previously, this
caused dropped packets in topologies where the tree parent was
closer but not a bloom candidate.
- **Auto-connect peers reconnect after a graceful Disconnect.**
Previously, a clean upstream shutdown left the auto-connect peer
orphaned; only the link-dead, decrypt-fail, and peer-restart paths
scheduled a reconnect
([#60](https://github.com/jmcorgan/fips/issues/60), reported by
[@SwapMarket](https://github.com/SwapMarket)).
- **`fipsctl connect` rejects FIPS mesh addresses** (`fd00::/8`) for
`udp`, `tcp`, and `ethernet` transports with a clear error message
instead of echoing success while the daemon silently failed the
bind with `EAFNOSUPPORT`
([#61](https://github.com/jmcorgan/fips/issues/61), reported by
[@SwapMarket](https://github.com/SwapMarket)).
- **OpenWrt ipk** cross-compiles cleanly again after excluding the
BLE feature that requires D-Bus, which is unavailable on OpenWrt
targets.
## Packaging
- **Linux release artifact workflow** builds x86_64 and aarch64
tarballs and `.deb` packages on `v*` tag push, with SHA-256
checksums, and publishes them to the GitHub release page.
- **AUR publish workflow** auto-publishes the `fips` PKGBUILD on
stable `v*` tags.
## Upgrade notes
Operator-actionable items when moving from v0.2.0 to v0.2.1:
- **Bloom filter fill-ratio cap (default 0.05).** Inbound
`FilterAnnounce` messages whose derived FPR exceeds the cap are
rejected silently on the wire. Operators with unusually saturated
filters in the field may want to confirm that the default applies
cleanly to their deployment; check the new `bloom.fill_exceeded`
counter if mesh-size estimates drift after upgrade.
- **TreeAnnounce ancestry tightening.** Mixed v0.2.0 / v0.2.1 meshes
may produce WARN log lines on the v0.2.1 side until all peers
upgrade. Behavior is correct, log noise only.
## Getting v0.2.1
- **Linux x86_64 / aarch64**: `.deb` and tarball at the
[v0.2.1 release page](https://github.com/jmcorgan/fips/releases/tag/v0.2.1).
- **Arch Linux**: `fips` from the AUR.
- **OpenWrt**: `.ipk` at the v0.2.1 release page.
- **From source**: `cargo build --release` from a checkout of the
v0.2.1 tag.
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 or bug reports to this
release.
**Code and packaging**:
- [@jcorgan](https://github.com/jmcorgan): release shepherd, bloom
fill-ratio validation, auto-connect reconnect fix, `fipsctl`
mesh-address rejection, control-socket path detection,
Tailscale-vs-`fd00::/8` routing policy, bloom routing greedy
fallback, rustfmt baseline.
- [@Origami74](https://github.com/Origami74): OpenWrt ipk
BLE-feature build fix.
- [@jodobear](https://github.com/jodobear): Linux release-artifact
workflow and target-aware build scripts.
- [@dskvr](https://github.com/dskvr): AUR publish workflow.
- [@SatsAndSports](https://github.com/SatsAndSports): TreeAnnounce
semantic validation.
**Issue reports that drove fixes in this release**:
- [@Sebastix](https://github.com/Sebastix): `fipsctl` / `fipstop`
control-socket path detection
([#30](https://github.com/jmcorgan/fips/issues/30)).
- [@SwapMarket](https://github.com/SwapMarket): auto-connect
reconnect after graceful disconnect
([#60](https://github.com/jmcorgan/fips/issues/60)) and
`fipsctl` mesh-address rejection
([#61](https://github.com/jmcorgan/fips/issues/61)).
+59 -4
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# Build a .deb package for FIPS using cargo-deb.
#
# Usage: ./build-deb.sh
# Usage: ./build-deb.sh [--target <triple>] [--version <version>] [--no-build]
#
# Prerequisites: cargo-deb (install with: cargo install cargo-deb)
# Output: deploy/fips_<version>_<arch>.deb
@@ -11,6 +11,48 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="${SCRIPT_DIR}/../.."
usage() {
cat <<'EOF'
Usage: packaging/debian/build-deb.sh [options]
Options:
--target <triple> Rust target triple to build/package
--version <version> Override Debian package version
--no-build Package existing binaries without running cargo build
-h, --help Show this help
EOF
}
TARGET_TRIPLE=""
VERSION_OVERRIDE=""
NO_BUILD=0
while [[ $# -gt 0 ]]; do
case "$1" in
--target)
TARGET_TRIPLE="${2:?missing value for --target}"
shift 2
;;
--version)
VERSION_OVERRIDE="${2:?missing value for --version}"
shift 2
;;
--no-build)
NO_BUILD=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 1
;;
esac
done
cd "${PROJECT_ROOT}"
# Ensure cargo-deb is available
@@ -26,14 +68,27 @@ fi
# Build the .deb package
echo "Building .deb package..."
cargo deb
OUTPUT_DIR="$(mktemp -d)"
trap 'rm -rf "${OUTPUT_DIR}"' EXIT
cargo_args=(deb --output "${OUTPUT_DIR}")
if [[ -n "${TARGET_TRIPLE}" ]]; then
cargo_args+=(--target "${TARGET_TRIPLE}")
fi
if [[ -n "${VERSION_OVERRIDE}" ]]; then
cargo_args+=(--deb-version "${VERSION_OVERRIDE}")
fi
if [[ "${NO_BUILD}" -eq 1 ]]; then
cargo_args+=(--no-build)
fi
cargo "${cargo_args[@]}"
# Move output to deploy/
mkdir -p deploy
DEB_FILE=$(find target/debian -name '*.deb' -printf '%T@ %p\n' | sort -rn | head -1 | cut -d' ' -f2)
DEB_FILE=$(find "${OUTPUT_DIR}" -maxdepth 1 -name '*.deb' -printf '%T@ %p\n' | sort -rn | head -1 | cut -d' ' -f2)
if [ -z "${DEB_FILE}" ]; then
echo "Error: No .deb file found in target/debian/" >&2
echo "Error: No .deb file found in ${OUTPUT_DIR}" >&2
exit 1
fi
+2
View File
@@ -109,6 +109,8 @@ cd "$PROJECT_ROOT"
cargo zigbuild \
--release \
--target "$RUST_TARGET" \
--no-default-features \
--features tui \
--bin fips \
--bin fipsctl \
--bin fipstop
+88 -9
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# Build FIPS release binaries and create an install tarball.
#
# Usage: ./packaging/build-tarball.sh
# Usage: ./packaging/build-tarball.sh [--target <triple>] [--version <version>] [--arch <arch>] [--no-build]
# Output: deploy/fips-<version>-linux-<arch>.tar.gz
set -euo pipefail
@@ -10,29 +10,108 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PACKAGING_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
PROJECT_ROOT="$(cd "${PACKAGING_DIR}/.." && pwd)"
# Extract version from Cargo.toml
VERSION=$(grep '^version' "${PROJECT_ROOT}/Cargo.toml" | head -1 | sed 's/.*"\(.*\)"/\1/')
ARCH=$(uname -m)
usage() {
cat <<'EOF'
Usage: packaging/systemd/build-tarball.sh [options]
Options:
--target <triple> Rust target triple to build/package
--version <version> Override artifact version
--arch <arch> Override artifact architecture name
--no-build Package existing binaries without running cargo build
-h, --help Show this help
EOF
}
target_to_arch() {
local target="$1"
printf '%s\n' "${target%%-*}"
}
VERSION_OVERRIDE=""
TARGET_TRIPLE=""
ARCH_OVERRIDE=""
NO_BUILD=0
while [[ $# -gt 0 ]]; do
case "$1" in
--target)
TARGET_TRIPLE="${2:?missing value for --target}"
shift 2
;;
--version)
VERSION_OVERRIDE="${2:?missing value for --version}"
shift 2
;;
--arch)
ARCH_OVERRIDE="${2:?missing value for --arch}"
shift 2
;;
--no-build)
NO_BUILD=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 1
;;
esac
done
VERSION="${VERSION_OVERRIDE:-$(grep '^version' "${PROJECT_ROOT}/Cargo.toml" | head -1 | sed 's/.*"\(.*\)"/\1/')}"
if [[ -n "${ARCH_OVERRIDE}" ]]; then
ARCH="${ARCH_OVERRIDE}"
elif [[ -n "${TARGET_TRIPLE}" ]]; then
ARCH="$(target_to_arch "${TARGET_TRIPLE}")"
else
ARCH="$(uname -m)"
fi
TARBALL_NAME="fips-${VERSION}-linux-${ARCH}"
DEPLOY_DIR="${PROJECT_ROOT}/deploy"
STAGING_DIR="${DEPLOY_DIR}/${TARBALL_NAME}"
STRIP_BIN="${STRIP:-strip}"
if [[ -n "${TARGET_TRIPLE}" ]]; then
BINARY_DIR="${PROJECT_ROOT}/target/${TARGET_TRIPLE}/release"
else
BINARY_DIR="${PROJECT_ROOT}/target/release"
fi
echo "Building FIPS v${VERSION} for ${ARCH}..."
# Build release binaries (tui is a default feature, includes fipstop)
cargo build --release --manifest-path="${PROJECT_ROOT}/Cargo.toml"
if [[ "${NO_BUILD}" -eq 0 ]]; then
cargo_args=(build --release --manifest-path="${PROJECT_ROOT}/Cargo.toml")
if [[ -n "${TARGET_TRIPLE}" ]]; then
cargo_args+=(--target "${TARGET_TRIPLE}")
fi
cargo "${cargo_args[@]}"
fi
# Create staging directory
rm -rf "${STAGING_DIR}"
mkdir -p "${STAGING_DIR}"
# Copy binaries
cp "${PROJECT_ROOT}/target/release/fips" "${STAGING_DIR}/"
cp "${PROJECT_ROOT}/target/release/fipsctl" "${STAGING_DIR}/"
cp "${PROJECT_ROOT}/target/release/fipstop" "${STAGING_DIR}/"
for bin in fips fipsctl fipstop; do
if [[ ! -f "${BINARY_DIR}/${bin}" ]]; then
echo "Missing binary: ${BINARY_DIR}/${bin}" >&2
exit 1
fi
cp "${BINARY_DIR}/${bin}" "${STAGING_DIR}/"
done
# Strip binaries to reduce size
strip "${STAGING_DIR}/fips" "${STAGING_DIR}/fipsctl" "${STAGING_DIR}/fipstop"
if ! command -v "${STRIP_BIN}" &>/dev/null; then
echo "Strip tool not found: ${STRIP_BIN}" >&2
exit 1
fi
"${STRIP_BIN}" "${STAGING_DIR}/fips" "${STAGING_DIR}/fipsctl" "${STAGING_DIR}/fipstop"
# Copy packaging files
cp "${SCRIPT_DIR}/install.sh" "${STAGING_DIR}/"
+2 -1
View File
@@ -1,2 +1,3 @@
[toolchain]
channel = "1.94.0"
channel = "1.94.1"
components = ["rustfmt"]
+1
View File
@@ -0,0 +1 @@
# Use stable defaults
+15 -10
View File
@@ -3,12 +3,12 @@
//! Loads configuration and creates the top-level node instance.
use clap::Parser;
use fips::config::{resolve_identity, IdentitySource};
use fips::config::{IdentitySource, resolve_identity};
use fips::version;
use fips::{Config, Node};
use std::path::PathBuf;
use tracing::{error, info, warn, Level};
use tracing_subscriber::{fmt, EnvFilter};
use tracing::{Level, error, info, warn};
use tracing_subscriber::{EnvFilter, fmt};
/// FIPS mesh network daemon
#[derive(Parser, Debug)]
@@ -31,10 +31,7 @@ async fn main() {
.with_default_directive(Level::INFO.into())
.from_env_lossy();
fmt()
.with_env_filter(filter)
.with_target(true)
.init();
fmt().with_env_filter(filter).with_target(true).init();
let args = Args::parse();
@@ -47,7 +44,11 @@ async fn main() {
match Config::load_file(config_path) {
Ok(config) => (config, vec![config_path.clone()]),
Err(e) => {
error!("Failed to load configuration from {}: {}", config_path.display(), e);
error!(
"Failed to load configuration from {}: {}",
config_path.display(),
e
);
std::process::exit(1);
}
}
@@ -80,8 +81,12 @@ async fn main() {
};
match &resolved.source {
IdentitySource::Config => info!("Using identity from configuration"),
IdentitySource::KeyFile(p) => info!(path = %p.display(), "Loaded persistent identity from key file"),
IdentitySource::Generated(p) => info!(path = %p.display(), "Generated persistent identity, saved to key file"),
IdentitySource::KeyFile(p) => {
info!(path = %p.display(), "Loaded persistent identity from key file")
}
IdentitySource::Generated(p) => {
info!(path = %p.display(), "Generated persistent identity, saved to key file")
}
IdentitySource::Ephemeral => info!("Using ephemeral identity (new keypair each start)"),
}
+113 -8
View File
@@ -7,8 +7,9 @@ use clap::{Parser, Subcommand};
use fips::config::{write_key_file, write_pub_file};
use fips::upper::hosts::HostMap;
use fips::version;
use fips::{encode_nsec, Identity};
use fips::{Identity, encode_nsec};
use std::io::{BufRead, BufReader, Write};
use std::net::{Ipv6Addr, SocketAddrV6};
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::time::Duration;
@@ -113,8 +114,10 @@ impl ShowCommands {
///
/// Checks the system-wide path first (used when the daemon runs as a
/// systemd service), then falls back to the user's XDG runtime directory.
/// Uses directory existence rather than socket file existence so the check
/// works even when the user lacks traverse permission on /run/fips/ (0750).
fn default_socket_path() -> PathBuf {
if Path::new("/run/fips/control.sock").exists() {
if Path::new("/run/fips").exists() {
PathBuf::from("/run/fips/control.sock")
} else if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
PathBuf::from(format!("{runtime_dir}/fips/control.sock"))
@@ -197,6 +200,50 @@ fn print_response(value: &serde_json::Value) {
println!("{}", output.unwrap_or_else(|_| format!("{value}")));
}
/// Check if `address` is an IPv6 literal in `fd00::/8` (FIPS mesh ULA range).
///
/// Handles three common syntaxes:
/// - bare IPv6: `fd9d:...`
/// - bracketed + port: `[fd9d:...]:2121`
/// - bare IPv6 + port: `fd9d:...:2121` (ambiguous; accepted if tail is numeric)
fn is_fips_mesh_address(address: &str) -> bool {
let is_ula = |a: &Ipv6Addr| a.octets()[0] == 0xfd;
if let Ok(a) = address.parse::<Ipv6Addr>() {
return is_ula(&a);
}
if let Ok(sa) = address.parse::<SocketAddrV6>() {
return is_ula(sa.ip());
}
if let Some((host, port)) = address.rsplit_once(':')
&& port.chars().all(|c| c.is_ascii_digit())
&& !port.is_empty()
{
let host = host.trim_start_matches('[').trim_end_matches(']');
if let Ok(a) = host.parse::<Ipv6Addr>() {
return is_ula(&a);
}
}
false
}
/// Reject `fd00::/8` addresses for transports that expect a reachable network endpoint.
///
/// FIPS mesh ULAs are derived from npubs and only make sense as destinations
/// inside an already-established mesh — they are not valid udp/tcp/ethernet
/// transport endpoints. Without this check the CLI echoes success while the
/// daemon rejects the bind with EAFNOSUPPORT (issue #61).
fn validate_connect_address(address: &str, transport: &str) -> Result<(), String> {
let checked = matches!(transport, "udp" | "tcp" | "ethernet");
if checked && is_fips_mesh_address(address) {
return Err(format!(
"'{address}' is a FIPS mesh address (fd00::/8), not a reachable {transport} endpoint.\n\
Provide the peer's routable IP/hostname and port (e.g., '192.0.2.1:2121' or 'peer.example.com:2121')."
));
}
Ok(())
}
/// Resolve a peer identifier to an npub.
///
/// If the identifier starts with "npub1", it's returned as-is.
@@ -221,12 +268,7 @@ fn main() {
let cli = Cli::parse();
// Commands that don't require a running daemon
if let Commands::Keygen {
dir,
force,
stdout,
} = &cli.command
{
if let Commands::Keygen { dir, force, stdout } = &cli.command {
let identity = Identity::generate();
let nsec = encode_nsec(&identity.keypair().secret_key());
let npub = identity.npub();
@@ -278,6 +320,10 @@ fn main() {
address,
transport,
} => {
if let Err(e) = validate_connect_address(address, transport) {
eprintln!("error: {e}");
std::process::exit(1);
}
let npub = resolve_peer(peer);
build_command(
"connect",
@@ -303,3 +349,62 @@ fn main() {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_bare_ula_literal() {
assert!(is_fips_mesh_address("fd9d:abcd::1"));
assert!(is_fips_mesh_address("fd00::"));
assert!(is_fips_mesh_address(
"fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff"
));
}
#[test]
fn detects_bracketed_ula_with_port() {
assert!(is_fips_mesh_address("[fd9d:abcd::1]:2121"));
assert!(is_fips_mesh_address("[fd00::1]:8443"));
}
#[test]
fn detects_bare_ula_with_port() {
assert!(is_fips_mesh_address("fd9d:abcd::1:2121"));
}
#[test]
fn rejects_non_ula_ipv6() {
// fc00::/7 other half (fcXX:) is also ULA but not fd00::/8 — we only
// block the fd-prefixed half that FIPS actually uses.
assert!(!is_fips_mesh_address("fc00::1"));
assert!(!is_fips_mesh_address("::1"));
assert!(!is_fips_mesh_address("2001:db8::1"));
assert!(!is_fips_mesh_address("[2001:db8::1]:2121"));
}
#[test]
fn ignores_ipv4_and_hostnames() {
assert!(!is_fips_mesh_address("192.0.2.1:2121"));
assert!(!is_fips_mesh_address("peer.example.com:2121"));
assert!(!is_fips_mesh_address("coinos.pro:2121"));
}
#[test]
fn validates_only_target_transports() {
assert!(validate_connect_address("fd9d::1:2121", "udp").is_err());
assert!(validate_connect_address("fd9d::1:2121", "tcp").is_err());
assert!(validate_connect_address("fd9d::1:2121", "ethernet").is_err());
// Other transports are not inspected — they may legitimately accept
// non-IP endpoints (tor onion, etc.).
assert!(validate_connect_address("fd9d::1:2121", "tor").is_ok());
}
#[test]
fn allows_valid_endpoints() {
assert!(validate_connect_address("192.0.2.1:2121", "udp").is_ok());
assert!(validate_connect_address("peer.example.com:2121", "tcp").is_ok());
assert!(validate_connect_address("[2001:db8::1]:2121", "udp").is_ok());
}
}
+5 -5
View File
@@ -94,10 +94,7 @@ impl Tab {
/// Whether this tab has a table view with row selection.
pub fn has_table(&self) -> bool {
matches!(
self,
Tab::Peers | Tab::Sessions | Tab::Transports
)
matches!(self, Tab::Peers | Tab::Sessions | Tab::Transports)
}
}
@@ -172,7 +169,10 @@ impl App {
return;
}
let state = self.table_states.entry(self.active_tab).or_default();
let i = state.selected().map(|s| (s + 1).min(count - 1)).unwrap_or(0);
let i = state
.selected()
.map(|s| (s + 1).min(count - 1))
.unwrap_or(0);
state.select(Some(i));
}
+19 -17
View File
@@ -17,27 +17,29 @@ impl EventHandler {
pub fn new(tick_rate: Duration) -> Self {
let (tx, rx) = mpsc::channel();
thread::spawn(move || loop {
if event::poll(tick_rate).unwrap_or(false) {
if let Ok(evt) = event::read() {
match evt {
CrosstermEvent::Key(key) => {
if tx.send(Event::Key(key)).is_err() {
return;
thread::spawn(move || {
loop {
if event::poll(tick_rate).unwrap_or(false) {
if let Ok(evt) = event::read() {
match evt {
CrosstermEvent::Key(key) => {
if tx.send(Event::Key(key)).is_err() {
return;
}
}
}
CrosstermEvent::Resize(..) => {
if tx.send(Event::Resize).is_err() {
return;
CrosstermEvent::Resize(..) => {
if tx.send(Event::Resize).is_err() {
return;
}
}
_ => {}
}
_ => {}
}
}
} else {
// Poll timed out — send a tick
if tx.send(Event::Tick).is_err() {
return;
} else {
// Poll timed out — send a tick
if tx.send(Event::Tick).is_err() {
return;
}
}
}
});
+3 -1
View File
@@ -34,8 +34,10 @@ struct Cli {
///
/// Checks the system-wide path first (used when the daemon runs as a
/// systemd service), then falls back to the user's XDG runtime directory.
/// Uses directory existence rather than socket file existence so the check
/// works even when the user lacks traverse permission on /run/fips/ (0750).
fn default_socket_path() -> PathBuf {
if Path::new("/run/fips/control.sock").exists() {
if Path::new("/run/fips").exists() {
PathBuf::from("/run/fips/control.sock")
} else if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
PathBuf::from(format!("{runtime_dir}/fips/control.sock"))
+20 -9
View File
@@ -12,8 +12,8 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
let data = match app.data.get(&Tab::Bloom) {
Some(d) => d,
None => {
let msg = Paragraph::new(" Waiting for data...")
.style(Style::default().fg(Color::DarkGray));
let msg =
Paragraph::new(" Waiting for data...").style(Style::default().fg(Color::DarkGray));
frame.render_widget(msg, area);
return;
}
@@ -22,7 +22,7 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
let chunks = Layout::vertical([
Constraint::Length(7), // Bloom Filter State
Constraint::Length(15), // Bloom Announce Stats
Constraint::Min(3), // Peer Filters
Constraint::Min(3), // Peer Filters
])
.split(area);
@@ -64,16 +64,28 @@ fn draw_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
helpers::section_header("Inbound"),
helpers::kv_line("Received", &helpers::nested_u64(data, "stats", "received")),
helpers::kv_line("Accepted", &helpers::nested_u64(data, "stats", "accepted")),
helpers::kv_line("Decode Error", &helpers::nested_u64(data, "stats", "decode_error")),
helpers::kv_line(
"Decode Error",
&helpers::nested_u64(data, "stats", "decode_error"),
),
helpers::kv_line("Invalid", &helpers::nested_u64(data, "stats", "invalid")),
helpers::kv_line("Non-V1", &helpers::nested_u64(data, "stats", "non_v1")),
helpers::kv_line("Unknown Peer", &helpers::nested_u64(data, "stats", "unknown_peer")),
helpers::kv_line(
"Unknown Peer",
&helpers::nested_u64(data, "stats", "unknown_peer"),
),
helpers::kv_line("Stale", &helpers::nested_u64(data, "stats", "stale")),
Line::from(""),
helpers::section_header("Outbound"),
helpers::kv_line("Sent", &helpers::nested_u64(data, "stats", "sent")),
helpers::kv_line("Debounce Suppressed", &helpers::nested_u64(data, "stats", "debounce_suppressed")),
helpers::kv_line("Send Failed", &helpers::nested_u64(data, "stats", "send_failed")),
helpers::kv_line(
"Debounce Suppressed",
&helpers::nested_u64(data, "stats", "debounce_suppressed"),
),
helpers::kv_line(
"Send Failed",
&helpers::nested_u64(data, "stats", "send_failed"),
),
];
let max_lines = inner.height as usize;
@@ -97,8 +109,7 @@ fn draw_peer_filters(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
frame.render_widget(block, area);
if filters.is_empty() {
let msg =
Paragraph::new(" No peers").style(Style::default().fg(Color::DarkGray));
let msg = Paragraph::new(" No peers").style(Style::default().fg(Color::DarkGray));
frame.render_widget(msg, inner);
return;
}
+18 -20
View File
@@ -12,18 +12,18 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
let data = match app.data.get(&crate::app::Tab::Node) {
Some(d) => d,
None => {
let msg = Paragraph::new(" Waiting for data...")
.style(Style::default().fg(Color::DarkGray));
let msg =
Paragraph::new(" Waiting for data...").style(Style::default().fg(Color::DarkGray));
frame.render_widget(msg, area);
return;
}
};
let chunks = Layout::vertical([
Constraint::Length(7), // Runtime
Constraint::Length(7), // Identity
Constraint::Length(5), // State
Constraint::Length(9), // Traffic
Constraint::Length(7), // Runtime
Constraint::Length(7), // Identity
Constraint::Length(5), // State
Constraint::Length(9), // Traffic
Constraint::Min(0), // remaining
])
.split(area);
@@ -35,16 +35,17 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
}
fn draw_runtime(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.title(" Runtime ");
let block = Block::default().borders(Borders::ALL).title(" Runtime ");
let inner = block.inner(area);
frame.render_widget(block, area);
let version = helpers::str_field(data, "version");
let pid = helpers::u64_field(data, "pid");
let exe = helpers::str_field(data, "exe_path");
let uptime_secs = data.get("uptime_secs").and_then(|v| v.as_u64()).unwrap_or(0);
let uptime_secs = data
.get("uptime_secs")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let uptime = format_uptime(uptime_secs);
let socket = helpers::str_field(data, "control_socket");
let tun_name = helpers::str_field(data, "tun_name");
@@ -78,9 +79,7 @@ fn draw_runtime(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
}
fn draw_identity(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.title(" Identity ");
let block = Block::default().borders(Borders::ALL).title(" Identity ");
let inner = block.inner(area);
frame.render_widget(block, area);
@@ -109,9 +108,7 @@ fn draw_identity(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
}
fn draw_state(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.title(" State ");
let block = Block::default().borders(Borders::ALL).title(" State ");
let inner = block.inner(area);
frame.render_widget(block, area);
@@ -167,9 +164,7 @@ fn draw_state(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
}
fn draw_node_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.title(" Traffic ");
let block = Block::default().borders(Borders::ALL).title(" Traffic ");
let inner = block.inner(area);
frame.render_widget(block, area);
@@ -197,7 +192,10 @@ fn fwd_line(data: &serde_json::Value, label: &str, pkt_key: &str, byte_key: &str
.and_then(|f| f.get(byte_key))
.and_then(|v| v.as_u64())
.unwrap_or(0);
helpers::kv_line(label, &format!("{} pkts ({})", pkts, helpers::format_bytes(bytes)))
helpers::kv_line(
label,
&format!("{} pkts ({})", pkts, helpers::format_bytes(bytes)),
)
}
/// Format seconds as human-readable uptime (e.g., "3d 2h 15m 4s").
+8 -6
View File
@@ -119,7 +119,13 @@ pub fn nested_f64(data: &Value, outer: &str, inner: &str, decimals: usize) -> St
}
/// Get a nested f64 field, preferring `preferred` key with fallback to `fallback` key.
pub fn nested_f64_prefer(data: &Value, outer: &str, preferred: &str, fallback: &str, decimals: usize) -> String {
pub fn nested_f64_prefer(
data: &Value,
outer: &str,
preferred: &str,
fallback: &str,
decimals: usize,
) -> String {
data.get(outer)
.and_then(|o| o.get(preferred).or_else(|| o.get(fallback)))
.and_then(|v| v.as_f64())
@@ -161,11 +167,7 @@ pub fn section_header(title: &str) -> Line<'static> {
/// Key-value line for detail views.
pub fn kv_line(key: &str, value: &str) -> Line<'static> {
Line::from(vec![
Span::styled(
format!(" {key}: "),
Style::default().fg(Color::DarkGray),
),
Span::styled(format!(" {key}: "), Style::default().fg(Color::DarkGray)),
Span::raw(value.to_string()),
])
}
+6 -12
View File
@@ -12,18 +12,15 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
let data = match app.data.get(&Tab::Mmp) {
Some(d) => d,
None => {
let msg = Paragraph::new(" Waiting for data...")
.style(Style::default().fg(Color::DarkGray));
let msg =
Paragraph::new(" Waiting for data...").style(Style::default().fg(Color::DarkGray));
frame.render_widget(msg, area);
return;
}
};
let chunks = Layout::vertical([
Constraint::Percentage(60),
Constraint::Percentage(40),
])
.split(area);
let chunks =
Layout::vertical([Constraint::Percentage(60), Constraint::Percentage(40)]).split(area);
draw_link_mmp(frame, data, chunks[0]);
draw_session_mmp(frame, data, chunks[1]);
@@ -44,8 +41,7 @@ fn draw_link_mmp(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
frame.render_widget(block, area);
if peers.is_empty() {
let msg =
Paragraph::new(" No peers").style(Style::default().fg(Color::DarkGray));
let msg = Paragraph::new(" No peers").style(Style::default().fg(Color::DarkGray));
frame.render_widget(msg, inner);
return;
}
@@ -152,8 +148,7 @@ fn draw_session_mmp(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
frame.render_widget(block, area);
if sessions.is_empty() {
let msg = Paragraph::new(" No sessions")
.style(Style::default().fg(Color::DarkGray));
let msg = Paragraph::new(" No sessions").style(Style::default().fg(Color::DarkGray));
frame.render_widget(msg, inner);
return;
}
@@ -234,4 +229,3 @@ fn trend_color(trend: &str, bad_rising: bool) -> Color {
_ => Color::DarkGray, // "stable"
}
}
+6 -2
View File
@@ -19,7 +19,7 @@ use crate::app::{App, ConnectionState, Tab};
pub fn draw(frame: &mut Frame, app: &mut App) {
let chunks = Layout::vertical([
Constraint::Length(3), // tab bar
Constraint::Min(1), // content
Constraint::Min(1), // content
Constraint::Length(1), // status bar
])
.split(frame.area());
@@ -51,7 +51,11 @@ fn draw_tab_bar(frame: &mut Frame, app: &App, area: Rect) {
spans.push(Span::styled(" | ", divider));
}
}
let style = if *tab == app.active_tab { highlight } else { normal };
let style = if *tab == app.active_tab {
highlight
} else {
normal
};
spans.push(Span::styled(tab.label(), style));
}
+169 -61
View File
@@ -2,7 +2,9 @@ use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Line;
use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table};
use ratatui::widgets::{
Block, Borders, Cell, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table,
};
use crate::app::{App, Tab};
@@ -14,11 +16,8 @@ pub fn draw(frame: &mut Frame, app: &mut App, area: Rect) {
if app.detail_view.is_some() {
// Split: left 40% table, right 60% detail
let chunks = Layout::horizontal([
Constraint::Percentage(40),
Constraint::Percentage(60),
])
.split(area);
let chunks = Layout::horizontal([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(area);
draw_table(frame, app, chunks[0], &peers, row_count);
draw_detail(frame, app, chunks[1], &peers);
@@ -29,7 +28,8 @@ pub fn draw(frame: &mut Frame, app: &mut App, area: Rect) {
/// Get peers sorted by LQI ascending (best first). Peers without LQI sort last.
fn get_peers_sorted(app: &App) -> Vec<serde_json::Value> {
let mut peers = app.data
let mut peers = app
.data
.get(&Tab::Peers)
.and_then(|v| v.get("peers"))
.and_then(|v| v.as_array())
@@ -37,8 +37,14 @@ fn get_peers_sorted(app: &App) -> Vec<serde_json::Value> {
.unwrap_or_default();
peers.sort_by(|a, b| {
let lqi_a = a.get("mmp").and_then(|m| m.get("lqi")).and_then(|v| v.as_f64());
let lqi_b = b.get("mmp").and_then(|m| m.get("lqi")).and_then(|v| v.as_f64());
let lqi_a = a
.get("mmp")
.and_then(|m| m.get("lqi"))
.and_then(|v| v.as_f64());
let lqi_b = b
.get("mmp")
.and_then(|m| m.get("lqi"))
.and_then(|v| v.as_f64());
match (lqi_a, lqi_b) {
(Some(a), Some(b)) => a.partial_cmp(&b).unwrap_or(std::cmp::Ordering::Equal),
(Some(_), None) => std::cmp::Ordering::Less,
@@ -50,7 +56,13 @@ fn get_peers_sorted(app: &App) -> Vec<serde_json::Value> {
peers
}
fn draw_table(frame: &mut Frame, app: &mut App, area: Rect, peers: &[serde_json::Value], row_count: usize) {
fn draw_table(
frame: &mut Frame,
app: &mut App,
area: Rect,
peers: &[serde_json::Value],
row_count: usize,
) {
let header = Row::new(vec![
Cell::from("Name"),
Cell::from("Npub"),
@@ -74,13 +86,25 @@ fn draw_table(frame: &mut Frame, app: &mut App, area: Rect, peers: &[serde_json:
.map(|peer| {
let name = helpers::str_field(peer, "display_name");
let npub = helpers::str_field(peer, "npub");
let is_parent = peer.get("is_parent").and_then(|v| v.as_bool()).unwrap_or(false);
let is_child = peer.get("is_child").and_then(|v| v.as_bool()).unwrap_or(false);
let is_parent = peer
.get("is_parent")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let is_child = peer
.get("is_child")
.and_then(|v| v.as_bool())
.unwrap_or(false);
// Transport: "type addr" (e.g., "udp 1.2.3.4:2121")
let transport = {
let t_type = peer.get("transport_type").and_then(|v| v.as_str()).unwrap_or("");
let t_addr = peer.get("transport_addr").and_then(|v| v.as_str()).unwrap_or("");
let t_type = peer
.get("transport_type")
.and_then(|v| v.as_str())
.unwrap_or("");
let t_addr = peer
.get("transport_addr")
.and_then(|v| v.as_str())
.unwrap_or("");
if t_type.is_empty() && t_addr.is_empty() {
"-".to_string()
} else if t_type.is_empty() {
@@ -92,11 +116,15 @@ fn draw_table(frame: &mut Frame, app: &mut App, area: Rect, peers: &[serde_json:
}
};
let dir = peer.get("direction").and_then(|v| v.as_str()).map(|d| match d {
"inbound" => "in",
"outbound" => "out",
other => other,
}).unwrap_or("-");
let dir = peer
.get("direction")
.and_then(|v| v.as_str())
.map(|d| match d {
"inbound" => "in",
"outbound" => "out",
other => other,
})
.unwrap_or("-");
let srtt = helpers::nested_f64(peer, "mmp", "srtt_ms", 1);
let loss = helpers::nested_f64_prefer(peer, "mmp", "smoothed_loss", "loss_rate", 3);
let lqi = helpers::nested_f64(peer, "mmp", "lqi", 2);
@@ -130,16 +158,16 @@ fn draw_table(frame: &mut Frame, app: &mut App, area: Rect, peers: &[serde_json:
.collect();
let widths = [
Constraint::Min(12), // Name
Constraint::Length(67), // Npub (full bech32)
Constraint::Min(20), // Transport
Constraint::Length(4), // Dir
Constraint::Length(8), // SRTT
Constraint::Length(7), // Loss
Constraint::Length(6), // LQI
Constraint::Length(10), // Goodput
Constraint::Length(9), // Pkts Tx
Constraint::Length(9), // Pkts Rx
Constraint::Min(12), // Name
Constraint::Length(67), // Npub (full bech32)
Constraint::Min(20), // Transport
Constraint::Length(4), // Dir
Constraint::Length(8), // SRTT
Constraint::Length(7), // Loss
Constraint::Length(6), // LQI
Constraint::Length(10), // Goodput
Constraint::Length(9), // Pkts Tx
Constraint::Length(9), // Pkts Rx
];
let table = Table::new(rows, widths)
@@ -189,10 +217,22 @@ fn draw_detail(frame: &mut Frame, app: &App, area: Rect, peers: &[serde_json::Va
return;
};
let has_tree = peer.get("has_tree_position").and_then(|v| v.as_bool()).unwrap_or(false);
let has_bloom = peer.get("has_bloom_filter").and_then(|v| v.as_bool()).unwrap_or(false);
let is_parent = peer.get("is_parent").and_then(|v| v.as_bool()).unwrap_or(false);
let is_child = peer.get("is_child").and_then(|v| v.as_bool()).unwrap_or(false);
let has_tree = peer
.get("has_tree_position")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let has_bloom = peer
.get("has_bloom_filter")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let is_parent = peer
.get("is_parent")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let is_child = peer
.get("is_child")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let tree_role = if is_parent {
"parent"
@@ -215,7 +255,12 @@ fn draw_detail(frame: &mut Frame, app: &App, area: Rect, peers: &[serde_json::Va
helpers::section_header("Connection"),
helpers::kv_line("Connectivity", helpers::str_field(peer, "connectivity")),
helpers::kv_line("Link ID", &helpers::u64_field(peer, "link_id")),
helpers::kv_line("Direction", peer.get("direction").and_then(|v| v.as_str()).unwrap_or("-")),
helpers::kv_line(
"Direction",
peer.get("direction")
.and_then(|v| v.as_str())
.unwrap_or("-"),
),
];
if let Some(addr) = peer.get("transport_addr").and_then(|v| v.as_str()) {
lines.push(helpers::kv_line("Transport Addr", addr));
@@ -227,30 +272,52 @@ fn draw_detail(frame: &mut Frame, app: &App, area: Rect, peers: &[serde_json::Va
let link_id = peer.get("link_id").and_then(|v| v.as_u64());
let link = lookup_link(app, link_id);
if let Some(ref link) = link {
lines.push(helpers::kv_line("Link State", helpers::str_field(link, "state")));
lines.push(helpers::kv_line(
"Link State",
helpers::str_field(link, "state"),
));
}
lines.extend([
helpers::kv_line("Authenticated", &helpers::format_elapsed_ms(
peer.get("authenticated_at_ms").and_then(|v| v.as_u64()).unwrap_or(0),
)),
helpers::kv_line("Last Seen", &helpers::format_elapsed_ms(
peer.get("last_seen_ms").and_then(|v| v.as_u64()).unwrap_or(0),
)),
helpers::kv_line(
"Authenticated",
&helpers::format_elapsed_ms(
peer.get("authenticated_at_ms")
.and_then(|v| v.as_u64())
.unwrap_or(0),
),
),
helpers::kv_line(
"Last Seen",
&helpers::format_elapsed_ms(
peer.get("last_seen_ms")
.and_then(|v| v.as_u64())
.unwrap_or(0),
),
),
Line::from(""),
]);
// Transport info (cross-referenced from link -> transport)
if let Some(transport) = link.as_ref().and_then(|l| lookup_transport(app, l)) {
lines.push(helpers::section_header("Transport"));
lines.push(helpers::kv_line("Type", helpers::str_field(&transport, "type")));
lines.push(helpers::kv_line(
"Type",
helpers::str_field(&transport, "type"),
));
if let Some(name) = transport.get("name").and_then(|v| v.as_str()) {
lines.push(helpers::kv_line("Name", name));
}
lines.push(helpers::kv_line("MTU", &helpers::u64_field(&transport, "mtu")));
lines.push(helpers::kv_line(
"MTU",
&helpers::u64_field(&transport, "mtu"),
));
if let Some(addr) = transport.get("local_addr").and_then(|v| v.as_str()) {
lines.push(helpers::kv_line("Local Addr", addr));
}
lines.push(helpers::kv_line("State", helpers::str_field(&transport, "state")));
lines.push(helpers::kv_line(
"State",
helpers::str_field(&transport, "state"),
));
lines.push(Line::from(""));
}
@@ -268,28 +335,70 @@ fn draw_detail(frame: &mut Frame, app: &App, area: Rect, peers: &[serde_json::Va
Line::from(""),
// Stats
helpers::section_header("Link Stats"),
helpers::kv_line("Pkts Sent", &helpers::nested_u64(peer, "stats", "packets_sent")),
helpers::kv_line("Pkts Recv", &helpers::nested_u64(peer, "stats", "packets_recv")),
helpers::kv_line("Bytes Sent", &helpers::format_bytes(
peer.get("stats").and_then(|s| s.get("bytes_sent")).and_then(|v| v.as_u64()).unwrap_or(0),
)),
helpers::kv_line("Bytes Recv", &helpers::format_bytes(
peer.get("stats").and_then(|s| s.get("bytes_recv")).and_then(|v| v.as_u64()).unwrap_or(0),
)),
helpers::kv_line(
"Pkts Sent",
&helpers::nested_u64(peer, "stats", "packets_sent"),
),
helpers::kv_line(
"Pkts Recv",
&helpers::nested_u64(peer, "stats", "packets_recv"),
),
helpers::kv_line(
"Bytes Sent",
&helpers::format_bytes(
peer.get("stats")
.and_then(|s| s.get("bytes_sent"))
.and_then(|v| v.as_u64())
.unwrap_or(0),
),
),
helpers::kv_line(
"Bytes Recv",
&helpers::format_bytes(
peer.get("stats")
.and_then(|s| s.get("bytes_recv"))
.and_then(|v| v.as_u64())
.unwrap_or(0),
),
),
Line::from(""),
]);
// MMP (if present)
if peer.get("mmp").is_some() {
lines.push(helpers::section_header("MMP Metrics"));
lines.push(helpers::kv_line("Mode", &helpers::nested_str(peer, "mmp", "mode")));
lines.push(helpers::kv_line("SRTT", &format!("{}ms", helpers::nested_f64(peer, "mmp", "srtt_ms", 1))));
lines.push(helpers::kv_line("Loss Rate", &helpers::nested_f64_prefer(peer, "mmp", "smoothed_loss", "loss_rate", 4)));
lines.push(helpers::kv_line("ETX", &helpers::nested_f64_prefer(peer, "mmp", "smoothed_etx", "etx", 2)));
lines.push(helpers::kv_line("LQI", &helpers::nested_f64(peer, "mmp", "lqi", 2)));
lines.push(helpers::kv_line("Goodput", &helpers::nested_throughput(peer, "mmp", "goodput_bps")));
lines.push(helpers::kv_line("Delivery Fwd", &helpers::nested_f64(peer, "mmp", "delivery_ratio_forward", 3)));
lines.push(helpers::kv_line("Delivery Rev", &helpers::nested_f64(peer, "mmp", "delivery_ratio_reverse", 3)));
lines.push(helpers::kv_line(
"Mode",
&helpers::nested_str(peer, "mmp", "mode"),
));
lines.push(helpers::kv_line(
"SRTT",
&format!("{}ms", helpers::nested_f64(peer, "mmp", "srtt_ms", 1)),
));
lines.push(helpers::kv_line(
"Loss Rate",
&helpers::nested_f64_prefer(peer, "mmp", "smoothed_loss", "loss_rate", 4),
));
lines.push(helpers::kv_line(
"ETX",
&helpers::nested_f64_prefer(peer, "mmp", "smoothed_etx", "etx", 2),
));
lines.push(helpers::kv_line(
"LQI",
&helpers::nested_f64(peer, "mmp", "lqi", 2),
));
lines.push(helpers::kv_line(
"Goodput",
&helpers::nested_throughput(peer, "mmp", "goodput_bps"),
));
lines.push(helpers::kv_line(
"Delivery Fwd",
&helpers::nested_f64(peer, "mmp", "delivery_ratio_forward", 3),
));
lines.push(helpers::kv_line(
"Delivery Rev",
&helpers::nested_f64(peer, "mmp", "delivery_ratio_reverse", 3),
));
}
let detail_scroll = app.detail_view.as_ref().map(|d| d.scroll).unwrap_or(0);
@@ -320,4 +429,3 @@ fn lookup_transport(app: &App, link: &serde_json::Value) -> Option<serde_json::V
.find(|t| t.get("transport_id").and_then(|v| v.as_u64()) == Some(transport_id))
.cloned()
}
+144 -41
View File
@@ -12,16 +12,16 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
let data = match app.data.get(&Tab::Routing) {
Some(d) => d,
None => {
let msg = Paragraph::new(" Waiting for data...")
.style(Style::default().fg(Color::DarkGray));
let msg =
Paragraph::new(" Waiting for data...").style(Style::default().fg(Color::DarkGray));
frame.render_widget(msg, area);
return;
}
};
let chunks = Layout::vertical([
Constraint::Length(7), // Routing State
Constraint::Length(8), // Coordinate Cache
Constraint::Length(7), // Routing State
Constraint::Length(8), // Coordinate Cache
Constraint::Min(3), // Routing Statistics
])
.split(area);
@@ -33,7 +33,10 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
fn draw_routing_state(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
let lines = vec![
helpers::kv_line("Coord Cache", &helpers::u64_field(data, "coord_cache_entries")),
helpers::kv_line(
"Coord Cache",
&helpers::u64_field(data, "coord_cache_entries"),
),
helpers::kv_line(
"Identity Cache",
&helpers::u64_field(data, "identity_cache_entries"),
@@ -68,7 +71,10 @@ fn fwd_line(data: &serde_json::Value, label: &str, pkt_key: &str, byte_key: &str
.and_then(|f| f.get(byte_key))
.and_then(|v| v.as_u64())
.unwrap_or(0);
helpers::kv_line(label, &format!("{} pkts ({})", pkts, helpers::format_bytes(bytes)))
helpers::kv_line(
label,
&format!("{} pkts ({})", pkts, helpers::format_bytes(bytes)),
)
}
fn draw_routing_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
@@ -78,11 +84,8 @@ fn draw_routing_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
let inner = block.inner(area);
frame.render_widget(block, area);
let cols = Layout::horizontal([
Constraint::Percentage(50),
Constraint::Percentage(50),
])
.split(inner);
let cols =
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).split(inner);
// Left column: Forwarding + Discovery
let mut left = vec![
@@ -91,47 +94,147 @@ fn draw_routing_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
fwd_line(data, "Delivered", "delivered_packets", "delivered_bytes"),
fwd_line(data, "Forwarded", "forwarded_packets", "forwarded_bytes"),
fwd_line(data, "Originated", "originated_packets", "originated_bytes"),
fwd_line(data, "Decode Error", "decode_error_packets", "decode_error_bytes"),
fwd_line(data, "TTL Exhausted", "ttl_exhausted_packets", "ttl_exhausted_bytes"),
fwd_line(data, "No Route", "drop_no_route_packets", "drop_no_route_bytes"),
fwd_line(data, "MTU Exceeded", "drop_mtu_exceeded_packets", "drop_mtu_exceeded_bytes"),
fwd_line(data, "Send Error", "drop_send_error_packets", "drop_send_error_bytes"),
fwd_line(
data,
"Decode Error",
"decode_error_packets",
"decode_error_bytes",
),
fwd_line(
data,
"TTL Exhausted",
"ttl_exhausted_packets",
"ttl_exhausted_bytes",
),
fwd_line(
data,
"No Route",
"drop_no_route_packets",
"drop_no_route_bytes",
),
fwd_line(
data,
"MTU Exceeded",
"drop_mtu_exceeded_packets",
"drop_mtu_exceeded_bytes",
),
fwd_line(
data,
"Send Error",
"drop_send_error_packets",
"drop_send_error_bytes",
),
Line::from(""),
helpers::section_header("Discovery Requests"),
helpers::kv_line("Received", &helpers::nested_u64(data, "discovery", "req_received")),
helpers::kv_line("Forwarded", &helpers::nested_u64(data, "discovery", "req_forwarded")),
helpers::kv_line("Initiated", &helpers::nested_u64(data, "discovery", "req_initiated")),
helpers::kv_line("Deduplicated", &helpers::nested_u64(data, "discovery", "req_deduplicated")),
helpers::kv_line("Target Is Us", &helpers::nested_u64(data, "discovery", "req_target_is_us")),
helpers::kv_line("Duplicate", &helpers::nested_u64(data, "discovery", "req_duplicate")),
helpers::kv_line("Bloom Miss", &helpers::nested_u64(data, "discovery", "req_bloom_miss")),
helpers::kv_line("Backoff Suppressed", &helpers::nested_u64(data, "discovery", "req_backoff_suppressed")),
helpers::kv_line("Fwd Rate Limited", &helpers::nested_u64(data, "discovery", "req_forward_rate_limited")),
helpers::kv_line("TTL Exhausted", &helpers::nested_u64(data, "discovery", "req_ttl_exhausted")),
helpers::kv_line("Decode Error", &helpers::nested_u64(data, "discovery", "req_decode_error")),
helpers::kv_line(
"Received",
&helpers::nested_u64(data, "discovery", "req_received"),
),
helpers::kv_line(
"Forwarded",
&helpers::nested_u64(data, "discovery", "req_forwarded"),
),
helpers::kv_line(
"Initiated",
&helpers::nested_u64(data, "discovery", "req_initiated"),
),
helpers::kv_line(
"Deduplicated",
&helpers::nested_u64(data, "discovery", "req_deduplicated"),
),
helpers::kv_line(
"Target Is Us",
&helpers::nested_u64(data, "discovery", "req_target_is_us"),
),
helpers::kv_line(
"Duplicate",
&helpers::nested_u64(data, "discovery", "req_duplicate"),
),
helpers::kv_line(
"Bloom Miss",
&helpers::nested_u64(data, "discovery", "req_bloom_miss"),
),
helpers::kv_line(
"Backoff Suppressed",
&helpers::nested_u64(data, "discovery", "req_backoff_suppressed"),
),
helpers::kv_line(
"Fwd Rate Limited",
&helpers::nested_u64(data, "discovery", "req_forward_rate_limited"),
),
helpers::kv_line(
"TTL Exhausted",
&helpers::nested_u64(data, "discovery", "req_ttl_exhausted"),
),
helpers::kv_line(
"Decode Error",
&helpers::nested_u64(data, "discovery", "req_decode_error"),
),
Line::from(""),
helpers::section_header("Discovery Responses"),
helpers::kv_line("Received", &helpers::nested_u64(data, "discovery", "resp_received")),
helpers::kv_line("Accepted", &helpers::nested_u64(data, "discovery", "resp_accepted")),
helpers::kv_line("Forwarded", &helpers::nested_u64(data, "discovery", "resp_forwarded")),
helpers::kv_line("Timed Out", &helpers::nested_u64(data, "discovery", "resp_timed_out")),
helpers::kv_line("Identity Miss", &helpers::nested_u64(data, "discovery", "resp_identity_miss")),
helpers::kv_line("Proof Failed", &helpers::nested_u64(data, "discovery", "resp_proof_failed")),
helpers::kv_line("Decode Error", &helpers::nested_u64(data, "discovery", "resp_decode_error")),
helpers::kv_line(
"Received",
&helpers::nested_u64(data, "discovery", "resp_received"),
),
helpers::kv_line(
"Accepted",
&helpers::nested_u64(data, "discovery", "resp_accepted"),
),
helpers::kv_line(
"Forwarded",
&helpers::nested_u64(data, "discovery", "resp_forwarded"),
),
helpers::kv_line(
"Timed Out",
&helpers::nested_u64(data, "discovery", "resp_timed_out"),
),
helpers::kv_line(
"Identity Miss",
&helpers::nested_u64(data, "discovery", "resp_identity_miss"),
),
helpers::kv_line(
"Proof Failed",
&helpers::nested_u64(data, "discovery", "resp_proof_failed"),
),
helpers::kv_line(
"Decode Error",
&helpers::nested_u64(data, "discovery", "resp_decode_error"),
),
];
// Right column: Error Signals + Congestion
let mut right = vec![
helpers::section_header("Error Signals"),
helpers::kv_line("Coords Required", &helpers::nested_u64(data, "error_signals", "coords_required")),
helpers::kv_line("Path Broken", &helpers::nested_u64(data, "error_signals", "path_broken")),
helpers::kv_line("MTU Exceeded", &helpers::nested_u64(data, "error_signals", "mtu_exceeded")),
helpers::kv_line(
"Coords Required",
&helpers::nested_u64(data, "error_signals", "coords_required"),
),
helpers::kv_line(
"Path Broken",
&helpers::nested_u64(data, "error_signals", "path_broken"),
),
helpers::kv_line(
"MTU Exceeded",
&helpers::nested_u64(data, "error_signals", "mtu_exceeded"),
),
Line::from(""),
helpers::section_header("Congestion"),
helpers::kv_line("CE Forwarded", &helpers::nested_u64(data, "congestion", "ce_forwarded")),
helpers::kv_line("CE Received", &helpers::nested_u64(data, "congestion", "ce_received")),
helpers::kv_line("Congestion Detected", &helpers::nested_u64(data, "congestion", "congestion_detected")),
helpers::kv_line("Kernel Drops", &helpers::nested_u64(data, "congestion", "kernel_drop_events")),
helpers::kv_line(
"CE Forwarded",
&helpers::nested_u64(data, "congestion", "ce_forwarded"),
),
helpers::kv_line(
"CE Received",
&helpers::nested_u64(data, "congestion", "ce_received"),
),
helpers::kv_line(
"Congestion Detected",
&helpers::nested_u64(data, "congestion", "congestion_detected"),
),
helpers::kv_line(
"Kernel Drops",
&helpers::nested_u64(data, "congestion", "kernel_drop_events"),
),
];
let max_lines = cols[0].height as usize;
+3 -10
View File
@@ -15,11 +15,8 @@ pub fn draw(frame: &mut Frame, app: &mut App, area: Rect) {
let row_count = sessions.len();
if app.detail_view.is_some() {
let chunks = Layout::horizontal([
Constraint::Percentage(40),
Constraint::Percentage(60),
])
.split(area);
let chunks = Layout::horizontal([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(area);
draw_table(frame, app, chunks[0], &sessions, row_count);
draw_detail(frame, app, chunks[1], &sessions);
@@ -223,10 +220,7 @@ fn draw_detail(frame: &mut Frame, app: &App, area: Rect, sessions: &[serde_json:
));
lines.push(helpers::kv_line(
"SRTT",
&format!(
"{}ms",
helpers::nested_f64(session, "mmp", "srtt_ms", 1)
),
&format!("{}ms", helpers::nested_f64(session, "mmp", "srtt_ms", 1)),
));
lines.push(helpers::kv_line(
"Loss Rate",
@@ -271,4 +265,3 @@ fn state_styled(state: &str) -> Span<'static> {
};
Span::styled(state.to_string(), Style::default().fg(color))
}
+15 -20
View File
@@ -33,11 +33,8 @@ pub fn draw(frame: &mut Frame, app: &mut App, area: Rect) {
update_selected_tree_item(app, &tree_rows);
if app.detail_view.is_some() {
let chunks = Layout::horizontal([
Constraint::Percentage(40),
Constraint::Percentage(60),
])
.split(area);
let chunks = Layout::horizontal([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(area);
draw_table(frame, app, chunks[0], &transports, &links, &tree_rows);
draw_detail(frame, app, chunks[1], &transports, &links, &tree_rows);
@@ -110,9 +107,7 @@ fn update_selected_tree_item(app: &mut App, tree_rows: &[TreeRow]) {
.unwrap_or(0);
app.selected_tree_item = match tree_rows.get(selected) {
Some(TreeRow::Transport { transport_id, .. }) => {
SelectedTreeItem::Transport(*transport_id)
}
Some(TreeRow::Transport { transport_id, .. }) => SelectedTreeItem::Transport(*transport_id),
Some(TreeRow::Link { .. }) => SelectedTreeItem::Link,
None => SelectedTreeItem::None,
};
@@ -169,11 +164,7 @@ fn draw_table(
.get("onion_address")
.and_then(|v| v.as_str())
.map(|a| {
let short = if a.len() > 16 {
&a[..16]
} else {
a
};
let short = if a.len() > 16 { &a[..16] } else { a };
format!(" {short}..")
})
.unwrap_or_default();
@@ -209,7 +200,11 @@ fn draw_table(
}
TreeRow::Link { index, is_last } => {
let link = &links[*index];
let tree_char = if *is_last { "\u{2514}\u{2500}" } else { "\u{251C}\u{2500}" }; // └─ or ├─
let tree_char = if *is_last {
"\u{2514}\u{2500}"
} else {
"\u{251C}\u{2500}"
}; // └─ or ├─
let dir = helpers::str_field(link, "direction");
let dir_short = match dir {
"Outbound" => "Out",
@@ -303,13 +298,10 @@ fn draw_detail(
.unwrap_or(0);
let Some(tree_row) = tree_rows.get(selected) else {
let block = Block::default()
.borders(Borders::ALL)
.title(" Detail ");
let block = Block::default().borders(Borders::ALL).title(" Detail ");
let inner = block.inner(area);
frame.render_widget(block, area);
let msg = Paragraph::new(" No item selected")
.style(Style::default().fg(Color::DarkGray));
let msg = Paragraph::new(" No item selected").style(Style::default().fg(Color::DarkGray));
frame.render_widget(msg, inner);
return;
};
@@ -539,7 +531,10 @@ fn draw_transport_detail(frame: &mut Frame, app: &App, area: Rect, t: &serde_jso
"Network",
&helpers::nested_str(t, "tor_monitoring", "network_liveness"),
));
lines.push(helpers::kv_line("Dormant", helpers::bool_field(mon, "dormant")));
lines.push(helpers::kv_line(
"Dormant",
helpers::bool_field(mon, "dormant"),
));
let tor_read = mon
.get("traffic_read")
+58 -25
View File
@@ -12,8 +12,8 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
let data = match app.data.get(&Tab::Tree) {
Some(d) => d,
None => {
let msg = Paragraph::new(" Waiting for data...")
.style(Style::default().fg(Color::DarkGray));
let msg =
Paragraph::new(" Waiting for data...").style(Style::default().fg(Color::DarkGray));
frame.render_widget(msg, area);
return;
}
@@ -22,7 +22,7 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
let chunks = Layout::vertical([
Constraint::Length(10), // Tree Position
Constraint::Length(22), // Tree Announce Stats
Constraint::Min(3), // Tree Peers
Constraint::Min(3), // Tree Peers
])
.split(area);
@@ -70,16 +70,12 @@ fn draw_position(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
)];
if coords.is_empty() {
path_parts.push(Span::styled(
"[root]",
Style::default().fg(Color::Yellow),
));
path_parts.push(Span::styled("[root]", Style::default().fg(Color::Yellow)));
} else {
// Reverse: root first, self last
for (i, entry) in coords.iter().rev().enumerate() {
if i > 0 {
path_parts
.push(Span::styled(" > ", Style::default().fg(Color::DarkGray)));
path_parts.push(Span::styled(" > ", Style::default().fg(Color::DarkGray)));
}
let hex = entry.as_str().unwrap_or("-");
let color = if i == 0 {
@@ -87,8 +83,10 @@ fn draw_position(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
} else {
Color::White
};
path_parts
.push(Span::styled(helpers::truncate_hex(hex, 8), Style::default().fg(color)));
path_parts.push(Span::styled(
helpers::truncate_hex(hex, 8),
Style::default().fg(color),
));
}
path_parts.push(Span::styled(" > ", Style::default().fg(Color::DarkGray)));
path_parts.push(Span::styled(
@@ -121,24 +119,60 @@ fn draw_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
helpers::section_header("Inbound"),
helpers::kv_line("Received", &helpers::nested_u64(data, "stats", "received")),
helpers::kv_line("Accepted", &helpers::nested_u64(data, "stats", "accepted")),
helpers::kv_line("Decode Error", &helpers::nested_u64(data, "stats", "decode_error")),
helpers::kv_line("Unknown Peer", &helpers::nested_u64(data, "stats", "unknown_peer")),
helpers::kv_line("Addr Mismatch", &helpers::nested_u64(data, "stats", "addr_mismatch")),
helpers::kv_line("Sig Failed", &helpers::nested_u64(data, "stats", "sig_failed")),
helpers::kv_line(
"Decode Error",
&helpers::nested_u64(data, "stats", "decode_error"),
),
helpers::kv_line(
"Unknown Peer",
&helpers::nested_u64(data, "stats", "unknown_peer"),
),
helpers::kv_line(
"Addr Mismatch",
&helpers::nested_u64(data, "stats", "addr_mismatch"),
),
helpers::kv_line(
"Sig Failed",
&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")),
helpers::kv_line("Ancestry Changed", &helpers::nested_u64(data, "stats", "ancestry_changed")),
helpers::kv_line(
"Parent Switched",
&helpers::nested_u64(data, "stats", "parent_switched"),
),
helpers::kv_line(
"Loop Detected",
&helpers::nested_u64(data, "stats", "loop_detected"),
),
helpers::kv_line(
"Ancestry Changed",
&helpers::nested_u64(data, "stats", "ancestry_changed"),
),
Line::from(""),
helpers::section_header("Outbound"),
helpers::kv_line("Sent", &helpers::nested_u64(data, "stats", "sent")),
helpers::kv_line("Rate Limited", &helpers::nested_u64(data, "stats", "rate_limited")),
helpers::kv_line("Send Failed", &helpers::nested_u64(data, "stats", "send_failed")),
helpers::kv_line(
"Rate Limited",
&helpers::nested_u64(data, "stats", "rate_limited"),
),
helpers::kv_line(
"Send Failed",
&helpers::nested_u64(data, "stats", "send_failed"),
),
Line::from(""),
helpers::section_header("Cumulative"),
helpers::kv_line("Parent Switches", &helpers::nested_u64(data, "stats", "parent_switches")),
helpers::kv_line("Parent Losses", &helpers::nested_u64(data, "stats", "parent_losses")),
helpers::kv_line("Flap Dampened", &helpers::nested_u64(data, "stats", "flap_dampened")),
helpers::kv_line(
"Parent Switches",
&helpers::nested_u64(data, "stats", "parent_switches"),
),
helpers::kv_line(
"Parent Losses",
&helpers::nested_u64(data, "stats", "parent_losses"),
),
helpers::kv_line(
"Flap Dampened",
&helpers::nested_u64(data, "stats", "flap_dampened"),
),
];
// Trim to fit available height
@@ -164,8 +198,7 @@ fn draw_peers(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
frame.render_widget(block, area);
if peers.is_empty() {
let msg =
Paragraph::new(" No peers").style(Style::default().fg(Color::DarkGray));
let msg = Paragraph::new(" No peers").style(Style::default().fg(Color::DarkGray));
frame.render_widget(msg, inner);
return;
}
+26 -4
View File
@@ -2,6 +2,8 @@
use std::fmt;
use tracing::trace;
use super::{BloomError, DEFAULT_FILTER_SIZE_BITS, DEFAULT_HASH_COUNT};
use crate::NodeAddr;
@@ -143,16 +145,30 @@ impl BloomFilter {
///
/// Uses the formula: n = -(m/k) * ln(1 - X/m)
/// where m = num_bits, k = hash_count, X = count_ones
pub fn estimated_count(&self) -> f64 {
///
/// Returns `None` when the filter's FPR exceeds `max_fpr` (antipoison
/// cap) or the filter is saturated (`count_ones() >= num_bits`). Pass
/// `f64::INFINITY` for `max_fpr` to disable the cap — useful in
/// Debug/log contexts where no policy is in scope. The saturated
/// branch is always honored regardless of `max_fpr`, preventing the
/// `f64::INFINITY` return that the previous signature produced.
pub fn estimated_count(&self, max_fpr: f64) -> Option<f64> {
let m = self.num_bits as f64;
let k = self.hash_count as f64;
let x = self.count_ones() as f64;
if x >= m {
return f64::INFINITY;
return None;
}
-(m / k) * (1.0 - x / m).ln()
let fill = x / m;
let fpr = fill.powi(self.hash_count as i32);
if fpr > max_fpr {
trace!(fill, fpr, max_fpr, "estimated_count: filter above cap");
return None;
}
Some(-(m / k) * (1.0 - fill).ln())
}
/// Check if the filter is empty.
@@ -234,7 +250,13 @@ impl fmt::Debug for BloomFilter {
.field("bits", &self.num_bits)
.field("hash_count", &self.hash_count)
.field("fill_ratio", &format!("{:.2}%", self.fill_ratio() * 100.0))
.field("est_count", &format!("{:.0}", self.estimated_count()))
.field(
"est_count",
&match self.estimated_count(f64::INFINITY) {
Some(n) => format!("{:.0}", n),
None => "saturated".to_string(),
},
)
.finish()
}
}
+39 -5
View File
@@ -149,8 +149,7 @@ fn test_bloom_filter_from_bytes() {
let original = BloomFilter::new();
let bytes = original.as_bytes().to_vec();
let restored =
BloomFilter::from_bytes(bytes, original.hash_count()).unwrap();
let restored = BloomFilter::from_bytes(bytes, original.hash_count()).unwrap();
assert_eq!(original, restored);
}
@@ -160,7 +159,7 @@ fn test_bloom_filter_estimated_count() {
let mut filter = BloomFilter::new();
// Empty filter
assert_eq!(filter.estimated_count(), 0.0);
assert_eq!(filter.estimated_count(f64::INFINITY), Some(0.0));
// Insert some items
for i in 0..50 {
@@ -168,7 +167,7 @@ fn test_bloom_filter_estimated_count() {
}
// Estimate should be reasonably close to 50
let estimate = filter.estimated_count();
let estimate = filter.estimated_count(f64::INFINITY).unwrap();
assert!(
estimate > 30.0 && estimate < 100.0,
"Unexpected estimate: {}",
@@ -235,7 +234,42 @@ fn test_bloom_filter_estimated_count_saturated() {
let bytes = vec![0xFF; 8]; // all bits set
let filter = BloomFilter::from_bytes(bytes, 3).unwrap();
assert!(filter.estimated_count().is_infinite());
// Saturated filter returns None regardless of cap (defense in depth).
// Previously returned f64::INFINITY.
assert_eq!(filter.estimated_count(f64::INFINITY), None);
assert_eq!(filter.estimated_count(0.05), None);
}
#[test]
fn test_bloom_filter_estimated_count_fpr_cap_boundary() {
// Cap boundary: FPR = fill^k = 0.05 at k=5 ⇒ fill ≈ 0.5493
// 1KB filter (8192 bits). 560 bytes of 0xFF = 4480 bits set =
// fill 0.5469, FPR ≈ 0.04877 — just below cap.
// 564 bytes of 0xFF = 4512 bits set = fill 0.5508, FPR ≈ 0.05060 —
// just above cap.
let mut below = vec![0x00u8; 1024];
below[..560].fill(0xFF);
let below_filter = BloomFilter::from_bytes(below, DEFAULT_HASH_COUNT).unwrap();
assert!(
below_filter.estimated_count(0.05).is_some(),
"fill 0.5469 (FPR ≈ 0.049) must be accepted by cap 0.05"
);
let mut above = vec![0x00u8; 1024];
above[..564].fill(0xFF);
let above_filter = BloomFilter::from_bytes(above, DEFAULT_HASH_COUNT).unwrap();
assert_eq!(
above_filter.estimated_count(0.05),
None,
"fill 0.5508 (FPR ≈ 0.051) must be rejected by cap 0.05"
);
// Same above-cap filter with a looser cap is accepted.
assert!(
above_filter.estimated_count(0.10).is_some(),
"fill 0.5508 (FPR ≈ 0.051) must be accepted by cap 0.10"
);
}
#[test]
+2 -2
View File
@@ -6,10 +6,10 @@
use std::collections::HashMap;
use super::entry::CacheEntry;
use super::CacheStats;
use crate::tree::TreeCoordinate;
use super::entry::CacheEntry;
use crate::NodeAddr;
use crate::tree::TreeCoordinate;
/// Default maximum entries in coordinate cache.
pub const DEFAULT_COORD_CACHE_SIZE: usize = 50_000;
+25 -28
View File
@@ -34,7 +34,10 @@ pub use node::{
TreeConfig,
};
pub use peer::{ConnectPolicy, PeerAddress, PeerConfig};
pub use transport::{DirectoryServiceConfig, EthernetConfig, TcpConfig, TorConfig, TransportInstances, TransportsConfig, UdpConfig};
pub use transport::{
DirectoryServiceConfig, EthernetConfig, TcpConfig, TorConfig, TransportInstances,
TransportsConfig, UdpConfig,
};
/// Default config filename.
const CONFIG_FILENAME: &str = "fips.yaml";
@@ -539,10 +542,7 @@ node:
override_config.node.identity.nsec = Some("override_nsec".to_string());
base.merge(override_config);
assert_eq!(
base.node.identity.nsec,
Some("override_nsec".to_string())
);
assert_eq!(base.node.identity.nsec, Some("override_nsec".to_string()));
}
#[test]
@@ -559,9 +559,8 @@ node:
#[test]
fn test_create_identity_from_nsec() {
let mut config = Config::new();
config.node.identity.nsec = Some(
"0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20".to_string(),
);
config.node.identity.nsec =
Some("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20".to_string());
let identity = config.create_identity().unwrap();
assert!(!identity.npub().is_empty());
@@ -660,9 +659,11 @@ node:
assert!(paths.iter().any(|p| p.ends_with("fips.yaml")));
// Should include /etc/fips
assert!(paths
.iter()
.any(|p| p.starts_with("/etc/fips") && p.ends_with("fips.yaml")));
assert!(
paths
.iter()
.any(|p| p.starts_with("/etc/fips") && p.ends_with("fips.yaml"))
);
}
#[test]
@@ -747,16 +748,21 @@ node:
#[test]
fn test_key_file_path_derivation() {
let config_path = PathBuf::from("/etc/fips/fips.yaml");
assert_eq!(key_file_path(&config_path), PathBuf::from("/etc/fips/fips.key"));
assert_eq!(pub_file_path(&config_path), PathBuf::from("/etc/fips/fips.pub"));
assert_eq!(
key_file_path(&config_path),
PathBuf::from("/etc/fips/fips.key")
);
assert_eq!(
pub_file_path(&config_path),
PathBuf::from("/etc/fips/fips.pub")
);
}
#[test]
fn test_resolve_identity_from_config() {
let mut config = Config::new();
config.node.identity.nsec = Some(
"0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20".to_string(),
);
config.node.identity.nsec =
Some("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20".to_string());
let resolved = resolve_identity(&config, &[]).unwrap();
assert!(matches!(resolved.source, IdentitySource::Config));
@@ -803,11 +809,7 @@ node:
let config_path = temp_dir.path().join("fips.yaml");
let key_path = temp_dir.path().join("fips.key");
fs::write(
&config_path,
"node:\n identity:\n persistent: true\n",
)
.unwrap();
fs::write(&config_path, "node:\n identity:\n persistent: true\n").unwrap();
// Write a key file
let identity = crate::Identity::generate();
@@ -827,11 +829,7 @@ node:
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("fips.yaml");
fs::write(
&config_path,
"node:\n identity:\n persistent: true\n",
)
.unwrap();
fs::write(&config_path, "node:\n identity:\n persistent: true\n").unwrap();
let config = Config::load_file(&config_path).unwrap();
let resolved = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
@@ -892,8 +890,7 @@ transports:
assert_eq!(config.transports.udp.len(), 2);
let instances: std::collections::HashMap<_, _> =
config.transports.udp.iter().collect();
let instances: std::collections::HashMap<_, _> = config.transports.udp.iter().collect();
// Named instances have Some(name)
assert!(instances.contains_key(&Some("main")));
+177 -56
View File
@@ -7,7 +7,7 @@
use serde::{Deserialize, Serialize};
use super::IdentityConfig;
use crate::mmp::{MmpConfig, MmpMode, DEFAULT_LOG_INTERVAL_SECS, DEFAULT_OWD_WINDOW_SIZE};
use crate::mmp::{DEFAULT_LOG_INTERVAL_SECS, DEFAULT_OWD_WINDOW_SIZE, MmpConfig, MmpMode};
// ============================================================================
// Node Configuration Subsections
@@ -42,10 +42,18 @@ impl Default for LimitsConfig {
}
impl LimitsConfig {
fn default_max_connections() -> usize { 256 }
fn default_max_peers() -> usize { 128 }
fn default_max_links() -> usize { 256 }
fn default_max_pending_inbound() -> usize { 1000 }
fn default_max_connections() -> usize {
256
}
fn default_max_peers() -> usize {
128
}
fn default_max_links() -> usize {
256
}
fn default_max_pending_inbound() -> usize {
1000
}
}
/// Rate limiting (`node.rate_limit.*`).
@@ -86,12 +94,24 @@ impl Default for RateLimitConfig {
}
impl RateLimitConfig {
fn default_handshake_burst() -> u32 { 100 }
fn default_handshake_rate() -> f64 { 10.0 }
fn default_handshake_timeout_secs() -> u64 { 30 }
fn default_handshake_resend_interval_ms() -> u64 { 1000 }
fn default_handshake_resend_backoff() -> f64 { 2.0 }
fn default_handshake_max_resends() -> u32 { 5 }
fn default_handshake_burst() -> u32 {
100
}
fn default_handshake_rate() -> f64 {
10.0
}
fn default_handshake_timeout_secs() -> u64 {
30
}
fn default_handshake_resend_interval_ms() -> u64 {
1000
}
fn default_handshake_resend_backoff() -> f64 {
2.0
}
fn default_handshake_max_resends() -> u32 {
5
}
}
/// Retry/backoff configuration (`node.retry.*`).
@@ -119,9 +139,15 @@ impl Default for RetryConfig {
}
impl RetryConfig {
fn default_max_retries() -> u32 { 5 }
fn default_base_interval_secs() -> u64 { 5 }
fn default_max_backoff_secs() -> u64 { 300 }
fn default_max_retries() -> u32 {
5
}
fn default_base_interval_secs() -> u64 {
5
}
fn default_max_backoff_secs() -> u64 {
300
}
}
/// Cache parameters (`node.cache.*`).
@@ -149,9 +175,15 @@ impl Default for CacheConfig {
}
impl CacheConfig {
fn default_coord_size() -> usize { 50_000 }
fn default_coord_ttl_secs() -> u64 { 300 }
fn default_identity_size() -> usize { 10_000 }
fn default_coord_size() -> usize {
50_000
}
fn default_coord_ttl_secs() -> u64 {
300
}
fn default_identity_size() -> usize {
10_000
}
}
/// Discovery protocol (`node.discovery.*`).
@@ -205,14 +237,30 @@ impl Default for DiscoveryConfig {
}
impl DiscoveryConfig {
fn default_ttl() -> u8 { 64 }
fn default_timeout_secs() -> u64 { 10 }
fn default_recent_expiry_secs() -> u64 { 10 }
fn default_backoff_base_secs() -> u64 { 30 }
fn default_backoff_max_secs() -> u64 { 300 }
fn default_forward_min_interval_secs() -> u64 { 2 }
fn default_retry_interval_secs() -> u64 { 5 }
fn default_max_attempts() -> u8 { 2 }
fn default_ttl() -> u8 {
64
}
fn default_timeout_secs() -> u64 {
10
}
fn default_recent_expiry_secs() -> u64 {
10
}
fn default_backoff_base_secs() -> u64 {
30
}
fn default_backoff_max_secs() -> u64 {
300
}
fn default_forward_min_interval_secs() -> u64 {
2
}
fn default_retry_interval_secs() -> u64 {
5
}
fn default_max_attempts() -> u8 {
2
}
}
/// Spanning tree (`node.tree.*`).
@@ -267,13 +315,27 @@ impl Default for TreeConfig {
}
impl TreeConfig {
fn default_announce_min_interval_ms() -> u64 { 500 }
fn default_parent_hysteresis() -> f64 { 0.2 }
fn default_hold_down_secs() -> u64 { 30 }
fn default_reeval_interval_secs() -> u64 { 60 }
fn default_flap_threshold() -> u32 { 4 }
fn default_flap_window_secs() -> u64 { 60 }
fn default_flap_dampening_secs() -> u64 { 120 }
fn default_announce_min_interval_ms() -> u64 {
500
}
fn default_parent_hysteresis() -> f64 {
0.2
}
fn default_hold_down_secs() -> u64 {
30
}
fn default_reeval_interval_secs() -> u64 {
60
}
fn default_flap_threshold() -> u32 {
4
}
fn default_flap_window_secs() -> u64 {
60
}
fn default_flap_dampening_secs() -> u64 {
120
}
}
/// Bloom filter (`node.bloom.*`).
@@ -282,16 +344,31 @@ pub struct BloomConfig {
/// Debounce interval for filter updates in ms (`node.bloom.update_debounce_ms`).
#[serde(default = "BloomConfig::default_update_debounce_ms")]
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.05` ≈ fill 0.549 at k=5 ≈ ~3,200 entries on the 1KB
/// filter. Conceptually distinct from future autoscaling hysteresis
/// setpoints — same unit, different knobs.
#[serde(default = "BloomConfig::default_max_inbound_fpr")]
pub max_inbound_fpr: f64,
}
impl Default for BloomConfig {
fn default() -> Self {
Self { update_debounce_ms: 500 }
Self {
update_debounce_ms: 500,
max_inbound_fpr: 0.05,
}
}
}
impl BloomConfig {
fn default_update_debounce_ms() -> u64 { 500 }
fn default_update_debounce_ms() -> u64 {
500
}
fn default_max_inbound_fpr() -> f64 {
0.05
}
}
/// Session/data plane (`node.session.*`).
@@ -337,12 +414,24 @@ impl Default for SessionConfig {
}
impl SessionConfig {
fn default_ttl() -> u8 { 64 }
fn default_pending_packets_per_dest() -> usize { 16 }
fn default_pending_max_destinations() -> usize { 256 }
fn default_idle_timeout_secs() -> u64 { 90 }
fn default_coords_warmup_packets() -> u8 { 5 }
fn default_coords_response_interval_ms() -> u64 { 2000 }
fn default_ttl() -> u8 {
64
}
fn default_pending_packets_per_dest() -> usize {
16
}
fn default_pending_max_destinations() -> usize {
256
}
fn default_idle_timeout_secs() -> u64 {
90
}
fn default_coords_warmup_packets() -> u8 {
5
}
fn default_coords_response_interval_ms() -> u64 {
2000
}
}
/// Session-layer Metrics Measurement Protocol (`node.session_mmp.*`).
@@ -377,8 +466,12 @@ impl Default for SessionMmpConfig {
}
impl SessionMmpConfig {
fn default_log_interval_secs() -> u64 { DEFAULT_LOG_INTERVAL_SECS }
fn default_owd_window_size() -> usize { DEFAULT_OWD_WINDOW_SIZE }
fn default_log_interval_secs() -> u64 {
DEFAULT_LOG_INTERVAL_SECS
}
fn default_owd_window_size() -> usize {
DEFAULT_OWD_WINDOW_SIZE
}
}
/// Control socket configuration (`node.control.*`).
@@ -402,7 +495,9 @@ impl Default for ControlConfig {
}
impl ControlConfig {
fn default_enabled() -> bool { true }
fn default_enabled() -> bool {
true
}
fn default_socket_path() -> String {
if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
@@ -440,9 +535,15 @@ impl Default for BuffersConfig {
}
impl BuffersConfig {
fn default_packet_channel() -> usize { 1024 }
fn default_tun_channel() -> usize { 1024 }
fn default_dns_channel() -> usize { 64 }
fn default_packet_channel() -> usize {
1024
}
fn default_tun_channel() -> usize {
1024
}
fn default_dns_channel() -> usize {
64
}
}
// ============================================================================
@@ -480,9 +581,15 @@ impl Default for RekeyConfig {
}
impl RekeyConfig {
fn default_enabled() -> bool { true }
fn default_after_secs() -> u64 { 120 }
fn default_after_messages() -> u64 { 1 << 16 }
fn default_enabled() -> bool {
true
}
fn default_after_secs() -> u64 {
120
}
fn default_after_messages() -> u64 {
1 << 16
}
}
/// ECN congestion signaling configuration (`node.ecn.*`).
@@ -520,9 +627,15 @@ impl Default for EcnConfig {
}
impl EcnConfig {
fn default_enabled() -> bool { true }
fn default_loss_threshold() -> f64 { 0.05 }
fn default_etx_threshold() -> f64 { 3.0 }
fn default_enabled() -> bool {
true
}
fn default_loss_threshold() -> f64 {
0.05
}
fn default_etx_threshold() -> f64 {
3.0
}
}
// ============================================================================
@@ -642,10 +755,18 @@ impl Default for NodeConfig {
}
impl NodeConfig {
fn default_tick_interval_secs() -> u64 { 1 }
fn default_base_rtt_ms() -> u64 { 100 }
fn default_heartbeat_interval_secs() -> u64 { 10 }
fn default_link_dead_timeout_secs() -> u64 { 30 }
fn default_tick_interval_secs() -> u64 {
1
}
fn default_base_rtt_ms() -> u64 {
100
}
fn default_heartbeat_interval_secs() -> u64 {
10
}
fn default_link_dead_timeout_secs() -> u64 {
30
}
}
#[cfg(test)]
+10 -2
View File
@@ -66,7 +66,11 @@ impl PeerAddress {
}
/// Create a new peer address with priority.
pub fn with_priority(transport: impl Into<String>, addr: impl Into<String>, priority: u8) -> Self {
pub fn with_priority(
transport: impl Into<String>,
addr: impl Into<String>,
priority: u8,
) -> Self {
Self {
transport: transport.into(),
addr: addr.into(),
@@ -118,7 +122,11 @@ impl Default for PeerConfig {
impl PeerConfig {
/// Create a new peer config with a single address.
pub fn new(npub: impl Into<String>, transport: impl Into<String>, addr: impl Into<String>) -> Self {
pub fn new(
npub: impl Into<String>,
transport: impl Into<String>,
addr: impl Into<String>,
) -> Self {
Self {
npub: npub.into(),
alias: None,
+33 -19
View File
@@ -112,15 +112,12 @@ impl<T> TransportInstances<T> {
/// Named instances have `Some(name)`.
pub fn iter(&self) -> impl Iterator<Item = (Option<&str>, &T)> {
match self {
TransportInstances::Single(config) => {
vec![(None, config)].into_iter()
}
TransportInstances::Named(map) => {
map.iter()
.map(|(k, v)| (Some(k.as_str()), v))
.collect::<Vec<_>>()
.into_iter()
}
TransportInstances::Single(config) => vec![(None, config)].into_iter(),
TransportInstances::Named(map) => map
.iter()
.map(|(k, v)| (Some(k.as_str()), v))
.collect::<Vec<_>>()
.into_iter(),
}
}
}
@@ -306,7 +303,8 @@ impl TcpConfig {
/// Get the connect timeout in milliseconds.
pub fn connect_timeout_ms(&self) -> u64 {
self.connect_timeout_ms.unwrap_or(DEFAULT_TCP_CONNECT_TIMEOUT_MS)
self.connect_timeout_ms
.unwrap_or(DEFAULT_TCP_CONNECT_TIMEOUT_MS)
}
/// Whether TCP_NODELAY is enabled. Default: true.
@@ -331,7 +329,8 @@ impl TcpConfig {
/// Get the maximum number of inbound connections. Default: 256.
pub fn max_inbound_connections(&self) -> usize {
self.max_inbound_connections.unwrap_or(DEFAULT_TCP_MAX_INBOUND)
self.max_inbound_connections
.unwrap_or(DEFAULT_TCP_MAX_INBOUND)
}
}
@@ -447,12 +446,16 @@ pub struct DirectoryServiceConfig {
impl DirectoryServiceConfig {
/// Path to the hostname file. Default: "/var/lib/tor/fips_onion_service/hostname".
pub fn hostname_file(&self) -> &str {
self.hostname_file.as_deref().unwrap_or(DEFAULT_HOSTNAME_FILE)
self.hostname_file
.as_deref()
.unwrap_or(DEFAULT_HOSTNAME_FILE)
}
/// Local bind address for the listener. Default: "127.0.0.1:8443".
pub fn bind_addr(&self) -> &str {
self.bind_addr.as_deref().unwrap_or(DEFAULT_DIRECTORY_BIND_ADDR)
self.bind_addr
.as_deref()
.unwrap_or(DEFAULT_DIRECTORY_BIND_ADDR)
}
}
@@ -464,12 +467,16 @@ impl TorConfig {
/// Get the SOCKS5 proxy address. Default: "127.0.0.1:9050".
pub fn socks5_addr(&self) -> &str {
self.socks5_addr.as_deref().unwrap_or(DEFAULT_TOR_SOCKS5_ADDR)
self.socks5_addr
.as_deref()
.unwrap_or(DEFAULT_TOR_SOCKS5_ADDR)
}
/// Get the control port address. Default: "/run/tor/control".
pub fn control_addr(&self) -> &str {
self.control_addr.as_deref().unwrap_or(DEFAULT_TOR_CONTROL_ADDR)
self.control_addr
.as_deref()
.unwrap_or(DEFAULT_TOR_CONTROL_ADDR)
}
/// Get the control auth string. Default: "cookie".
@@ -479,12 +486,15 @@ impl TorConfig {
/// Get the cookie file path. Default: "/var/run/tor/control.authcookie".
pub fn cookie_path(&self) -> &str {
self.cookie_path.as_deref().unwrap_or(DEFAULT_TOR_COOKIE_PATH)
self.cookie_path
.as_deref()
.unwrap_or(DEFAULT_TOR_COOKIE_PATH)
}
/// Get the connect timeout in milliseconds. Default: 120000.
pub fn connect_timeout_ms(&self) -> u64 {
self.connect_timeout_ms.unwrap_or(DEFAULT_TOR_CONNECT_TIMEOUT_MS)
self.connect_timeout_ms
.unwrap_or(DEFAULT_TOR_CONNECT_TIMEOUT_MS)
}
/// Get the default MTU. Default: 1400.
@@ -494,7 +504,8 @@ impl TorConfig {
/// Get the max inbound connections. Default: 64.
pub fn max_inbound_connections(&self) -> usize {
self.max_inbound_connections.unwrap_or(DEFAULT_TOR_MAX_INBOUND)
self.max_inbound_connections
.unwrap_or(DEFAULT_TOR_MAX_INBOUND)
}
}
@@ -533,7 +544,10 @@ fn is_transport_empty<T>(instances: &TransportInstances<T>) -> bool {
impl TransportsConfig {
/// Check if any transports are configured.
pub fn is_empty(&self) -> bool {
self.udp.is_empty() && self.ethernet.is_empty() && self.tcp.is_empty() && self.tor.is_empty()
self.udp.is_empty()
&& self.ethernet.is_empty()
&& self.tcp.is_empty()
&& self.tor.is_empty()
}
/// Merge another TransportsConfig into this one.
+5 -4
View File
@@ -105,7 +105,10 @@ impl ControlSocket {
let group_name = CString::new("fips").unwrap();
let grp = unsafe { libc::getgrnam(group_name.as_ptr()) };
if grp.is_null() {
debug!("'fips' group not found, skipping chown for {}", path.display());
debug!(
"'fips' group not found, skipping chown for {}",
path.display()
);
return;
}
let gid = unsafe { (*grp).gr_gid };
@@ -184,9 +187,7 @@ impl ControlSocket {
.await;
let response = match read_result {
Ok(Ok(())) if line.is_empty() => {
Response::error("empty request")
}
Ok(Ok(())) if line.is_empty() => Response::error("empty request"),
Ok(Ok(())) => {
// Parse the request
match serde_json::from_str::<Request>(line.trim()) {
+286 -258
View File
@@ -5,7 +5,7 @@
use crate::identity::encode_npub;
use crate::node::Node;
use serde_json::{json, Value};
use serde_json::{Value, json};
/// Helper: get current Unix time in milliseconds.
fn now_ms() -> u64 {
@@ -70,113 +70,120 @@ pub fn show_peers(node: &Node) -> Value {
let parent_id = *tree.my_declaration().parent_id();
let is_root = tree.is_root();
let peers: Vec<Value> = node.peers().map(|peer| {
let node_addr = *peer.node_addr();
let addr_hex = hex::encode(node_addr.as_bytes());
let peers: Vec<Value> = node
.peers()
.map(|peer| {
let node_addr = *peer.node_addr();
let addr_hex = hex::encode(node_addr.as_bytes());
// Determine tree relationship
let is_parent = !is_root && node_addr == parent_id;
let is_child = tree.peer_declaration(&node_addr)
.is_some_and(|decl| *decl.parent_id() == my_addr);
// Determine tree relationship
let is_parent = !is_root && node_addr == parent_id;
let is_child = tree
.peer_declaration(&node_addr)
.is_some_and(|decl| *decl.parent_id() == my_addr);
let mut peer_json = json!({
"node_addr": addr_hex,
"npub": peer.npub(),
"display_name": node.peer_display_name(&node_addr),
"ipv6_addr": format!("{}", peer.address()),
"connectivity": format!("{}", peer.connectivity()),
"link_id": peer.link_id().as_u64(),
"authenticated_at_ms": peer.authenticated_at(),
"last_seen_ms": peer.last_seen(),
"has_tree_position": peer.has_tree_position(),
"has_bloom_filter": peer.filter_sequence() > 0,
"filter_sequence": peer.filter_sequence(),
"is_parent": is_parent,
"is_child": is_child,
});
// Add transport address if available
if let Some(addr) = peer.current_addr() {
peer_json["transport_addr"] = json!(format!("{}", addr));
}
// Add link info (direction, transport type)
let link_id = peer.link_id();
if let Some(link) = node.get_link(&link_id) {
peer_json["direction"] = json!(format!("{}", link.direction()));
let transport_id = link.transport_id();
if let Some(handle) = node.get_transport(&transport_id) {
peer_json["transport_type"] = json!(handle.transport_type().name);
}
}
// Add tree depth if available
if let Some(coords) = peer.coords() {
peer_json["tree_depth"] = json!(coords.depth());
}
// Add link stats
let stats = peer.link_stats();
peer_json["stats"] = json!({
"packets_sent": stats.packets_sent,
"packets_recv": stats.packets_recv,
"bytes_sent": stats.bytes_sent,
"bytes_recv": stats.bytes_recv,
});
// Add MMP metrics if available
if let Some(mmp) = peer.mmp() {
let mut mmp_json = json!({
"mode": format!("{}", mmp.mode()),
let mut peer_json = json!({
"node_addr": addr_hex,
"npub": peer.npub(),
"display_name": node.peer_display_name(&node_addr),
"ipv6_addr": format!("{}", peer.address()),
"connectivity": format!("{}", peer.connectivity()),
"link_id": peer.link_id().as_u64(),
"authenticated_at_ms": peer.authenticated_at(),
"last_seen_ms": peer.last_seen(),
"has_tree_position": peer.has_tree_position(),
"has_bloom_filter": peer.filter_sequence() > 0,
"filter_sequence": peer.filter_sequence(),
"is_parent": is_parent,
"is_child": is_child,
});
if let Some(srtt) = mmp.metrics.srtt_ms() {
mmp_json["srtt_ms"] = json!(srtt);
}
mmp_json["loss_rate"] = json!(mmp.metrics.loss_rate());
mmp_json["etx"] = json!(mmp.metrics.etx);
mmp_json["goodput_bps"] = json!(mmp.metrics.goodput_bps);
mmp_json["delivery_ratio_forward"] = json!(mmp.metrics.delivery_ratio_forward);
mmp_json["delivery_ratio_reverse"] = json!(mmp.metrics.delivery_ratio_reverse);
if let Some(smoothed_loss) = mmp.metrics.smoothed_loss() {
mmp_json["smoothed_loss"] = json!(smoothed_loss);
}
if let Some(smoothed_etx) = mmp.metrics.smoothed_etx() {
mmp_json["smoothed_etx"] = json!(smoothed_etx);
}
if let Some(srtt) = mmp.metrics.srtt_ms()
&& let Some(setx) = mmp.metrics.smoothed_etx()
{
mmp_json["lqi"] = json!(setx * (1.0 + srtt / 100.0));
}
peer_json["mmp"] = mmp_json;
}
peer_json
}).collect();
// Add transport address if available
if let Some(addr) = peer.current_addr() {
peer_json["transport_addr"] = json!(format!("{}", addr));
}
// Add link info (direction, transport type)
let link_id = peer.link_id();
if let Some(link) = node.get_link(&link_id) {
peer_json["direction"] = json!(format!("{}", link.direction()));
let transport_id = link.transport_id();
if let Some(handle) = node.get_transport(&transport_id) {
peer_json["transport_type"] = json!(handle.transport_type().name);
}
}
// Add tree depth if available
if let Some(coords) = peer.coords() {
peer_json["tree_depth"] = json!(coords.depth());
}
// Add link stats
let stats = peer.link_stats();
peer_json["stats"] = json!({
"packets_sent": stats.packets_sent,
"packets_recv": stats.packets_recv,
"bytes_sent": stats.bytes_sent,
"bytes_recv": stats.bytes_recv,
});
// Add MMP metrics if available
if let Some(mmp) = peer.mmp() {
let mut mmp_json = json!({
"mode": format!("{}", mmp.mode()),
});
if let Some(srtt) = mmp.metrics.srtt_ms() {
mmp_json["srtt_ms"] = json!(srtt);
}
mmp_json["loss_rate"] = json!(mmp.metrics.loss_rate());
mmp_json["etx"] = json!(mmp.metrics.etx);
mmp_json["goodput_bps"] = json!(mmp.metrics.goodput_bps);
mmp_json["delivery_ratio_forward"] = json!(mmp.metrics.delivery_ratio_forward);
mmp_json["delivery_ratio_reverse"] = json!(mmp.metrics.delivery_ratio_reverse);
if let Some(smoothed_loss) = mmp.metrics.smoothed_loss() {
mmp_json["smoothed_loss"] = json!(smoothed_loss);
}
if let Some(smoothed_etx) = mmp.metrics.smoothed_etx() {
mmp_json["smoothed_etx"] = json!(smoothed_etx);
}
if let Some(srtt) = mmp.metrics.srtt_ms()
&& let Some(setx) = mmp.metrics.smoothed_etx()
{
mmp_json["lqi"] = json!(setx * (1.0 + srtt / 100.0));
}
peer_json["mmp"] = mmp_json;
}
peer_json
})
.collect();
json!({ "peers": peers })
}
/// `show_links` — Active links.
pub fn show_links(node: &Node) -> Value {
let links: Vec<Value> = node.links().map(|link| {
let stats = link.stats();
json!({
"link_id": link.link_id().as_u64(),
"transport_id": link.transport_id().as_u32(),
"remote_addr": format!("{}", link.remote_addr()),
"direction": format!("{}", link.direction()),
"state": format!("{}", link.state()),
"created_at_ms": link.created_at(),
"stats": {
"packets_sent": stats.packets_sent,
"packets_recv": stats.packets_recv,
"bytes_sent": stats.bytes_sent,
"bytes_recv": stats.bytes_recv,
"last_recv_ms": stats.last_recv_ms,
},
let links: Vec<Value> = node
.links()
.map(|link| {
let stats = link.stats();
json!({
"link_id": link.link_id().as_u64(),
"transport_id": link.transport_id().as_u32(),
"remote_addr": format!("{}", link.remote_addr()),
"direction": format!("{}", link.direction()),
"state": format!("{}", link.state()),
"created_at_ms": link.created_at(),
"stats": {
"packets_sent": stats.packets_sent,
"packets_recv": stats.packets_recv,
"bytes_sent": stats.bytes_sent,
"bytes_recv": stats.bytes_recv,
"last_recv_ms": stats.last_recv_ms,
},
})
})
}).collect();
.collect();
json!({ "links": links })
}
@@ -188,29 +195,34 @@ pub fn show_tree(node: &Node) -> Value {
let decl = tree.my_declaration();
// Build coords array as hex strings
let coords: Vec<String> = my_coords.entries()
let coords: Vec<String> = my_coords
.entries()
.iter()
.map(|e| hex::encode(e.node_addr.as_bytes()))
.collect();
// Build peer tree data
let peers: Vec<Value> = tree.peer_ids().map(|peer_id| {
let mut peer_json = json!({
"node_addr": hex::encode(peer_id.as_bytes()),
"display_name": node.peer_display_name(peer_id),
});
if let Some(coords) = tree.peer_coords(peer_id) {
let coord_path: Vec<String> = coords.entries()
.iter()
.map(|e| hex::encode(e.node_addr.as_bytes()))
.collect();
peer_json["depth"] = json!(coords.depth());
peer_json["root"] = json!(hex::encode(coords.root_id().as_bytes()));
peer_json["coords"] = json!(coord_path);
peer_json["distance_to_us"] = json!(my_coords.distance_to(coords));
}
peer_json
}).collect();
let peers: Vec<Value> = tree
.peer_ids()
.map(|peer_id| {
let mut peer_json = json!({
"node_addr": hex::encode(peer_id.as_bytes()),
"display_name": node.peer_display_name(peer_id),
});
if let Some(coords) = tree.peer_coords(peer_id) {
let coord_path: Vec<String> = coords
.entries()
.iter()
.map(|e| hex::encode(e.node_addr.as_bytes()))
.collect();
peer_json["depth"] = json!(coords.depth());
peer_json["root"] = json!(hex::encode(coords.root_id().as_bytes()));
peer_json["coords"] = json!(coord_path);
peer_json["distance_to_us"] = json!(my_coords.distance_to(coords));
}
peer_json
})
.collect();
// Determine parent display name
let parent_addr = my_coords.parent_id();
@@ -237,68 +249,71 @@ pub fn show_tree(node: &Node) -> Value {
/// `show_sessions` — End-to-end sessions.
pub fn show_sessions(node: &Node) -> Value {
let sessions: Vec<Value> = node.session_entries().map(|(addr, entry)| {
let state_str = if entry.is_established() {
"established"
} else if entry.is_initiating() {
"initiating"
} else if entry.is_awaiting_msg3() {
"awaiting_msg3"
} else {
"unknown"
};
let sessions: Vec<Value> = node
.session_entries()
.map(|(addr, entry)| {
let state_str = if entry.is_established() {
"established"
} else if entry.is_initiating() {
"initiating"
} else if entry.is_awaiting_msg3() {
"awaiting_msg3"
} else {
"unknown"
};
let mut session_json = json!({
"remote_addr": hex::encode(addr.as_bytes()),
"display_name": node.peer_display_name(addr),
"state": state_str,
"is_initiator": entry.is_initiator(),
"last_activity_ms": entry.last_activity(),
});
// Derive npub from session's remote public key
let (xonly, _parity) = entry.remote_pubkey().x_only_public_key();
session_json["npub"] = json!(encode_npub(&xonly));
// Traffic counters
let (pkts_tx, pkts_rx, bytes_tx, bytes_rx) = entry.traffic_counters();
session_json["stats"] = json!({
"packets_sent": pkts_tx,
"packets_recv": pkts_rx,
"bytes_sent": bytes_tx,
"bytes_recv": bytes_rx,
});
// Add session MMP if available
if let Some(mmp) = entry.mmp() {
let mut mmp_json = json!({
"mode": format!("{}", mmp.mode()),
"loss_rate": mmp.metrics.loss_rate(),
"etx": mmp.metrics.etx,
"goodput_bps": mmp.metrics.goodput_bps,
"delivery_ratio_forward": mmp.metrics.delivery_ratio_forward,
"delivery_ratio_reverse": mmp.metrics.delivery_ratio_reverse,
"path_mtu": mmp.path_mtu.current_mtu(),
let mut session_json = json!({
"remote_addr": hex::encode(addr.as_bytes()),
"display_name": node.peer_display_name(addr),
"state": state_str,
"is_initiator": entry.is_initiator(),
"last_activity_ms": entry.last_activity(),
});
if let Some(srtt) = mmp.metrics.srtt_ms() {
mmp_json["srtt_ms"] = json!(srtt);
}
if let Some(smoothed_loss) = mmp.metrics.smoothed_loss() {
mmp_json["smoothed_loss"] = json!(smoothed_loss);
}
if let Some(smoothed_etx) = mmp.metrics.smoothed_etx() {
mmp_json["smoothed_etx"] = json!(smoothed_etx);
}
if let Some(srtt) = mmp.metrics.srtt_ms()
&& let Some(setx) = mmp.metrics.smoothed_etx()
{
mmp_json["sqi"] = json!(setx * (1.0 + srtt / 100.0));
}
session_json["mmp"] = mmp_json;
}
session_json
}).collect();
// Derive npub from session's remote public key
let (xonly, _parity) = entry.remote_pubkey().x_only_public_key();
session_json["npub"] = json!(encode_npub(&xonly));
// Traffic counters
let (pkts_tx, pkts_rx, bytes_tx, bytes_rx) = entry.traffic_counters();
session_json["stats"] = json!({
"packets_sent": pkts_tx,
"packets_recv": pkts_rx,
"bytes_sent": bytes_tx,
"bytes_recv": bytes_rx,
});
// Add session MMP if available
if let Some(mmp) = entry.mmp() {
let mut mmp_json = json!({
"mode": format!("{}", mmp.mode()),
"loss_rate": mmp.metrics.loss_rate(),
"etx": mmp.metrics.etx,
"goodput_bps": mmp.metrics.goodput_bps,
"delivery_ratio_forward": mmp.metrics.delivery_ratio_forward,
"delivery_ratio_reverse": mmp.metrics.delivery_ratio_reverse,
"path_mtu": mmp.path_mtu.current_mtu(),
});
if let Some(srtt) = mmp.metrics.srtt_ms() {
mmp_json["srtt_ms"] = json!(srtt);
}
if let Some(smoothed_loss) = mmp.metrics.smoothed_loss() {
mmp_json["smoothed_loss"] = json!(smoothed_loss);
}
if let Some(smoothed_etx) = mmp.metrics.smoothed_etx() {
mmp_json["smoothed_etx"] = json!(smoothed_etx);
}
if let Some(srtt) = mmp.metrics.srtt_ms()
&& let Some(setx) = mmp.metrics.smoothed_etx()
{
mmp_json["sqi"] = json!(setx * (1.0 + srtt / 100.0));
}
session_json["mmp"] = mmp_json;
}
session_json
})
.collect();
json!({ "sessions": sessions })
}
@@ -307,27 +322,32 @@ pub fn show_sessions(node: &Node) -> Value {
pub fn show_bloom(node: &Node) -> Value {
let bloom = node.bloom_state();
let leaf_deps: Vec<String> = bloom.leaf_dependents()
let leaf_deps: Vec<String> = bloom
.leaf_dependents()
.iter()
.map(|addr| hex::encode(addr.as_bytes()))
.collect();
// Build per-peer filter info
let peer_filters: Vec<Value> = node.peers().map(|peer| {
let addr = *peer.node_addr();
let mut pf = json!({
"peer": hex::encode(addr.as_bytes()),
"display_name": node.peer_display_name(&addr),
"has_filter": peer.filter_sequence() > 0,
"filter_sequence": peer.filter_sequence(),
});
if let Some(filter) = peer.inbound_filter() {
pf["estimated_count"] = json!(filter.estimated_count());
pf["set_bits"] = json!(filter.count_ones());
pf["fill_ratio"] = json!(filter.fill_ratio());
}
pf
}).collect();
let peer_filters: Vec<Value> = node
.peers()
.map(|peer| {
let addr = *peer.node_addr();
let mut pf = json!({
"peer": hex::encode(addr.as_bytes()),
"display_name": node.peer_display_name(&addr),
"has_filter": peer.filter_sequence() > 0,
"filter_sequence": peer.filter_sequence(),
});
if let Some(filter) = peer.inbound_filter() {
let max_fpr = node.config().node.bloom.max_inbound_fpr;
pf["estimated_count"] = json!(filter.estimated_count(max_fpr));
pf["set_bits"] = json!(filter.count_ones());
pf["fill_ratio"] = json!(filter.fill_ratio());
}
pf
})
.collect();
let bloom_stats = node.stats().snapshot().bloom;
@@ -397,36 +417,39 @@ pub fn show_mmp(node: &Node) -> Value {
}).collect();
// Session-layer MMP
let sessions: Vec<Value> = node.session_entries().filter_map(|(addr, entry)| {
let mmp = entry.mmp()?;
let metrics = &mmp.metrics;
let sessions: Vec<Value> = node
.session_entries()
.filter_map(|(addr, entry)| {
let mmp = entry.mmp()?;
let metrics = &mmp.metrics;
let mut session_layer = json!({
"loss_rate": metrics.loss_rate(),
"etx": metrics.etx,
"path_mtu": mmp.path_mtu.current_mtu(),
});
let mut session_layer = json!({
"loss_rate": metrics.loss_rate(),
"etx": metrics.etx,
"path_mtu": mmp.path_mtu.current_mtu(),
});
if let Some(smoothed_loss) = metrics.smoothed_loss() {
session_layer["smoothed_loss"] = json!(smoothed_loss);
}
if let Some(smoothed_etx) = metrics.smoothed_etx() {
session_layer["smoothed_etx"] = json!(smoothed_etx);
}
if let Some(srtt) = metrics.srtt_ms() {
session_layer["srtt_ms"] = json!(srtt);
if let Some(setx) = metrics.smoothed_etx() {
session_layer["sqi"] = json!(setx * (1.0 + srtt / 100.0));
if let Some(smoothed_loss) = metrics.smoothed_loss() {
session_layer["smoothed_loss"] = json!(smoothed_loss);
}
if let Some(smoothed_etx) = metrics.smoothed_etx() {
session_layer["smoothed_etx"] = json!(smoothed_etx);
}
if let Some(srtt) = metrics.srtt_ms() {
session_layer["srtt_ms"] = json!(srtt);
if let Some(setx) = metrics.smoothed_etx() {
session_layer["sqi"] = json!(setx * (1.0 + srtt / 100.0));
}
}
}
Some(json!({
"remote": hex::encode(addr.as_bytes()),
"display_name": node.peer_display_name(addr),
"mode": format!("{}", mmp.mode()),
"session_layer": session_layer,
}))
}).collect();
Some(json!({
"remote": hex::encode(addr.as_bytes()),
"display_name": node.peer_display_name(addr),
"mode": format!("{}", mmp.mode()),
"session_layer": session_layer,
}))
})
.collect();
json!({
"peers": peers,
@@ -452,60 +475,65 @@ pub fn show_cache(node: &Node) -> Value {
/// `show_connections` — Pending handshakes.
pub fn show_connections(node: &Node) -> Value {
let now = now_ms();
let connections: Vec<Value> = node.connections().map(|conn| {
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(),
});
let connections: Vec<Value> = node
.connections()
.map(|conn| {
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(),
});
if let Some(identity) = conn.expected_identity() {
conn_json["expected_peer"] = json!(identity.npub());
}
if let Some(identity) = conn.expected_identity() {
conn_json["expected_peer"] = json!(identity.npub());
}
conn_json
}).collect();
conn_json
})
.collect();
json!({ "connections": connections })
}
/// `show_transports` — Transport instances.
pub fn show_transports(node: &Node) -> Value {
let transports: Vec<Value> = node.transport_ids().map(|id| {
let handle = node.get_transport(id).unwrap();
let mut t_json = json!({
"transport_id": id.as_u32(),
"type": handle.transport_type().name,
"state": format!("{}", handle.state()),
"mtu": handle.mtu(),
});
let transports: Vec<Value> = node
.transport_ids()
.map(|id| {
let handle = node.get_transport(id).unwrap();
let mut t_json = json!({
"transport_id": id.as_u32(),
"type": handle.transport_type().name,
"state": format!("{}", handle.state()),
"mtu": handle.mtu(),
});
if let Some(name) = handle.name() {
t_json["name"] = json!(name);
}
if let Some(addr) = handle.local_addr() {
t_json["local_addr"] = json!(format!("{}", addr));
}
if let Some(name) = handle.name() {
t_json["name"] = json!(name);
}
if let Some(addr) = handle.local_addr() {
t_json["local_addr"] = json!(format!("{}", addr));
}
// Tor-specific fields
if let Some(mode) = handle.tor_mode() {
t_json["tor_mode"] = json!(mode);
}
if let Some(onion) = handle.onion_address() {
t_json["onion_address"] = json!(onion);
}
if let Some(monitoring) = handle.tor_monitoring() {
t_json["tor_monitoring"] =
serde_json::to_value(&monitoring).unwrap_or_default();
}
// Tor-specific fields
if let Some(mode) = handle.tor_mode() {
t_json["tor_mode"] = json!(mode);
}
if let Some(onion) = handle.onion_address() {
t_json["onion_address"] = json!(onion);
}
if let Some(monitoring) = handle.tor_monitoring() {
t_json["tor_monitoring"] = serde_json::to_value(&monitoring).unwrap_or_default();
}
t_json["stats"] = handle.transport_stats();
t_json["stats"] = handle.transport_stats();
t_json
}).collect();
t_json
})
.collect();
json!({ "transports": transports })
}
+1 -1
View File
@@ -3,7 +3,7 @@
use std::fmt;
use std::net::Ipv6Addr;
use super::{IdentityError, NodeAddr, FIPS_ADDRESS_PREFIX};
use super::{FIPS_ADDRESS_PREFIX, IdentityError, NodeAddr};
/// 128-bit FIPS address with IPv6-compatible format.
///
+4 -4
View File
@@ -3,9 +3,9 @@
use secp256k1::{Keypair, PublicKey, Secp256k1, SecretKey, XOnlyPublicKey};
use std::fmt;
use super::auth::{auth_challenge_digest, AuthResponse};
use super::auth::{AuthResponse, auth_challenge_digest};
use super::encoding::{decode_secret, encode_npub};
use super::{sha256, FipsAddress, IdentityError, NodeAddr};
use super::{FipsAddress, IdentityError, NodeAddr, sha256};
/// A FIPS node identity consisting of a keypair and derived identifiers.
///
@@ -22,8 +22,8 @@ impl Identity {
pub fn generate() -> Self {
let mut secret_bytes = [0u8; 32];
rand::Rng::fill_bytes(&mut rand::rng(), &mut secret_bytes);
let secret_key = SecretKey::from_slice(&secret_bytes)
.expect("32 random bytes is a valid secret key");
let secret_key =
SecretKey::from_slice(&secret_bytes).expect("32 random bytes is a valid secret key");
Self::from_secret_key(secret_key)
}
+1 -1
View File
@@ -4,7 +4,7 @@ use secp256k1::XOnlyPublicKey;
use sha2::{Digest, Sha256};
use std::fmt;
use super::{hex_encode, IdentityError};
use super::{IdentityError, hex_encode};
/// 16-byte node identifier derived from truncated SHA-256(pubkey).
///
+3 -2
View File
@@ -4,7 +4,7 @@ use secp256k1::{Parity, PublicKey, Secp256k1, XOnlyPublicKey};
use std::fmt;
use super::encoding::{decode_npub, encode_npub};
use super::{sha256, FipsAddress, IdentityError, NodeAddr};
use super::{FipsAddress, IdentityError, NodeAddr, sha256};
/// A known peer's identity (public key only, no signing capability).
///
@@ -99,7 +99,8 @@ impl PeerIdentity {
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).is_ok()
secp.verify_schnorr(signature, &digest, &self.pubkey)
.is_ok()
}
}
+36 -41
View File
@@ -110,9 +110,9 @@ fn test_node_addr_ordering() {
fn test_identity_from_secret_bytes() {
// A known secret key (32 bytes)
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,
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 identity1 = Identity::from_secret_bytes(&secret_bytes).unwrap();
@@ -163,9 +163,10 @@ fn test_identity_sign() {
// Verify the signature manually
let secp = secp256k1::Secp256k1::new();
let digest = super::sha256(data);
assert!(secp
.verify_schnorr(&sig, &digest, &identity.pubkey())
.is_ok());
assert!(
secp.verify_schnorr(&sig, &digest, &identity.pubkey())
.is_ok()
);
}
#[test]
@@ -193,9 +194,9 @@ fn test_npub_roundtrip() {
fn test_npub_known_vector() {
// Test against a known npub (from NIP-19 test vectors or generated externally)
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,
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 identity = Identity::from_secret_bytes(&secret_bytes).unwrap();
@@ -274,9 +275,9 @@ fn test_peer_identity_display() {
#[test]
fn test_nsec_roundtrip() {
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,
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 secret_key = SecretKey::from_slice(&secret_bytes).unwrap();
@@ -301,9 +302,9 @@ fn test_decode_nsec_invalid_prefix() {
#[test]
fn test_decode_secret_nsec() {
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,
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 secret_key = SecretKey::from_slice(&secret_bytes).unwrap();
@@ -319,9 +320,9 @@ fn test_decode_secret_hex() {
let decoded = decode_secret(hex_str).unwrap();
let expected: [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,
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,
];
assert_eq!(decoded.secret_bytes(), expected);
}
@@ -329,9 +330,9 @@ fn test_decode_secret_hex() {
#[test]
fn test_identity_from_secret_str_nsec() {
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,
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 secret_key = SecretKey::from_slice(&secret_bytes).unwrap();
@@ -347,9 +348,9 @@ fn test_identity_from_secret_str_nsec() {
fn test_identity_from_secret_str_hex() {
let hex_str = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20";
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,
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 identity = Identity::from_secret_str(hex_str).unwrap();
@@ -379,11 +380,8 @@ fn test_hex_conversion_case2() {
#[test]
fn test_decode_npub_invalid_length() {
// Encode 16 bytes (too short) as bech32 with npub prefix
let short = bech32::encode::<bech32::Bech32>(
bech32::Hrp::parse_unchecked("npub"),
&[0u8; 16],
)
.unwrap();
let short =
bech32::encode::<bech32::Bech32>(bech32::Hrp::parse_unchecked("npub"), &[0u8; 16]).unwrap();
let result = decode_npub(&short);
assert!(matches!(result, Err(IdentityError::InvalidNpubLength(16))));
}
@@ -391,11 +389,8 @@ fn test_decode_npub_invalid_length() {
#[test]
fn test_decode_nsec_invalid_length() {
// Encode 16 bytes (too short) as bech32 with nsec prefix
let short = bech32::encode::<bech32::Bech32>(
bech32::Hrp::parse_unchecked("nsec"),
&[0u8; 16],
)
.unwrap();
let short =
bech32::encode::<bech32::Bech32>(bech32::Hrp::parse_unchecked("nsec"), &[0u8; 16]).unwrap();
let result = decode_nsec(&short);
assert!(matches!(result, Err(IdentityError::InvalidNsecLength(16))));
}
@@ -418,8 +413,8 @@ fn test_decode_secret_hex_invalid_chars() {
#[test]
fn test_node_addr_debug() {
let bytes = [
0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00,
0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00,
];
let node_addr = NodeAddr::from_bytes(bytes);
let debug = format!("{:?}", node_addr);
@@ -429,8 +424,8 @@ fn test_node_addr_debug() {
#[test]
fn test_node_addr_display() {
let bytes = [
0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54,
0x32, 0x10,
0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54, 0x32,
0x10,
];
let node_addr = NodeAddr::from_bytes(bytes);
let display = format!("{}", node_addr);
@@ -587,9 +582,9 @@ 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,
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 full_pubkey = keypair.public_key();
+15 -16
View File
@@ -3,26 +3,26 @@
//! A distributed, decentralized network routing protocol for mesh nodes
//! connecting over arbitrary transports.
pub mod version;
pub mod bloom;
pub mod cache;
pub mod config;
pub mod control;
pub mod identity;
pub mod mmp;
pub mod noise;
pub mod utils;
pub mod node;
pub mod noise;
pub mod peer;
pub mod protocol;
pub mod transport;
pub mod tree;
pub mod upper;
pub mod utils;
pub mod version;
// Re-export identity types
pub use identity::{
decode_npub, decode_nsec, decode_secret, encode_npub, encode_nsec, AuthChallenge, AuthResponse,
FipsAddress, Identity, IdentityError, NodeAddr, PeerIdentity,
AuthChallenge, AuthResponse, FipsAddress, Identity, IdentityError, NodeAddr, PeerIdentity,
decode_npub, decode_nsec, decode_secret, encode_npub, encode_nsec,
};
// Re-export config types
@@ -36,18 +36,18 @@ pub use tree::{CoordEntry, ParentDeclaration, TreeCoordinate, TreeError, TreeSta
pub use bloom::{BloomError, BloomFilter, BloomState};
// Re-export transport types
pub use transport::{
packet_channel, DiscoveredPeer, Link, LinkDirection, LinkId, LinkState, LinkStats, PacketRx,
PacketTx, ReceivedPacket, Transport, TransportAddr, TransportError, TransportHandle,
TransportId, TransportState, TransportType,
};
pub use transport::udp::UdpTransport;
pub use transport::{
DiscoveredPeer, Link, LinkDirection, LinkId, LinkState, LinkStats, PacketRx, PacketTx,
ReceivedPacket, Transport, TransportAddr, TransportError, TransportHandle, TransportId,
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,
CoordsRequired, FilterAnnounce, HandshakeMessageType, LinkMessageType, LookupRequest,
LookupResponse, PathBroken, ProtocolError, SessionAck, SessionDatagram, SessionFlags,
SessionMessageType, SessionSetup, TreeAnnounce,
};
// Re-export cache types
@@ -55,10 +55,9 @@ pub use cache::{CacheEntry, CacheError, CacheStats, CoordCache};
// Re-export peer types
pub use peer::{
cross_connection_winner, ActivePeer, ConnectivityState, HandshakeState, PeerConnection,
PeerError, PeerSlot, PromotionResult,
ActivePeer, ConnectivityState, HandshakeState, PeerConnection, PeerError, PeerSlot,
PromotionResult, cross_connection_winner,
};
// Re-export node types
pub use node::{Node, NodeError, NodeState};
+15 -7
View File
@@ -371,7 +371,10 @@ mod tests {
}
// Should converge near 1000µs
let jitter = j.jitter_us();
assert!(jitter > 900 && jitter < 1100, "jitter={jitter}, expected ~1000");
assert!(
jitter > 900 && jitter < 1100,
"jitter={jitter}, expected ~1000"
);
}
#[test]
@@ -391,10 +394,7 @@ mod tests {
s.update(50_000);
}
let srtt = s.srtt_us();
assert!(
(srtt - 50_000).abs() < 1000,
"srtt={srtt}, expected ~50000"
);
assert!((srtt - 50_000).abs() < 1000, "srtt={srtt}, expected ~50000");
}
#[test]
@@ -417,7 +417,12 @@ mod tests {
e.update(100.0);
}
// Short should be closer to 100 than long
assert!(e.short() > e.long(), "short={} long={}", e.short(), e.long());
assert!(
e.short() > e.long(),
"short={} long={}",
e.short(),
e.long()
);
}
#[test]
@@ -437,7 +442,10 @@ mod tests {
d.push(i, 5000 + (i as i64) * 100); // increasing by 100µs per packet
}
let trend = d.trend_us_per_sec();
assert!(trend > 0, "increasing OWD should have positive trend, got {trend}");
assert!(
trend > 0,
"increasing OWD should have positive trend, got {trend}"
);
}
#[test]
+35 -10
View File
@@ -110,7 +110,12 @@ impl MmpMetrics {
///
/// 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 {
pub fn process_receiver_report(
&mut self,
rr: &ReceiverReport,
our_timestamp_ms: u32,
now: Instant,
) -> bool {
let had_srtt = self.srtt.initialized();
// --- RTT from timestamp echo ---
@@ -138,8 +143,12 @@ impl MmpMetrics {
// --- 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);
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);
@@ -153,7 +162,9 @@ impl MmpMetrics {
// --- 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);
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
@@ -379,8 +390,16 @@ mod tests {
// 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());
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]
@@ -397,8 +416,11 @@ mod tests {
// 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);
assert!(
(m.delivery_ratio_reverse - 0.5).abs() < 0.001,
"reverse={}, expected 0.5",
m.delivery_ratio_reverse
);
}
#[test]
@@ -422,7 +444,10 @@ mod tests {
// 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);
assert!(
(m.delivery_ratio_reverse - 0.8).abs() < 0.001,
"reverse={}, expected 0.8",
m.delivery_ratio_reverse
);
}
}
+17 -11
View File
@@ -7,8 +7,10 @@ use std::time::{Duration, Instant};
use crate::mmp::algorithms::{JitterEstimator, OwdTrendDetector};
use crate::mmp::report::ReceiverReport;
use crate::mmp::{DEFAULT_COLD_START_INTERVAL_MS, DEFAULT_OWD_WINDOW_SIZE,
MAX_REPORT_INTERVAL_MS, MIN_REPORT_INTERVAL_MS};
use crate::mmp::{
DEFAULT_COLD_START_INTERVAL_MS, DEFAULT_OWD_WINDOW_SIZE, MAX_REPORT_INTERVAL_MS,
MIN_REPORT_INTERVAL_MS,
};
/// Grace period after rekey before resuming jitter calculation.
///
@@ -228,8 +230,7 @@ impl ReceiverState {
self.owd_seq = 0;
self.last_sender_timestamp = 0;
self.last_recv_time = None;
self.rekey_jitter_grace_until =
Some(now + Duration::from_secs(REKEY_JITTER_GRACE_SECS));
self.rekey_jitter_grace_until = Some(now + Duration::from_secs(REKEY_JITTER_GRACE_SECS));
self.ecn_ce_count = 0;
self.interval_has_data = false;
// Keep cumulative_packets_recv, cumulative_bytes_recv (lifetime stats)
@@ -281,14 +282,14 @@ impl ReceiverState {
// We can't get absolute µs from Instant, but we can compute the delta
// between consecutive transits using relative Instant differences.
// Skip during post-rekey grace period to avoid drain-window spikes.
let in_grace = self.rekey_jitter_grace_until
let in_grace = self
.rekey_jitter_grace_until
.is_some_and(|deadline| now < deadline);
if !in_grace {
self.rekey_jitter_grace_until = None; // clear expired grace
if let Some(prev_recv) = self.last_recv_time {
let recv_delta_us = now.duration_since(prev_recv).as_micros() as i64;
let send_delta_us =
sender_us - (self.last_sender_timestamp as i64 * 1000);
let send_delta_us = sender_us - (self.last_sender_timestamp as i64 * 1000);
let transit_delta = (recv_delta_us - send_delta_us) as i32;
self.jitter.update(transit_delta);
}
@@ -318,7 +319,8 @@ impl ReceiverState {
}
// Dwell time: ms between last frame reception and report generation
let dwell_time = self.last_recv_time
let dwell_time = self
.last_recv_time
.map(|t| now.duration_since(t).as_millis() as u16)
.unwrap_or(0);
@@ -365,7 +367,11 @@ impl ReceiverState {
///
/// Receiver reports at 1× SRTT, clamped to [MIN, MAX].
pub fn update_report_interval_from_srtt(&mut self, srtt_us: i64) {
self.update_report_interval_with_bounds(srtt_us, MIN_REPORT_INTERVAL_MS, MAX_REPORT_INTERVAL_MS);
self.update_report_interval_with_bounds(
srtt_us,
MIN_REPORT_INTERVAL_MS,
MAX_REPORT_INTERVAL_MS,
);
}
/// Update the report interval based on SRTT with custom bounds.
@@ -600,8 +606,8 @@ mod tests {
assert_eq!(r.jitter_us(), 0);
// After grace expires, jitter updates resume
let after_grace = t0 + Duration::from_secs(2)
+ Duration::from_secs(REKEY_JITTER_GRACE_SECS + 1);
let after_grace =
t0 + Duration::from_secs(2) + Duration::from_secs(REKEY_JITTER_GRACE_SECS + 1);
r.record_recv(2, 200, 100, false, after_grace);
r.record_recv(3, 300, 100, false, after_grace + Duration::from_millis(100));
// Now jitter should be updating (non-zero or zero depending on timing)
+12 -3
View File
@@ -117,7 +117,9 @@ impl SenderState {
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());
let effective = self
.report_interval
.mul_f64(self.send_failure_backoff_multiplier());
now.duration_since(last) >= effective
}
}
@@ -152,7 +154,11 @@ impl SenderState {
///
/// Sender reports at 2× SRTT clamped to [MIN, MAX].
pub fn update_report_interval_from_srtt(&mut self, srtt_us: i64) {
self.update_report_interval_with_bounds(srtt_us, MIN_REPORT_INTERVAL_MS, MAX_REPORT_INTERVAL_MS);
self.update_report_interval_with_bounds(
srtt_us,
MIN_REPORT_INTERVAL_MS,
MAX_REPORT_INTERVAL_MS,
);
}
/// Update the report interval based on SRTT with custom bounds.
@@ -301,7 +307,10 @@ mod tests {
// 2s RTT → 4s, clamped to max 2s
s.update_report_interval_from_srtt(2_000_000);
assert_eq!(s.report_interval(), Duration::from_millis(MAX_REPORT_INTERVAL_MS));
assert_eq!(
s.report_interval(),
Duration::from_millis(MAX_REPORT_INTERVAL_MS)
);
}
#[test]
+59 -4
View File
@@ -3,13 +3,13 @@
//! Handles building, sending, and receiving FilterAnnounce messages,
//! including debounced propagation to peers.
use crate::NodeAddr;
use crate::bloom::BloomFilter;
use crate::protocol::FilterAnnounce;
use crate::NodeAddr;
use super::{Node, NodeError};
use std::collections::HashMap;
use tracing::debug;
use tracing::{debug, warn};
impl Node {
/// Collect inbound filters from all peers for outgoing filter computation.
@@ -78,11 +78,41 @@ impl Node {
self.stats_mut().bloom.sent += 1;
// Self-plausibility check: WARN if our own outgoing filter is
// above the antipoison cap. Independent detection signal if
// aggregation drift or an ingress-check bypass pushes us over
// despite M1. Rate-limited to once per 60s globally — outgoing
// cadence can be per-tick during churn, and we want the
// 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);
if out_fpr > max_fpr {
let now = std::time::Instant::now();
let should_warn = self
.last_self_warn
.map(|t| now.duration_since(t) >= std::time::Duration::from_secs(60))
.unwrap_or(true);
if should_warn {
self.last_self_warn = Some(now);
warn!(
to = %self.peer_display_name(peer_addr),
fill = format_args!("{:.3}", out_fill),
fpr = format_args!("{:.4}", out_fpr),
cap = format_args!("{:.4}", max_fpr),
"Outgoing filter above FPR cap — aggregation drift or missed ingress?"
);
}
}
// Record send and store the filter for change detection
debug!(
peer = %self.peer_display_name(peer_addr),
seq = announce.sequence,
est_entries = format_args!("{:.0}", sent_filter.estimated_count()),
est_entries = match sent_filter.estimated_count(max_fpr) {
Some(n) => format!("{:.0}", n),
None => "".to_string(),
},
set_bits = sent_filter.count_ones(),
fill = format_args!("{:.1}%", sent_filter.fill_ratio() * 100.0),
tree_peer = self.is_tree_peer(peer_addr),
@@ -174,6 +204,28 @@ impl Node {
return;
}
// Antipoison FPR cap. Reject announces whose FPR exceeds
// node.bloom.max_inbound_fpr. Silent on the wire (no NACK) —
// the peer's prior accepted filter and filter_sequence stay
// untouched so the peer is not permanently silenced and an
// on-path attacker cannot weaponize a single corrupted frame
// 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);
if fpr > max_fpr {
self.stats_mut().bloom.fill_exceeded += 1;
warn!(
from = %self.peer_display_name(from),
seq = announce.sequence,
fill = format_args!("{:.3}", fill),
fpr = format_args!("{:.4}", fpr),
cap = format_args!("{:.4}", max_fpr),
"FilterAnnounce above FPR cap — rejected"
);
return;
}
self.stats_mut().bloom.accepted += 1;
let now_ms = std::time::SystemTime::now()
@@ -184,7 +236,10 @@ impl Node {
debug!(
from = %self.peer_display_name(from),
seq = announce.sequence,
est_entries = format_args!("{:.0}", announce.filter.estimated_count()),
est_entries = match announce.filter.estimated_count(max_fpr) {
Some(n) => format!("{:.0}", n),
None => "".to_string(),
},
set_bits = announce.filter.count_ones(),
fill = format_args!("{:.1}%", announce.filter.fill_ratio() * 100.0),
tree_peer = self.is_tree_peer(from),
+2 -7
View File
@@ -81,11 +81,7 @@ impl DiscoveryBackoff {
/// 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 failures = self.entries.get(target).map_or(0, |e| e.failures) + 1;
let backoff_secs = self
.base
@@ -345,8 +341,7 @@ mod tests {
#[test]
fn test_forward_allowed_after_interval() {
let mut limiter =
DiscoveryForwardRateLimiter::with_interval(Duration::from_millis(100));
let mut limiter = DiscoveryForwardRateLimiter::with_interval(Duration::from_millis(100));
assert!(limiter.should_forward(&addr(1)));
thread::sleep(Duration::from_millis(110));
+23 -39
View File
@@ -20,11 +20,7 @@ impl Node {
/// 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],
) {
pub(in crate::node) async fn handle_lookup_request(&mut self, from: &NodeAddr, payload: &[u8]) {
self.stats_mut().discovery.req_received += 1;
let request = match LookupRequest::decode(payload) {
@@ -52,10 +48,8 @@ impl Node {
}
// Record for reverse-path forwarding and dedup
self.recent_requests.insert(
request.request_id,
RecentRequest::new(*from, now_ms),
);
self.recent_requests
.insert(request.request_id, RecentRequest::new(*from, now_ms));
// Lazy purge expired entries
self.purge_expired_requests(now_ms);
@@ -76,7 +70,10 @@ impl Node {
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) {
if !self
.discovery_forward_limiter
.should_forward(&request.target)
{
self.stats_mut().discovery.req_forward_rate_limited += 1;
debug!(
request_id = request.request_id,
@@ -192,11 +189,8 @@ impl Node {
// 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,
);
let proof_data =
LookupResponse::proof_bytes(response.request_id, &target, &response.target_coords);
if !peer_id.verify(&proof_data, &response.proof) {
self.stats_mut().discovery.resp_proof_failed += 1;
warn!(
@@ -220,12 +214,8 @@ impl Node {
"Discovery succeeded, proof verified, route cached"
);
self.coord_cache.insert_with_path_mtu(
target,
response.target_coords,
now_ms,
path_mtu,
);
self.coord_cache
.insert_with_path_mtu(target, response.target_coords, now_ms, path_mtu);
// Clean up pending lookup tracking
self.pending_lookups.remove(&target);
@@ -262,15 +252,11 @@ impl Node {
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_data =
LookupResponse::proof_bytes(request.request_id, &request.target, &our_coords);
let proof = self.identity().sign(&proof_data);
let response = LookupResponse::new(
request.request_id,
request.target,
our_coords,
proof,
);
let 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) {
@@ -297,7 +283,10 @@ impl Node {
);
let encoded = response.encode();
if let Err(e) = self.send_encrypted_link_message(&next_hop_addr, &encoded).await {
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,
@@ -324,9 +313,7 @@ impl Node {
let forward_to: Vec<NodeAddr> = self
.peers
.iter()
.filter(|(addr, peer)| {
self.is_tree_peer(addr) && peer.may_reach(&request.target)
})
.filter(|(addr, peer)| self.is_tree_peer(addr) && peer.may_reach(&request.target))
.map(|(addr, _)| *addr)
.collect();
@@ -335,9 +322,7 @@ impl Node {
let fallback: Vec<NodeAddr> = self
.peers
.iter()
.filter(|(addr, peer)| {
!self.is_tree_peer(addr) && peer.may_reach(&request.target)
})
.filter(|(addr, peer)| !self.is_tree_peer(addr) && peer.may_reach(&request.target))
.map(|(addr, _)| *addr)
.collect();
if fallback.is_empty() {
@@ -402,9 +387,7 @@ impl Node {
let peer_addrs: Vec<NodeAddr> = self
.peers
.iter()
.filter(|(addr, peer)| {
self.is_tree_peer(addr) && peer.may_reach(target)
})
.filter(|(addr, peer)| self.is_tree_peer(addr) && peer.may_reach(target))
.map(|(addr, _)| *addr)
.collect();
@@ -488,7 +471,8 @@ impl Node {
return;
}
self.pending_lookups.insert(*dest, PendingLookup::new(now_ms));
self.pending_lookups
.insert(*dest, PendingLookup::new(now_ms));
let ttl = self.config.node.discovery.ttl;
let sent = self.initiate_lookup(dest, ttl).await;
+22 -5
View File
@@ -1,14 +1,19 @@
//! Link message dispatch and peer removal.
use crate::node::Node;
use crate::NodeAddr;
use crate::node::Node;
use tracing::{debug, info, trace};
impl Node {
/// Dispatch a decrypted link message to the appropriate handler.
///
/// Link messages are protocol messages exchanged between authenticated peers.
pub(in crate::node) async fn dispatch_link_message(&mut self, from: &NodeAddr, plaintext: &[u8], ce_flag: bool) {
pub(in crate::node) async fn dispatch_link_message(
&mut self,
from: &NodeAddr,
plaintext: &[u8],
ce_flag: bool,
) {
if plaintext.is_empty() {
return;
}
@@ -62,8 +67,12 @@ impl Node {
/// Handle a Disconnect notification from a peer.
///
/// The peer is signaling an orderly departure. We immediately remove
/// them from all state rather than waiting for timeout detection.
fn handle_disconnect(&mut self, from: &NodeAddr, payload: &[u8]) {
/// them from all state rather than waiting for timeout detection, and
/// schedule a reconnect if the peer is configured as auto-connect.
/// Without this, a graceful upstream shutdown orphans auto-connect
/// 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) {
Ok(msg) => msg,
Err(e) => {
@@ -78,7 +87,13 @@ impl Node {
"Peer sent disconnect notification"
);
let addr = *from;
self.remove_active_peer(from);
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
self.schedule_reconnect(addr, now_ms);
}
/// Remove an active peer and clean up all associated state.
@@ -109,7 +124,9 @@ impl Node {
}
// MMP teardown log (before we drop the peer)
let peer_name = self.peer_aliases.get(node_addr)
let peer_name = self
.peer_aliases
.get(node_addr)
.cloned()
.unwrap_or_else(|| peer.identity().short_npub());
if let Some(mmp) = peer.mmp() {
+12 -9
View File
@@ -1,8 +1,8 @@
//! Encrypted frame handling (hot path).
use crate::noise::NoiseError;
use crate::node::Node;
use crate::node::wire::{EncryptedHeader, strip_inner_header, FLAG_CE, FLAG_KEY_EPOCH, FLAG_SP};
use crate::node::wire::{EncryptedHeader, FLAG_CE, FLAG_KEY_EPOCH, FLAG_SP, strip_inner_header};
use crate::noise::NoiseError;
use crate::transport::ReceivedPacket;
use std::time::Instant;
use tracing::{debug, info, trace, warn};
@@ -53,8 +53,8 @@ impl Node {
// Check and perform cutover in a scoped borrow.
{
let peer = self.peers.get(&node_addr).unwrap();
let k_bit_flipped = received_k_bit != peer.current_k_bit()
&& peer.pending_new_session().is_some();
let k_bit_flipped =
received_k_bit != peer.current_k_bit() && peer.pending_new_session().is_some();
if k_bit_flipped {
let display_name = self.peer_display_name(&node_addr);
@@ -70,9 +70,10 @@ impl Node {
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())
),
&& 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 K-bit flip"
);
}
@@ -162,12 +163,14 @@ impl Node {
let _spin_rtt = mmp.spin_bit.rx_observe(sp_flag, header.counter, now);
}
peer.set_current_addr(packet.transport_id, packet.remote_addr.clone());
peer.link_stats_mut().record_recv(packet.data.len(), packet.timestamp_ms);
peer.link_stats_mut()
.record_recv(packet.data.len(), packet.timestamp_ms);
peer.touch(packet.timestamp_ms);
}
// Dispatch to link message handler
self.dispatch_link_message(&node_addr, link_message, ce_flag).await;
self.dispatch_link_message(&node_addr, link_message, ce_flag)
.await;
}
/// Log a decryption failure with replay suppression.
+82 -82
View File
@@ -5,15 +5,15 @@
//! plaintext session-layer headers, routes to the next hop or delivers
//! locally, and generates error signals on routing failure.
use crate::node::{Node, NodeError};
use crate::NodeAddr;
use crate::node::session_wire::{
parse_encrypted_coords, FspCommonPrefix, FSP_COMMON_PREFIX_SIZE, FSP_HEADER_SIZE,
FSP_PHASE_ESTABLISHED, FSP_PHASE_MSG1, FSP_PHASE_MSG2,
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, SessionSetup,
};
use crate::NodeAddr;
use std::time::{Duration, Instant};
use tracing::{debug, warn};
@@ -22,13 +22,20 @@ impl Node {
///
/// Called by `dispatch_link_message` for msg_type 0x00. The payload
/// has already had its msg_type byte stripped by dispatch.
pub(in crate::node) async fn handle_session_datagram(&mut self, _from: &NodeAddr, payload: &[u8], incoming_ce: bool) {
pub(in crate::node) async fn handle_session_datagram(
&mut self,
_from: &NodeAddr,
payload: &[u8],
incoming_ce: bool,
) {
self.stats_mut().forwarding.record_received(payload.len());
let mut datagram = match SessionDatagram::decode(payload) {
Ok(dg) => dg,
Err(e) => {
self.stats_mut().forwarding.record_decode_error(payload.len());
self.stats_mut()
.forwarding
.record_decode_error(payload.len());
debug!(error = %e, "Malformed SessionDatagram");
return;
}
@@ -36,7 +43,9 @@ impl Node {
// TTL enforcement: decrement and drop if exhausted
if !datagram.decrement_ttl() {
self.stats_mut().forwarding.record_ttl_exhausted(payload.len());
self.stats_mut()
.forwarding
.record_ttl_exhausted(payload.len());
debug!(
src = %datagram.src_addr,
dest = %datagram.dest_addr,
@@ -51,8 +60,13 @@ impl Node {
// Local delivery: dispatch to session layer handlers
if datagram.dest_addr == *self.node_addr() {
self.stats_mut().forwarding.record_delivered(payload.len());
self.handle_session_payload(&datagram.src_addr, &datagram.payload, datagram.path_mtu, incoming_ce)
.await;
self.handle_session_payload(
&datagram.src_addr,
&datagram.payload,
datagram.path_mtu,
incoming_ce,
)
.await;
return;
}
@@ -60,7 +74,9 @@ impl Node {
let next_hop_addr = match self.find_next_hop(&datagram.dest_addr) {
Some(peer) => *peer.node_addr(),
None => {
self.stats_mut().forwarding.record_drop_no_route(payload.len());
self.stats_mut()
.forwarding
.record_drop_no_route(payload.len());
self.send_routing_error(&datagram).await;
return;
}
@@ -84,7 +100,8 @@ impl Node {
if local_congestion {
self.stats_mut().congestion.record_congestion_detected();
let now = Instant::now();
let should_log = self.last_congestion_log
let should_log = self
.last_congestion_log
.map(|t| now.duration_since(t) >= Duration::from_secs(5))
.unwrap_or(true);
if should_log {
@@ -101,11 +118,15 @@ impl Node {
{
match e {
NodeError::MtuExceeded { mtu, .. } => {
self.stats_mut().forwarding.record_drop_mtu_exceeded(payload.len());
self.stats_mut()
.forwarding
.record_drop_mtu_exceeded(payload.len());
self.send_mtu_exceeded_error(&datagram, mtu).await;
}
_ => {
self.stats_mut().forwarding.record_drop_send_error(payload.len());
self.stats_mut()
.forwarding
.record_drop_send_error(payload.len());
debug!(
next_hop = %next_hop_addr,
dest = %datagram.dest_addr,
@@ -146,54 +167,38 @@ impl Node {
.unwrap_or(0);
match prefix.phase {
FSP_PHASE_MSG1 => {
match SessionSetup::decode(inner) {
Ok(setup) => {
self.coord_cache_mut().insert(
datagram.src_addr,
setup.src_coords,
now_ms,
);
self.coord_cache_mut().insert(
datagram.dest_addr,
setup.dest_coords,
now_ms,
);
debug!(
src = %datagram.src_addr,
dest = %datagram.dest_addr,
"Cached coords from SessionSetup"
);
}
Err(e) => {
debug!(error = %e, "Failed to decode SessionSetup for cache warming");
}
FSP_PHASE_MSG1 => match SessionSetup::decode(inner) {
Ok(setup) => {
self.coord_cache_mut()
.insert(datagram.src_addr, setup.src_coords, now_ms);
self.coord_cache_mut()
.insert(datagram.dest_addr, setup.dest_coords, now_ms);
debug!(
src = %datagram.src_addr,
dest = %datagram.dest_addr,
"Cached coords from SessionSetup"
);
}
}
FSP_PHASE_MSG2 => {
match SessionAck::decode(inner) {
Ok(ack) => {
self.coord_cache_mut().insert(
datagram.src_addr,
ack.src_coords,
now_ms,
);
self.coord_cache_mut().insert(
datagram.dest_addr,
ack.dest_coords,
now_ms,
);
debug!(
src = %datagram.src_addr,
dest = %datagram.dest_addr,
"Cached coords from SessionAck"
);
}
Err(e) => {
debug!(error = %e, "Failed to decode SessionAck for cache warming");
}
Err(e) => {
debug!(error = %e, "Failed to decode SessionSetup for cache warming");
}
}
},
FSP_PHASE_MSG2 => match SessionAck::decode(inner) {
Ok(ack) => {
self.coord_cache_mut()
.insert(datagram.src_addr, ack.src_coords, now_ms);
self.coord_cache_mut()
.insert(datagram.dest_addr, ack.dest_coords, now_ms);
debug!(
src = %datagram.src_addr,
dest = %datagram.dest_addr,
"Cached coords from SessionAck"
);
}
Err(e) => {
debug!(error = %e, "Failed to decode SessionAck for cache warming");
}
},
FSP_PHASE_ESTABLISHED if prefix.has_coords() => {
// CP flag set: coords in cleartext between header and ciphertext.
// Parse coords from the cleartext section after the 12-byte header.
@@ -203,18 +208,12 @@ impl Node {
match parse_encrypted_coords(coord_data) {
Ok((src_coords, dest_coords, _bytes_consumed)) => {
if let Some(coords) = src_coords {
self.coord_cache_mut().insert(
datagram.src_addr,
coords,
now_ms,
);
self.coord_cache_mut()
.insert(datagram.src_addr, coords, now_ms);
}
if let Some(coords) = dest_coords {
self.coord_cache_mut().insert(
datagram.dest_addr,
coords,
now_ms,
);
self.coord_cache_mut()
.insert(datagram.dest_addr, coords, now_ms);
}
debug!(
src = %datagram.src_addr,
@@ -243,7 +242,10 @@ impl Node {
/// 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) {
if !self
.routing_error_rate_limiter
.should_send(&original.dest_addr)
{
return;
}
@@ -303,23 +305,18 @@ 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,
) {
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) {
if !self
.routing_error_rate_limiter
.should_send(&original.dest_addr)
{
return;
}
let my_addr = *self.node_addr();
let error_payload = MtuExceeded::new(
original.dest_addr,
my_addr,
bottleneck_mtu,
).encode();
let error_payload = MtuExceeded::new(original.dest_addr, my_addr, bottleneck_mtu).encode();
let error_dg = SessionDatagram::new(my_addr, original.src_addr, error_payload)
.with_ttl(self.config.node.session.default_ttl);
@@ -403,7 +400,10 @@ impl Node {
}
for tid in new_drop_events {
self.stats_mut().congestion.record_kernel_drop_event();
warn!(transport_id = tid.as_u32(), "Kernel recv drops first observed on transport");
warn!(
transport_id = tid.as_u32(),
"Kernel recv drops first observed on transport"
);
}
}
}
+79 -76
View File
@@ -1,12 +1,10 @@
//! Handshake handlers and connection promotion.
use crate::node::{Node, NodeError};
use crate::peer::{
cross_connection_winner, ActivePeer, PeerConnection, PromotionResult,
};
use crate::transport::{Link, LinkDirection, LinkId, ReceivedPacket};
use crate::node::wire::{build_msg2, Msg1Header, Msg2Header};
use crate::PeerIdentity;
use crate::node::wire::{Msg1Header, Msg2Header, build_msg2};
use crate::node::{Node, NodeError};
use crate::peer::{ActivePeer, PeerConnection, PromotionResult, cross_connection_winner};
use crate::transport::{Link, LinkDirection, LinkId, ReceivedPacket};
use std::time::Duration;
use tracing::{debug, info, warn};
@@ -63,8 +61,7 @@ impl Node {
{
if link.direction() == LinkDirection::Inbound {
// Check if this link belongs to an already-promoted active peer
let is_active_peer = self.peers.values()
.any(|p| p.link_id() == existing_link_id);
let is_active_peer = self.peers.values().any(|p| p.link_id() == existing_link_id);
if is_active_peer {
// Possible restart — fall through to decrypt and check epoch
@@ -100,8 +97,7 @@ impl Node {
// peer, this may be a rekey msg1 (same epoch) or a
// restart (different epoch). Set possible_restart to enable
// the epoch/rekey check below.
let is_active_peer = self.peers.values()
.any(|p| p.link_id() == existing_link_id);
let is_active_peer = self.peers.values().any(|p| p.link_id() == existing_link_id);
if is_active_peer {
possible_restart = true;
} else {
@@ -126,7 +122,12 @@ impl Node {
let our_keypair = self.identity.keypair();
let noise_msg1 = &packet.data[header.noise_msg1_offset..];
let msg2_response = match conn.receive_handshake_init(our_keypair, self.startup_epoch, noise_msg1, packet.timestamp_ms) {
let msg2_response = match conn.receive_handshake_init(
our_keypair,
self.startup_epoch,
noise_msg1,
packet.timestamp_ms,
) {
Ok(m) => m,
Err(e) => {
self.msg1_rate_limiter.complete_handshake();
@@ -162,9 +163,7 @@ impl Node {
// If we fell through from the addr_to_link check above with
// possible_restart=true, we now have the decrypted epoch from msg1.
// Compare it against the stored epoch for this peer.
if possible_restart
&& let Some(existing_peer) = self.peers.get(&peer_node_addr)
{
if possible_restart && let Some(existing_peer) = self.peers.get(&peer_node_addr) {
let new_epoch = conn.remote_epoch();
let existing_epoch = existing_peer.remote_epoch();
@@ -192,10 +191,8 @@ impl Node {
// During simultaneous connection, both sides promote
// within the same tick and the peer's msg1 arrives
// immediately — a genuine rekey can't fire that fast.
let session_age_secs = existing_peer
.session_established_at()
.elapsed()
.as_secs();
let session_age_secs =
existing_peer.session_established_at().elapsed().as_secs();
if self.config.node.rekey.enabled
&& existing_peer.has_session()
&& existing_peer.is_healthy()
@@ -272,7 +269,8 @@ impl Node {
};
// Send msg2 response using the new handshake
let wire_msg2 = build_msg2(our_new_index, header.sender_idx, &msg2_response);
let wire_msg2 =
build_msg2(our_new_index, header.sender_idx, &msg2_response);
if let Some(transport) = self.transports.get(&packet.transport_id) {
match transport.send(&packet.remote_addr, &wire_msg2).await {
Ok(_) => {
@@ -401,7 +399,8 @@ impl Node {
// Clean up on failure
self.connections.remove(&link_id);
self.links.remove(&link_id);
self.addr_to_link.remove(&(packet.transport_id, packet.remote_addr));
self.addr_to_link
.remove(&(packet.transport_id, packet.remote_addr));
let _ = self.index_allocator.free(our_index);
self.msg1_rate_limiter.complete_handshake();
return;
@@ -434,7 +433,10 @@ impl Node {
self.bloom_state.mark_update_needed(node_addr);
self.reset_discovery_backoff();
}
PromotionResult::CrossConnectionWon { loser_link_id, node_addr } => {
PromotionResult::CrossConnectionWon {
loser_link_id,
node_addr,
} => {
// Store msg2 on peer for resend on duplicate msg1
if let Some(peer) = self.peers.get_mut(&node_addr) {
peer.set_handshake_msg2(wire_msg2.clone());
@@ -552,9 +554,7 @@ impl Node {
// Find peer with rekey in progress for this index
let peer_addr = self.peers.iter().find_map(|(addr, peer)| {
if peer.rekey_in_progress()
&& peer.rekey_our_index() == Some(header.receiver_idx)
{
if peer.rekey_in_progress() && peer.rekey_our_index() == Some(header.receiver_idx) {
Some(*addr)
} else {
None
@@ -568,15 +568,12 @@ impl Node {
if let Some(peer) = self.peers.get_mut(&peer_node_addr) {
match peer.complete_rekey_msg2(noise_msg2) {
Ok(session) => {
let our_index = peer.rekey_our_index()
.unwrap_or(header.receiver_idx);
let our_index = peer.rekey_our_index().unwrap_or(header.receiver_idx);
peer.set_pending_session(session, our_index, header.sender_idx);
if let Some(transport_id) = peer.transport_id() {
self.peers_by_index.insert(
(transport_id, our_index.as_u32()),
peer_node_addr,
);
self.peers_by_index
.insert((transport_id, our_index.as_u32()), peer_node_addr);
}
debug!(
@@ -679,15 +676,17 @@ impl Node {
let outbound_our_index = conn.our_index();
let outbound_session = conn.take_session();
let (outbound_session, outbound_our_index) =
match (outbound_session, outbound_our_index) {
(Some(s), Some(idx)) => (s, idx),
_ => {
warn!(peer = %self.peer_display_name(&peer_node_addr), "Incomplete outbound connection");
self.pending_outbound.remove(&key);
return;
}
};
let (outbound_session, outbound_our_index) = match (
outbound_session,
outbound_our_index,
) {
(Some(s), Some(idx)) => (s, idx),
_ => {
warn!(peer = %self.peer_display_name(&peer_node_addr), "Incomplete outbound connection");
self.pending_outbound.remove(&key);
return;
}
};
if let Some(peer) = self.peers.get_mut(&peer_node_addr) {
let suppressed = peer.replay_suppressed_count();
@@ -700,13 +699,12 @@ impl Node {
// Update peers_by_index: remove old inbound index, add outbound
let transport_id = peer.transport_id().unwrap();
if let Some(old_idx) = old_our_index {
self.peers_by_index.remove(&(transport_id, old_idx.as_u32()));
self.peers_by_index
.remove(&(transport_id, old_idx.as_u32()));
let _ = self.index_allocator.free(old_idx);
}
self.peers_by_index.insert(
(transport_id, outbound_our_index.as_u32()),
peer_node_addr,
);
self.peers_by_index
.insert((transport_id, outbound_our_index.as_u32()), peer_node_addr);
if suppressed > 0 {
debug!(
@@ -791,7 +789,10 @@ impl Node {
self.bloom_state.mark_update_needed(node_addr);
self.reset_discovery_backoff();
}
PromotionResult::CrossConnectionWon { loser_link_id, node_addr } => {
PromotionResult::CrossConnectionWon {
loser_link_id,
node_addr,
} => {
// Close the losing TCP connection (no-op for connectionless)
if let Some(loser_link) = self.links.get(&loser_link_id) {
let loser_tid = loser_link.transport_id();
@@ -803,10 +804,8 @@ impl Node {
// Clean up the losing connection's link
self.remove_link(&loser_link_id);
// Ensure addr_to_link points to the winning link
self.addr_to_link.insert(
(packet.transport_id, packet.remote_addr.clone()),
link_id,
);
self.addr_to_link
.insert((packet.transport_id, packet.remote_addr.clone()), link_id);
info!(
peer = %self.peer_display_name(&node_addr),
loser_link_id = %loser_link_id,
@@ -873,30 +872,31 @@ impl Node {
.take_session()
.ok_or(NodeError::NoSession(link_id))?;
let our_index = connection.our_index().ok_or_else(|| {
NodeError::PromotionFailed {
let our_index = connection
.our_index()
.ok_or_else(|| NodeError::PromotionFailed {
link_id,
reason: "missing our_index".into(),
}
})?;
let their_index = connection.their_index().ok_or_else(|| {
NodeError::PromotionFailed {
})?;
let their_index = connection
.their_index()
.ok_or_else(|| NodeError::PromotionFailed {
link_id,
reason: "missing their_index".into(),
}
})?;
let transport_id = connection.transport_id().ok_or_else(|| {
NodeError::PromotionFailed {
})?;
let transport_id = connection
.transport_id()
.ok_or_else(|| NodeError::PromotionFailed {
link_id,
reason: "missing transport_id".into(),
}
})?;
let current_addr = connection.source_addr().ok_or_else(|| {
NodeError::PromotionFailed {
})?;
let current_addr = connection
.source_addr()
.ok_or_else(|| NodeError::PromotionFailed {
link_id,
reason: "missing source_addr".into(),
}
})?.clone();
})?
.clone();
let link_stats = connection.link_stats().clone();
let remote_epoch = connection.remote_epoch();
@@ -908,11 +908,8 @@ impl Node {
let existing_link_id = existing_peer.link_id();
// Determine which connection wins
let this_wins = cross_connection_winner(
self.identity.node_addr(),
&peer_node_addr,
is_outbound,
);
let this_wins =
cross_connection_winner(self.identity.node_addr(), &peer_node_addr, is_outbound);
if this_wins {
// This connection wins, replace the existing peer
@@ -923,8 +920,7 @@ impl Node {
if let (Some(old_tid), Some(old_idx)) =
(old_peer.transport_id(), old_peer.our_index())
{
self.peers_by_index
.remove(&(old_tid, old_idx.as_u32()));
self.peers_by_index.remove(&(old_tid, old_idx.as_u32()));
let _ = self.index_allocator.free(old_idx);
}
@@ -942,7 +938,9 @@ impl Node {
&self.config.node.mmp,
remote_epoch,
);
new_peer.set_tree_announce_min_interval_ms(self.config.node.tree.announce_min_interval_ms);
new_peer.set_tree_announce_min_interval_ms(
self.config.node.tree.announce_min_interval_ms,
);
self.peers.insert(peer_node_addr, new_peer);
self.peers_by_index
@@ -1008,13 +1006,17 @@ impl Node {
// Normal promotion
if self.max_peers > 0 && self.peers.len() >= self.max_peers {
let _ = self.index_allocator.free(our_index);
return Err(NodeError::MaxPeersExceeded { max: self.max_peers });
return Err(NodeError::MaxPeersExceeded {
max: self.max_peers,
});
}
// Preserve tree announce rate-limit state from old peer (if reconnecting).
// Without this, reconnection resets the rate limit window to zero,
// allowing an immediate announce that can feed an announce loop.
let old_announce_ts = self.peers.get(&peer_node_addr)
let old_announce_ts = self
.peers
.get(&peer_node_addr)
.map(|p| p.last_tree_announce_sent_ms());
let mut new_peer = ActivePeer::with_session(
@@ -1031,7 +1033,8 @@ impl Node {
&self.config.node.mmp,
remote_epoch,
);
new_peer.set_tree_announce_min_interval_ms(self.config.node.tree.announce_min_interval_ms);
new_peer
.set_tree_announce_min_interval_ms(self.config.node.tree.announce_min_interval_ms);
if let Some(ts) = old_announce_ts {
new_peer.set_last_tree_announce_sent_ms(ts);
}
+28 -10
View File
@@ -4,6 +4,7 @@
//! periodic report generation on the tick timer, and emits periodic
//! and teardown metric logs.
use crate::NodeAddr;
use crate::mmp::MmpMode;
use crate::mmp::MmpSessionState;
use crate::mmp::report::{ReceiverReport, SenderReport};
@@ -12,7 +13,6 @@ use crate::protocol::{
LinkMessageType, PathMtuNotification, SessionMessageType, SessionReceiverReport,
SessionSenderReport,
};
use crate::NodeAddr;
use std::time::{Duration, Instant};
use tracing::{debug, info, trace, warn};
@@ -72,7 +72,11 @@ impl Node {
///
/// The peer is telling us about what they received from us. We feed
/// this to our metrics to compute RTT, loss rate, and trend indicators.
pub(in crate::node) async fn handle_receiver_report(&mut self, from: &NodeAddr, payload: &[u8]) {
pub(in crate::node) async fn handle_receiver_report(
&mut self,
from: &NodeAddr,
payload: &[u8],
) {
let rr = match ReceiverReport::decode(payload) {
Ok(rr) => rr,
Err(e) => {
@@ -101,7 +105,9 @@ 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 first_rtt = mmp
.metrics
.process_receiver_report(&rr, our_timestamp_ms, now);
// Feed SRTT back to sender/receiver report interval tuning
if let Some(srtt_ms) = mmp.metrics.srtt_ms() {
@@ -114,7 +120,8 @@ impl Node {
// (what fraction of peer's frames we received), using per-interval deltas.
let our_recv_packets = mmp.receiver.cumulative_packets_recv();
let peer_highest = mmp.receiver.highest_counter();
mmp.metrics.update_reverse_delivery(our_recv_packets, peer_highest);
mmp.metrics
.update_reverse_delivery(our_recv_packets, peer_highest);
trace!(
from = %peer_name,
@@ -128,7 +135,9 @@ 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.peers.iter()
let peer_costs: std::collections::HashMap<crate::NodeAddr, f64> = self
.peers
.iter()
.filter(|(_, p)| p.has_srtt())
.map(|(a, p)| (*a, p.link_cost()))
.collect();
@@ -179,7 +188,9 @@ impl Node {
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)
let peer_name = self
.peer_aliases
.get(node_addr)
.cloned()
.unwrap_or_else(|| peer.identity().short_npub());
@@ -261,7 +272,7 @@ impl Node {
let rtt_str = match m.srtt_ms() {
Some(rtt) => format!("{:.1}ms", rtt),
None => "n/a".to_string()
None => "n/a".to_string(),
};
let loss_str = format!("{:.1}%", m.loss_rate() * 100.0);
@@ -294,7 +305,9 @@ impl Node {
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)
let session_name = self
.peer_aliases
.get(dest_addr)
.cloned()
.unwrap_or_else(|| {
let (xonly, _) = entry.remote_pubkey().x_only_public_key();
@@ -362,7 +375,9 @@ impl Node {
}
Err(e) => {
// Peek at current failure count for log suppression
let failures = self.sessions.get(&dest_addr)
let failures = self
.sessions
.get(&dest_addr)
.and_then(|entry| entry.mmp())
.map(|mmp| mmp.sender.consecutive_send_failures())
.unwrap_or(0);
@@ -541,7 +556,10 @@ impl Node {
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 {
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");
}
}
+11 -7
View File
@@ -5,11 +5,11 @@
//! 2. Drain window expiry (clean up previous session after cutover)
//! 3. Initiator-side cutover (first send after handshake completion)
use crate::NodeAddr;
use crate::node::Node;
use crate::node::wire::build_msg1;
use crate::noise::HandshakeState;
use crate::protocol::{SessionDatagram, SessionSetup};
use crate::NodeAddr;
use tracing::{debug, info, trace, warn};
/// Keep previous session alive for this long after cutover.
@@ -69,7 +69,8 @@ impl Node {
}
let elapsed = peer.session_established_at().elapsed().as_secs();
let counter = peer.noise_session()
let counter = peer
.noise_session()
.map(|s| s.current_send_counter())
.unwrap_or(0);
@@ -88,9 +89,10 @@ impl Node {
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())
),
&& 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"
);
info!(
@@ -106,7 +108,8 @@ impl Node {
&& let Some(old_our_index) = peer.complete_drain()
{
if let Some(transport_id) = peer.transport_id() {
self.peers_by_index.remove(&(transport_id, old_our_index.as_u32()));
self.peers_by_index
.remove(&(transport_id, old_our_index.as_u32()));
}
let _ = self.index_allocator.free(old_our_index);
trace!(
@@ -208,7 +211,8 @@ impl Node {
}
// Register in pending_outbound for msg2 dispatch (maps to existing link)
self.pending_outbound.insert((transport_id, our_index.as_u32()), link_id);
self.pending_outbound
.insert((transport_id, our_index.as_u32()), link_id);
}
/// Resend pending rekey msg1s and abandon timed-out rekeys.
+9 -8
View File
@@ -1,10 +1,12 @@
//! RX event loop and packet dispatch.
use crate::control::{commands, ControlSocket};
use crate::control::queries;
use crate::control::{ControlSocket, commands};
use crate::node::wire::{
COMMON_PREFIX_SIZE, CommonPrefix, FMP_VERSION, PHASE_ESTABLISHED, PHASE_MSG1, PHASE_MSG2,
};
use crate::node::{Node, NodeError};
use crate::transport::ReceivedPacket;
use crate::node::wire::{CommonPrefix, PHASE_ESTABLISHED, PHASE_MSG1, PHASE_MSG2, FMP_VERSION, COMMON_PREFIX_SIZE};
use std::time::Duration;
use tracing::{debug, info, warn};
@@ -29,8 +31,7 @@ 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> {
let mut packet_rx = self.packet_rx.take()
.ok_or(NodeError::NotStarted)?;
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
@@ -53,12 +54,12 @@ impl Node {
}
};
let mut tick = tokio::time::interval(Duration::from_secs(self.config.node.tick_interval_secs));
let mut tick =
tokio::time::interval(Duration::from_secs(self.config.node.tick_interval_secs));
// Set up control socket channel
let (control_tx, mut control_rx) = tokio::sync::mpsc::channel::<
crate::control::ControlMessage,
>(32);
let (control_tx, mut control_rx) =
tokio::sync::mpsc::channel::<crate::control::ControlMessage>(32);
if self.config.node.control.enabled {
let config = self.config.node.control.clone();
+169 -101
View File
@@ -5,25 +5,27 @@
//! SessionSetup (Noise XK msg1), SessionAck (msg2), SessionMsg3 (msg3),
//! encrypted data, and error signals (CoordsRequired, PathBroken).
use crate::node::session::{EndToEndState, SessionEntry};
use crate::node::session_wire::{
build_fsp_header, fsp_prepend_inner_header, fsp_strip_inner_header,
parse_encrypted_coords, FspCommonPrefix, FspEncryptedHeader, 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,
};
use crate::protocol::{coords_wire_size, encode_coords};
use crate::upper::icmp::FIPS_OVERHEAD;
use crate::node::{Node, NodeError};
use crate::noise::{HandshakeState, XK_HANDSHAKE_MSG1_SIZE, XK_HANDSHAKE_MSG2_SIZE, XK_HANDSHAKE_MSG3_SIZE};
use crate::NodeAddr;
use crate::mmp::report::ReceiverReport;
use crate::mmp::{MAX_SESSION_REPORT_INTERVAL_MS, MIN_SESSION_REPORT_INTERVAL_MS};
use crate::node::session::{EndToEndState, 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,
};
use crate::node::{Node, NodeError};
use crate::noise::{
HandshakeState, XK_HANDSHAKE_MSG1_SIZE, XK_HANDSHAKE_MSG2_SIZE, XK_HANDSHAKE_MSG3_SIZE,
};
use crate::protocol::{
CoordsRequired, FspInnerFlags, MtuExceeded, PathBroken, PathMtuNotification, SessionAck,
SessionDatagram, SessionMessageType, SessionMsg3, SessionReceiverReport, SessionSenderReport,
SessionSetup,
};
use crate::NodeAddr;
use crate::protocol::{coords_wire_size, encode_coords};
use crate::upper::icmp::FIPS_OVERHEAD;
use secp256k1::PublicKey;
use tracing::{debug, info, trace};
@@ -48,7 +50,10 @@ impl Node {
let prefix = match FspCommonPrefix::parse(payload) {
Some(p) => p,
None => {
debug!(len = payload.len(), "Session payload too short for FSP prefix");
debug!(
len = payload.len(),
"Session payload too short for FSP prefix"
);
return;
}
};
@@ -89,7 +94,8 @@ impl Node {
}
}
FSP_PHASE_ESTABLISHED => {
self.handle_encrypted_session_msg(src_addr, payload, path_mtu, ce_flag).await;
self.handle_encrypted_session_msg(src_addr, payload, path_mtu, ce_flag)
.await;
}
_ => {
debug!(phase = prefix.phase, "Unknown FSP phase");
@@ -106,12 +112,21 @@ impl Node {
/// 4. AEAD decrypt with AAD = header_bytes
/// 5. Strip FSP inner header → timestamp, msg_type, inner_flags
/// 6. Dispatch by msg_type
async fn handle_encrypted_session_msg(&mut self, src_addr: &NodeAddr, payload: &[u8], path_mtu: u16, ce_flag: bool) {
async fn handle_encrypted_session_msg(
&mut self,
src_addr: &NodeAddr,
payload: &[u8],
path_mtu: u16,
ce_flag: bool,
) {
// Parse the 12-byte encrypted header (includes the 4-byte prefix)
let header = match FspEncryptedHeader::parse(payload) {
Some(h) => h,
None => {
debug!(len = payload.len(), "Encrypted session message too short for FSP header");
debug!(
len = payload.len(),
"Encrypted session message too short for FSP header"
);
return;
}
};
@@ -166,8 +181,8 @@ impl Node {
let received_k_bit = header.flags & FSP_FLAG_K != 0;
{
let entry = self.sessions.get(src_addr).unwrap();
let k_bit_flipped = received_k_bit != entry.current_k_bit()
&& entry.pending_new_session().is_some();
let k_bit_flipped =
received_k_bit != entry.current_k_bit() && entry.pending_new_session().is_some();
if k_bit_flipped {
let display_name = self.peer_display_name(src_addr);
@@ -234,7 +249,8 @@ impl Node {
self.sessions.insert(*src_addr, entry);
// Strip FSP inner header (6 bytes)
let (timestamp, msg_type, inner_flags_byte, rest) = match fsp_strip_inner_header(&plaintext) {
let (timestamp, msg_type, inner_flags_byte, rest) = match fsp_strip_inner_header(&plaintext)
{
Some(parts) => parts,
None => {
debug!(src = %self.peer_display_name(src_addr), "Decrypted payload too short for FSP inner header");
@@ -247,16 +263,15 @@ impl Node {
&& let Some(mmp) = entry.mmp_mut()
{
let now = std::time::Instant::now();
mmp.receiver.record_recv(
header.counter, timestamp, plaintext.len(), ce_flag, now,
);
mmp.receiver
.record_recv(header.counter, timestamp, plaintext.len(), ce_flag, now);
// 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,
);
let _spin_rtt = mmp
.spin_bit
.rx_observe(inner_flags.spin_bit, header.counter, now);
}
// Feed path_mtu from datagram envelope to MMP path MTU tracking.
@@ -283,9 +298,15 @@ impl Node {
FSP_PORT_IPV6_SHIM => {
use crate::FipsAddress;
let src_ipv6 = FipsAddress::from_node_addr(src_addr).to_ipv6().octets();
let dst_ipv6 = FipsAddress::from_node_addr(self.node_addr()).to_ipv6().octets();
let dst_ipv6 = FipsAddress::from_node_addr(self.node_addr())
.to_ipv6()
.octets();
match crate::upper::ipv6_shim::decompress_ipv6(service_payload, src_ipv6, dst_ipv6) {
match crate::upper::ipv6_shim::decompress_ipv6(
service_payload,
src_ipv6,
dst_ipv6,
) {
Some(mut packet) => {
if ce_flag {
mark_ipv6_ecn_ce(&mut packet);
@@ -533,7 +554,13 @@ impl Node {
let placeholder_pubkey = self.identity.keypair().public_key();
let now_ms = Self::now_ms();
let resend_interval = self.config.node.rate_limit.handshake_resend_interval_ms;
let mut entry = SessionEntry::new(*src_addr, placeholder_pubkey, EndToEndState::AwaitingMsg3(handshake), now_ms, false);
let mut entry = SessionEntry::new(
*src_addr,
placeholder_pubkey,
EndToEndState::AwaitingMsg3(handshake),
now_ms,
false,
);
entry.set_handshake_payload(ack_payload, now_ms + resend_interval);
self.sessions.insert(*src_addr, entry);
@@ -809,7 +836,13 @@ impl Node {
let now_ms = Self::now_ms();
// Replace the placeholder pubkey with the real one
let mut new_entry = SessionEntry::new(*src_addr, remote_pubkey, EndToEndState::Established(session), now_ms, false);
let mut new_entry = SessionEntry::new(
*src_addr,
remote_pubkey,
EndToEndState::Established(session),
now_ms,
false,
);
new_entry.set_coords_warmup_remaining(self.config.node.session.coords_warmup_packets);
new_entry.mark_established(now_ms);
new_entry.init_mmp(&self.config.node.session_mmp);
@@ -878,7 +911,8 @@ impl Node {
};
let now = std::time::Instant::now();
mmp.metrics.process_receiver_report(&rr, our_timestamp_ms, now);
mmp.metrics
.process_receiver_report(&rr, our_timestamp_ms, now);
// Feed SRTT back to sender/receiver report interval tuning (session-layer bounds)
if let Some(srtt_ms) = mmp.metrics.srtt_ms() {
@@ -900,7 +934,8 @@ impl Node {
// Update reverse delivery ratio from our own receiver state, using per-interval deltas.
let our_recv_packets = mmp.receiver.cumulative_packets_recv();
let peer_highest = mmp.receiver.highest_counter();
mmp.metrics.update_reverse_delivery(our_recv_packets, peer_highest);
mmp.metrics
.update_reverse_delivery(our_recv_packets, peer_highest);
trace!(
src = %peer_name,
@@ -975,7 +1010,10 @@ impl Node {
);
// Send standalone CoordsWarmup immediately (rate-limited)
if self.coords_response_rate_limiter.should_send(&msg.dest_addr) {
if self
.coords_response_rate_limiter
.should_send(&msg.dest_addr)
{
if let Some(entry) = self.sessions.get(&msg.dest_addr)
&& entry.is_established()
&& let Err(e) = self.send_coords_warmup(&msg.dest_addr).await
@@ -1033,7 +1071,10 @@ impl Node {
);
// Send standalone CoordsWarmup immediately (rate-limited)
if self.coords_response_rate_limiter.should_send(&msg.dest_addr) {
if self
.coords_response_rate_limiter
.should_send(&msg.dest_addr)
{
if let Some(entry) = self.sessions.get(&msg.dest_addr)
&& entry.is_established()
&& let Err(e) = self.send_coords_warmup(&msg.dest_addr).await
@@ -1139,16 +1180,17 @@ impl Node {
let our_keypair = self.identity.keypair();
let mut handshake = HandshakeState::new_xk_initiator(our_keypair, dest_pubkey);
handshake.set_local_epoch(self.startup_epoch);
let msg1 = handshake.write_xk_message_1().map_err(|e| NodeError::SendFailed {
node_addr: dest_addr,
reason: format!("Noise XK msg1 generation failed: {}", e),
})?;
let msg1 = handshake
.write_xk_message_1()
.map_err(|e| NodeError::SendFailed {
node_addr: dest_addr,
reason: format!("Noise XK msg1 generation failed: {}", e),
})?;
// Build SessionSetup with coordinates
let our_coords = self.tree_state.my_coords().clone();
let dest_coords = self.get_dest_coords(&dest_addr);
let setup = SessionSetup::new(our_coords, dest_coords)
.with_handshake(msg1);
let setup = SessionSetup::new(our_coords, dest_coords).with_handshake(msg1);
let setup_payload = setup.encode();
// Wrap in SessionDatagram
@@ -1165,7 +1207,13 @@ impl Node {
// Store session entry with handshake payload for potential resend
let now_ms = Self::now_ms();
let resend_interval = self.config.node.rate_limit.handshake_resend_interval_ms;
let mut entry = SessionEntry::new(dest_addr, dest_pubkey, EndToEndState::Initiating(handshake), now_ms, true);
let mut entry = SessionEntry::new(
dest_addr,
dest_pubkey,
EndToEndState::Initiating(handshake),
now_ms,
true,
);
entry.set_handshake_payload(setup_payload, now_ms + resend_interval);
self.sessions.insert(dest_addr, entry);
@@ -1192,10 +1240,13 @@ impl Node {
let now_ms = Self::now_ms();
// First borrow: read session metadata (NLL releases before coord decision)
let entry = self.sessions.get(dest_addr).ok_or_else(|| NodeError::SendFailed {
node_addr: *dest_addr,
reason: "no session".into(),
})?;
let entry = self
.sessions
.get(dest_addr)
.ok_or_else(|| NodeError::SendFailed {
node_addr: *dest_addr,
reason: "no session".into(),
})?;
let wants_coords = entry.coords_warmup_remaining() > 0;
let timestamp = entry.session_timestamp(now_ms);
let spin_bit = entry.mmp().is_some_and(|m| m.spin_bit.tx_bit());
@@ -1215,7 +1266,8 @@ impl Node {
// Build inner plaintext (doesn't depend on counter)
let msg_type = SessionMessageType::DataPacket.to_byte(); // 0x10
let inner_flags = FspInnerFlags { spin_bit }.to_byte();
let inner_plaintext = fsp_prepend_inner_header(timestamp, msg_type, inner_flags, &port_payload);
let inner_plaintext =
fsp_prepend_inner_header(timestamp, msg_type, inner_flags, &port_payload);
// Determine whether coords fit within transport MTU.
// If not, send standalone CoordsWarmup before the data packet.
@@ -1223,7 +1275,8 @@ impl Node {
let src = self.tree_state.my_coords().clone();
let dst = self.get_dest_coords(dest_addr);
let coords_size = coords_wire_size(&src) + coords_wire_size(&dst);
let total_wire = FIPS_OVERHEAD as usize + FSP_PORT_HEADER_SIZE + coords_size + payload.len();
let total_wire =
FIPS_OVERHEAD as usize + FSP_PORT_HEADER_SIZE + coords_size + payload.len();
if total_wire <= self.transport_mtu() as usize {
(true, Some(src), Some(dst))
} else {
@@ -1239,9 +1292,7 @@ impl Node {
};
// Decrement warmup counter if we sent coords (piggybacked or standalone)
if wants_coords
&& let Some(entry) = self.sessions.get_mut(dest_addr)
{
if wants_coords && let Some(entry) = self.sessions.get_mut(dest_addr) {
entry.set_coords_warmup_remaining(entry.coords_warmup_remaining() - 1);
}
@@ -1254,10 +1305,13 @@ impl Node {
}
// Borrow session for counter + encryption (after potential standalone send)
let entry = self.sessions.get_mut(dest_addr).ok_or_else(|| NodeError::SendFailed {
node_addr: *dest_addr,
reason: "no session".into(),
})?;
let entry = self
.sessions
.get_mut(dest_addr)
.ok_or_else(|| NodeError::SendFailed {
node_addr: *dest_addr,
reason: "no session".into(),
})?;
let session = match entry.state_mut() {
EndToEndState::Established(s) => s,
_ => {
@@ -1274,12 +1328,12 @@ impl Node {
let header = build_fsp_header(counter, flags, payload_len);
// Encrypt with AAD binding to the FSP header
let ciphertext = session.encrypt_with_aad(&inner_plaintext, &header).map_err(|e| {
NodeError::SendFailed {
let ciphertext = session
.encrypt_with_aad(&inner_plaintext, &header)
.map_err(|e| NodeError::SendFailed {
node_addr: *dest_addr,
reason: format!("session encrypt failed: {}", e),
}
})?;
})?;
// Assemble: header(12) + [coords] + ciphertext
let mut fsp_payload = Vec::with_capacity(FSP_HEADER_SIZE + ciphertext.len() + 200);
@@ -1317,13 +1371,19 @@ impl Node {
dest_addr: &NodeAddr,
ipv6_packet: &[u8],
) -> Result<(), NodeError> {
let compressed = crate::upper::ipv6_shim::compress_ipv6(ipv6_packet)
.ok_or_else(|| NodeError::SendFailed {
let compressed = crate::upper::ipv6_shim::compress_ipv6(ipv6_packet).ok_or_else(|| {
NodeError::SendFailed {
node_addr: *dest_addr,
reason: "IPv6 header compression failed".into(),
})?;
self.send_session_data(dest_addr, FSP_PORT_IPV6_SHIM, FSP_PORT_IPV6_SHIM, &compressed)
.await
}
})?;
self.send_session_data(
dest_addr,
FSP_PORT_IPV6_SHIM,
FSP_PORT_IPV6_SHIM,
&compressed,
)
.await
}
/// Send a non-data session message (reports, notifications) over an established session.
@@ -1342,10 +1402,13 @@ impl Node {
let now_ms = Self::now_ms();
// Read spin bit and session timestamp from entry
let entry = self.sessions.get(dest_addr).ok_or_else(|| NodeError::SendFailed {
node_addr: *dest_addr,
reason: "no session".into(),
})?;
let entry = self
.sessions
.get(dest_addr)
.ok_or_else(|| NodeError::SendFailed {
node_addr: *dest_addr,
reason: "no session".into(),
})?;
let timestamp = entry.session_timestamp(now_ms);
let spin_bit = entry.mmp().is_some_and(|m| m.spin_bit.tx_bit());
@@ -1353,10 +1416,13 @@ impl Node {
let inner_flags = FspInnerFlags { spin_bit }.to_byte();
// Get mutable access for encryption
let entry = self.sessions.get_mut(dest_addr).ok_or_else(|| NodeError::SendFailed {
node_addr: *dest_addr,
reason: "no session".into(),
})?;
let entry = self
.sessions
.get_mut(dest_addr)
.ok_or_else(|| NodeError::SendFailed {
node_addr: *dest_addr,
reason: "no session".into(),
})?;
// Read K-bit before mutable borrow of session state
let k_flags = if entry.current_k_bit() { FSP_FLAG_K } else { 0 };
@@ -1381,12 +1447,12 @@ impl Node {
let header = build_fsp_header(counter, k_flags, payload_len);
// Encrypt with AAD
let ciphertext = session.encrypt_with_aad(&inner_plaintext, &header).map_err(|e| {
NodeError::SendFailed {
let ciphertext = session
.encrypt_with_aad(&inner_plaintext, &header)
.map_err(|e| NodeError::SendFailed {
node_addr: *dest_addr,
reason: format!("session encrypt failed: {}", e),
}
})?;
})?;
// Assemble: header(12) + ciphertext (no coords)
let mut fsp_payload = Vec::with_capacity(FSP_HEADER_SIZE + ciphertext.len());
@@ -1416,28 +1482,31 @@ impl Node {
/// coordinates via `try_warm_coord_cache()` (same as CP-flagged data
/// packets). The encrypted inner payload is the 6-byte inner header
/// with no application data.
async fn send_coords_warmup(
&mut self,
dest_addr: &NodeAddr,
) -> Result<(), NodeError> {
async fn send_coords_warmup(&mut self, dest_addr: &NodeAddr) -> Result<(), NodeError> {
let now_ms = Self::now_ms();
let my_coords = self.tree_state.my_coords().clone();
let dest_coords = self.get_dest_coords(dest_addr);
// Read session metadata
let entry = self.sessions.get(dest_addr).ok_or_else(|| NodeError::SendFailed {
node_addr: *dest_addr,
reason: "no session".into(),
})?;
let entry = self
.sessions
.get(dest_addr)
.ok_or_else(|| NodeError::SendFailed {
node_addr: *dest_addr,
reason: "no session".into(),
})?;
let timestamp = entry.session_timestamp(now_ms);
let spin_bit = entry.mmp().is_some_and(|m| m.spin_bit.tx_bit());
// Get mutable access for encryption
let entry = self.sessions.get_mut(dest_addr).ok_or_else(|| NodeError::SendFailed {
node_addr: *dest_addr,
reason: "no session".into(),
})?;
let entry = self
.sessions
.get_mut(dest_addr)
.ok_or_else(|| NodeError::SendFailed {
node_addr: *dest_addr,
reason: "no session".into(),
})?;
let session = match entry.state_mut() {
EndToEndState::Established(s) => s,
_ => {
@@ -1460,12 +1529,12 @@ impl Node {
let header = build_fsp_header(counter, FSP_FLAG_CP, payload_len);
// Encrypt with AAD
let ciphertext = session.encrypt_with_aad(&inner_plaintext, &header).map_err(|e| {
NodeError::SendFailed {
let ciphertext = session
.encrypt_with_aad(&inner_plaintext, &header)
.map_err(|e| NodeError::SendFailed {
node_addr: *dest_addr,
reason: format!("session encrypt failed: {}", e),
}
})?;
})?;
// Assemble: header(12) + coords + ciphertext
let coords_size = coords_wire_size(&my_coords) + coords_wire_size(&dest_coords);
@@ -1532,7 +1601,8 @@ impl Node {
}
let encoded = datagram.encode();
self.send_encrypted_link_message(&next_hop_addr, &encoded).await?;
self.send_encrypted_link_message(&next_hop_addr, &encoded)
.await?;
self.stats_mut().forwarding.record_originated(encoded.len());
Ok(())
}
@@ -1639,19 +1709,20 @@ impl Node {
/// Send ICMPv6 Destination Unreachable back through TUN.
pub(in crate::node) fn send_icmpv6_dest_unreachable(&self, original_packet: &[u8]) {
use crate::upper::icmp::{build_dest_unreachable, should_send_icmp_error, DestUnreachableCode};
use crate::FipsAddress;
use crate::upper::icmp::{
DestUnreachableCode, build_dest_unreachable, should_send_icmp_error,
};
if !should_send_icmp_error(original_packet) {
return;
}
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 {
if let Some(response) =
build_dest_unreachable(original_packet, DestUnreachableCode::NoRoute, our_ipv6)
&& let Some(tun_tx) = &self.tun_tx
{
let _ = tun_tx.send(response);
}
}
@@ -1708,10 +1779,7 @@ impl Node {
return;
}
let queue = self
.pending_tun_packets
.entry(dest_addr)
.or_default();
let queue = self.pending_tun_packets.entry(dest_addr).or_default();
if queue.len() >= self.config.node.session.pending_packets_per_dest {
queue.pop_front(); // Drop oldest
}
+20 -17
View File
@@ -22,7 +22,9 @@ impl Node {
.unwrap_or(0);
let timeout_ms = self.config.node.rate_limit.handshake_timeout_secs * 1000;
let stale: Vec<LinkId> = self.connections.iter()
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();
@@ -96,7 +98,9 @@ impl Node {
// 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.iter()
let candidates: Vec<(LinkId, Vec<u8>)> = self
.connections
.iter()
.filter(|(_, conn)| {
conn.is_outbound()
&& conn.handshake_state() == HandshakeState::SentMsg1
@@ -136,9 +140,7 @@ impl Node {
false
};
if sent
&& let Some(conn) = self.connections.get_mut(&link_id)
{
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);
@@ -169,10 +171,11 @@ impl Node {
let ttl = self.config.node.session.default_ttl;
// First pass: find timed-out sessions to remove
let timed_out: Vec<crate::NodeAddr> = self.sessions.iter()
let timed_out: Vec<crate::NodeAddr> = self
.sessions
.iter()
.filter(|(_, entry)| {
!entry.is_established()
&& now_ms.saturating_sub(entry.last_activity()) > timeout_ms
!entry.is_established() && now_ms.saturating_sub(entry.last_activity()) > timeout_ms
})
.map(|(addr, _)| *addr)
.collect();
@@ -186,7 +189,9 @@ impl Node {
// Second pass: collect resend candidates
let my_addr = *self.node_addr();
let candidates: Vec<(crate::NodeAddr, Vec<u8>)> = self.sessions.iter()
let candidates: Vec<(crate::NodeAddr, Vec<u8>)> = self
.sessions
.iter()
.filter(|(_, entry)| {
!entry.is_established()
&& entry.handshake_payload().is_some()
@@ -200,8 +205,7 @@ impl Node {
for (dest_addr, payload) in candidates {
use crate::protocol::SessionDatagram;
let mut datagram = SessionDatagram::new(my_addr, dest_addr, payload)
.with_ttl(ttl);
let mut datagram = SessionDatagram::new(my_addr, dest_addr, payload).with_ttl(ttl);
let sent = match self.send_session_datagram(&mut datagram).await {
Ok(_) => true,
Err(e) => {
@@ -214,9 +218,7 @@ impl Node {
}
};
if sent
&& let Some(entry) = self.sessions.get_mut(&dest_addr)
{
if sent && let Some(entry) = self.sessions.get_mut(&dest_addr) {
let count = entry.resend_count() + 1;
let next = now_ms + (interval_ms as f64 * backoff.powi(count as i32)) as u64;
entry.record_resend(next);
@@ -239,10 +241,11 @@ impl Node {
return; // disabled
}
let idle: Vec<_> = self.sessions.iter()
let idle: Vec<_> = self
.sessions
.iter()
.filter(|(_, entry)| {
entry.is_established()
&& now_ms.saturating_sub(entry.last_activity()) > timeout_ms
entry.is_established() && now_ms.saturating_sub(entry.last_activity()) > timeout_ms
})
.map(|(addr, _)| *addr)
.collect();
+81 -41
View File
@@ -1,11 +1,11 @@
//! Node lifecycle management: start, stop, and peer connection initiation.
use super::{Node, NodeError, NodeState};
use crate::node::wire::build_msg1;
use crate::peer::PeerConnection;
use crate::protocol::{Disconnect, DisconnectReason};
use crate::transport::{packet_channel, Link, LinkDirection, LinkId, TransportAddr, TransportId};
use crate::upper::tun::{run_tun_reader, shutdown_tun_interface, TunDevice, TunState};
use crate::node::wire::build_msg1;
use crate::transport::{Link, LinkDirection, LinkId, TransportAddr, TransportId, packet_channel};
use crate::upper::tun::{TunDevice, TunState, run_tun_reader, shutdown_tun_interface};
use crate::{NodeAddr, PeerIdentity};
use std::thread;
use std::time::Duration;
@@ -50,7 +50,10 @@ impl Node {
return;
}
info!(count = peer_configs.len(), "Initiating static peer connections");
info!(
count = peer_configs.len(),
"Initiating static peer connections"
);
for peer_config in peer_configs {
if let Err(e) = self.initiate_peer_connection(&peer_config).await {
@@ -67,14 +70,16 @@ impl Node {
/// Initiate a connection to a single peer.
///
/// Creates a link, starts the Noise handshake, and sends the first message.
pub(super) async fn initiate_peer_connection(&mut self, peer_config: &crate::config::PeerConfig) -> Result<(), NodeError> {
pub(super) async fn initiate_peer_connection(
&mut self,
peer_config: &crate::config::PeerConfig,
) -> Result<(), NodeError> {
// Parse the peer's npub to get their identity
let peer_identity = PeerIdentity::from_npub(&peer_config.npub).map_err(|e| {
NodeError::InvalidPeerNpub {
let peer_identity =
PeerIdentity::from_npub(&peer_config.npub).map_err(|e| NodeError::InvalidPeerNpub {
npub: peer_config.npub.clone(),
reason: e.to_string(),
}
})?;
})?;
let peer_node_addr = *peer_identity.node_addr();
@@ -134,7 +139,10 @@ impl Node {
(tid, TransportAddr::from_string(&addr.addr))
};
match self.initiate_connection(transport_id, remote_addr, peer_identity).await {
match self
.initiate_connection(transport_id, remote_addr, peer_identity)
.await
{
Ok(()) => return Ok(()),
Err(e) => {
debug!(
@@ -173,7 +181,9 @@ impl Node {
) -> Result<(), NodeError> {
let peer_node_addr = *peer_identity.node_addr();
let is_connection_oriented = self.transports.get(&transport_id)
let is_connection_oriented = self
.transports
.get(&transport_id)
.map(|t| t.transport_type().connection_oriented)
.unwrap_or(false);
@@ -234,7 +244,8 @@ impl Node {
Ok(())
} else {
// Connectionless: proceed with immediate handshake
self.start_handshake(link_id, transport_id, remote_addr, peer_identity).await
self.start_handshake(link_id, transport_id, remote_addr, peer_identity)
.await
}
}
@@ -271,16 +282,17 @@ impl Node {
// Start the Noise handshake and get message 1
let our_keypair = self.identity.keypair();
let noise_msg1 = match connection.start_handshake(our_keypair, self.startup_epoch, current_time_ms) {
Ok(msg) => msg,
Err(e) => {
// Clean up the index and link
let _ = self.index_allocator.free(our_index);
self.links.remove(&link_id);
self.addr_to_link.remove(&(transport_id, remote_addr));
return Err(NodeError::HandshakeFailed(e.to_string()));
}
};
let noise_msg1 =
match connection.start_handshake(our_keypair, self.startup_epoch, current_time_ms) {
Ok(msg) => msg,
Err(e) => {
// Clean up the index and link
let _ = self.index_allocator.free(our_index);
self.links.remove(&link_id);
self.addr_to_link.remove(&(transport_id, remote_addr));
return Err(NodeError::HandshakeFailed(e.to_string()));
}
};
// Set index and transport info on the connection
connection.set_our_index(our_index);
@@ -304,7 +316,8 @@ impl Node {
connection.set_handshake_msg1(wire_msg1.clone(), current_time_ms + resend_interval);
// Track in pending_outbound for msg2 dispatch
self.pending_outbound.insert((transport_id, our_index.as_u32()), link_id);
self.pending_outbound
.insert((transport_id, our_index.as_u32()), link_id);
self.connections.insert(link_id, connection);
// Send the wire format handshake message
@@ -395,7 +408,10 @@ impl Node {
remote_addr = %remote_addr,
"Auto-connecting to discovered peer"
);
if let Err(e) = self.initiate_connection(transport_id, remote_addr, identity).await {
if let Err(e) = self
.initiate_connection(transport_id, remote_addr, identity)
.await
{
warn!(error = %e, "Failed to auto-connect to discovered peer");
}
}
@@ -457,12 +473,15 @@ impl Node {
);
// Start the handshake now that the transport is connected
if let Err(e) = self.start_handshake(
pending.link_id,
pending.transport_id,
pending.remote_addr.clone(),
pending.peer_identity,
).await {
if let Err(e) = self
.start_handshake(
pending.link_id,
pending.transport_id,
pending.remote_addr.clone(),
pending.peer_identity,
)
.await
{
warn!(
link_id = %pending.link_id,
error = %e,
@@ -559,7 +578,7 @@ impl Node {
// Calculate max MSS for TCP clamping
let effective_mtu = self.effective_ipv6_mtu();
let max_mss = effective_mtu.saturating_sub(40).saturating_sub(20); // IPv6 + TCP headers
info!("effective MTU: {} bytes", effective_mtu);
info!(" max TCP MSS: {} bytes", max_mss);
@@ -581,7 +600,14 @@ impl Node {
// Spawn reader thread
let transport_mtu = self.transport_mtu();
let reader_handle = thread::spawn(move || {
run_tun_reader(device, mtu, our_addr, reader_tun_tx, outbound_tx, transport_mtu);
run_tun_reader(
device,
mtu,
our_addr,
reader_tun_tx,
outbound_tx,
transport_mtu,
);
});
self.tun_state = TunState::Active;
@@ -606,11 +632,19 @@ impl Node {
let dns_channel_size = self.config.node.buffers.dns_channel;
let (identity_tx, identity_rx) = tokio::sync::mpsc::channel(dns_channel_size);
let dns_ttl = self.config.dns.ttl();
let base_hosts = crate::upper::hosts::HostMap::from_peer_configs(self.config.peers());
let hosts_path = std::path::PathBuf::from(crate::upper::hosts::DEFAULT_HOSTS_PATH);
let reloader = crate::upper::hosts::HostMapReloader::new(base_hosts, hosts_path);
let base_hosts =
crate::upper::hosts::HostMap::from_peer_configs(self.config.peers());
let hosts_path =
std::path::PathBuf::from(crate::upper::hosts::DEFAULT_HOSTS_PATH);
let reloader =
crate::upper::hosts::HostMapReloader::new(base_hosts, hosts_path);
info!(bind = %bind, hosts = reloader.hosts().len(), "DNS responder started for .fips domain (auto-reload enabled)");
let handle = tokio::spawn(crate::upper::dns::run_dns_responder(socket, identity_tx, dns_ttl, reloader));
let handle = tokio::spawn(crate::upper::dns::run_dns_responder(
socket,
identity_tx,
dns_ttl,
reloader,
));
self.dns_identity_rx = Some(identity_rx);
self.dns_task = Some(handle);
}
@@ -646,7 +680,8 @@ impl Node {
}
// Send disconnect notifications to all active peers before closing transports
self.send_disconnect_to_all_peers(DisconnectReason::Shutdown).await;
self.send_disconnect_to_all_peers(DisconnectReason::Shutdown)
.await;
// Shutdown transports (they're packet producers)
let transport_ids: Vec<_> = self.transports.keys().cloned().collect();
@@ -710,7 +745,9 @@ impl Node {
let plaintext = disconnect.encode();
// Collect node_addrs to avoid borrow conflict with send helper
let peer_addrs: Vec<NodeAddr> = self.peers.iter()
let peer_addrs: Vec<NodeAddr> = self
.peers
.iter()
.filter(|(_, peer)| peer.can_send() && peer.has_session())
.map(|(addr, _)| *addr)
.collect();
@@ -725,7 +762,10 @@ impl Node {
let mut sent = 0usize;
for node_addr in &peer_addrs {
match self.send_encrypted_link_message(node_addr, &plaintext).await {
match self
.send_encrypted_link_message(node_addr, &plaintext)
.await
{
Ok(()) => sent += 1,
Err(e) => {
debug!(
@@ -790,8 +830,8 @@ impl Node {
///
/// Removes the peer and suppresses auto-reconnect.
pub(crate) fn api_disconnect(&mut self, npub: &str) -> Result<serde_json::Value, String> {
let peer_identity = PeerIdentity::from_npub(npub)
.map_err(|e| format!("invalid npub '{npub}': {e}"))?;
let peer_identity =
PeerIdentity::from_npub(npub).map_err(|e| format!("invalid npub '{npub}': {e}"))?;
let node_addr = *peer_identity.node_addr();
if !self.peers.contains_key(&node_addr) {
+129 -63
View File
@@ -5,41 +5,44 @@
//! Bloom filters, coordinate caches, transports, links, and peers.
mod bloom;
mod discovery_rate_limit;
mod handlers;
mod lifecycle;
mod retry;
mod discovery_rate_limit;
mod rate_limit;
mod retry;
mod routing_error_rate_limit;
pub(crate) mod session;
pub(crate) mod session_wire;
pub(crate) mod wire;
pub(crate) mod stats;
mod tree;
#[cfg(test)]
mod tests;
mod tree;
pub(crate) mod wire;
use crate::bloom::BloomState;
use crate::cache::CoordCache;
use crate::utils::index::IndexAllocator;
use crate::node::session::SessionEntry;
use crate::peer::{ActivePeer, PeerConnection};
use self::discovery_rate_limit::{DiscoveryBackoff, DiscoveryForwardRateLimiter};
use self::rate_limit::HandshakeRateLimiter;
use self::routing_error_rate_limit::RoutingErrorRateLimiter;
use self::wire::{
FLAG_CE, FLAG_KEY_EPOCH, FLAG_SP, build_encrypted, build_established_header,
prepend_inner_header,
};
use crate::bloom::BloomState;
use crate::cache::CoordCache;
use crate::node::session::SessionEntry;
use crate::peer::{ActivePeer, PeerConnection};
#[cfg(target_os = "linux")]
use crate::transport::ethernet::EthernetTransport;
use crate::transport::tcp::TcpTransport;
use crate::transport::tor::TorTransport;
use crate::transport::udp::UdpTransport;
use crate::transport::{
Link, LinkId, PacketRx, PacketTx, TransportAddr, TransportError, TransportHandle, TransportId,
};
use crate::transport::udp::UdpTransport;
use crate::transport::tcp::TcpTransport;
use crate::transport::tor::TorTransport;
#[cfg(target_os = "linux")]
use crate::transport::ethernet::EthernetTransport;
use crate::tree::TreeState;
use crate::upper::hosts::HostMap;
use crate::upper::icmp_rate_limit::IcmpRateLimiter;
use crate::upper::tun::{TunError, TunOutboundRx, TunState, TunTx};
use self::wire::{build_encrypted, build_established_header, prepend_inner_header, FLAG_CE, FLAG_KEY_EPOCH, FLAG_SP};
use crate::utils::index::IndexAllocator;
use crate::{Config, ConfigError, Identity, IdentityError, NodeAddr, PeerIdentity};
use rand::Rng;
use std::collections::{HashMap, VecDeque};
@@ -106,7 +109,11 @@ pub enum NodeError {
SendFailed { node_addr: NodeAddr, reason: String },
#[error("mtu exceeded forwarding to {node_addr}: packet {packet_size} > mtu {mtu}")]
MtuExceeded { node_addr: NodeAddr, packet_size: usize, mtu: u16 },
MtuExceeded {
node_addr: NodeAddr,
packet_size: usize,
mtu: u16,
},
#[error("config error: {0}")]
Config(#[from] ConfigError),
@@ -423,6 +430,13 @@ pub struct Node {
/// Timestamp of last mesh size log emission.
last_mesh_size_log: Option<std::time::Instant>,
// === Bloom Self-Plausibility ===
/// Rate-limit state for the self-plausibility WARN. Fires at most
/// once per 60s globally when our own outgoing FilterAnnounce has
/// an FPR above `node.bloom.max_inbound_fpr`, signalling either
/// aggregation drift or an ingress bypass.
last_self_warn: Option<std::time::Instant>,
// === Display Names ===
/// Human-readable names for configured peers (alias or short npub).
/// Populated at startup from peer config.
@@ -541,10 +555,7 @@ impl Node {
coords_response_rate_limiter: RoutingErrorRateLimiter::with_interval(
std::time::Duration::from_millis(coords_response_interval_ms),
),
discovery_backoff: DiscoveryBackoff::with_params(
backoff_base_secs,
backoff_max_secs,
),
discovery_backoff: DiscoveryBackoff::with_params(backoff_base_secs, backoff_max_secs),
discovery_forward_limiter: DiscoveryForwardRateLimiter::with_interval(
std::time::Duration::from_secs(forward_min_interval_secs),
),
@@ -554,6 +565,7 @@ impl Node {
last_congestion_log: None,
estimated_mesh_size: None,
last_mesh_size_log: None,
last_self_warn: None,
peer_aliases: HashMap::new(),
host_map,
})
@@ -659,6 +671,7 @@ impl Node {
last_congestion_log: None,
estimated_mesh_size: None,
last_mesh_size_log: None,
last_self_warn: None,
peer_aliases: HashMap::new(),
host_map,
}
@@ -708,7 +721,8 @@ impl Node {
let xonly = self.identity.pubkey();
for (name, eth_config) in eth_instances {
let transport_id = self.allocate_transport_id();
let mut eth = EthernetTransport::new(transport_id, name, eth_config, packet_tx.clone());
let mut eth =
EthernetTransport::new(transport_id, name, eth_config, packet_tx.clone());
eth.set_local_pubkey(xonly);
transports.push(TransportHandle::Ethernet(eth));
}
@@ -947,17 +961,30 @@ impl Node {
let parent_id = *self.tree_state.my_declaration().parent_id();
let is_root = self.tree_state.is_root();
let max_fpr = self.config.node.bloom.max_inbound_fpr;
let mut total: f64 = 1.0; // count self
let mut child_count: u32 = 0;
let mut has_data = false;
// Parent's filter: nodes reachable upward through the tree
// Parent's filter: nodes reachable upward through the tree.
// If any contributing filter is above the FPR cap, we refuse to
// estimate rather than substitute a partial/biased aggregate —
// Node.estimated_mesh_size is already Option<u64> and consumers
// (control socket, fipstop, periodic debug log) handle None.
if !is_root
&& let Some(parent) = self.peers.get(&parent_id)
&& let Some(filter) = parent.inbound_filter()
{
total += filter.estimated_count();
has_data = true;
match filter.estimated_count(max_fpr) {
Some(n) => {
total += n;
has_data = true;
}
None => {
self.estimated_mesh_size = None;
return;
}
}
}
// Children's filters: each child's subtree is disjoint
@@ -967,8 +994,16 @@ impl Node {
{
child_count += 1;
if let Some(filter) = peer.inbound_filter() {
total += filter.estimated_count();
has_data = true;
match filter.estimated_count(max_fpr) {
Some(n) => {
total += n;
has_data = true;
}
None => {
self.estimated_mesh_size = None;
return;
}
}
}
}
}
@@ -985,9 +1020,10 @@ impl Node {
let now = std::time::Instant::now();
let should_log = match self.last_mesh_size_log {
None => true,
Some(last) => now.duration_since(last) >= std::time::Duration::from_secs(
self.config.node.mmp.log_interval_secs,
),
Some(last) => {
now.duration_since(last)
>= std::time::Duration::from_secs(self.config.node.mmp.log_interval_secs)
}
};
if should_log {
tracing::info!(
@@ -1036,7 +1072,6 @@ impl Node {
self.tun_name.as_deref()
}
// === Resource Limits ===
/// Set the maximum number of connections (handshake phase).
@@ -1117,14 +1152,17 @@ impl Node {
/// Add a link.
pub fn add_link(&mut self, link: Link) -> Result<(), NodeError> {
if self.max_links > 0 && self.links.len() >= self.max_links {
return Err(NodeError::MaxLinksExceeded { max: self.max_links });
return Err(NodeError::MaxLinksExceeded {
max: self.max_links,
});
}
let link_id = link.link_id();
let transport_id = link.transport_id();
let remote_addr = link.remote_addr().clone();
self.links.insert(link_id, link);
self.addr_to_link.insert((transport_id, remote_addr), link_id);
self.addr_to_link
.insert((transport_id, remote_addr), link_id);
Ok(())
}
@@ -1139,8 +1177,14 @@ impl Node {
}
/// Find link ID by transport address.
pub fn find_link_by_addr(&self, transport_id: TransportId, addr: &TransportAddr) -> Option<LinkId> {
self.addr_to_link.get(&(transport_id, addr.clone())).copied()
pub fn find_link_by_addr(
&self,
transport_id: TransportId,
addr: &TransportAddr,
) -> Option<LinkId> {
self.addr_to_link
.get(&(transport_id, addr.clone()))
.copied()
}
/// Remove a link.
@@ -1286,11 +1330,14 @@ impl Node {
pub(crate) fn register_identity(&mut self, node_addr: NodeAddr, pubkey: secp256k1::PublicKey) {
let mut prefix = [0u8; 15];
prefix.copy_from_slice(&node_addr.as_bytes()[0..15]);
self.identity_cache.insert(prefix, (node_addr, pubkey, Self::now_ms()));
self.identity_cache
.insert(prefix, (node_addr, pubkey, Self::now_ms()));
// LRU eviction
let max = self.config.node.cache.identity_size;
if self.identity_cache.len() > max
&& let Some(oldest_key) = self.identity_cache.iter()
&& let Some(oldest_key) = self
.identity_cache
.iter()
.min_by_key(|(_, (_, _, ts))| *ts)
.map(|(k, _)| *k)
{
@@ -1299,7 +1346,10 @@ impl Node {
}
/// Look up a destination by FipsAddress prefix (bytes 1-15 of the IPv6 address).
pub(crate) fn lookup_by_fips_prefix(&mut self, prefix: &[u8; 15]) -> Option<(NodeAddr, secp256k1::PublicKey)> {
pub(crate) fn lookup_by_fips_prefix(
&mut self,
prefix: &[u8; 15],
) -> Option<(NodeAddr, secp256k1::PublicKey)> {
if let Some(entry) = self.identity_cache.get_mut(prefix) {
entry.2 = Self::now_ms(); // LRU touch
Some((entry.0, entry.1))
@@ -1338,9 +1388,7 @@ impl Node {
/// has declared us as their parent (making them our child).
pub(crate) fn is_tree_peer(&self, peer_addr: &NodeAddr) -> bool {
// Peer is our parent
if !self.tree_state.is_root()
&& self.tree_state.my_declaration().parent_id() == peer_addr
{
if !self.tree_state.is_root() && self.tree_state.my_declaration().parent_id() == peer_addr {
return true;
}
// Peer is our child (their declaration names us as parent)
@@ -1389,12 +1437,18 @@ impl Node {
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let dest_coords = self.coord_cache.get_and_touch(dest_node_addr, now_ms)?.clone();
let dest_coords = self
.coord_cache
.get_and_touch(dest_node_addr, now_ms)?
.clone();
// 3. Bloom filter candidates — requires dest_coords for loop-free selection
// 3. Bloom filter candidates — requires dest_coords for loop-free selection.
// If no candidate is strictly closer, fall through to tree routing.
let candidates: Vec<&ActivePeer> = self.destination_in_filters(dest_node_addr);
if !candidates.is_empty() {
return self.select_best_candidate(&candidates, &dest_coords);
if !candidates.is_empty()
&& let Some(peer) = self.select_best_candidate(&candidates, &dest_coords)
{
return Some(peer);
}
// 4. Greedy tree routing fallback
@@ -1488,7 +1542,8 @@ impl Node {
node_addr: &NodeAddr,
plaintext: &[u8],
) -> Result<(), NodeError> {
self.send_encrypted_link_message_with_ce(node_addr, plaintext, false).await
self.send_encrypted_link_message_with_ce(node_addr, plaintext, false)
.await
}
/// Like `send_encrypted_link_message` but allows setting the FMP CE flag.
@@ -1500,7 +1555,9 @@ impl Node {
plaintext: &[u8],
ce_flag: bool,
) -> Result<(), NodeError> {
let peer = self.peers.get_mut(node_addr)
let peer = self
.peers
.get_mut(node_addr)
.ok_or(NodeError::PeerNotFound(*node_addr))?;
let their_index = peer.their_index().ok_or_else(|| NodeError::SendFailed {
@@ -1511,18 +1568,19 @@ impl Node {
node_addr: *node_addr,
reason: "no transport_id".into(),
})?;
let remote_addr = peer.current_addr().cloned().ok_or_else(|| NodeError::SendFailed {
node_addr: *node_addr,
reason: "no current_addr".into(),
})?;
let remote_addr = peer
.current_addr()
.cloned()
.ok_or_else(|| NodeError::SendFailed {
node_addr: *node_addr,
reason: "no current_addr".into(),
})?;
// Prepend 4-byte session-relative timestamp (inner header)
let timestamp_ms = peer.session_elapsed_ms();
// MMP: read spin bit value before entering session borrow
let sp_flag = peer.mmp()
.map(|mmp| mmp.spin_bit.tx_bit())
.unwrap_or(false);
let sp_flag = peer.mmp().map(|mmp| mmp.spin_bit.tx_bit()).unwrap_or(false);
let mut flags = if sp_flag { FLAG_SP } else { 0 };
if ce_flag {
flags |= FLAG_CE;
@@ -1531,10 +1589,12 @@ impl Node {
flags |= FLAG_KEY_EPOCH;
}
let session = peer.noise_session_mut().ok_or_else(|| NodeError::SendFailed {
node_addr: *node_addr,
reason: "no noise session".into(),
})?;
let session = peer
.noise_session_mut()
.ok_or_else(|| NodeError::SendFailed {
node_addr: *node_addr,
reason: "no noise session".into(),
})?;
// Inner plaintext: [timestamp:4 LE][msg_type][payload...]
let inner_plaintext = prepend_inner_header(timestamp_ms, plaintext);
@@ -1545,18 +1605,24 @@ impl Node {
let header = build_established_header(their_index, counter, flags, payload_len);
// Encrypt with AAD binding to the outer header
let ciphertext = session.encrypt_with_aad(&inner_plaintext, &header).map_err(|e| NodeError::SendFailed {
node_addr: *node_addr,
reason: format!("encryption failed: {}", e),
})?;
let ciphertext = session
.encrypt_with_aad(&inner_plaintext, &header)
.map_err(|e| NodeError::SendFailed {
node_addr: *node_addr,
reason: format!("encryption failed: {}", e),
})?;
let wire_packet = build_encrypted(&header, &ciphertext);
// Re-borrow peer for stats update after sending
let transport = self.transports.get(&transport_id)
let transport = self
.transports
.get(&transport_id)
.ok_or(NodeError::TransportNotFound(transport_id))?;
let bytes_sent = transport.send(&remote_addr, &wire_packet).await
let bytes_sent = transport
.send(&remote_addr, &wire_packet)
.await
.map_err(|e| match e {
TransportError::MtuExceeded { packet_size, mtu } => NodeError::MtuExceeded {
node_addr: *node_addr,
-1
View File
@@ -237,7 +237,6 @@ impl HandshakeRateLimiter {
}
}
#[cfg(test)]
mod tests {
use super::*;
+10 -10
View File
@@ -5,9 +5,9 @@
//! (not PeerConnection) because each retry creates a fresh connection.
use super::Node;
use crate::PeerIdentity;
use crate::config::PeerConfig;
use crate::identity::NodeAddr;
use crate::PeerIdentity;
use tracing::{debug, info, warn};
// MAX_BACKOFF_MS is now derived from config: node.retry.max_backoff_secs * 1000
@@ -44,7 +44,9 @@ impl RetryState {
/// 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)
base_interval_ms
.saturating_mul(multiplier)
.min(max_backoff_ms)
}
}
@@ -55,11 +57,7 @@ impl Node {
/// 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,
) {
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 {
@@ -240,8 +238,7 @@ impl Node {
// 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;
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;
}
@@ -317,7 +314,10 @@ mod tests {
retry_after_ms: 0,
reconnect: false,
};
assert_eq!(state.backoff_ms(5000, TEST_MAX_BACKOFF_MS), TEST_MAX_BACKOFF_MS);
assert_eq!(
state.backoff_ms(5000, TEST_MAX_BACKOFF_MS),
TEST_MAX_BACKOFF_MS
);
}
#[test]
-1
View File
@@ -70,7 +70,6 @@ impl RoutingErrorRateLimiter {
pub fn len(&self) -> usize {
self.last_sent.len()
}
}
impl Default for RoutingErrorRateLimiter {
+13 -4
View File
@@ -7,10 +7,10 @@
use std::time::Instant;
use crate::NodeAddr;
use crate::config::SessionMmpConfig;
use crate::mmp::MmpSessionState;
use crate::noise::{HandshakeState, NoiseSession};
use crate::NodeAddr;
use secp256k1::PublicKey;
/// State machine for an end-to-end session.
@@ -159,12 +159,16 @@ impl SessionEntry {
/// Get the current session state.
#[cfg(test)]
pub(crate) fn state(&self) -> &EndToEndState {
self.state.as_ref().expect("session state taken but not restored")
self.state
.as_ref()
.expect("session state taken but not restored")
}
/// Get mutable access to the session state.
pub(crate) fn state_mut(&mut self) -> &mut EndToEndState {
self.state.as_mut().expect("session state taken but not restored")
self.state
.as_mut()
.expect("session state taken but not restored")
}
/// Replace the session state.
@@ -278,7 +282,12 @@ impl SessionEntry {
/// Get traffic counters: (packets_sent, packets_recv, bytes_sent, bytes_recv).
pub(crate) fn traffic_counters(&self) -> (u64, u64, u64, u64) {
(self.packets_sent, self.packets_recv, self.bytes_sent, self.bytes_recv)
(
self.packets_sent,
self.packets_recv,
self.bytes_sent,
self.bytes_recv,
)
}
// === Handshake Resend ===
+11 -13
View File
@@ -208,8 +208,7 @@ impl FspEncryptedHeader {
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],
data[4], data[5], data[6], data[7], data[8], data[9], data[10], data[11],
]);
let mut header_bytes = [0u8; FSP_HEADER_SIZE];
@@ -242,11 +241,7 @@ impl FspEncryptedHeader {
/// 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] {
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;
@@ -323,12 +318,15 @@ 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 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..]))
Some((
timestamp,
msg_type,
inner_flags,
&plaintext[FSP_INNER_HEADER_SIZE..],
))
}
// ============================================================================
@@ -465,8 +463,8 @@ mod tests {
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],
header[4], header[5], header[6], header[7], header[8], header[9], header[10],
header[11],
]),
1000
);
+3
View File
@@ -211,6 +211,7 @@ pub struct BloomStats {
pub non_v1: u64,
pub unknown_peer: u64,
pub stale: u64,
pub fill_exceeded: u64,
pub accepted: u64,
// Outbound announce sending
pub sent: u64,
@@ -227,6 +228,7 @@ impl BloomStats {
non_v1: self.non_v1,
unknown_peer: self.unknown_peer,
stale: self.stale,
fill_exceeded: self.fill_exceeded,
accepted: self.accepted,
sent: self.sent,
debounce_suppressed: self.debounce_suppressed,
@@ -397,6 +399,7 @@ pub struct BloomStatsSnapshot {
pub non_v1: u64,
pub unknown_peer: u64,
pub stale: u64,
pub fill_exceeded: u64,
pub accepted: u64,
pub sent: u64,
pub debounce_suppressed: u64,
+35 -21
View File
@@ -16,10 +16,7 @@ fn get_tree_edges(nodes: &[TestNode]) -> Vec<(usize, usize)> {
let ts = tn.node.tree_state();
if !ts.is_root() {
let parent_addr = ts.my_declaration().parent_id();
if let Some(j) = nodes
.iter()
.position(|n| n.node.node_addr() == parent_addr)
{
if let Some(j) = nodes.iter().position(|n| n.node.node_addr() == parent_addr) {
edges.push((i, j));
}
}
@@ -174,8 +171,7 @@ async fn test_bloom_filter_star() {
/// entries, and so on. Both endpoints should see all other nodes.
#[tokio::test]
async fn test_bloom_filter_chain_propagation() {
let edges: Vec<(usize, usize)> =
vec![(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 6), (6, 7)];
let edges: Vec<(usize, usize)> = vec![(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 6), (6, 7)];
let mut nodes = run_tree_test(8, &edges, false).await;
verify_tree_convergence(&nodes);
verify_filter_exchange(&nodes, &edges);
@@ -274,10 +270,13 @@ fn print_filter_cardinality(nodes: &[TestNode]) {
{
let is_tree = tn.node.is_tree_peer(&addr);
println!(
" n{} <- n{}: est={:.1} set_bits={} fill={:.1}% tree={}",
" n{} <- n{}: est={} set_bits={} fill={:.1}% tree={}",
i,
j,
filter.estimated_count(),
match filter.estimated_count(f64::INFINITY) {
Some(n) => format!("{:.1}", n),
None => "saturated".to_string(),
},
filter.count_ones(),
filter.fill_ratio() * 100.0,
is_tree,
@@ -315,8 +314,7 @@ fn collect_subtree(
#[tokio::test]
async fn test_bloom_filter_split_horizon() {
// Pure tree: 7 nodes, 6 edges
let edges: Vec<(usize, usize)> =
vec![(0, 1), (0, 2), (1, 3), (1, 4), (2, 5), (5, 6)];
let edges: Vec<(usize, usize)> = vec![(0, 1), (0, 2), (1, 3), (1, 4), (2, 5), (5, 6)];
let mut nodes = run_tree_test(7, &edges, false).await;
verify_tree_convergence(&nodes);
verify_filter_exchange(&nodes, &edges);
@@ -340,9 +338,7 @@ async fn test_bloom_filter_split_horizon() {
// - parent's filter to child contains the complement only
for &(child_idx, parent_idx) in &tree_edges {
let child_subtree = collect_subtree(child_idx, Some(parent_idx), &tree_adj);
let complement: Vec<usize> = (0..n)
.filter(|i| !child_subtree.contains(i))
.collect();
let complement: Vec<usize> = (0..n).filter(|i| !child_subtree.contains(i)).collect();
// --- Upward filter: child → parent ---
// This is stored as parent's inbound filter from child
@@ -358,7 +354,9 @@ async fn test_bloom_filter_split_horizon() {
assert!(
filter_up.contains(&addrs[idx]),
"Upward filter (n{}→n{}): should contain subtree member n{} but doesn't",
child_idx, parent_idx, idx
child_idx,
parent_idx,
idx
);
}
@@ -367,16 +365,23 @@ async fn test_bloom_filter_split_horizon() {
assert!(
!filter_up.contains(&addrs[idx]),
"Upward filter (n{}→n{}): should NOT contain complement member n{} but does",
child_idx, parent_idx, idx
child_idx,
parent_idx,
idx
);
}
// Cardinality should match subtree size
let up_est = filter_up.estimated_count();
let up_est = filter_up
.estimated_count(f64::INFINITY)
.expect("upward filter should not be saturated in tree convergence test");
assert!(
(up_est - child_subtree.len() as f64).abs() < 1.5,
"Upward filter (n{}→n{}): expected ~{} entries, got {:.1}",
child_idx, parent_idx, child_subtree.len(), up_est
child_idx,
parent_idx,
child_subtree.len(),
up_est
);
// --- Downward filter: parent → child ---
@@ -393,7 +398,9 @@ async fn test_bloom_filter_split_horizon() {
assert!(
filter_down.contains(&addrs[idx]),
"Downward filter (n{}→n{}): should contain complement member n{} but doesn't",
parent_idx, child_idx, idx
parent_idx,
child_idx,
idx
);
}
@@ -405,16 +412,23 @@ async fn test_bloom_filter_split_horizon() {
assert!(
!filter_down.contains(&addrs[idx]),
"Downward filter (n{}→n{}): should NOT contain subtree member n{} but does",
parent_idx, child_idx, idx
parent_idx,
child_idx,
idx
);
}
// Cardinality should match complement size
let down_est = filter_down.estimated_count();
let down_est = filter_down
.estimated_count(f64::INFINITY)
.expect("downward filter should not be saturated in tree convergence test");
assert!(
(down_est - complement.len() as f64).abs() < 1.5,
"Downward filter (n{}→n{}): expected ~{} entries, got {:.1}",
parent_idx, child_idx, complement.len(), down_est
parent_idx,
child_idx,
complement.len(),
down_est
);
// Together, subtree + complement = all nodes
+164
View File
@@ -0,0 +1,164 @@
//! Direct tests for the M1 antipoison FPR cap in handle_filter_announce.
//!
//! These tests construct a minimal Node with a single synthetic peer,
//! then call handle_filter_announce directly with crafted FilterAnnounce
//! payloads. Focused on the ingress check semantics; broader
//! filter-exchange behavior is covered by the multi-node tests in
//! bloom.rs.
use super::*;
use crate::bloom::{BloomFilter, DEFAULT_FILTER_SIZE_BITS, DEFAULT_HASH_COUNT};
use crate::peer::ActivePeer;
use crate::protocol::FilterAnnounce;
/// Inject a synthetic active peer into the node with a known NodeAddr.
/// Returns the peer's NodeAddr.
fn inject_peer(node: &mut Node) -> NodeAddr {
let peer_identity = make_peer_identity();
let peer_addr = *peer_identity.node_addr();
let peer = ActivePeer::new(peer_identity, LinkId::new(1), 0);
node.peers.insert(peer_addr, peer);
peer_addr
}
/// Encode a FilterAnnounce to the payload format handle_filter_announce
/// expects (msg_type byte stripped).
fn encode_payload(announce: &FilterAnnounce) -> Vec<u8> {
let mut full = announce.encode().unwrap();
full.remove(0); // strip msg_type byte
full
}
#[tokio::test]
async fn test_m1_rejects_all_ones_filter_announce() {
let mut node = make_node();
let peer_addr = inject_peer(&mut node);
// Craft an all-ones FilterAnnounce (the observed-in-the-wild attack).
let all_ones = BloomFilter::from_bytes(
vec![0xFFu8; DEFAULT_FILTER_SIZE_BITS / 8],
DEFAULT_HASH_COUNT,
)
.unwrap();
let announce = FilterAnnounce::new(all_ones, 1);
let payload = encode_payload(&announce);
let before_fill_exceeded = node.stats().bloom.fill_exceeded;
let before_accepted = node.stats().bloom.accepted;
node.handle_filter_announce(&peer_addr, &payload).await;
let after = &node.stats().bloom;
assert_eq!(
after.fill_exceeded,
before_fill_exceeded + 1,
"fill_exceeded counter must increment on all-ones rejection"
);
assert_eq!(
after.accepted, before_accepted,
"accepted counter must NOT increment on rejection"
);
// Peer state unchanged: no filter stored, sequence not advanced.
let peer = node.get_peer(&peer_addr).expect("peer still present");
assert!(
peer.inbound_filter().is_none(),
"peer must NOT have a stored filter after rejection"
);
assert_eq!(
peer.filter_sequence(),
0,
"peer filter_sequence must NOT advance on rejection"
);
}
#[tokio::test]
async fn test_m1_accepts_sub_cap_filter() {
let mut node = make_node();
let peer_addr = inject_peer(&mut node);
// A legitimate filter with 50 entries — fill ~0.03, FPR ~2e-8,
// far below the 0.05 cap. Represents normal mesh traffic.
let mut filter = BloomFilter::new();
for i in 0..50u8 {
let mut bytes = [0u8; 16];
bytes[0] = i;
filter.insert(&NodeAddr::from_bytes(bytes));
}
let announce = FilterAnnounce::new(filter, 1);
let payload = encode_payload(&announce);
let before_fill_exceeded = node.stats().bloom.fill_exceeded;
let before_accepted = node.stats().bloom.accepted;
node.handle_filter_announce(&peer_addr, &payload).await;
let after = &node.stats().bloom;
assert_eq!(
after.fill_exceeded, before_fill_exceeded,
"fill_exceeded must NOT increment on legitimate sub-cap filter"
);
assert_eq!(
after.accepted,
before_accepted + 1,
"accepted must increment on legitimate filter"
);
// Peer state updated: filter stored, sequence advanced.
let peer = node.get_peer(&peer_addr).expect("peer still present");
assert!(
peer.inbound_filter().is_some(),
"peer must have a stored filter after acceptance"
);
assert_eq!(
peer.filter_sequence(),
1,
"peer filter_sequence must advance to announce's sequence"
);
}
#[tokio::test]
async fn test_m1_sequence_not_advanced_allows_recovery() {
// Confirms the "keep prior filter, don't advance seq" rejection
// semantics: a compliant announce after a rejected one still
// succeeds at seq=1, because the rejected announce (also seq=1)
// did not advance the peer's recorded sequence.
let mut node = make_node();
let peer_addr = inject_peer(&mut node);
// First announce: all-ones, rejected.
let bad = BloomFilter::from_bytes(
vec![0xFFu8; DEFAULT_FILTER_SIZE_BITS / 8],
DEFAULT_HASH_COUNT,
)
.unwrap();
let bad_announce = FilterAnnounce::new(bad, 1);
node.handle_filter_announce(&peer_addr, &encode_payload(&bad_announce))
.await;
assert_eq!(
node.get_peer(&peer_addr).unwrap().filter_sequence(),
0,
"rejected announce must not advance sequence"
);
// Second announce: legitimate, seq=1 (would be stale if rejection
// had advanced the recorded sequence). Must be accepted.
let mut good = BloomFilter::new();
for i in 0..10u8 {
let mut bytes = [0u8; 16];
bytes[0] = i;
good.insert(&NodeAddr::from_bytes(bytes));
}
let good_announce = FilterAnnounce::new(good, 1);
node.handle_filter_announce(&peer_addr, &encode_payload(&good_announce))
.await;
let peer = node.get_peer(&peer_addr).unwrap();
assert!(
peer.inbound_filter().is_some(),
"compliant announce at same seq must be accepted after rejection"
);
assert_eq!(peer.filter_sequence(), 1);
assert_eq!(node.stats().bloom.fill_exceeded, 1);
assert_eq!(node.stats().bloom.accepted, 1);
}
+16 -8
View File
@@ -258,10 +258,8 @@ async fn test_disconnect_clears_session() {
{
let our_identity = nodes[1].node.identity();
let mut initiator = HandshakeState::new_initiator(
our_identity.keypair(),
remote_identity.pubkey_full(),
);
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);
@@ -285,8 +283,16 @@ async fn test_disconnect_clears_session() {
nodes[1].node.sessions.insert(node0_addr, entry);
}
assert_eq!(nodes[1].node.session_count(), 1, "Session should exist before disconnect");
assert_eq!(nodes[1].node.peer_count(), 1, "Peer should exist before disconnect");
assert_eq!(
nodes[1].node.session_count(),
1,
"Session should exist before disconnect"
);
assert_eq!(
nodes[1].node.peer_count(),
1,
"Peer should exist before disconnect"
);
// Node 0 sends Disconnect to node 1.
let disconnect = crate::protocol::Disconnect::new(DisconnectReason::Shutdown);
@@ -301,7 +307,8 @@ async fn test_disconnect_clears_session() {
// Peer must be gone.
assert_eq!(
nodes[1].node.peer_count(), 0,
nodes[1].node.peer_count(),
0,
"Peer should be removed after disconnect"
);
@@ -309,7 +316,8 @@ async fn test_disconnect_clears_session() {
// Before the fix, session_count() would still be 1 here because
// remove_active_peer didn't remove self.sessions[node0_addr].
assert_eq!(
nodes[1].node.session_count(), 0,
nodes[1].node.session_count(),
0,
"Session must be cleaned up when peer is removed (regression: issue #5)"
);
+57 -47
View File
@@ -149,10 +149,8 @@ async fn test_response_transit_needs_recent_request() {
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
node.recent_requests.insert(
444,
RecentRequest::new(make_node_addr(0xDD), now_ms),
);
node.recent_requests
.insert(444, RecentRequest::new(make_node_addr(0xDD), now_ms));
// Handle response — should try to reverse-path forward to 0xDD
// (will fail silently since 0xDD is not an actual peer)
@@ -282,11 +280,7 @@ async fn test_response_coord_substitution_detected() {
let target = *target_identity.node_addr();
let root = make_node_addr(0xF0);
let real_coords = TreeCoordinate::from_addrs(vec![target, root]).unwrap();
let fake_coords = TreeCoordinate::from_addrs(vec![
target,
make_node_addr(0xEE),
root,
]).unwrap();
let fake_coords = TreeCoordinate::from_addrs(vec![target, make_node_addr(0xEE), root]).unwrap();
// Register target in identity_cache
node.register_identity(target, target_identity.pubkey_full());
@@ -325,16 +319,12 @@ async fn test_recent_request_expiry() {
.as_millis() as u64;
// Insert an old request (11 seconds ago)
node.recent_requests.insert(
123,
RecentRequest::new(make_node_addr(1), now_ms - 11_000),
);
node.recent_requests
.insert(123, RecentRequest::new(make_node_addr(1), now_ms - 11_000));
// Insert a recent request
node.recent_requests.insert(
456,
RecentRequest::new(make_node_addr(2), now_ms),
);
node.recent_requests
.insert(456, RecentRequest::new(make_node_addr(2), now_ms));
assert_eq!(node.recent_requests.len(), 2);
@@ -344,7 +334,8 @@ async fn test_recent_request_expiry() {
let coords = TreeCoordinate::from_addrs(vec![origin, make_node_addr(0)]).unwrap();
let request = LookupRequest::new(789, target, origin, coords, 3, 0);
let payload = &request.encode()[1..];
node.handle_lookup_request(&make_node_addr(0xAA), payload).await;
node.handle_lookup_request(&make_node_addr(0xAA), payload)
.await;
// Old entry (123) should be purged, recent entry (456) and new entry (789) kept
assert!(!node.recent_requests.contains_key(&123));
@@ -381,7 +372,10 @@ async fn test_request_forwarding_two_node() {
// Process packets — node1 should receive the forwarded request
tokio::time::sleep(Duration::from_millis(50)).await;
let count = process_available_packets(&mut nodes).await;
assert!(count > 0, "Expected forwarded LookupRequest to arrive at node 1");
assert!(
count > 0,
"Expected forwarded LookupRequest to arrive at node 1"
);
// Node1 should have recorded the request
assert!(
@@ -546,10 +540,7 @@ async fn test_discovery_100_nodes() {
}
// Collect all node addresses and public keys for lookup targets
let all_addrs: Vec<NodeAddr> = nodes
.iter()
.map(|tn| *tn.node.node_addr())
.collect();
let all_addrs: Vec<NodeAddr> = nodes.iter().map(|tn| *tn.node.node_addr()).collect();
let all_pubkeys: Vec<secp256k1::PublicKey> = nodes
.iter()
.map(|tn| tn.node.identity().pubkey_full())
@@ -563,7 +554,8 @@ async fn test_discovery_100_nodes() {
if src == dst {
continue;
}
node.node.register_identity(all_addrs[dst], all_pubkeys[dst]);
node.node
.register_identity(all_addrs[dst], all_pubkeys[dst]);
}
}
@@ -588,10 +580,7 @@ async fn test_discovery_100_nodes() {
let mut initiated = false;
for &(s, dst) in &lookup_pairs {
if s == src {
nodes[src]
.node
.initiate_lookup(&all_addrs[dst], TTL)
.await;
nodes[src].node.initiate_lookup(&all_addrs[dst], TTL).await;
initiated = true;
}
}
@@ -628,7 +617,11 @@ async fn test_discovery_100_nodes() {
let mut failed_pairs: Vec<(usize, usize)> = Vec::new();
for &(src, dst) in &lookup_pairs {
if nodes[src].node.coord_cache().contains(&all_addrs[dst], now_ms) {
if nodes[src]
.node
.coord_cache()
.contains(&all_addrs[dst], now_ms)
{
resolved += 1;
} else {
failed += 1;
@@ -638,9 +631,7 @@ async fn test_discovery_100_nodes() {
}
}
eprintln!(
"\n === Discovery 100-Node Test ===",
);
eprintln!("\n === Discovery 100-Node Test ===",);
eprintln!(
" Lookups: {} | Resolved: {} | Failed: {} | Success rate: {:.1}%",
total_lookups,
@@ -651,8 +642,16 @@ async fn test_discovery_100_nodes() {
// Report coord_cache stats across all nodes
let total_cached: usize = nodes.iter().map(|tn| tn.node.coord_cache().len()).sum();
let min_cached = nodes.iter().map(|tn| tn.node.coord_cache().len()).min().unwrap();
let max_cached = nodes.iter().map(|tn| tn.node.coord_cache().len()).max().unwrap();
let min_cached = nodes
.iter()
.map(|tn| tn.node.coord_cache().len())
.min()
.unwrap();
let max_cached = nodes
.iter()
.map(|tn| tn.node.coord_cache().len())
.max()
.unwrap();
eprintln!(
" Coord cache entries: total={} min={} max={} avg={:.1}",
total_cached,
@@ -663,21 +662,32 @@ async fn test_discovery_100_nodes() {
// Detailed diagnostics for failures (to aid future debugging)
if !failed_pairs.is_empty() {
eprintln!(" --- Failure Diagnostics ({} failures) ---", failed_pairs.len());
eprintln!(
" --- Failure Diagnostics ({} failures) ---",
failed_pairs.len()
);
for &(src, dst) in &failed_pairs {
let src_coords = nodes[src].node.tree_state().my_coords().clone();
let dst_coords = nodes[dst].node.tree_state().my_coords().clone();
let tree_dist = src_coords.distance_to(&dst_coords);
let reverse_cached = nodes[dst].node.coord_cache().contains(&all_addrs[src], now_ms);
let reverse_cached = nodes[dst]
.node
.coord_cache()
.contains(&all_addrs[src], now_ms);
let src_peers = nodes[src].node.peers.len();
let dst_peers = nodes[dst].node.peers.len();
eprintln!(
" node {} -> node {}: tree_dist={} src_depth={} dst_depth={} \
src_peers={} dst_peers={} reverse_cached={}",
src, dst, tree_dist,
src_coords.depth(), dst_coords.depth(),
src_peers, dst_peers, reverse_cached
src,
dst,
tree_dist,
src_coords.depth(),
dst_coords.depth(),
src_peers,
dst_peers,
reverse_cached
);
}
}
@@ -730,7 +740,9 @@ async fn test_response_path_mtu_two_node() {
// Check that path_mtu was stored in the cache entry
let entry = nodes[0].node.coord_cache().get_entry(&node1_addr).unwrap();
let path_mtu = entry.path_mtu().expect("path_mtu should be set from discovery");
let path_mtu = entry
.path_mtu()
.expect("path_mtu should be set from discovery");
// In a 2-node setup, no transit node applies the min() so path_mtu stays u16::MAX
assert_eq!(
path_mtu,
@@ -774,7 +786,9 @@ async fn test_response_path_mtu_three_node_chain() {
// Node1 is transit and applies min(u16::MAX, 1280) = 1280
let entry = nodes[0].node.coord_cache().get_entry(&node2_addr).unwrap();
let path_mtu = entry.path_mtu().expect("path_mtu should be set from discovery");
let path_mtu = entry
.path_mtu()
.expect("path_mtu should be set from discovery");
assert_eq!(
path_mtu, 1280,
"Three-node chain path_mtu should reflect transit node's transport MTU (1280)"
@@ -796,12 +810,8 @@ async fn test_cache_entry_path_mtu_stored() {
let coords = TreeCoordinate::from_addrs(vec![target, make_node_addr(0)]).unwrap();
let now_ms = 1000u64;
node.coord_cache_mut().insert_with_path_mtu(
target,
coords,
now_ms,
1280,
);
node.coord_cache_mut()
.insert_with_path_mtu(target, coords, now_ms, 1280);
let entry = node.coord_cache().get_entry(&target).unwrap();
assert_eq!(entry.path_mtu(), Some(1280));
+8 -4
View File
@@ -6,8 +6,8 @@
use super::*;
use crate::config::EthernetConfig;
use crate::transport::ethernet::EthernetTransport;
use crate::transport::{packet_channel, TransportAddr, TransportHandle, TransportId};
use spanning_tree::{cleanup_nodes, drain_all_packets, initiate_handshake, TestNode};
use crate::transport::{TransportAddr, TransportHandle, TransportId, packet_channel};
use spanning_tree::{TestNode, cleanup_nodes, drain_all_packets, initiate_handshake};
use std::process::Command;
use std::sync::atomic::{AtomicU32, Ordering};
@@ -40,7 +40,9 @@ impl VethPair {
// Create veth pair
let status = Command::new("ip")
.args(["link", "add", &name_a, "type", "veth", "peer", "name", &name_b])
.args([
"link", "add", &name_a, "type", "veth", "peer", "name", &name_b,
])
.status()
.expect("failed to run 'ip link add'");
assert!(status.success(), "failed to create veth pair");
@@ -91,7 +93,9 @@ async fn make_test_node_ethernet(interface: &str) -> TestNode {
let mut transport = EthernetTransport::new(transport_id, None, config, packet_tx);
transport.start_async().await.unwrap();
let mac = transport.local_mac().expect("transport should have MAC after start");
let mac = transport
.local_mac()
.expect("transport should have MAC after start");
let addr = TransportAddr::from_bytes(&mac);
node.transports
+58 -31
View File
@@ -5,12 +5,11 @@
//! multi-hop forwarding through live node topologies.
use super::*;
use crate::node::session_wire::{build_fsp_header, FSP_FLAG_CP};
use crate::node::session_wire::{FSP_FLAG_CP, build_fsp_header};
use crate::protocol::{SessionAck, SessionDatagram, SessionSetup, encode_coords};
use crate::tree::TreeCoordinate;
use spanning_tree::{
cleanup_nodes, process_available_packets, run_tree_test, verify_tree_convergence,
TestNode,
TestNode, cleanup_nodes, process_available_packets, run_tree_test, verify_tree_convergence,
};
// ============================================================================
@@ -35,11 +34,11 @@ async fn test_forwarding_hop_limit_exhausted() {
let from = make_node_addr(0xAA);
let src = make_node_addr(0x01);
let dest = make_node_addr(0x02);
let dg = SessionDatagram::new(src, dest, vec![0x10, 0x00, 0x00, 0x00])
.with_ttl(0);
let dg = SessionDatagram::new(src, dest, vec![0x10, 0x00, 0x00, 0x00]).with_ttl(0);
let encoded = dg.encode();
// Dispatch with payload after msg_type byte
node.handle_session_datagram(&from, &encoded[1..], false).await;
node.handle_session_datagram(&from, &encoded[1..], false)
.await;
// No panic, no send (node has no peers)
}
@@ -52,11 +51,11 @@ async fn test_forwarding_hop_limit_one_drops_at_transit() {
let from = make_node_addr(0xAA);
let my_addr = *node.node_addr();
let src = make_node_addr(0x01);
let dg = SessionDatagram::new(src, my_addr, vec![0x10, 0x00, 0x00, 0x00])
.with_ttl(1);
let dg = SessionDatagram::new(src, my_addr, vec![0x10, 0x00, 0x00, 0x00]).with_ttl(1);
let encoded = dg.encode();
// Should succeed — ttl=1 decrements to 0 but packet is still processed
node.handle_session_datagram(&from, &encoded[1..], false).await;
node.handle_session_datagram(&from, &encoded[1..], false)
.await;
}
// --- Local delivery ---
@@ -69,7 +68,8 @@ async fn test_forwarding_local_delivery() {
let dg = SessionDatagram::new(from, my_addr, vec![0x10, 0x00, 0x00, 0x00]);
let encoded = dg.encode();
// Should detect local delivery and return without forwarding
node.handle_session_datagram(&from, &encoded[1..], false).await;
node.handle_session_datagram(&from, &encoded[1..], false)
.await;
}
// --- Direct peer forwarding ---
@@ -135,7 +135,8 @@ async fn test_coord_cache_warming_session_setup() {
// Handle the datagram (will be local delivery or no-route, but cache warming
// happens before routing decision)
node.handle_session_datagram(&from, &encoded[1..], false).await;
node.handle_session_datagram(&from, &encoded[1..], false)
.await;
// After: both src and dest coords should be cached
let cached_src = node.coord_cache().get(&src_addr, now_ms);
@@ -175,15 +176,22 @@ async fn test_coord_cache_warming_session_ack() {
assert!(node.coord_cache().get(&src_addr, now_ms).is_none());
assert!(node.coord_cache().get(&dest_addr, now_ms).is_none());
node.handle_session_datagram(&from, &encoded[1..], false).await;
node.handle_session_datagram(&from, &encoded[1..], false)
.await;
// SessionAck caches both src_coords and dest_coords
let cached_src = node.coord_cache().get(&src_addr, now_ms);
assert!(cached_src.is_some(), "src_addr coords not cached from SessionAck");
assert!(
cached_src.is_some(),
"src_addr coords not cached from SessionAck"
);
assert_eq!(cached_src.unwrap().root_id(), &root_addr);
let cached_dest = node.coord_cache().get(&dest_addr, now_ms);
assert!(cached_dest.is_some(), "dest_addr coords not cached from SessionAck");
assert!(
cached_dest.is_some(),
"dest_addr coords not cached from SessionAck"
);
assert_eq!(cached_dest.unwrap().root_id(), &root_addr);
}
@@ -217,7 +225,8 @@ async fn test_coord_cache_warming_encrypted_msg_with_coords() {
assert!(node.coord_cache().get(&src_addr, now_ms).is_none());
assert!(node.coord_cache().get(&dest_addr, now_ms).is_none());
node.handle_session_datagram(&from, &encoded[1..], false).await;
node.handle_session_datagram(&from, &encoded[1..], false)
.await;
assert!(
node.coord_cache().get(&src_addr, now_ms).is_some(),
@@ -250,7 +259,8 @@ async fn test_coord_cache_warming_encrypted_msg_no_coords() {
.unwrap()
.as_millis() as u64;
node.handle_session_datagram(&from, &encoded[1..], false).await;
node.handle_session_datagram(&from, &encoded[1..], false)
.await;
assert!(
node.coord_cache().get(&src_addr, now_ms).is_none(),
@@ -512,8 +522,16 @@ async fn test_forwarding_with_cache_warming_enables_routing() {
// Give each node coords for its direct peers only
let j_addr = *nodes[j].node.node_addr();
if nodes[i].node.get_peer(&j_addr).is_some() {
let coords = all_coords.iter().find(|(a, _)| a == &j_addr).unwrap().1.clone();
nodes[i].node.coord_cache_mut().insert(j_addr, coords, now_ms);
let coords = all_coords
.iter()
.find(|(a, _)| a == &j_addr)
.unwrap()
.1
.clone();
nodes[i]
.node
.coord_cache_mut()
.insert(j_addr, coords, now_ms);
}
}
}
@@ -572,8 +590,8 @@ async fn test_forwarding_with_cache_warming_enables_routing() {
// ECN Tests
// ============================================================================
use crate::node::handlers::session::mark_ipv6_ecn_ce;
use crate::node::TransportDropState;
use crate::node::handlers::session::mark_ipv6_ecn_ce;
use crate::transport::TransportId;
/// Build a minimal IPv6 header (40 bytes) with specified ECN bits.
@@ -721,10 +739,13 @@ fn test_detect_congestion_with_transport_drops() {
// Simulate transport kernel drops
let tid = TransportId::new(1);
node.transport_drops.insert(tid, TransportDropState {
prev_drops: 100,
dropping: true,
});
node.transport_drops.insert(
tid,
TransportDropState {
prev_drops: 100,
dropping: true,
},
);
// Now detect_congestion should return true (local transport congestion)
assert!(node.detect_congestion(&fake_addr));
@@ -741,10 +762,13 @@ fn test_detect_congestion_disabled_ecn() {
// Even with transport drops, disabled ECN should return false
let tid = TransportId::new(1);
node.transport_drops.insert(tid, TransportDropState {
prev_drops: 50,
dropping: true,
});
node.transport_drops.insert(
tid,
TransportDropState {
prev_drops: 50,
dropping: true,
},
);
let fake_addr = NodeAddr::from_bytes([1; 16]);
assert!(!node.detect_congestion(&fake_addr));
@@ -756,10 +780,13 @@ fn test_sample_transport_congestion() {
// Insert a transport drop state with a baseline
let tid = TransportId::new(1);
node.transport_drops.insert(tid, TransportDropState {
prev_drops: 0,
dropping: false,
});
node.transport_drops.insert(
tid,
TransportDropState {
prev_drops: 0,
dropping: false,
},
);
// No transports registered — sample_transport_congestion is a no-op
// (transport_drops entry stays unchanged)
+215 -107
View File
@@ -5,9 +5,11 @@ use super::*;
#[tokio::test]
async fn test_two_node_handshake_udp() {
use crate::config::UdpConfig;
use crate::node::wire::{
build_encrypted, build_established_header, build_msg1, prepend_inner_header,
};
use crate::transport::udp::UdpTransport;
use crate::node::wire::{build_encrypted, build_established_header, build_msg1, prepend_inner_header};
use tokio::time::{timeout, Duration};
use tokio::time::{Duration, timeout};
// === Setup: Two nodes with UDP transports on localhost ===
@@ -26,10 +28,8 @@ async fn test_two_node_handshake_udp() {
let (packet_tx_a, mut packet_rx_a) = packet_channel(64);
let (packet_tx_b, mut packet_rx_b) = packet_channel(64);
let mut transport_a =
UdpTransport::new(transport_id_a, None, udp_config.clone(), packet_tx_a);
let mut transport_b =
UdpTransport::new(transport_id_b, None, udp_config, packet_tx_b);
let mut transport_a = UdpTransport::new(transport_id_a, None, udp_config.clone(), packet_tx_a);
let mut transport_b = UdpTransport::new(transport_id_b, None, udp_config, packet_tx_b);
transport_a.start_async().await.unwrap();
transport_b.start_async().await.unwrap();
@@ -49,23 +49,20 @@ async fn test_two_node_handshake_udp() {
// === Phase 1: Node A initiates handshake to Node B ===
// Create peer identity for B (must use full key for ECDH parity)
let peer_b_identity =
PeerIdentity::from_pubkey_full(node_b.identity.pubkey_full());
let peer_b_identity = PeerIdentity::from_pubkey_full(node_b.identity.pubkey_full());
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 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();
// 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).unwrap();
let noise_msg1 = conn_a
.start_handshake(our_keypair_a, node_a.startup_epoch, 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());
@@ -82,10 +79,9 @@ async fn test_two_node_handshake_udp() {
);
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,
);
node_a
.pending_outbound
.insert((transport_id_a, our_index_a.as_u32()), link_id_a);
// Send msg1 from A to B over UDP
let transport = node_a.transports.get(&transport_id_a).unwrap();
@@ -104,11 +100,13 @@ async fn test_two_node_handshake_udp() {
node_b.handle_msg1(packet_b).await;
// Verify B promoted the inbound connection
let peer_a_node_addr = *PeerIdentity::from_pubkey_full(
node_a.identity.pubkey_full(),
)
.node_addr();
assert_eq!(node_b.peer_count(), 1, "Node B should have 1 peer after msg1");
let peer_a_node_addr =
*PeerIdentity::from_pubkey_full(node_a.identity.pubkey_full()).node_addr();
assert_eq!(
node_b.peer_count(),
1,
"Node B should have 1 peer after msg1"
);
let peer_a_on_b = node_b
.get_peer(&peer_a_node_addr)
.expect("Node B should have peer A");
@@ -134,7 +132,11 @@ async fn test_two_node_handshake_udp() {
node_a.handle_msg2(packet_a).await;
// Verify A promoted the outbound connection
assert_eq!(node_a.peer_count(), 1, "Node A should have 1 peer after msg2");
assert_eq!(
node_a.peer_count(),
1,
"Node A should have 1 peer after msg2"
);
let peer_b_on_a = node_a
.get_peer(&peer_b_node_addr)
.expect("Node A should have peer B");
@@ -241,8 +243,8 @@ async fn test_two_node_handshake_udp() {
#[tokio::test]
async fn test_run_rx_loop_handshake() {
use crate::config::UdpConfig;
use crate::transport::udp::UdpTransport;
use crate::node::wire::build_msg1;
use crate::transport::udp::UdpTransport;
use tokio::time::Duration;
// === Setup: Two nodes with UDP transports on localhost ===
@@ -262,10 +264,8 @@ async fn test_run_rx_loop_handshake() {
let (packet_tx_a, packet_rx_a) = packet_channel(64);
let (packet_tx_b, packet_rx_b) = packet_channel(64);
let mut transport_a =
UdpTransport::new(transport_id_a, None, udp_config.clone(), packet_tx_a);
let mut transport_b =
UdpTransport::new(transport_id_b, None, udp_config, packet_tx_b);
let mut transport_a = UdpTransport::new(transport_id_a, None, udp_config.clone(), packet_tx_a);
let mut transport_b = UdpTransport::new(transport_id_b, None, udp_config, packet_tx_b);
transport_a.start_async().await.unwrap();
transport_b.start_async().await.unwrap();
@@ -290,20 +290,17 @@ async fn test_run_rx_loop_handshake() {
// === Phase 1: Node A initiates handshake to Node B ===
let peer_b_identity =
PeerIdentity::from_pubkey_full(node_b.identity.pubkey_full());
let peer_b_identity = PeerIdentity::from_pubkey_full(node_b.identity.pubkey_full());
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 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).unwrap();
let noise_msg1 = conn_a
.start_handshake(our_keypair_a, node_a.startup_epoch, 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());
@@ -319,10 +316,9 @@ async fn test_run_rx_loop_handshake() {
);
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,
);
node_a
.pending_outbound
.insert((transport_id_a, our_index_a.as_u32()), link_id_a);
// Send msg1 from A to B over real UDP
let transport = node_a.transports.get(&transport_id_a).unwrap();
@@ -350,12 +346,14 @@ async fn test_run_rx_loop_handshake() {
}
// Verify Node B promoted the inbound connection via rx loop dispatch
let peer_a_node_addr = *PeerIdentity::from_pubkey_full(
node_a.identity.pubkey_full(),
)
.node_addr();
let peer_a_node_addr =
*PeerIdentity::from_pubkey_full(node_a.identity.pubkey_full()).node_addr();
assert_eq!(node_b.peer_count(), 1, "Node B should have 1 peer after rx loop processed msg1");
assert_eq!(
node_b.peer_count(),
1,
"Node B should have 1 peer after rx loop processed msg1"
);
let peer_a_on_b = node_b
.get_peer(&peer_a_node_addr)
.expect("Node B should have peer A");
@@ -390,7 +388,11 @@ async fn test_run_rx_loop_handshake() {
}
// Verify Node A promoted the outbound connection via rx loop dispatch
assert_eq!(node_a.peer_count(), 1, "Node A should have 1 peer after rx loop processed msg2");
assert_eq!(
node_a.peer_count(),
1,
"Node A should have 1 peer after rx loop processed msg2"
);
let peer_b_on_a = node_a
.get_peer(&peer_b_node_addr)
.expect("Node A should have peer B");
@@ -432,9 +434,9 @@ async fn test_run_rx_loop_handshake() {
#[tokio::test]
async fn test_cross_connection_both_initiate() {
use crate::config::UdpConfig;
use crate::transport::udp::UdpTransport;
use crate::node::wire::build_msg1;
use tokio::time::{timeout, Duration};
use crate::transport::udp::UdpTransport;
use tokio::time::{Duration, timeout};
// === Setup: Two nodes with UDP transports on localhost ===
@@ -453,10 +455,8 @@ async fn test_cross_connection_both_initiate() {
let (packet_tx_a, mut packet_rx_a) = packet_channel(64);
let (packet_tx_b, mut packet_rx_b) = packet_channel(64);
let mut transport_a =
UdpTransport::new(transport_id_a, None, udp_config.clone(), packet_tx_a);
let mut transport_b =
UdpTransport::new(transport_id_b, None, udp_config, packet_tx_b);
let mut transport_a = UdpTransport::new(transport_id_a, None, udp_config.clone(), packet_tx_a);
let mut transport_b = UdpTransport::new(transport_id_b, None, udp_config, packet_tx_b);
transport_a.start_async().await.unwrap();
transport_b.start_async().await.unwrap();
@@ -474,11 +474,9 @@ async fn test_cross_connection_both_initiate() {
.insert(transport_id_b, TransportHandle::Udp(transport_b));
// Peer identities (must use full key for ECDH parity)
let peer_b_identity =
PeerIdentity::from_pubkey_full(node_b.identity.pubkey_full());
let peer_b_identity = PeerIdentity::from_pubkey_full(node_b.identity.pubkey_full());
let peer_b_node_addr = *peer_b_identity.node_addr();
let peer_a_identity =
PeerIdentity::from_pubkey_full(node_a.identity.pubkey_full());
let peer_a_identity = PeerIdentity::from_pubkey_full(node_a.identity.pubkey_full());
let peer_a_node_addr = *peer_a_identity.node_addr();
// === Phase 1: Both nodes initiate handshakes (simulate auto_connect) ===
@@ -488,7 +486,9 @@ async fn test_cross_connection_both_initiate() {
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).unwrap();
let noise_msg1_a = conn_a
.start_handshake(our_keypair_a, node_a.startup_epoch, 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());
@@ -496,20 +496,29 @@ async fn test_cross_connection_both_initiate() {
let wire_msg1_a = build_msg1(our_index_a, &noise_msg1_a);
let link_a_out = Link::connectionless(
link_id_a_out, transport_id_a, remote_addr_b.clone(),
LinkDirection::Outbound, Duration::from_millis(100),
link_id_a_out,
transport_id_a,
remote_addr_b.clone(),
LinkDirection::Outbound,
Duration::from_millis(100),
);
node_a.links.insert(link_id_a_out, link_a_out);
node_a.addr_to_link.insert((transport_id_a, remote_addr_b.clone()), link_id_a_out);
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_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).unwrap();
let noise_msg1_b = conn_b
.start_handshake(our_keypair_b, node_b.startup_epoch, 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());
@@ -517,20 +526,33 @@ async fn test_cross_connection_both_initiate() {
let wire_msg1_b = build_msg1(our_index_b, &noise_msg1_b);
let link_b_out = Link::connectionless(
link_id_b_out, transport_id_b, remote_addr_a.clone(),
LinkDirection::Outbound, Duration::from_millis(100),
link_id_b_out,
transport_id_b,
remote_addr_a.clone(),
LinkDirection::Outbound,
Duration::from_millis(100),
);
node_b.links.insert(link_id_b_out, link_b_out);
node_b.addr_to_link.insert((transport_id_b, remote_addr_a.clone()), link_id_b_out);
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);
node_b
.pending_outbound
.insert((transport_id_b, our_index_b.as_u32()), link_id_b_out);
// Both send msg1 over UDP
let transport = node_a.transports.get(&transport_id_a).unwrap();
transport.send(&remote_addr_b, &wire_msg1_a).await.expect("A send msg1");
transport
.send(&remote_addr_b, &wire_msg1_a)
.await
.expect("A send msg1");
let transport = node_b.transports.get(&transport_id_b).unwrap();
transport.send(&remote_addr_a, &wire_msg1_b).await.expect("B send msg1");
transport
.send(&remote_addr_a, &wire_msg1_b)
.await
.expect("B send msg1");
// === Phase 2: Both nodes receive the other's msg1 ===
// Before the fix, addr_to_link would reject these because outbound links
@@ -538,21 +560,39 @@ async fn test_cross_connection_both_initiate() {
// B receives A's msg1
let packet_at_b = timeout(Duration::from_secs(1), packet_rx_b.recv())
.await.expect("Timeout").expect("Channel closed");
.await
.expect("Timeout")
.expect("Channel closed");
node_b.handle_msg1(packet_at_b).await;
// B should have promoted the inbound connection
assert_eq!(node_b.peer_count(), 1, "Node B should have 1 peer after processing A's msg1");
assert!(node_b.get_peer(&peer_a_node_addr).is_some(), "Node B should have peer A");
assert_eq!(
node_b.peer_count(),
1,
"Node B should have 1 peer after processing A's msg1"
);
assert!(
node_b.get_peer(&peer_a_node_addr).is_some(),
"Node B should have peer A"
);
// A receives B's msg1
let packet_at_a = timeout(Duration::from_secs(1), packet_rx_a.recv())
.await.expect("Timeout").expect("Channel closed");
.await
.expect("Timeout")
.expect("Channel closed");
node_a.handle_msg1(packet_at_a).await;
// A should have promoted the inbound connection
assert_eq!(node_a.peer_count(), 1, "Node A should have 1 peer after processing B's msg1");
assert!(node_a.get_peer(&peer_b_node_addr).is_some(), "Node A should have peer B");
assert_eq!(
node_a.peer_count(),
1,
"Node A should have 1 peer after processing B's msg1"
);
assert!(
node_a.get_peer(&peer_b_node_addr).is_some(),
"Node A should have peer B"
);
// === Phase 3: Both nodes receive msg2 responses ===
// The msg2 was sent during handle_msg1 processing. When handle_msg2
@@ -560,21 +600,37 @@ async fn test_cross_connection_both_initiate() {
// A receives B's msg2 (response to A's original msg1)
let msg2_at_a = timeout(Duration::from_secs(1), packet_rx_a.recv())
.await.expect("Timeout waiting for msg2 at A").expect("Channel closed");
.await
.expect("Timeout waiting for msg2 at A")
.expect("Channel closed");
node_a.handle_msg2(msg2_at_a).await;
// B receives A's msg2 (response to B's original msg1)
let msg2_at_b = timeout(Duration::from_secs(1), packet_rx_b.recv())
.await.expect("Timeout waiting for msg2 at B").expect("Channel closed");
.await
.expect("Timeout waiting for msg2 at B")
.expect("Channel closed");
node_b.handle_msg2(msg2_at_b).await;
// === Verification ===
// Both nodes should have exactly 1 peer each after cross-connection resolution
assert_eq!(node_a.peer_count(), 1, "Node A should have exactly 1 peer after cross-connection");
assert_eq!(node_b.peer_count(), 1, "Node B should have exactly 1 peer after cross-connection");
assert_eq!(
node_a.peer_count(),
1,
"Node A should have exactly 1 peer after cross-connection"
);
assert_eq!(
node_b.peer_count(),
1,
"Node B should have exactly 1 peer after cross-connection"
);
let peer_b_on_a = node_a.get_peer(&peer_b_node_addr).expect("A should have peer B");
let peer_a_on_b = node_b.get_peer(&peer_a_node_addr).expect("B should have peer A");
let peer_b_on_a = node_a
.get_peer(&peer_b_node_addr)
.expect("A should have peer B");
let peer_a_on_b = node_b
.get_peer(&peer_a_node_addr)
.expect("B should have peer A");
assert!(peer_b_on_a.has_session(), "Peer B on A should have session");
assert!(peer_a_on_b.has_session(), "Peer A on B should have session");
@@ -611,25 +667,35 @@ async fn test_stale_connection_cleanup() {
// Allocate session index and set transport info
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, past_time_ms).unwrap();
let _noise_msg1 = conn
.start_handshake(our_keypair, node.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(
link_id, transport_id, remote_addr.clone(),
LinkDirection::Outbound, Duration::from_millis(100),
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.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);
node.pending_outbound
.insert((transport_id, our_index.as_u32()), link_id);
// Verify state before timeout check
assert_eq!(node.connection_count(), 1);
assert_eq!(node.link_count(), 1);
assert!(node.pending_outbound.contains_key(&(transport_id, our_index.as_u32())));
assert!(
node.pending_outbound
.contains_key(&(transport_id, our_index.as_u32()))
);
assert_eq!(node.index_allocator.count(), 1);
// Connection was created at time 1000ms. check_timeouts uses SystemTime::now(),
@@ -637,13 +703,27 @@ async fn test_stale_connection_cleanup() {
node.check_timeouts();
// Verify everything was cleaned up
assert_eq!(node.connection_count(), 0, "Stale connection should be removed");
assert_eq!(
node.connection_count(),
0,
"Stale connection should be removed"
);
assert_eq!(node.link_count(), 0, "Stale link should be removed");
assert!(!node.pending_outbound.contains_key(&(transport_id, our_index.as_u32())),
"pending_outbound should be cleaned up");
assert_eq!(node.index_allocator.count(), 0, "Session index should be freed");
assert!(!node.addr_to_link.contains_key(&(transport_id, remote_addr)),
"addr_to_link should be cleaned up");
assert!(
!node
.pending_outbound
.contains_key(&(transport_id, our_index.as_u32())),
"pending_outbound should be cleaned up"
);
assert_eq!(
node.index_allocator.count(),
0,
"Session index should be freed"
);
assert!(
!node.addr_to_link.contains_key(&(transport_id, remote_addr)),
"addr_to_link should be cleaned up"
);
}
/// Test that failed connections are cleaned up by check_timeouts().
@@ -665,29 +745,44 @@ async fn test_failed_connection_cleanup() {
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();
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.mark_failed(); // Simulate send failure
let link = Link::connectionless(
link_id, transport_id, remote_addr.clone(),
LinkDirection::Outbound, Duration::from_millis(100),
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.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);
node.pending_outbound
.insert((transport_id, our_index.as_u32()), link_id);
assert_eq!(node.connection_count(), 1);
// Failed connections should be cleaned up immediately regardless of age
node.check_timeouts();
assert_eq!(node.connection_count(), 0, "Failed connection should be removed");
assert_eq!(
node.connection_count(),
0,
"Failed connection should be removed"
);
assert_eq!(node.link_count(), 0, "Failed link should be removed");
assert_eq!(node.index_allocator.count(), 0, "Session index should be freed");
assert_eq!(
node.index_allocator.count(),
0,
"Session index should be freed"
);
}
/// Test that msg1 bytes are stored on connection for resend.
@@ -710,7 +805,9 @@ async fn test_msg1_stored_for_resend() {
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();
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());
@@ -741,7 +838,9 @@ async fn test_resend_scheduling() {
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();
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());
@@ -751,12 +850,17 @@ async fn test_resend_scheduling() {
conn.set_handshake_msg1(wire_msg1, now_ms + 1000);
let link = Link::connectionless(
link_id, transport_id, remote_addr.clone(),
LinkDirection::Outbound, Duration::from_millis(100),
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), link_id);
node.pending_outbound.insert((transport_id, our_index.as_u32()), link_id);
node.addr_to_link
.insert((transport_id, remote_addr), 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,
@@ -772,7 +876,11 @@ async fn test_resend_scheduling() {
// 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();
assert_eq!(conn.resend_count(), 0, "No transport means no resend recorded");
assert_eq!(
conn.resend_count(),
0,
"No transport means no resend recorded"
);
}
/// Test that msg2 is stored on PeerConnection for responder resend.
+6 -3
View File
@@ -1,10 +1,11 @@
use super::*;
use crate::utils::index::SessionIndex;
use crate::transport::{packet_channel, LinkDirection, TransportAddr};
use crate::PeerIdentity;
use crate::transport::{LinkDirection, TransportAddr, packet_channel};
use crate::utils::index::SessionIndex;
use std::time::Duration;
mod bloom;
mod bloom_poison;
mod disconnect;
mod discovery;
#[cfg(target_os = "linux")]
@@ -53,7 +54,9 @@ pub(super) fn make_completed_connection(
// Run initiator side of handshake
let our_keypair = node.identity.keypair();
let msg1 = conn.start_handshake(our_keypair, node.startup_epoch, current_time_ms).unwrap();
let msg1 = conn
.start_handshake(our_keypair, node.startup_epoch, current_time_ms)
.unwrap();
// Run responder side to generate msg2
let mut resp_conn = PeerConnection::inbound(LinkId::new(999), current_time_ms);
+58 -41
View File
@@ -7,8 +7,8 @@ use super::*;
use crate::bloom::BloomFilter;
use crate::tree::{ParentDeclaration, TreeCoordinate};
use spanning_tree::{
cleanup_nodes, drain_all_packets, generate_random_edges, initiate_handshake, make_test_node,
run_tree_test, verify_tree_convergence, TestNode,
TestNode, cleanup_nodes, drain_all_packets, generate_random_edges, initiate_handshake,
make_test_node, run_tree_test, verify_tree_convergence,
};
use std::collections::HashSet;
@@ -83,8 +83,7 @@ fn test_routing_bloom_filter_hit() {
// Destination not directly connected — placed under peer1 in the tree
let dest = make_node_addr(99);
let dest_coords =
TreeCoordinate::from_addrs(vec![dest, peer1_addr, my_addr]).unwrap();
let dest_coords = TreeCoordinate::from_addrs(vec![dest, peer1_addr, my_addr]).unwrap();
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
@@ -126,17 +125,14 @@ fn test_routing_bloom_filter_multiple_hits_tiebreak() {
// Set up tree: we are root, all peers are our children (equidistant)
for &addr in &peer_addrs {
let coords = TreeCoordinate::from_addrs(vec![addr, my_addr]).unwrap();
node.tree_state_mut().update_peer(
ParentDeclaration::new(addr, my_addr, 1, 1000),
coords,
);
node.tree_state_mut()
.update_peer(ParentDeclaration::new(addr, my_addr, 1, 1000), coords);
}
// Destination placed under the first peer (arbitrary — all peers are
// equidistant from dest since dest is 2 hops from root via any child)
let dest = make_node_addr(99);
let dest_coords =
TreeCoordinate::from_addrs(vec![dest, peer_addrs[0], my_addr]).unwrap();
let dest_coords = TreeCoordinate::from_addrs(vec![dest, peer_addrs[0], my_addr]).unwrap();
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
@@ -187,8 +183,7 @@ fn test_routing_tree_fallback() {
// Destination: a node under our peer in the tree
let dest = make_node_addr(99);
let dest_coords =
TreeCoordinate::from_addrs(vec![dest, peer_addr, my_addr]).unwrap();
let dest_coords = TreeCoordinate::from_addrs(vec![dest, peer_addr, my_addr]).unwrap();
// Put dest coords in the cache
let now_ms = std::time::SystemTime::now()
@@ -239,8 +234,7 @@ fn test_routing_refreshes_coord_cache_ttl() {
// Set up tree coordinates
let dest = make_node_addr(99);
let dest_coords =
TreeCoordinate::from_addrs(vec![dest, peer_addr, my_addr]).unwrap();
let dest_coords = TreeCoordinate::from_addrs(vec![dest, peer_addr, my_addr]).unwrap();
node.tree_state_mut().update_peer(
ParentDeclaration::new(peer_addr, my_addr, 1, 1000),
TreeCoordinate::from_addrs(vec![peer_addr, my_addr]).unwrap(),
@@ -252,7 +246,8 @@ fn test_routing_refreshes_coord_cache_ttl() {
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let short_ttl = 10_000; // 10 seconds
node.coord_cache_mut().insert_with_ttl(dest, dest_coords, now_ms, short_ttl);
node.coord_cache_mut()
.insert_with_ttl(dest, dest_coords, now_ms, short_ttl);
let original_expiry = node.coord_cache().get_entry(&dest).unwrap().expires_at();
// find_next_hop should succeed and refresh TTL to now + default_ttl (300s)
@@ -263,7 +258,8 @@ fn test_routing_refreshes_coord_cache_ttl() {
assert!(
new_expiry > original_expiry,
"find_next_hop should refresh the coord_cache TTL: original={}, new={}",
original_expiry, new_expiry,
original_expiry,
new_expiry,
);
}
@@ -384,11 +380,7 @@ async fn test_routing_chain_topology() {
// Verify tree convergence
let root = nodes.iter().map(|n| *n.node.node_addr()).min().unwrap();
for tn in &nodes {
assert_eq!(
*tn.node.tree_state().root(),
root,
"Tree not converged"
);
assert_eq!(*tn.node.tree_state().root(), root, "Tree not converged");
}
// Populate coord caches: each node caches the far-end node's coords
@@ -453,8 +445,13 @@ async fn test_routing_bloom_preferred_over_tree() {
// filter routing selects peer2 (strictly closer to dest than us).
let dest = make_node_addr(99);
let peer2_addr = *nodes[2].node.node_addr();
let mut dest_path: Vec<NodeAddr> =
nodes[2].node.tree_state().my_coords().node_addrs().copied().collect();
let mut dest_path: Vec<NodeAddr> = nodes[2]
.node
.tree_state()
.my_coords()
.node_addrs()
.copied()
.collect();
dest_path.insert(0, dest);
let dest_coords = TreeCoordinate::from_addrs(dest_path).unwrap();
let now_ms = std::time::SystemTime::now()
@@ -602,13 +599,20 @@ async fn test_routing_reachability_100_nodes() {
// Collect all (addr, coords) pairs first to avoid borrow issues
let all_coords: Vec<(NodeAddr, TreeCoordinate)> = nodes
.iter()
.map(|tn| (*tn.node.node_addr(), tn.node.tree_state().my_coords().clone()))
.map(|tn| {
(
*tn.node.node_addr(),
tn.node.tree_state().my_coords().clone(),
)
})
.collect();
for node in &mut nodes {
for (addr, coords) in &all_coords {
if addr != node.node.node_addr() {
node.node.coord_cache_mut().insert(*addr, coords.clone(), now_ms);
node.node
.coord_cache_mut()
.insert(*addr, coords.clone(), now_ms);
}
}
}
@@ -654,10 +658,7 @@ async fn test_routing_reachability_100_nodes() {
0.0
};
eprintln!(
"\n === Routing Reachability ({} nodes) ===",
NUM_NODES
);
eprintln!("\n === Routing Reachability ({} nodes) ===", NUM_NODES);
eprintln!(
" Pairs tested: {} | Delivered: {} | Failed: {} | Loops: {}",
total_pairs,
@@ -665,10 +666,7 @@ async fn test_routing_reachability_100_nodes() {
failures.len(),
loops.len()
);
eprintln!(
" Hops: avg={:.1} max={}",
avg_hops, max_hops
);
eprintln!(" Hops: avg={:.1} max={}", avg_hops, max_hops);
if !failures.is_empty() {
let show = failures.len().min(10);
@@ -736,13 +734,20 @@ async fn test_routing_stops_after_peer_removal() {
let all_coords: Vec<(NodeAddr, crate::tree::TreeCoordinate)> = nodes
.iter()
.map(|tn| (*tn.node.node_addr(), tn.node.tree_state().my_coords().clone()))
.map(|tn| {
(
*tn.node.node_addr(),
tn.node.tree_state().my_coords().clone(),
)
})
.collect();
for node in &mut nodes {
for (addr, coords) in &all_coords {
if addr != node.node.node_addr() {
node.node.coord_cache_mut().insert(*addr, coords.clone(), now_ms);
node.node
.coord_cache_mut()
.insert(*addr, coords.clone(), now_ms);
}
}
}
@@ -792,9 +797,12 @@ async fn test_routing_stops_after_peer_removal() {
// matters is that delivery does NOT succeed.
match simulate_forwarding(&mut nodes, &addr_index, 0, 3) {
ForwardResult::NoRoute { .. } => {} // Expected: can't reach node 3
ForwardResult::Loop { .. } => {} // Also acceptable: stale coords cause loop detection
ForwardResult::Loop { .. } => {} // Also acceptable: stale coords cause loop detection
ForwardResult::Delivered(hops) => {
panic!("Should NOT deliver after partition, but got delivery in {} hops", hops);
panic!(
"Should NOT deliver after partition, but got delivery in {} hops",
hops
);
}
}
@@ -905,7 +913,12 @@ async fn test_routing_source_only_coords_100_nodes() {
// Collect all coords for injection
let all_coords: Vec<(NodeAddr, crate::tree::TreeCoordinate)> = nodes
.iter()
.map(|tn| (*tn.node.node_addr(), tn.node.tree_state().my_coords().clone()))
.map(|tn| {
(
*tn.node.node_addr(),
tn.node.tree_state().my_coords().clone(),
)
})
.collect();
let addr_index = build_addr_index(&nodes);
@@ -946,7 +959,10 @@ async fn test_routing_source_only_coords_100_nodes() {
ForwardResult::Delivered(_) => source_only_delivered += 1,
ForwardResult::NoRoute { .. } => source_only_failed += 1,
ForwardResult::Loop { .. } => {
panic!("Routing loop detected with source-only coords: {} -> {}", src, dst);
panic!(
"Routing loop detected with source-only coords: {} -> {}",
src, dst
);
}
}
}
@@ -977,7 +993,9 @@ async fn test_routing_source_only_coords_100_nodes() {
for node in &mut nodes {
for (addr, coords) in &all_coords {
if addr != node.node.node_addr() {
node.node.coord_cache_mut().insert(*addr, coords.clone(), now_ms);
node.node
.coord_cache_mut()
.insert(*addr, coords.clone(), now_ms);
}
}
}
@@ -996,4 +1014,3 @@ async fn test_routing_source_only_coords_100_nodes() {
cleanup_nodes(&mut nodes).await;
}
+337 -190
View File
@@ -3,8 +3,8 @@
use super::*;
use crate::node::session::EndToEndState;
use crate::node::tests::spanning_tree::{
cleanup_nodes, generate_random_edges, process_available_packets, run_tree_test,
run_tree_test_with_mtus, verify_tree_convergence, TestNode,
TestNode, cleanup_nodes, generate_random_edges, process_available_packets, run_tree_test,
run_tree_test_with_mtus, verify_tree_convergence,
};
use crate::protocol::{SessionAck, SessionDatagram};
@@ -50,10 +50,7 @@ fn test_session_entry_new_initiating() {
let identity_a = Identity::generate();
let identity_b = Identity::generate();
let handshake = HandshakeState::new_initiator(
identity_a.keypair(),
identity_b.pubkey_full(),
);
let handshake = HandshakeState::new_initiator(identity_a.keypair(), identity_b.pubkey_full());
let entry = crate::node::session::SessionEntry::new(
*identity_b.node_addr(),
@@ -77,10 +74,7 @@ fn test_session_entry_touch() {
let identity_a = Identity::generate();
let identity_b = Identity::generate();
let handshake = HandshakeState::new_initiator(
identity_a.keypair(),
identity_b.pubkey_full(),
);
let handshake = HandshakeState::new_initiator(identity_a.keypair(), identity_b.pubkey_full());
let mut entry = crate::node::session::SessionEntry::new(
*identity_b.node_addr(),
@@ -102,10 +96,8 @@ fn test_session_table_operations() {
let mut node = make_node();
let identity_b = Identity::generate();
let handshake = HandshakeState::new_initiator(
node.identity().keypair(),
identity_b.pubkey_full(),
);
let handshake =
HandshakeState::new_initiator(node.identity().keypair(), identity_b.pubkey_full());
let dest_addr = *identity_b.node_addr();
let entry = crate::node::session::SessionEntry::new(
@@ -151,12 +143,14 @@ async fn test_session_direct_peer_handshake() {
// Node 0 should have a session in Initiating state
assert_eq!(nodes[0].node.session_count(), 1);
assert!(nodes[0]
.node
.get_session(&node1_addr)
.unwrap()
.state()
.is_initiating());
assert!(
nodes[0]
.node
.get_session(&node1_addr)
.unwrap()
.state()
.is_initiating()
);
// Process packets: SessionSetup arrives at Node 1
tokio::time::sleep(Duration::from_millis(20)).await;
@@ -165,12 +159,14 @@ async fn test_session_direct_peer_handshake() {
// Node 1 should now have a session in AwaitingMsg3 state (XK: identity not yet known)
assert_eq!(nodes[1].node.session_count(), 1);
assert!(nodes[1]
.node
.get_session(&node0_addr)
.unwrap()
.state()
.is_awaiting_msg3());
assert!(
nodes[1]
.node
.get_session(&node0_addr)
.unwrap()
.state()
.is_awaiting_msg3()
);
// Process packets: SessionAck arrives at Node 0, Node 0 sends SessionMsg3
tokio::time::sleep(Duration::from_millis(20)).await;
@@ -178,12 +174,14 @@ async fn test_session_direct_peer_handshake() {
assert!(count > 0, "Expected SessionAck packet to arrive");
// Node 0 should now be Established (transitions after sending msg3)
assert!(nodes[0]
.node
.get_session(&node1_addr)
.unwrap()
.state()
.is_established());
assert!(
nodes[0]
.node
.get_session(&node1_addr)
.unwrap()
.state()
.is_established()
);
// Process packets: SessionMsg3 arrives at Node 1
tokio::time::sleep(Duration::from_millis(20)).await;
@@ -191,12 +189,14 @@ async fn test_session_direct_peer_handshake() {
assert!(count > 0, "Expected SessionMsg3 packet to arrive");
// Node 1 should now be Established (transitions after processing msg3)
assert!(nodes[1]
.node
.get_session(&node0_addr)
.unwrap()
.state()
.is_established());
assert!(
nodes[1]
.node
.get_session(&node0_addr)
.unwrap()
.state()
.is_established()
);
cleanup_nodes(&mut nodes).await;
}
@@ -226,18 +226,22 @@ async fn test_session_direct_peer_data_transfer() {
tokio::time::sleep(Duration::from_millis(20)).await;
process_available_packets(&mut nodes).await; // Msg3 → Node 1
assert!(nodes[0]
.node
.get_session(&node1_addr)
.unwrap()
.state()
.is_established());
assert!(nodes[1]
.node
.get_session(&node0_addr)
.unwrap()
.state()
.is_established());
assert!(
nodes[0]
.node
.get_session(&node1_addr)
.unwrap()
.state()
.is_established()
);
assert!(
nodes[1]
.node
.get_session(&node0_addr)
.unwrap()
.state()
.is_established()
);
// Send data from Node 0 to Node 1
let test_data = b"Hello, FIPS session!";
@@ -291,12 +295,14 @@ async fn test_session_3node_forwarded_handshake() {
nodes[2].node.get_session(&node0_addr).is_some(),
"Node 2 should have a session entry for Node 0"
);
assert!(nodes[2]
.node
.get_session(&node0_addr)
.unwrap()
.state()
.is_awaiting_msg3());
assert!(
nodes[2]
.node
.get_session(&node0_addr)
.unwrap()
.state()
.is_awaiting_msg3()
);
// Process: SessionAck: 2→1 (forwarded by transit B)
tokio::time::sleep(Duration::from_millis(20)).await;
@@ -307,12 +313,14 @@ async fn test_session_3node_forwarded_handshake() {
process_available_packets(&mut nodes).await;
// Node 0 should now be Established (transitions after sending msg3)
assert!(nodes[0]
.node
.get_session(&node2_addr)
.unwrap()
.state()
.is_established());
assert!(
nodes[0]
.node
.get_session(&node2_addr)
.unwrap()
.state()
.is_established()
);
// Process: SessionMsg3: 0→1 (forwarded by transit B)
tokio::time::sleep(Duration::from_millis(20)).await;
@@ -323,12 +331,14 @@ async fn test_session_3node_forwarded_handshake() {
process_available_packets(&mut nodes).await;
// Node 2 should now be Established (transitions after processing msg3)
assert!(nodes[2]
.node
.get_session(&node0_addr)
.unwrap()
.state()
.is_established());
assert!(
nodes[2]
.node
.get_session(&node0_addr)
.unwrap()
.state()
.is_established()
);
// Transit node B should NOT have a session
assert_eq!(
@@ -389,12 +399,14 @@ async fn test_session_3node_forwarded_data() {
}
// Node 2 should be Established (transitioned during XK handshake msg3)
assert!(nodes[2]
.node
.get_session(&node0_addr)
.unwrap()
.state()
.is_established());
assert!(
nodes[2]
.node
.get_session(&node0_addr)
.unwrap()
.state()
.is_established()
);
cleanup_nodes(&mut nodes).await;
}
@@ -520,12 +532,7 @@ async fn test_session_100_nodes() {
// Collect identities: (node_addr, pubkey) for all nodes
let all_info: Vec<(NodeAddr, secp256k1::PublicKey)> = nodes
.iter()
.map(|tn| {
(
*tn.node.node_addr(),
tn.node.identity().pubkey_full(),
)
})
.map(|tn| (*tn.node.node_addr(), tn.node.identity().pubkey_full()))
.collect();
// Each node picks one random target for its outbound session.
@@ -640,11 +647,7 @@ async fn test_session_100_nodes() {
// (Responder should already be Established after XK msg3)
let rev_payload = format!("rev-{}", pair_idx).into_bytes();
let rev_ipv6 = build_ipv6_packet(&dst_fips, &src_fips, &rev_payload);
match nodes[dst]
.node
.send_ipv6_packet(&src_addr, &rev_ipv6)
.await
{
match nodes[dst].node.send_ipv6_packet(&src_addr, &rev_ipv6).await {
Ok(()) => send_reverse_ok += 1,
Err(_) => send_reverse_err += 1,
}
@@ -723,10 +726,7 @@ async fn test_session_100_nodes() {
}
}
let session_counts: Vec<usize> = nodes
.iter()
.map(|tn| tn.node.session_count())
.collect();
let session_counts: Vec<usize> = nodes.iter().map(|tn| tn.node.session_count()).collect();
let total_sessions: usize = session_counts.iter().sum();
let min_sessions = *session_counts.iter().min().unwrap();
let max_sessions = *session_counts.iter().max().unwrap();
@@ -770,10 +770,8 @@ async fn test_session_100_nodes() {
};
// Coord cache stats
let coord_cache_sizes: Vec<usize> = nodes
.iter()
.map(|tn| tn.node.coord_cache().len())
.collect();
let coord_cache_sizes: Vec<usize> =
nodes.iter().map(|tn| tn.node.coord_cache().len()).collect();
let total_coord_entries: usize = coord_cache_sizes.iter().sum();
let min_coord = *coord_cache_sizes.iter().min().unwrap();
let max_coord = *coord_cache_sizes.iter().max().unwrap();
@@ -884,10 +882,7 @@ async fn test_session_100_nodes() {
// === Assertions ===
assert_eq!(
send_forward_err, 0,
"All forward sends should succeed"
);
assert_eq!(send_forward_err, 0, "All forward sends should succeed");
assert_eq!(
send_reverse_err, 0,
"All reverse sends should succeed (responder Established after XK msg3)"
@@ -915,7 +910,11 @@ async fn test_session_100_nodes() {
// ============================================================================
/// Build a minimal valid IPv6 packet with given source and destination addresses.
fn build_ipv6_packet(src: &crate::FipsAddress, dst: &crate::FipsAddress, payload: &[u8]) -> Vec<u8> {
fn build_ipv6_packet(
src: &crate::FipsAddress,
dst: &crate::FipsAddress,
payload: &[u8],
) -> Vec<u8> {
let payload_len = payload.len() as u16;
let mut packet = vec![0u8; 40 + payload.len()];
// Version (6) + traffic class high nibble
@@ -944,17 +943,14 @@ fn test_identity_cache_populated_on_promote() {
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,
1000,
);
let (conn, peer_identity) = make_completed_connection(&mut node, link_id, transport_id, 1000);
node.add_connection(conn).unwrap();
// Promote
let result = node.promote_connection(link_id, peer_identity, 2000).unwrap();
let result = node
.promote_connection(link_id, peer_identity, 2000)
.unwrap();
assert!(matches!(result, PromotionResult::Promoted(_)));
// Identity cache should contain the peer
@@ -962,7 +958,10 @@ fn test_identity_cache_populated_on_promote() {
let mut prefix = [0u8; 15];
prefix.copy_from_slice(&peer_addr.as_bytes()[0..15]);
let cached = node.lookup_by_fips_prefix(&prefix);
assert!(cached.is_some(), "Identity cache should contain promoted peer");
assert!(
cached.is_some(),
"Identity cache should contain promoted peer"
);
let (cached_addr, cached_pk) = cached.unwrap();
assert_eq!(cached_addr, peer_addr);
assert_eq!(cached_pk, peer_identity.pubkey_full());
@@ -986,7 +985,11 @@ async fn test_tun_outbound_established_session() {
let dst_fips = crate::FipsAddress::from_node_addr(&node1_addr);
// Establish session (XK: 3 messages — Setup, Ack, Msg3)
nodes[0].node.initiate_session(node1_addr, node1_pubkey).await.unwrap();
nodes[0]
.node
.initiate_session(node1_addr, node1_pubkey)
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(20)).await;
process_available_packets(&mut nodes).await; // Setup → Node 1
tokio::time::sleep(Duration::from_millis(20)).await;
@@ -994,7 +997,14 @@ async fn test_tun_outbound_established_session() {
tokio::time::sleep(Duration::from_millis(20)).await;
process_available_packets(&mut nodes).await; // Msg3 → Node 1
assert!(nodes[0].node.get_session(&node1_addr).unwrap().state().is_established());
assert!(
nodes[0]
.node
.get_session(&node1_addr)
.unwrap()
.state()
.is_established()
);
// Install TUN receiver on Node 1
let (tun_tx, tun_rx) = std::sync::mpsc::channel();
@@ -1013,7 +1023,10 @@ async fn test_tun_outbound_established_session() {
// Verify plaintext arrived at Node 1's TUN
let delivered: Vec<Vec<u8>> = std::iter::from_fn(|| tun_rx.try_recv().ok()).collect();
assert_eq!(delivered.len(), 1, "Exactly one packet should be delivered");
assert_eq!(delivered[0], ipv6_packet, "Delivered packet should match original");
assert_eq!(
delivered[0], ipv6_packet,
"Delivered packet should match original"
);
cleanup_nodes(&mut nodes).await;
}
@@ -1049,17 +1062,35 @@ async fn test_tun_outbound_triggers_session_initiation() {
// Session should now be initiating
assert_eq!(nodes[0].node.session_count(), 1);
assert!(nodes[0].node.get_session(&node1_addr).unwrap().state().is_initiating());
assert!(
nodes[0]
.node
.get_session(&node1_addr)
.unwrap()
.state()
.is_initiating()
);
// Drain packets until session established and queued packet delivered
drain_to_quiescence(&mut nodes).await;
// Session should be established on Node 0
assert!(nodes[0].node.get_session(&node1_addr).unwrap().state().is_established());
assert!(
nodes[0]
.node
.get_session(&node1_addr)
.unwrap()
.state()
.is_established()
);
// Verify the queued packet was delivered to Node 1
let delivered: Vec<Vec<u8>> = std::iter::from_fn(|| tun_rx.try_recv().ok()).collect();
assert_eq!(delivered.len(), 1, "Queued packet should be delivered after handshake");
assert_eq!(
delivered.len(),
1,
"Queued packet should be delivered after handshake"
);
assert_eq!(delivered[0], ipv6_packet);
cleanup_nodes(&mut nodes).await;
@@ -1087,12 +1118,19 @@ async fn test_tun_outbound_unknown_destination() {
// Should receive ICMPv6 Destination Unreachable back on TUN
let delivered: Vec<Vec<u8>> = std::iter::from_fn(|| tun_rx.try_recv().ok()).collect();
assert_eq!(delivered.len(), 1, "Should receive ICMPv6 Destination Unreachable");
assert_eq!(
delivered.len(),
1,
"Should receive ICMPv6 Destination Unreachable"
);
// Verify it's an ICMPv6 Destination Unreachable (type 1, code 0)
// ICMPv6 header starts at byte 40, type at byte 40, code at byte 41
assert!(delivered[0].len() >= 48, "ICMPv6 response too short");
assert_eq!(delivered[0][6], 58, "Next header should be ICMPv6 (58)");
assert_eq!(delivered[0][40], 1, "ICMPv6 type should be Destination Unreachable (1)");
assert_eq!(
delivered[0][40], 1,
"ICMPv6 type should be Destination Unreachable (1)"
);
assert_eq!(delivered[0][41], 0, "ICMPv6 code should be No Route (0)");
cleanup_nodes(&mut nodes).await;
@@ -1131,7 +1169,14 @@ async fn test_tun_outbound_3node_forwarded() {
drain_to_quiescence(&mut nodes).await;
// Session should be established
assert!(nodes[0].node.get_session(&node2_addr).unwrap().state().is_established());
assert!(
nodes[0]
.node
.get_session(&node2_addr)
.unwrap()
.state()
.is_established()
);
// Verify packet delivered to Node 2
let delivered: Vec<Vec<u8>> = std::iter::from_fn(|| tun_rx.try_recv().ok()).collect();
@@ -1170,16 +1215,34 @@ async fn test_tun_outbound_pending_queue_flush() {
// First packet triggers session initiation, rest are queued
assert_eq!(nodes[0].node.session_count(), 1);
assert!(nodes[0].node.get_session(&node1_addr).unwrap().state().is_initiating());
assert!(
nodes[0]
.node
.get_session(&node1_addr)
.unwrap()
.state()
.is_initiating()
);
// Drain until session established and queued packets flushed
drain_to_quiescence(&mut nodes).await;
assert!(nodes[0].node.get_session(&node1_addr).unwrap().state().is_established());
assert!(
nodes[0]
.node
.get_session(&node1_addr)
.unwrap()
.state()
.is_established()
);
// All 5 packets should have been delivered
let delivered: Vec<Vec<u8>> = std::iter::from_fn(|| tun_rx.try_recv().ok()).collect();
assert_eq!(delivered.len(), 5, "All 5 queued packets should be delivered");
assert_eq!(
delivered.len(),
5,
"All 5 queued packets should be delivered"
);
for (i, pkt) in delivered.iter().enumerate() {
assert_eq!(*pkt, packets[i], "Packet {} should match", i);
}
@@ -1198,10 +1261,8 @@ fn make_noise_session(
) -> crate::noise::NoiseSession {
use crate::noise::HandshakeState;
let mut initiator = HandshakeState::new_initiator(
our_identity.keypair(),
remote_identity.pubkey_full(),
);
let mut initiator =
HandshakeState::new_initiator(our_identity.keypair(), remote_identity.pubkey_full());
let mut responder = HandshakeState::new_responder(remote_identity.keypair());
// Set epochs for both sides (required for handshake message encryption)
@@ -1270,7 +1331,11 @@ fn test_purge_idle_sessions_keeps_active() {
let now_ms = 92_000;
node.purge_idle_sessions(now_ms);
assert_eq!(node.session_count(), 1, "Active session should survive purge");
assert_eq!(
node.session_count(),
1,
"Active session should survive purge"
);
}
#[test]
@@ -1281,10 +1346,7 @@ fn test_purge_idle_sessions_ignores_initiating() {
let remote = Identity::generate();
let remote_addr = *remote.node_addr();
let handshake = HandshakeState::new_initiator(
node.identity().keypair(),
remote.pubkey_full(),
);
let handshake = HandshakeState::new_initiator(node.identity().keypair(), remote.pubkey_full());
let entry = crate::node::session::SessionEntry::new(
remote_addr,
remote.pubkey_full(),
@@ -1299,7 +1361,11 @@ fn test_purge_idle_sessions_ignores_initiating() {
let now_ms = 1000 + 200_000;
node.purge_idle_sessions(now_ms);
assert_eq!(node.session_count(), 1, "Initiating session should not be purged by idle timeout");
assert_eq!(
node.session_count(),
1,
"Initiating session should not be purged by idle timeout"
);
}
#[test]
@@ -1330,8 +1396,10 @@ fn test_purge_idle_sessions_cleans_pending_packets() {
node.purge_idle_sessions(now_ms);
assert_eq!(node.session_count(), 0);
assert!(!node.pending_tun_packets.contains_key(&remote_addr),
"Pending packets should be cleaned up with idle session");
assert!(
!node.pending_tun_packets.contains_key(&remote_addr),
"Pending packets should be cleaned up with idle session"
);
}
#[test]
@@ -1357,7 +1425,11 @@ fn test_purge_idle_sessions_disabled_when_zero() {
let now_ms = 1000 + 1_000_000;
node.purge_idle_sessions(now_ms);
assert_eq!(node.session_count(), 1, "Sessions should not be purged when idle timeout is disabled");
assert_eq!(
node.session_count(),
1,
"Sessions should not be purged when idle timeout is disabled"
);
}
#[test]
@@ -1386,8 +1458,11 @@ fn test_purge_idle_sessions_mmp_activity_does_not_prevent_purge() {
let now_ms = 92_000;
node.purge_idle_sessions(now_ms);
assert_eq!(node.session_count(), 0,
"Session with MMP-only activity should be purged");
assert_eq!(
node.session_count(),
0,
"Session with MMP-only activity should be purged"
);
}
// ============================================================================
@@ -1401,10 +1476,7 @@ fn test_coords_warmup_counter_default_zero_on_new() {
let identity_a = Identity::generate();
let identity_b = Identity::generate();
let handshake = HandshakeState::new_initiator(
identity_a.keypair(),
identity_b.pubkey_full(),
);
let handshake = HandshakeState::new_initiator(identity_a.keypair(), identity_b.pubkey_full());
let entry = crate::node::session::SessionEntry::new(
*identity_b.node_addr(),
@@ -1414,8 +1486,11 @@ fn test_coords_warmup_counter_default_zero_on_new() {
true,
);
assert_eq!(entry.coords_warmup_remaining(), 0,
"Counter should be 0 for non-Established sessions");
assert_eq!(
entry.coords_warmup_remaining(),
0,
"Counter should be 0 for non-Established sessions"
);
}
#[test]
@@ -1466,15 +1541,20 @@ fn test_coords_warmup_counter_decrement() {
assert_eq!(entry.coords_warmup_remaining(), expected);
}
assert_eq!(entry.coords_warmup_remaining(), 0,
"Counter should reach 0 after N decrements");
assert_eq!(
entry.coords_warmup_remaining(),
0,
"Counter should reach 0 after N decrements"
);
}
#[test]
fn test_coords_warmup_config_default() {
let config = crate::config::Config::new();
assert_eq!(config.node.session.coords_warmup_packets, 5,
"Default coords_warmup_packets should be 5");
assert_eq!(
config.node.session.coords_warmup_packets, 5,
"Default coords_warmup_packets should be 5"
);
}
// ============================================================================
@@ -1493,11 +1573,13 @@ fn test_identity_cache_lru_eviction() {
// Insert first two with explicit timestamps to ensure deterministic ordering
let mut prefix1 = [0u8; 15];
prefix1.copy_from_slice(&id1.node_addr().as_bytes()[0..15]);
node.identity_cache.insert(prefix1, (*id1.node_addr(), id1.pubkey_full(), 1000));
node.identity_cache
.insert(prefix1, (*id1.node_addr(), id1.pubkey_full(), 1000));
let mut prefix2 = [0u8; 15];
prefix2.copy_from_slice(&id2.node_addr().as_bytes()[0..15]);
node.identity_cache.insert(prefix2, (*id2.node_addr(), id2.pubkey_full(), 2000));
node.identity_cache
.insert(prefix2, (*id2.node_addr(), id2.pubkey_full(), 2000));
assert_eq!(node.identity_cache_len(), 2);
@@ -1505,13 +1587,17 @@ fn test_identity_cache_lru_eviction() {
node.register_identity(*id3.node_addr(), id3.pubkey_full());
assert_eq!(node.identity_cache_len(), 2);
assert!(node.lookup_by_fips_prefix(&prefix1).is_none(),
"Oldest entry should have been evicted");
assert!(
node.lookup_by_fips_prefix(&prefix1).is_none(),
"Oldest entry should have been evicted"
);
let mut prefix3 = [0u8; 15];
prefix3.copy_from_slice(&id3.node_addr().as_bytes()[0..15]);
assert!(node.lookup_by_fips_prefix(&prefix3).is_some(),
"Newest entry should be present");
assert!(
node.lookup_by_fips_prefix(&prefix3).is_some(),
"Newest entry should be present"
);
}
#[test]
@@ -1546,10 +1632,7 @@ fn test_session_entry_handshake_payload_storage() {
let identity_a = Identity::generate();
let identity_b = Identity::generate();
let handshake = HandshakeState::new_initiator(
identity_a.keypair(),
identity_b.pubkey_full(),
);
let handshake = HandshakeState::new_initiator(identity_a.keypair(), identity_b.pubkey_full());
let mut entry = crate::node::session::SessionEntry::new(
*identity_b.node_addr(),
@@ -1581,10 +1664,7 @@ fn test_session_entry_resend_tracking() {
let identity_a = Identity::generate();
let identity_b = Identity::generate();
let handshake = HandshakeState::new_initiator(
identity_a.keypair(),
identity_b.pubkey_full(),
);
let handshake = HandshakeState::new_initiator(identity_a.keypair(), identity_b.pubkey_full());
let mut entry = crate::node::session::SessionEntry::new(
*identity_b.node_addr(),
@@ -1615,10 +1695,7 @@ fn test_session_entry_clear_handshake_payload() {
let identity_a = Identity::generate();
let identity_b = Identity::generate();
let handshake = HandshakeState::new_initiator(
identity_a.keypair(),
identity_b.pubkey_full(),
);
let handshake = HandshakeState::new_initiator(identity_a.keypair(), identity_b.pubkey_full());
let mut entry = crate::node::session::SessionEntry::new(
*identity_b.node_addr(),
@@ -1649,10 +1726,8 @@ async fn test_session_handshake_timeout() {
let mut node = make_node();
let identity_b = Identity::generate();
let handshake = HandshakeState::new_initiator(
node.identity.keypair(),
identity_b.pubkey_full(),
);
let handshake =
HandshakeState::new_initiator(node.identity.keypair(), identity_b.pubkey_full());
let dest_addr = *identity_b.node_addr();
@@ -1672,12 +1747,18 @@ async fn test_session_handshake_timeout() {
let timeout_secs = node.config.node.rate_limit.handshake_timeout_secs;
let before_timeout = 1000 + timeout_secs * 1000 - 1;
node.resend_pending_session_handshakes(before_timeout).await;
assert!(node.sessions.contains_key(&dest_addr), "Session should survive before timeout");
assert!(
node.sessions.contains_key(&dest_addr),
"Session should survive before timeout"
);
// After timeout: session should be removed
let after_timeout = 1000 + timeout_secs * 1000 + 1;
node.resend_pending_session_handshakes(after_timeout).await;
assert!(!node.sessions.contains_key(&dest_addr), "Timed-out session should be removed");
assert!(
!node.sessions.contains_key(&dest_addr),
"Timed-out session should be removed"
);
}
/// Test that session handshake timeout removes stale AwaitingMsg3 sessions.
@@ -1690,9 +1771,7 @@ async fn test_session_awaiting_msg3_timeout() {
let identity_a = Identity::generate();
let identity_b = Identity::generate();
let handshake = HandshakeState::new_xk_responder(
identity_b.keypair(),
);
let handshake = HandshakeState::new_xk_responder(identity_b.keypair());
let src_addr = *identity_a.node_addr();
@@ -1712,7 +1791,10 @@ async fn test_session_awaiting_msg3_timeout() {
let timeout_secs = node.config.node.rate_limit.handshake_timeout_secs;
let after_timeout = 1000 + timeout_secs * 1000 + 1;
node.resend_pending_session_handshakes(after_timeout).await;
assert!(!node.sessions.contains_key(&src_addr), "Timed-out AwaitingMsg3 session should be removed");
assert!(
!node.sessions.contains_key(&src_addr),
"Timed-out AwaitingMsg3 session should be removed"
);
}
#[tokio::test]
@@ -1734,7 +1816,11 @@ async fn test_tun_outbound_path_mtu_generates_ptb() {
let dst_fips = crate::FipsAddress::from_node_addr(&node1_addr);
// Establish session (XK: 3 messages — Setup, Ack, Msg3)
nodes[0].node.initiate_session(node1_addr, node1_pubkey).await.unwrap();
nodes[0]
.node
.initiate_session(node1_addr, node1_pubkey)
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(20)).await;
process_available_packets(&mut nodes).await;
tokio::time::sleep(Duration::from_millis(20)).await;
@@ -1742,7 +1828,14 @@ async fn test_tun_outbound_path_mtu_generates_ptb() {
tokio::time::sleep(Duration::from_millis(20)).await;
process_available_packets(&mut nodes).await;
assert!(nodes[0].node.get_session(&node1_addr).unwrap().state().is_established());
assert!(
nodes[0]
.node
.get_session(&node1_addr)
.unwrap()
.state()
.is_established()
);
// Simulate receipt of MtuExceeded by reducing PathMtuState to a value
// lower than the local transport MTU.
@@ -1751,7 +1844,8 @@ async fn test_tun_outbound_path_mtu_generates_ptb() {
{
let entry = nodes[0].node.get_session_mut(&node1_addr).unwrap();
let mmp = entry.mmp_mut().unwrap();
mmp.path_mtu.apply_notification(reduced_mtu, std::time::Instant::now());
mmp.path_mtu
.apply_notification(reduced_mtu, std::time::Instant::now());
assert_eq!(mmp.path_mtu.current_mtu(), reduced_mtu);
}
@@ -1764,14 +1858,24 @@ async fn test_tun_outbound_path_mtu_generates_ptb() {
let local_ipv6_mtu = nodes[0].node.effective_ipv6_mtu() as usize;
let oversized_payload = vec![0u8; reduced_ipv6_mtu - 39]; // 40-byte hdr + payload > reduced MTU
let ipv6_packet = build_ipv6_packet(&src_fips, &dst_fips, &oversized_payload);
assert!(ipv6_packet.len() > reduced_ipv6_mtu, "packet must exceed path MTU");
assert!(ipv6_packet.len() <= local_ipv6_mtu, "packet must fit local MTU");
assert!(
ipv6_packet.len() > reduced_ipv6_mtu,
"packet must exceed path MTU"
);
assert!(
ipv6_packet.len() <= local_ipv6_mtu,
"packet must fit local MTU"
);
nodes[0].node.handle_tun_outbound(ipv6_packet).await;
// Verify ICMPv6 Packet Too Big was generated
let ptb_messages: Vec<Vec<u8>> = std::iter::from_fn(|| tun_rx.try_recv().ok()).collect();
assert_eq!(ptb_messages.len(), 1, "Should generate exactly one ICMPv6 PTB");
assert_eq!(
ptb_messages.len(),
1,
"Should generate exactly one ICMPv6 PTB"
);
let ptb = &ptb_messages[0];
assert_eq!(ptb[0] >> 4, 6, "Should be IPv6");
@@ -1784,12 +1888,23 @@ async fn test_tun_outbound_path_mtu_generates_ptb() {
// address, causing a PMTUD blackhole.
let ptb_src = std::net::Ipv6Addr::from(<[u8; 16]>::try_from(&ptb[8..24]).unwrap());
let ptb_dst = std::net::Ipv6Addr::from(<[u8; 16]>::try_from(&ptb[24..40]).unwrap());
assert_eq!(ptb_src, dst_fips.to_ipv6(), "PTB source must be remote peer (original dst), not local node");
assert_eq!(ptb_dst, src_fips.to_ipv6(), "PTB destination must be local node (original src)");
assert_eq!(
ptb_src,
dst_fips.to_ipv6(),
"PTB source must be remote peer (original dst), not local node"
);
assert_eq!(
ptb_dst,
src_fips.to_ipv6(),
"PTB destination must be local node (original src)"
);
// Verify reported MTU (32-bit field at ICMPv6 header bytes 4-7)
let reported_mtu = u32::from_be_bytes([ptb[44], ptb[45], ptb[46], ptb[47]]);
assert_eq!(reported_mtu, reduced_ipv6_mtu as u32, "Reported MTU should match path IPv6 MTU");
assert_eq!(
reported_mtu, reduced_ipv6_mtu as u32,
"Reported MTU should match path IPv6 MTU"
);
// Verify a packet that fits within path MTU passes through (no PTB)
let (tun_tx2, tun_rx2) = std::sync::mpsc::channel();
@@ -1802,7 +1917,11 @@ async fn test_tun_outbound_path_mtu_generates_ptb() {
// No PTB should be generated for a fitting packet
let ptb_messages2: Vec<Vec<u8>> = std::iter::from_fn(|| tun_rx2.try_recv().ok()).collect();
assert_eq!(ptb_messages2.len(), 0, "Should not generate PTB for fitting packet");
assert_eq!(
ptb_messages2.len(),
0,
"Should not generate PTB for fitting packet"
);
cleanup_nodes(&mut nodes).await;
}
@@ -1845,10 +1964,19 @@ async fn test_multihop_pmtud_heterogeneous_mtu() {
nodes[0].node.register_identity(node2_addr, node2_pubkey);
// Establish session A→C via B (triggers routing through tree)
nodes[0].node.initiate_session(node2_addr, node2_pubkey).await.unwrap();
nodes[0]
.node
.initiate_session(node2_addr, node2_pubkey)
.await
.unwrap();
drain_to_quiescence(&mut nodes).await;
assert!(
nodes[0].node.get_session(&node2_addr).unwrap().state().is_established(),
nodes[0]
.node
.get_session(&node2_addr)
.unwrap()
.state()
.is_established(),
"Session A→C should be established"
);
@@ -1858,7 +1986,11 @@ async fn test_multihop_pmtud_heterogeneous_mtu() {
// With coords (~66 extra), the wire could exceed B's recv buffer.
for _ in 0..5 {
let small = build_ipv6_packet(&src_fips, &dst_fips, &[0u8; 10]);
nodes[0].node.send_ipv6_packet(&node2_addr, &small).await.unwrap();
nodes[0]
.node
.send_ipv6_packet(&node2_addr, &small)
.await
.unwrap();
}
drain_to_quiescence(&mut nodes).await;
@@ -1872,12 +2004,17 @@ async fn test_multihop_pmtud_heterogeneous_mtu() {
assert!(
ipv6_packet.len() <= local_effective_mtu,
"packet ({}) must fit A's local MTU ({})",
ipv6_packet.len(), local_effective_mtu
ipv6_packet.len(),
local_effective_mtu
);
// Send the oversized packet — B should fail to forward and send
// MtuExceeded signal back.
nodes[0].node.send_ipv6_packet(&node2_addr, &ipv6_packet).await.unwrap();
nodes[0]
.node
.send_ipv6_packet(&node2_addr, &ipv6_packet)
.await
.unwrap();
drain_to_quiescence(&mut nodes).await;
// Verify PathMtuState was updated on A
@@ -1902,7 +2039,8 @@ async fn test_multihop_pmtud_heterogeneous_mtu() {
let ptb_messages: Vec<Vec<u8>> = std::iter::from_fn(|| tun_rx2.try_recv().ok()).collect();
assert_eq!(
ptb_messages.len(), 1,
ptb_messages.len(),
1,
"Should generate ICMPv6 PTB for oversized packet after PathMtuState update"
);
@@ -1917,8 +2055,16 @@ async fn test_multihop_pmtud_heterogeneous_mtu() {
// address, causing a PMTUD blackhole.
let ptb_src = std::net::Ipv6Addr::from(<[u8; 16]>::try_from(&ptb[8..24]).unwrap());
let ptb_dst = std::net::Ipv6Addr::from(<[u8; 16]>::try_from(&ptb[24..40]).unwrap());
assert_eq!(ptb_src, dst_fips.to_ipv6(), "PTB source must be remote peer (original dst), not local node");
assert_eq!(ptb_dst, src_fips.to_ipv6(), "PTB destination must be local node (original src)");
assert_eq!(
ptb_src,
dst_fips.to_ipv6(),
"PTB source must be remote peer (original dst), not local node"
);
assert_eq!(
ptb_dst,
src_fips.to_ipv6(),
"PTB destination must be local node (original src)"
);
// Verify reported MTU is the path MTU (not local MTU)
let reported_mtu = u32::from_be_bytes([ptb[44], ptb[45], ptb[46], ptb[47]]);
@@ -1941,7 +2087,8 @@ async fn test_multihop_pmtud_heterogeneous_mtu() {
let ptb_messages3: Vec<Vec<u8>> = std::iter::from_fn(|| tun_rx3.try_recv().ok()).collect();
assert_eq!(
ptb_messages3.len(), 0,
ptb_messages3.len(),
0,
"Should not generate PTB for packet fitting within path MTU"
);
+98 -14
View File
@@ -5,6 +5,8 @@
//! reused by bloom filter tests.
use super::*;
use crate::protocol::TreeAnnounce;
use crate::tree::{CoordEntry, ParentDeclaration, TreeCoordinate};
/// A test node bundling a Node with its transport and packet channel.
pub(super) struct TestNode {
@@ -69,7 +71,9 @@ pub(super) async fn initiate_handshake(nodes: &mut [TestNode], i: usize, j: usiz
let our_index = initiator.node.index_allocator.allocate().unwrap();
let our_keypair = initiator.node.identity().keypair();
let noise_msg1 = conn.start_handshake(our_keypair, initiator.node.startup_epoch, 1000).unwrap();
let noise_msg1 = conn
.start_handshake(our_keypair, initiator.node.startup_epoch, 1000)
.unwrap();
conn.set_our_index(our_index);
conn.set_transport_id(transport_id);
conn.set_source_addr(responder_addr.clone());
@@ -184,7 +188,12 @@ pub(super) fn print_tree_snapshot(label: &str, nodes: &[TestNode]) {
.count();
eprintln!(
" node[{}] root=node[{}] depth={} parent=node[{}] peers={} pending={}",
i, root_idx, ts.my_coords().depth(), parent_idx, tn.node.peer_count(), pending,
i,
root_idx,
ts.my_coords().depth(),
parent_idx,
tn.node.peer_count(),
pending,
);
}
} else if correct_root_count < nodes.len() {
@@ -209,7 +218,9 @@ pub(super) fn print_tree_snapshot(label: &str, nodes: &[TestNode]) {
///
/// Returns the number of packets processed.
pub(super) async fn process_available_packets(nodes: &mut [TestNode]) -> usize {
use crate::node::wire::{CommonPrefix, FMP_VERSION, PHASE_ESTABLISHED, PHASE_MSG1, PHASE_MSG2, COMMON_PREFIX_SIZE};
use crate::node::wire::{
COMMON_PREFIX_SIZE, CommonPrefix, FMP_VERSION, PHASE_ESTABLISHED, PHASE_MSG1, PHASE_MSG2,
};
let mut count = 0;
for node in nodes.iter_mut() {
@@ -224,9 +235,7 @@ pub(super) async fn process_available_packets(nodes: &mut [TestNode]) -> usize {
match prefix.phase {
PHASE_MSG1 => node.node.handle_msg1(packet).await,
PHASE_MSG2 => node.node.handle_msg2(packet).await,
PHASE_ESTABLISHED => {
node.node.handle_encrypted_frame(packet).await
}
PHASE_ESTABLISHED => node.node.handle_encrypted_frame(packet).await,
_ => {}
}
count += 1;
@@ -319,7 +328,11 @@ pub(super) async fn drain_all_packets(nodes: &mut [TestNode], verbose: bool) ->
///
/// First builds a random spanning tree to ensure connectivity,
/// then adds extra edges up to the target count.
pub(super) fn generate_random_edges(n: usize, target_edges: usize, seed: u64) -> Vec<(usize, usize)> {
pub(super) fn generate_random_edges(
n: usize,
target_edges: usize,
seed: u64,
) -> Vec<(usize, usize)> {
use rand::rngs::StdRng;
use rand::{RngExt, SeedableRng};
@@ -373,11 +386,7 @@ pub(super) fn verify_tree_convergence(nodes: &[TestNode]) {
assert!(n > 0);
// Find the expected root (smallest NodeAddr across all nodes)
let expected_root = nodes
.iter()
.map(|tn| *tn.node.node_addr())
.min()
.unwrap();
let expected_root = nodes.iter().map(|tn| *tn.node.node_addr()).min().unwrap();
// All nodes should agree on the root
for (i, tn) in nodes.iter().enumerate() {
@@ -627,12 +636,16 @@ pub(super) async fn run_tree_test_with_mtus(
assert!(
nodes[i].node.get_peer(&j_addr).is_some(),
"Node {} should have peer {} (node {})",
i, j_addr, j
i,
j_addr,
j
);
assert!(
nodes[j].node.get_peer(&i_addr).is_some(),
"Node {} should have peer {} (node {})",
j, i_addr, i
j,
i_addr,
i
);
}
@@ -706,3 +719,74 @@ async fn test_spanning_tree_disconnected() {
verify_tree_convergence_components(&nodes, &[vec![0, 1, 2], vec![3, 4, 5]]);
cleanup_nodes(&mut nodes).await;
}
/// Tests that a node ignores a signed TreeAnnounce whose advertised root is not the smallest node_addr in the ancestry.
#[tokio::test]
async fn test_rejects_tree_announce_with_inconsistent_root() {
// Start from a healthy 2-node tree so node B already has a normal, trusted
// view of node A's coordinates.
let mut nodes = run_tree_test(2, &[(0, 1)], false).await;
let a_addr = *nodes[0].node.node_addr();
let current_root = *nodes[1].node.tree_state().root();
let current_depth = nodes[1].node.tree_state().my_coords().depth();
let peer_coords_before = nodes[1]
.node
.get_peer(&a_addr)
.unwrap()
.coords()
.unwrap()
.clone();
let accepted_before = nodes[1].node.stats().tree.accepted;
// Use two fixed synthetic ancestors so the forged path is explicit:
// - fake_parent = 00000000000000000000000000000000
// - fake_root = 00000000000000000000000000000001
//
// The forged ancestry is therefore:
// [A, 000...000, 000...001]
//
// That makes 000...001 the advertised root because it is the final entry,
// even though 000...000 is smaller and appears earlier in the path.
let fake_parent = NodeAddr::from_bytes([0u8; 16]);
let mut fake_root_bytes = [0u8; 16];
fake_root_bytes[15] = 1;
let fake_root = NodeAddr::from_bytes(fake_root_bytes);
// Sign a fresh declaration from A. The 99/12345 values are just a newer
// sequence/timestamp so the announce would be acceptable on freshness
// grounds if its ancestry semantics were valid.
let mut declaration = ParentDeclaration::new(a_addr, fake_parent, 99, 12345);
declaration.sign(nodes[0].node.identity()).unwrap();
let announce = TreeAnnounce::new(
declaration,
TreeCoordinate::new(vec![
CoordEntry::new(a_addr, 99, 12345),
CoordEntry::new(fake_parent, 98, 12344),
CoordEntry::new(fake_root, 97, 12343),
])
.unwrap(),
);
let encoded = announce.encode().unwrap();
nodes[1]
.node
.handle_tree_announce(&a_addr, &encoded[1..])
.await;
// B should reject the malformed ancestry before mutating either its local
// tree state or its cached view of peer A.
assert_eq!(*nodes[1].node.tree_state().root(), current_root);
assert_eq!(
nodes[1].node.tree_state().my_coords().depth(),
current_depth
);
assert_eq!(nodes[1].node.stats().tree.accepted, accepted_before);
assert_eq!(
nodes[1].node.get_peer(&a_addr).unwrap().coords().unwrap(),
&peer_coords_before
);
cleanup_nodes(&mut nodes).await;
}
+2 -2
View File
@@ -8,9 +8,9 @@
use super::*;
use crate::config::TcpConfig;
use crate::transport::tcp::TcpTransport;
use crate::transport::{packet_channel, TransportAddr, TransportHandle, TransportId};
use crate::transport::{TransportAddr, TransportHandle, TransportId, packet_channel};
use spanning_tree::{
cleanup_nodes, drain_all_packets, initiate_handshake, verify_tree_convergence, TestNode,
TestNode, cleanup_nodes, drain_all_packets, initiate_handshake, verify_tree_convergence,
};
use std::time::Duration;
+86 -34
View File
@@ -96,7 +96,10 @@ fn test_node_link_management() {
assert_eq!(node.link_count(), 0);
// Lookup should be gone
assert!(node.find_link_by_addr(TransportId::new(1), &TransportAddr::from_string("test")).is_none());
assert!(
node.find_link_by_addr(TransportId::new(1), &TransportAddr::from_string("test"))
.is_none()
);
}
#[test]
@@ -183,8 +186,14 @@ fn test_node_promote_connection() {
let peer = node.get_peer(&node_addr).unwrap();
assert_eq!(peer.authenticated_at(), 2000);
assert!(peer.has_session(), "Promoted peer should have NoiseSession");
assert!(peer.our_index().is_some(), "Promoted peer should have our_index");
assert!(peer.their_index().is_some(), "Promoted peer should have their_index");
assert!(
peer.our_index().is_some(),
"Promoted peer should have our_index"
);
assert!(
peer.their_index().is_some(),
"Promoted peer should have their_index"
);
// Verify peers_by_index is populated
let our_index = peer.our_index().unwrap();
@@ -201,8 +210,7 @@ fn test_node_cross_connection_resolution() {
// First connection and promotion (becomes active peer)
let link_id1 = LinkId::new(1);
let (conn1, identity) =
make_completed_connection(&mut node, link_id1, transport_id, 1000);
let (conn1, identity) = make_completed_connection(&mut node, link_id1, transport_id, 1000);
let node_addr = *identity.node_addr();
node.add_connection(conn1).unwrap();
@@ -236,8 +244,7 @@ fn test_node_peer_limit() {
// Add two peers via promotion
for i in 0..2 {
let link_id = LinkId::new(i as u64 + 1);
let (conn, identity) =
make_completed_connection(&mut node, link_id, transport_id, 1000);
let (conn, identity) = make_completed_connection(&mut node, link_id, transport_id, 1000);
node.add_connection(conn).unwrap();
node.promote_connection(link_id, identity, 2000).unwrap();
}
@@ -246,8 +253,7 @@ fn test_node_peer_limit() {
// Third should fail
let link_id = LinkId::new(3);
let (conn, identity) =
make_completed_connection(&mut node, link_id, transport_id, 3000);
let (conn, identity) = make_completed_connection(&mut node, link_id, transport_id, 3000);
node.add_connection(conn).unwrap();
let result = node.promote_connection(link_id, identity, 4000);
@@ -296,23 +302,20 @@ fn test_node_sendable_peers() {
// Add a healthy peer
let link_id1 = LinkId::new(1);
let (conn1, identity1) =
make_completed_connection(&mut node, link_id1, transport_id, 1000);
let (conn1, identity1) = make_completed_connection(&mut node, link_id1, transport_id, 1000);
let node_addr1 = *identity1.node_addr();
node.add_connection(conn1).unwrap();
node.promote_connection(link_id1, identity1, 2000).unwrap();
// Add another peer and mark it stale (still sendable)
let link_id2 = LinkId::new(2);
let (conn2, identity2) =
make_completed_connection(&mut node, link_id2, transport_id, 1000);
let (conn2, identity2) = make_completed_connection(&mut node, link_id2, transport_id, 1000);
node.add_connection(conn2).unwrap();
node.promote_connection(link_id2, identity2, 2000).unwrap();
// Add a third peer and mark it disconnected (not sendable)
let link_id3 = LinkId::new(3);
let (conn3, identity3) =
make_completed_connection(&mut node, link_id3, transport_id, 1000);
let (conn3, identity3) = make_completed_connection(&mut node, link_id3, transport_id, 1000);
let node_addr3 = *identity3.node_addr();
node.add_connection(conn3).unwrap();
node.promote_connection(link_id3, identity3, 2000).unwrap();
@@ -345,14 +348,16 @@ fn test_node_pending_outbound_tracking() {
let index = node.index_allocator.allocate().unwrap();
// Track in pending_outbound
node.pending_outbound.insert((transport_id, index.as_u32()), link_id);
node.pending_outbound
.insert((transport_id, index.as_u32()), link_id);
// Verify we can look it up
let found = node.pending_outbound.get(&(transport_id, index.as_u32()));
assert_eq!(found, Some(&link_id));
// Clean up
node.pending_outbound.remove(&(transport_id, index.as_u32()));
node.pending_outbound
.remove(&(transport_id, index.as_u32()));
let _ = node.index_allocator.free(index);
assert_eq!(node.index_allocator.count(), 0);
@@ -369,7 +374,8 @@ fn test_node_peers_by_index_tracking() {
let index = node.index_allocator.allocate().unwrap();
// Track in peers_by_index
node.peers_by_index.insert((transport_id, index.as_u32()), node_addr);
node.peers_by_index
.insert((transport_id, index.as_u32()), node_addr);
// Verify lookup
let found = node.peers_by_index.get(&(transport_id, index.as_u32()));
@@ -450,7 +456,9 @@ fn test_promote_cleans_up_pending_outbound_to_same_peer() {
PeerConnection::outbound(pending_link_id, peer_b_identity, pending_time_ms);
let our_keypair = node.identity.keypair();
let _msg1 = pending_conn.start_handshake(our_keypair, node.startup_epoch, pending_time_ms).unwrap();
let _msg1 = pending_conn
.start_handshake(our_keypair, node.startup_epoch, pending_time_ms)
.unwrap();
let pending_index = node.index_allocator.allocate().unwrap();
pending_conn.set_our_index(pending_index);
@@ -483,11 +491,8 @@ fn test_promote_cleans_up_pending_outbound_to_same_peer() {
let completing_link_id = LinkId::new(2);
let completing_time_ms = 2000;
let mut completing_conn = PeerConnection::outbound(
completing_link_id,
peer_b_identity,
completing_time_ms,
);
let mut completing_conn =
PeerConnection::outbound(completing_link_id, peer_b_identity, completing_time_ms);
let our_keypair = node.identity.keypair();
let msg1 = completing_conn
@@ -573,7 +578,10 @@ fn test_schedule_retry_creates_entry() {
assert_eq!(node.retry_pending.len(), 1);
let state = node.retry_pending.get(&peer_node_addr).unwrap();
assert_eq!(state.retry_count, 1);
assert!(state.reconnect, "Auto-connect peers always get reconnect=true");
assert!(
state.reconnect,
"Auto-connect peers always get reconnect=true"
);
// Default base = 5s, 2^1 = 10s, but first retry is 2^0... let me check:
// retry_count is set to 1, backoff_ms(5000) = 5000 * 2^1 = 10000
assert_eq!(state.retry_after_ms, 1000 + 10_000);
@@ -597,7 +605,10 @@ fn test_schedule_retry_increments() {
// First failure
node.schedule_retry(peer_node_addr, 1000);
assert_eq!(node.retry_pending.get(&peer_node_addr).unwrap().retry_count, 1);
assert_eq!(
node.retry_pending.get(&peer_node_addr).unwrap().retry_count,
1
);
// Second failure
node.schedule_retry(peer_node_addr, 11_000);
@@ -637,7 +648,10 @@ fn test_schedule_retry_auto_connect_never_exhausts() {
node.retry_pending.contains_key(&peer_node_addr),
"Auto-connect peers should never exhaust retries"
);
assert_eq!(node.retry_pending.get(&peer_node_addr).unwrap().retry_count, 3);
assert_eq!(
node.retry_pending.get(&peer_node_addr).unwrap().retry_count,
3
);
}
/// Test that schedule_retry does nothing when max_retries is 0.
@@ -725,7 +739,7 @@ fn test_schedule_reconnect_preserves_backoff() {
let mut node = Node::new(config).unwrap();
// Simulate two stale handshake timeouts incrementing the retry count.
node.schedule_retry(peer_node_addr, 1_000); // count=1, delay=10s
node.schedule_retry(peer_node_addr, 1_000); // count=1, delay=10s
node.schedule_retry(peer_node_addr, 11_000); // count=2, delay=20s
{
let state = node.retry_pending.get(&peer_node_addr).unwrap();
@@ -738,10 +752,7 @@ fn test_schedule_reconnect_preserves_backoff() {
node.schedule_reconnect(peer_node_addr, 31_000);
let state = node.retry_pending.get(&peer_node_addr).unwrap();
assert!(
state.reconnect,
"Entry should be marked as reconnect"
);
assert!(state.reconnect, "Entry should be marked as reconnect");
assert_eq!(
state.retry_count, 3,
"schedule_reconnect should increment existing count (was 2), not reset to 0 (regression: issue #5)"
@@ -752,7 +763,8 @@ fn test_schedule_reconnect_preserves_backoff() {
let max_ms = node.config.node.retry.max_backoff_secs * 1000;
let expected_delay = state.backoff_ms(base_ms, max_ms);
assert_eq!(
state.retry_after_ms, 31_000 + expected_delay,
state.retry_after_ms,
31_000 + expected_delay,
"retry_after_ms should reflect count=3 backoff"
);
}
@@ -778,7 +790,10 @@ fn test_schedule_reconnect_fresh_state() {
let state = node.retry_pending.get(&peer_node_addr).unwrap();
assert!(state.reconnect, "Entry should be marked as reconnect");
assert_eq!(state.retry_count, 0, "Fresh reconnect should start at count=0");
assert_eq!(
state.retry_count, 0,
"Fresh reconnect should start at count=0"
);
// Base delay: 5s * 2^0 = 5s
let base_ms = node.config.node.retry.base_interval_secs * 1000;
let max_ms = node.config.node.retry.max_backoff_secs * 1000;
@@ -786,6 +801,43 @@ fn test_schedule_reconnect_fresh_state() {
assert_eq!(state.retry_after_ms, 1_000 + expected_delay);
}
/// Test that a graceful Disconnect from an auto-connect peer schedules reconnect.
///
/// Regression test for issue #60: `handle_disconnect` previously called
/// `remove_active_peer` without `schedule_reconnect`, orphaning auto-connect
/// entries on a clean upstream shutdown. Other peer-removal paths (link-dead,
/// decrypt failure, peer restart) all schedule reconnect.
#[test]
fn test_disconnect_schedules_reconnect() {
use crate::protocol::{Disconnect, DisconnectReason};
let peer_identity = Identity::generate();
let peer_npub = peer_identity.npub();
let peer_node_addr = *PeerIdentity::from_npub(&peer_npub).unwrap().node_addr();
let mut config = Config::new();
config.peers.push(crate::config::PeerConfig::new(
peer_npub,
"udp",
"10.0.0.2:2121",
));
let mut node = Node::new(config).unwrap();
let payload = Disconnect::new(DisconnectReason::Shutdown).encode();
node.handle_disconnect(&peer_node_addr, &payload);
let state = node
.retry_pending
.get(&peer_node_addr)
.expect("handle_disconnect should schedule reconnect for auto-connect peer");
assert!(state.reconnect, "Entry should be marked as reconnect");
assert_eq!(
state.retry_count, 0,
"Fresh reconnect after disconnect should start at count=0"
);
}
/// Test that promote_connection clears retry_pending.
#[test]
fn test_promote_clears_retry_pending() {
+31 -13
View File
@@ -5,8 +5,8 @@
use std::collections::HashMap;
use crate::protocol::TreeAnnounce;
use crate::NodeAddr;
use crate::protocol::TreeAnnounce;
use super::{Node, NodeError};
use tracing::{debug, info, trace, warn};
@@ -105,7 +105,9 @@ impl Node {
let ready: Vec<NodeAddr> = self
.peers
.iter()
.filter(|(_, peer)| peer.has_pending_tree_announce() && peer.can_send_tree_announce(now_ms))
.filter(|(_, peer)| {
peer.has_pending_tree_announce() && peer.can_send_tree_announce(now_ms)
})
.map(|(addr, _)| *addr)
.collect();
@@ -170,6 +172,15 @@ impl Node {
return;
}
if let Err(e) = announce.validate_semantics() {
warn!(
from = %self.peer_display_name(from),
error = %e,
"Rejected TreeAnnounce with invalid ancestry"
);
return;
}
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
@@ -185,10 +196,9 @@ impl Node {
}
// Update in TreeState
let updated = self.tree_state.update_peer(
announce.declaration.clone(),
announce.ancestry.clone(),
);
let updated = self
.tree_state
.update_peer(announce.declaration.clone(), announce.ancestry.clone());
if !updated {
self.stats_mut().tree.stale += 1;
@@ -214,7 +224,9 @@ impl Node {
// Re-evaluate parent selection with current link costs.
// Exclude peers without MMP RTT data — they are not yet eligible
// as parent candidates (prevents oscillation from optimistic defaults).
let peer_costs: HashMap<NodeAddr, f64> = self.peers.iter()
let peer_costs: HashMap<NodeAddr, f64> = self
.peers
.iter()
.filter(|(_, peer)| peer.has_srtt())
.map(|(addr, peer)| (*addr, peer.link_cost()))
.collect();
@@ -266,7 +278,9 @@ impl Node {
parent = %self.peer_display_name(from),
"Parent ancestry contains us — loop detected, dropping parent"
);
let peer_costs: HashMap<NodeAddr, f64> = self.peers.iter()
let peer_costs: HashMap<NodeAddr, f64> = self
.peers
.iter()
.filter(|(_, peer)| peer.has_srtt())
.map(|(addr, peer)| (*addr, peer.link_cost()))
.collect();
@@ -276,7 +290,7 @@ impl Node {
return;
}
self.coord_cache.clear();
self.reset_discovery_backoff();
self.reset_discovery_backoff();
self.send_tree_announce_to_all().await;
}
return;
@@ -360,7 +374,9 @@ impl Node {
self.last_parent_reeval = Some(now);
let peer_costs: HashMap<NodeAddr, f64> = self.peers.iter()
let peer_costs: HashMap<NodeAddr, f64> = self
.peers
.iter()
.filter(|(_, peer)| peer.has_srtt())
.map(|(addr, peer)| (*addr, peer.link_cost()))
.collect();
@@ -411,14 +427,16 @@ impl Node {
///
/// Returns `true` if our tree state changed (caller should announce).
pub(super) fn handle_peer_removal_tree_cleanup(&mut self, node_addr: &NodeAddr) -> bool {
let was_parent = !self.tree_state.is_root()
&& self.tree_state.my_declaration().parent_id() == node_addr;
let was_parent =
!self.tree_state.is_root() && self.tree_state.my_declaration().parent_id() == node_addr;
self.tree_state.remove_peer(node_addr);
if was_parent {
self.stats_mut().tree.parent_losses += 1;
let peer_costs: HashMap<NodeAddr, f64> = self.peers.iter()
let peer_costs: HashMap<NodeAddr, f64> = self
.peers
.iter()
.filter(|(_, peer)| peer.has_srtt())
.map(|(addr, peer)| (*addr, peer.link_cost()))
.collect();
+16 -18
View File
@@ -17,8 +17,8 @@
//! | 0x1 | Noise IK msg1 | 114 bytes | Handshake initiation |
//! | 0x2 | Noise IK msg2 | 69 bytes | Handshake response |
use crate::utils::index::SessionIndex;
use crate::noise::{HANDSHAKE_MSG1_SIZE, HANDSHAKE_MSG2_SIZE, TAG_SIZE};
use crate::utils::index::SessionIndex;
// ============================================================================
// Constants
@@ -164,8 +164,7 @@ impl EncryptedHeader {
let payload_len = u16::from_le_bytes([data[2], data[3]]);
let receiver_idx = SessionIndex::from_le_bytes([data[4], data[5], data[6], data[7]]);
let counter = u64::from_le_bytes([
data[8], data[9], data[10], data[11],
data[12], data[13], data[14], data[15],
data[8], data[9], data[10], data[11], data[12], data[13], data[14], data[15],
]);
let mut header_bytes = [0u8; ESTABLISHED_HEADER_SIZE];
@@ -328,7 +327,11 @@ pub fn build_msg1(sender_idx: SessionIndex, noise_msg1: &[u8]) -> Vec<u8> {
/// Build a wire-format msg2 packet.
///
/// Format: `[0x02][0x00][payload_len:2 LE][sender_idx:4 LE][receiver_idx:4 LE][noise_msg2:57]`
pub fn build_msg2(sender_idx: SessionIndex, receiver_idx: SessionIndex, noise_msg2: &[u8]) -> Vec<u8> {
pub fn build_msg2(
sender_idx: SessionIndex,
receiver_idx: SessionIndex,
noise_msg2: &[u8],
) -> Vec<u8> {
debug_assert_eq!(noise_msg2.len(), HANDSHAKE_MSG2_SIZE);
let payload_len = (4 + 4 + noise_msg2.len()) as u16; // sender + receiver + noise
@@ -542,8 +545,8 @@ mod tests {
#[test]
fn test_wire_sizes() {
assert_eq!(MSG1_WIRE_SIZE, 114); // 4 + 4 + 106
assert_eq!(MSG2_WIRE_SIZE, 69); // 4 + 4 + 4 + 57
assert_eq!(MSG1_WIRE_SIZE, 114); // 4 + 4 + 106
assert_eq!(MSG2_WIRE_SIZE, 69); // 4 + 4 + 4 + 57
assert_eq!(ENCRYPTED_MIN_SIZE, 32); // 16 + 16
assert_eq!(COMMON_PREFIX_SIZE, 4);
assert_eq!(ESTABLISHED_HEADER_SIZE, 16);
@@ -585,22 +588,17 @@ mod tests {
#[test]
fn test_flags_byte() {
let header = build_established_header(
SessionIndex::new(1),
0,
FLAG_KEY_EPOCH | FLAG_SP,
100,
);
let header =
build_established_header(SessionIndex::new(1), 0, FLAG_KEY_EPOCH | FLAG_SP, 100);
assert_eq!(header[1], 0x05); // bits 0 and 2 set
let parsed = EncryptedHeader::parse(&[
header[0], header[1], header[2], header[3],
header[4], header[5], header[6], header[7],
header[8], header[9], header[10], header[11],
header[12], header[13], header[14], header[15],
// minimum: TAG_SIZE bytes of ciphertext
header[0], header[1], header[2], header[3], header[4], header[5], header[6], header[7],
header[8], header[9], header[10], header[11], header[12], header[13], header[14],
header[15], // minimum: TAG_SIZE bytes of ciphertext
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
]).unwrap();
])
.unwrap();
assert_eq!(parsed.flags & FLAG_KEY_EPOCH, FLAG_KEY_EPOCH);
assert_eq!(parsed.flags & FLAG_CE, 0);
assert_eq!(parsed.flags & FLAG_SP, FLAG_SP);
+30 -13
View File
@@ -1,12 +1,12 @@
use super::{
CipherState, HandshakeProgress, HandshakeRole, NoiseError, NoisePattern, NoiseSession,
EPOCH_ENCRYPTED_SIZE, EPOCH_SIZE, HANDSHAKE_MSG1_SIZE, HANDSHAKE_MSG2_SIZE,
PROTOCOL_NAME_IK, PROTOCOL_NAME_XK, PUBKEY_SIZE,
XK_HANDSHAKE_MSG1_SIZE, XK_HANDSHAKE_MSG2_SIZE, XK_HANDSHAKE_MSG3_SIZE,
CipherState, EPOCH_ENCRYPTED_SIZE, EPOCH_SIZE, HANDSHAKE_MSG1_SIZE, HANDSHAKE_MSG2_SIZE,
HandshakeProgress, HandshakeRole, NoiseError, NoisePattern, NoiseSession, PROTOCOL_NAME_IK,
PROTOCOL_NAME_XK, PUBKEY_SIZE, XK_HANDSHAKE_MSG1_SIZE, XK_HANDSHAKE_MSG2_SIZE,
XK_HANDSHAKE_MSG3_SIZE,
};
use hkdf::Hkdf;
use rand::Rng;
use secp256k1::{ecdh::shared_secret_point, Keypair, PublicKey, Secp256k1, SecretKey};
use secp256k1::{Keypair, PublicKey, Secp256k1, SecretKey, ecdh::shared_secret_point};
use sha2::{Digest, Sha256};
use std::fmt;
@@ -343,8 +343,12 @@ impl HandshakeState {
});
}
let remote_static = self.remote_static.expect("initiator must have remote static");
let epoch = self.local_epoch.expect("local epoch must be set before write_message_1");
let remote_static = self
.remote_static
.expect("initiator must have remote static");
let epoch = self
.local_epoch
.expect("local epoch must be set before write_message_1");
// Generate ephemeral keypair
self.generate_ephemeral();
@@ -462,7 +466,9 @@ impl HandshakeState {
}
let re = self.remote_ephemeral.expect("should have remote ephemeral");
let epoch = self.local_epoch.expect("local epoch must be set before write_message_2");
let epoch = self
.local_epoch
.expect("local epoch must be set before write_message_2");
// Generate ephemeral keypair
self.generate_ephemeral();
@@ -572,7 +578,9 @@ impl HandshakeState {
});
}
let remote_static = self.remote_static.expect("initiator must have remote static");
let remote_static = self
.remote_static
.expect("initiator must have remote static");
// Generate ephemeral keypair
self.generate_ephemeral();
@@ -657,7 +665,9 @@ impl HandshakeState {
}
let re = self.remote_ephemeral.expect("should have remote ephemeral");
let epoch = self.local_epoch.expect("local epoch must be set before write_xk_message_2");
let epoch = self
.local_epoch
.expect("local epoch must be set before write_xk_message_2");
// Generate ephemeral keypair
self.generate_ephemeral();
@@ -755,8 +765,12 @@ impl HandshakeState {
});
}
let re = self.remote_ephemeral.expect("should have remote ephemeral after msg2");
let epoch = self.local_epoch.expect("local epoch must be set before write_xk_message_3");
let re = self
.remote_ephemeral
.expect("should have remote ephemeral after msg2");
let epoch = self
.local_epoch
.expect("local epoch must be set before write_xk_message_3");
let mut message = Vec::with_capacity(XK_HANDSHAKE_MSG3_SIZE);
@@ -813,7 +827,10 @@ impl HandshakeState {
// -> se: DH(e, rs), mix into key
// (responder uses their ephemeral with initiator's now-known static)
let ephemeral = self.ephemeral_keypair.as_ref().expect("should have ephemeral after msg2");
let ephemeral = self
.ephemeral_keypair
.as_ref()
.expect("should have ephemeral after msg2");
let se = self.ecdh(&ephemeral.secret_key(), &rs);
self.symmetric.mix_key(&se);
+15 -3
View File
@@ -40,8 +40,8 @@ mod replay;
mod session;
use chacha20poly1305::{
aead::{Aead, KeyInit, Payload},
ChaCha20Poly1305, Nonce,
aead::{Aead, KeyInit, Payload},
};
use std::fmt;
use thiserror::Error;
@@ -327,7 +327,13 @@ impl CipherState {
let nonce = self.next_nonce()?;
let ciphertext = cipher
.encrypt(&nonce, Payload { msg: plaintext, aad })
.encrypt(
&nonce,
Payload {
msg: plaintext,
aad,
},
)
.map_err(|_| NoiseError::EncryptionFailed)?;
Ok(ciphertext)
@@ -360,7 +366,13 @@ impl CipherState {
let nonce = Self::counter_to_nonce(counter);
let plaintext = cipher
.decrypt(&nonce, Payload { msg: ciphertext, aad })
.decrypt(
&nonce,
Payload {
msg: ciphertext,
aad,
},
)
.map_err(|_| NoiseError::DecryptionFailed)?;
Ok(plaintext)
+3 -1
View File
@@ -133,7 +133,9 @@ impl NoiseSession {
}
// Attempt decryption with AAD (expensive)
let plaintext = self.recv_cipher.decrypt_with_counter_and_aad(ciphertext, counter, aad)?;
let plaintext = self
.recv_cipher
.decrypt_with_counter_and_aad(ciphertext, counter, aad)?;
// Only accept into window after successful decryption
self.replay_window.accept(counter);
+71 -26
View File
@@ -129,9 +129,11 @@ fn test_wrong_role_errors() {
initiator.set_local_epoch(generate_epoch());
// Initiator can't read message 1
assert!(initiator
.read_message_1(&[0u8; HANDSHAKE_MSG1_SIZE])
.is_err());
assert!(
initiator
.read_message_1(&[0u8; HANDSHAKE_MSG1_SIZE])
.is_err()
);
// Initiator can't write message 2 before message 1
assert!(initiator.write_message_2().is_err());
@@ -337,7 +339,11 @@ fn test_replay_window_sequential() {
// All should be marked as seen
for i in 0..1000 {
assert!(!window.check(i), "Counter {} should be rejected as replay", i);
assert!(
!window.check(i),
"Counter {} should be rejected as replay",
i
);
}
assert_eq!(window.highest(), 999);
@@ -403,18 +409,20 @@ fn test_handshake_with_odd_parity_responder() {
// Node B (responder) - odd parity key
let sk_b = secp256k1::SecretKey::from_slice(
&hex::decode("b102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fb0")
.unwrap(),
&hex::decode("b102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fb0").unwrap(),
)
.unwrap();
let kp_b = secp256k1::Keypair::from_secret_key(&secp, &sk_b);
let (xonly_b, parity_b) = kp_b.public_key().x_only_public_key();
assert_eq!(parity_b, Parity::Odd, "Test requires odd-parity responder key");
assert_eq!(
parity_b,
Parity::Odd,
"Test requires odd-parity responder key"
);
// Node A (initiator) - even parity key
let sk_a = secp256k1::SecretKey::from_slice(
&hex::decode("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20")
.unwrap(),
&hex::decode("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20").unwrap(),
)
.unwrap();
let kp_a = secp256k1::Keypair::from_secret_key(&secp, &sk_a);
@@ -423,7 +431,8 @@ fn test_handshake_with_odd_parity_responder() {
// (x-only -> assumed even parity)
let assumed_even_b = xonly_b.public_key(Parity::Even);
assert_ne!(
assumed_even_b, kp_b.public_key(),
assumed_even_b,
kp_b.public_key(),
"Even assumption should differ from actual odd key"
);
@@ -554,7 +563,8 @@ fn test_xk_identity_timing() {
let initiator_keypair = generate_keypair();
let responder_keypair = generate_keypair();
let mut initiator = HandshakeState::new_xk_initiator(initiator_keypair, responder_keypair.public_key());
let mut initiator =
HandshakeState::new_xk_initiator(initiator_keypair, responder_keypair.public_key());
initiator.set_local_epoch(generate_epoch());
let mut responder = HandshakeState::new_xk_responder(responder_keypair);
responder.set_local_epoch(generate_epoch());
@@ -565,18 +575,30 @@ fn test_xk_identity_timing() {
// After msg1
let msg1 = initiator.write_xk_message_1().unwrap();
responder.read_xk_message_1(&msg1).unwrap();
assert!(responder.remote_static().is_none(), "XK: responder should NOT know identity after msg1");
assert!(
responder.remote_static().is_none(),
"XK: responder should NOT know identity after msg1"
);
// After msg2
let msg2 = responder.write_xk_message_2().unwrap();
initiator.read_xk_message_2(&msg2).unwrap();
assert!(responder.remote_static().is_none(), "XK: responder should NOT know identity after msg2");
assert!(
responder.remote_static().is_none(),
"XK: responder should NOT know identity after msg2"
);
// After msg3
let msg3 = initiator.write_xk_message_3().unwrap();
responder.read_xk_message_3(&msg3).unwrap();
assert!(responder.remote_static().is_some(), "XK: responder should know identity after msg3");
assert_eq!(responder.remote_static().unwrap(), &initiator_keypair.public_key());
assert!(
responder.remote_static().is_some(),
"XK: responder should know identity after msg3"
);
assert_eq!(
responder.remote_static().unwrap(),
&initiator_keypair.public_key()
);
}
#[test]
@@ -587,7 +609,11 @@ fn test_xk_wrong_state_errors() {
// Initiator can't read XK msg1
let mut initiator = HandshakeState::new_xk_initiator(keypair1, keypair2.public_key());
initiator.set_local_epoch(generate_epoch());
assert!(initiator.read_xk_message_1(&[0u8; XK_HANDSHAKE_MSG1_SIZE]).is_err());
assert!(
initiator
.read_xk_message_1(&[0u8; XK_HANDSHAKE_MSG1_SIZE])
.is_err()
);
// Initiator can't write msg2
assert!(initiator.write_xk_message_2().is_err());
@@ -601,7 +627,11 @@ fn test_xk_wrong_state_errors() {
assert!(responder.write_xk_message_1().is_err());
// Responder can't read msg3 before msg2
assert!(responder.read_xk_message_3(&[0u8; XK_HANDSHAKE_MSG3_SIZE]).is_err());
assert!(
responder
.read_xk_message_3(&[0u8; XK_HANDSHAKE_MSG3_SIZE])
.is_err()
);
}
#[test]
@@ -636,7 +666,10 @@ fn test_xk_handshake_hash_differs_from_ik() {
xk_resp.read_xk_message_3(&msg3).unwrap();
let xk_hash = xk_init.handshake_hash();
assert_ne!(ik_hash, xk_hash, "IK and XK should produce different handshake hashes");
assert_ne!(
ik_hash, xk_hash,
"IK and XK should produce different handshake hashes"
);
}
#[test]
@@ -677,18 +710,20 @@ fn test_xk_with_odd_parity_responder() {
// Node B (responder) - odd parity key
let sk_b = secp256k1::SecretKey::from_slice(
&hex::decode("b102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fb0")
.unwrap(),
&hex::decode("b102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fb0").unwrap(),
)
.unwrap();
let kp_b = secp256k1::Keypair::from_secret_key(&secp, &sk_b);
let (xonly_b, parity_b) = kp_b.public_key().x_only_public_key();
assert_eq!(parity_b, Parity::Odd, "Test requires odd-parity responder key");
assert_eq!(
parity_b,
Parity::Odd,
"Test requires odd-parity responder key"
);
// Node A (initiator)
let sk_a = secp256k1::SecretKey::from_slice(
&hex::decode("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20")
.unwrap(),
&hex::decode("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20").unwrap(),
)
.unwrap();
let kp_a = secp256k1::Keypair::from_secret_key(&secp, &sk_a);
@@ -716,7 +751,9 @@ fn test_xk_with_odd_parity_responder() {
let counter = sender.current_send_counter();
let ciphertext = sender.encrypt(b"xk parity test").unwrap();
let plaintext = receiver.decrypt_with_replay_check(&ciphertext, counter).unwrap();
let plaintext = receiver
.decrypt_with_replay_check(&ciphertext, counter)
.unwrap();
assert_eq!(plaintext, b"xk parity test");
}
@@ -727,7 +764,11 @@ fn test_xk_invalid_msg1_size() {
responder.set_local_epoch(generate_epoch());
// Wrong size (IK msg1 size instead of XK)
assert!(responder.read_xk_message_1(&[0u8; HANDSHAKE_MSG1_SIZE]).is_err());
assert!(
responder
.read_xk_message_1(&[0u8; HANDSHAKE_MSG1_SIZE])
.is_err()
);
// Too short
assert!(responder.read_xk_message_1(&[0u8; 10]).is_err());
}
@@ -748,5 +789,9 @@ fn test_xk_invalid_msg3_size() {
// Responder is now in Message2Done, try wrong-size msg3
assert!(responder.read_xk_message_3(&[0u8; 10]).is_err());
assert!(responder.read_xk_message_3(&[0u8; XK_HANDSHAKE_MSG3_SIZE + 1]).is_err());
assert!(
responder
.read_xk_message_3(&[0u8; XK_HANDSHAKE_MSG3_SIZE + 1])
.is_err()
);
}
+15 -22
View File
@@ -5,10 +5,10 @@
use crate::bloom::BloomFilter;
use crate::mmp::{MmpConfig, MmpPeerState};
use crate::utils::index::SessionIndex;
use crate::noise::{HandshakeState as NoiseHandshakeState, NoiseError, NoiseSession};
use crate::transport::{LinkId, LinkStats, TransportAddr, TransportId};
use crate::tree::{ParentDeclaration, TreeCoordinate};
use crate::utils::index::SessionIndex;
use crate::{FipsAddress, NodeAddr, PeerIdentity};
use secp256k1::XOnlyPublicKey;
use std::fmt;
@@ -32,7 +32,10 @@ pub enum ConnectivityState {
impl ConnectivityState {
/// Check if the peer is usable for sending traffic.
pub fn can_send(&self) -> bool {
matches!(self, ConnectivityState::Connected | ConnectivityState::Stale)
matches!(
self,
ConnectivityState::Connected | ConnectivityState::Stale
)
}
/// Check if this is a terminal state requiring cleanup.
@@ -744,12 +747,7 @@ impl ActivePeer {
// === Filter Updates ===
/// Update peer's inbound filter.
pub fn update_filter(
&mut self,
filter: BloomFilter,
sequence: u64,
current_time_ms: u64,
) {
pub fn update_filter(&mut self, filter: BloomFilter, sequence: u64, current_time_ms: u64) {
self.inbound_filter = Some(filter);
self.filter_sequence = sequence;
self.filter_received_at = current_time_ms;
@@ -964,12 +962,11 @@ impl ActivePeer {
self.rekey_msg1_next_resend = 0;
self.rekey_in_progress = false;
// Return whichever index needs freeing
self.rekey_our_index.take()
.or_else(|| {
self.pending_new_session = None;
self.pending_their_index = None;
self.pending_our_index.take()
})
self.rekey_our_index.take().or_else(|| {
self.pending_new_session = None;
self.pending_their_index = None;
self.pending_our_index.take()
})
}
// === Rekey Handshake State (Initiator) ===
@@ -999,11 +996,9 @@ impl ActivePeer {
/// Takes the stored handshake state, reads msg2, and returns the
/// completed NoiseSession. Clears the handshake-related fields but
/// leaves rekey_our_index for set_pending_session to use.
pub fn complete_rekey_msg2(
&mut self,
msg2_bytes: &[u8],
) -> Result<NoiseSession, NoiseError> {
let mut hs = self.rekey_handshake
pub fn complete_rekey_msg2(&mut self, msg2_bytes: &[u8]) -> Result<NoiseSession, NoiseError> {
let mut hs = self
.rekey_handshake
.take()
.ok_or_else(|| NoiseError::WrongState {
expected: "rekey handshake in progress".to_string(),
@@ -1022,9 +1017,7 @@ impl ActivePeer {
/// Check if msg1 needs resending.
pub fn needs_msg1_resend(&self, now_ms: u64) -> bool {
self.rekey_in_progress
&& self.rekey_msg1.is_some()
&& now_ms >= self.rekey_msg1_next_resend
self.rekey_in_progress && self.rekey_msg1.is_some() && now_ms >= self.rekey_msg1_next_resend
}
/// Get msg1 bytes for resend (without consuming).
+16 -10
View File
@@ -4,10 +4,10 @@
//! PeerConnection tracks the Noise IK handshake state and transitions to
//! ActivePeer upon successful authentication.
use crate::utils::index::SessionIndex;
use crate::PeerIdentity;
use crate::noise::{self, NoiseError, NoiseSession};
use crate::transport::{LinkDirection, LinkId, LinkStats, TransportAddr, TransportId};
use crate::PeerIdentity;
use crate::utils::index::SessionIndex;
use secp256k1::Keypair;
use std::fmt;
@@ -558,7 +558,6 @@ impl PeerConnection {
pub fn is_timed_out(&self, current_time_ms: u64, timeout_ms: u64) -> bool {
self.idle_time(current_time_ms) > timeout_ms
}
}
impl fmt::Debug for PeerConnection {
@@ -651,12 +650,13 @@ mod tests {
let responder_peer_id = PeerIdentity::from_pubkey_full(responder_identity.pubkey_full());
// Create connections
let mut initiator_conn =
PeerConnection::outbound(LinkId::new(1), responder_peer_id, 1000);
let mut initiator_conn = PeerConnection::outbound(LinkId::new(1), responder_peer_id, 1000);
let mut responder_conn = PeerConnection::inbound(LinkId::new(2), 1000);
// Initiator starts handshake
let msg1 = initiator_conn.start_handshake(initiator_keypair, initiator_epoch, 1100).unwrap();
let msg1 = initiator_conn
.start_handshake(initiator_keypair, initiator_epoch, 1100)
.unwrap();
assert_eq!(initiator_conn.handshake_state(), HandshakeState::SentMsg1);
// Responder processes msg1 and sends msg2
@@ -723,12 +723,18 @@ mod tests {
// Outbound can't receive_handshake_init
let mut outbound = PeerConnection::outbound(LinkId::new(1), identity, 1000);
assert!(outbound
.receive_handshake_init(keypair, make_epoch(), &[0u8; 106], 1100)
.is_err());
assert!(
outbound
.receive_handshake_init(keypair, make_epoch(), &[0u8; 106], 1100)
.is_err()
);
// Inbound can't start_handshake
let mut inbound = PeerConnection::inbound(LinkId::new(2), 1000);
assert!(inbound.start_handshake(keypair, make_epoch(), 1100).is_err());
assert!(
inbound
.start_handshake(keypair, make_epoch(), 1100)
.is_err()
);
}
}
+17 -4
View File
@@ -13,8 +13,8 @@ mod connection;
pub use active::{ActivePeer, ConnectivityState};
pub use connection::{HandshakeState, PeerConnection};
use crate::transport::LinkId;
use crate::NodeAddr;
use crate::transport::LinkId;
use std::fmt;
use thiserror::Error;
@@ -44,7 +44,10 @@ pub enum PeerError {
HandshakeTimeout,
#[error("identity mismatch: expected {expected:?}, got {actual:?}")]
IdentityMismatch { expected: NodeAddr, actual: NodeAddr },
IdentityMismatch {
expected: NodeAddr,
actual: NodeAddr,
},
#[error("peer disconnected")]
Disconnected,
@@ -240,10 +243,20 @@ impl fmt::Display for PeerSlot {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PeerSlot::Connecting(conn) => {
write!(f, "connecting(link={}, state={})", conn.link_id(), conn.handshake_state())
write!(
f,
"connecting(link={}, state={})",
conn.link_id(),
conn.handshake_state()
)
}
PeerSlot::Active(peer) => {
write!(f, "active(node={:?}, link={})", peer.node_addr(), peer.link_id())
write!(
f,
"active(node={:?}, link={})",
peer.node_addr(),
peer.link_id()
)
}
}
}
+6 -2
View File
@@ -1,9 +1,9 @@
//! Discovery messages: LookupRequest and LookupResponse.
use crate::NodeAddr;
use crate::protocol::error::ProtocolError;
use crate::protocol::session::{decode_coords, encode_coords};
use crate::tree::TreeCoordinate;
use crate::NodeAddr;
use secp256k1::schnorr::Signature;
/// Request to discover a node's coordinates.
@@ -192,7 +192,11 @@ impl LookupResponse {
/// Get the bytes that should be signed as proof.
///
/// Format: request_id (8) || target (16) || coords_encoding (2 + 16×n)
pub fn proof_bytes(request_id: u64, target: &NodeAddr, target_coords: &TreeCoordinate) -> Vec<u8> {
pub fn proof_bytes(
request_id: u64,
target: &NodeAddr,
target_coords: &TreeCoordinate,
) -> Vec<u8> {
let coord_size = 2 + target_coords.entries().len() * 16;
let mut bytes = Vec::with_capacity(24 + coord_size);
bytes.extend_from_slice(&request_id.to_le_bytes());
+15 -14
View File
@@ -42,11 +42,7 @@ impl FilterAnnounce {
}
/// Create with explicit size_class (for testing or future protocol versions).
pub fn with_size_class(
filter: BloomFilter,
sequence: u64,
size_class: u8,
) -> Self {
pub fn with_size_class(filter: BloomFilter, sequence: u64, size_class: u8) -> Self {
Self {
hash_count: filter.hash_count(),
size_class,
@@ -164,10 +160,8 @@ impl FilterAnnounce {
}
// Construct BloomFilter from bytes
let filter =
crate::bloom::BloomFilter::from_slice(&payload[pos..], hash_count).map_err(|e| {
ProtocolError::Malformed(format!("invalid bloom filter: {e}"))
})?;
let filter = crate::bloom::BloomFilter::from_slice(&payload[pos..], hash_count)
.map_err(|e| ProtocolError::Malformed(format!("invalid bloom filter: {e}")))?;
let announce = Self {
filter,
@@ -253,7 +247,12 @@ mod tests {
let result = FilterAnnounce::decode(&encoded[1..]);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("invalid size_class"));
assert!(
result
.unwrap_err()
.to_string()
.contains("invalid size_class")
);
}
#[test]
@@ -265,10 +264,12 @@ mod tests {
let result = FilterAnnounce::decode(&encoded[1..]);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("unsupported size_class"));
assert!(
result
.unwrap_err()
.to_string()
.contains("unsupported size_class")
);
}
#[test]
+1 -2
View File
@@ -533,8 +533,7 @@ mod tests {
let src = make_node_addr(0xAA);
let dest = make_node_addr(0xBB);
let payload = vec![0x10, 0x00, 0x05, 0x00, 1, 2, 3, 4, 5]; // session payload
let dg = SessionDatagram::new(src, dest, payload.clone())
.with_ttl(32);
let dg = SessionDatagram::new(src, dest, payload.clone()).with_ttl(32);
let encoded = dg.encode();
assert_eq!(encoded[0], 0x00); // msg_type (SessionDatagram)
+11 -11
View File
@@ -28,21 +28,21 @@ mod session;
mod tree;
// Re-export all public types at protocol:: level
pub use error::ProtocolError;
pub use link::{
Disconnect, DisconnectReason, HandshakeMessageType, LinkMessageType, SessionDatagram,
SESSION_DATAGRAM_HEADER_SIZE,
};
pub use tree::TreeAnnounce;
pub use filter::FilterAnnounce;
pub use discovery::{LookupRequest, LookupResponse};
pub use error::ProtocolError;
pub use filter::FilterAnnounce;
pub use link::{
Disconnect, DisconnectReason, HandshakeMessageType, LinkMessageType,
SESSION_DATAGRAM_HEADER_SIZE, SessionDatagram,
};
pub use session::{
CoordsRequired, FspFlags, FspInnerFlags, MtuExceeded, PathBroken, PathMtuNotification,
SessionAck, SessionFlags, SessionMessageType, SessionMsg3, SessionReceiverReport,
SessionSenderReport, SessionSetup, COORDS_REQUIRED_SIZE, MTU_EXCEEDED_SIZE,
PATH_MTU_NOTIFICATION_SIZE, SESSION_RECEIVER_REPORT_SIZE, SESSION_SENDER_REPORT_SIZE,
COORDS_REQUIRED_SIZE, CoordsRequired, FspFlags, FspInnerFlags, MTU_EXCEEDED_SIZE, MtuExceeded,
PATH_MTU_NOTIFICATION_SIZE, PathBroken, PathMtuNotification, SESSION_RECEIVER_REPORT_SIZE,
SESSION_SENDER_REPORT_SIZE, SessionAck, SessionFlags, SessionMessageType, SessionMsg3,
SessionReceiverReport, SessionSenderReport, SessionSetup,
};
pub(crate) use session::{coords_wire_size, decode_optional_coords, encode_coords};
pub use tree::TreeAnnounce;
/// Protocol version for message compatibility.
pub const PROTOCOL_VERSION: u8 = 1;
+34 -14
View File
@@ -1,8 +1,8 @@
//! Session-layer message types: setup, ack, data, and error messages.
use super::ProtocolError;
use crate::tree::TreeCoordinate;
use crate::NodeAddr;
use crate::tree::TreeCoordinate;
use std::fmt;
// ============================================================================
@@ -141,15 +141,17 @@ pub(crate) fn decode_coords(data: &[u8]) -> Result<(TreeCoordinate, usize), Prot
bytes.copy_from_slice(&data[offset..offset + 16]);
addrs.push(NodeAddr::from_bytes(bytes));
}
let coord = TreeCoordinate::from_addrs(addrs)
.map_err(|e| ProtocolError::Malformed(e.to_string()))?;
let coord =
TreeCoordinate::from_addrs(addrs).map_err(|e| ProtocolError::Malformed(e.to_string()))?;
Ok((coord, needed))
}
/// Decode an optional coordinate field (count may be 0).
///
/// Returns None if count is 0, Some(coord) otherwise, plus bytes consumed.
pub(crate) fn decode_optional_coords(data: &[u8]) -> Result<(Option<TreeCoordinate>, usize), ProtocolError> {
pub(crate) fn decode_optional_coords(
data: &[u8],
) -> Result<(Option<TreeCoordinate>, usize), ProtocolError> {
if data.len() < 2 {
return Err(ProtocolError::MessageTooShort {
expected: 2,
@@ -174,8 +176,8 @@ pub(crate) fn decode_optional_coords(data: &[u8]) -> Result<(Option<TreeCoordina
bytes.copy_from_slice(&data[offset..offset + 16]);
addrs.push(NodeAddr::from_bytes(bytes));
}
let coord = TreeCoordinate::from_addrs(addrs)
.map_err(|e| ProtocolError::Malformed(e.to_string()))?;
let coord =
TreeCoordinate::from_addrs(addrs).map_err(|e| ProtocolError::Malformed(e.to_string()))?;
Ok((Some(coord), needed))
}
@@ -909,7 +911,10 @@ pub const COORDS_REQUIRED_SIZE: usize = 34;
impl CoordsRequired {
/// Create a new CoordsRequired error.
pub fn new(dest_addr: NodeAddr, reporter: NodeAddr) -> Self {
Self { dest_addr, reporter }
Self {
dest_addr,
reporter,
}
}
/// Encode as wire format (4-byte FSP prefix + msg_type + body).
@@ -1081,7 +1086,11 @@ pub const MTU_EXCEEDED_SIZE: usize = 36;
impl MtuExceeded {
/// Create a new MtuExceeded error.
pub fn new(dest_addr: NodeAddr, reporter: NodeAddr, mtu: u16) -> Self {
Self { dest_addr, reporter, mtu }
Self {
dest_addr,
reporter,
mtu,
}
}
/// Encode as wire format (4-byte FSP prefix + msg_type + body).
@@ -1359,8 +1368,7 @@ mod tests {
let addrs: Vec<u8> = (0..11).collect();
let src = make_coords(&addrs);
let dest = make_coords(&[20, 21, 22, 23, 24]);
let setup = SessionSetup::new(src.clone(), dest.clone())
.with_handshake(vec![0x55; 82]);
let setup = SessionSetup::new(src.clone(), dest.clone()).with_handshake(vec![0x55; 82]);
let encoded = setup.encode();
let decoded = SessionSetup::decode(&encoded[4..]).unwrap();
@@ -1459,9 +1467,18 @@ mod tests {
#[test]
fn test_session_message_type_display() {
assert_eq!(format!("{}", SessionMessageType::SenderReport), "SenderReport");
assert_eq!(format!("{}", SessionMessageType::ReceiverReport), "ReceiverReport");
assert_eq!(format!("{}", SessionMessageType::PathMtuNotification), "PathMtuNotification");
assert_eq!(
format!("{}", SessionMessageType::SenderReport),
"SenderReport"
);
assert_eq!(
format!("{}", SessionMessageType::ReceiverReport),
"ReceiverReport"
);
assert_eq!(
format!("{}", SessionMessageType::PathMtuNotification),
"PathMtuNotification"
);
}
// ===== SessionSenderReport Tests =====
@@ -1639,7 +1656,10 @@ mod tests {
#[test]
fn test_mtu_exceeded_display() {
assert_eq!(format!("{}", SessionMessageType::MtuExceeded), "MtuExceeded");
assert_eq!(
format!("{}", SessionMessageType::MtuExceeded),
"MtuExceeded"
);
}
// ===== SessionMsg3 Tests =====
+206 -8
View File
@@ -2,8 +2,8 @@
use super::error::ProtocolError;
use super::link::LinkMessageType;
use crate::tree::{CoordEntry, ParentDeclaration, TreeCoordinate};
use crate::NodeAddr;
use crate::tree::{CoordEntry, ParentDeclaration, TreeCoordinate, TreeError};
use secp256k1::schnorr::Signature;
/// Spanning tree announcement carrying parent declaration and ancestry.
@@ -35,6 +35,58 @@ impl TreeAnnounce {
}
}
/// Validate that the ancestry is structurally consistent with the signed
/// declaration.
///
/// Expected properties:
/// - the first ancestry entry is the declaring node's `node_addr`
/// - a root declaration has exactly one ancestry entry
/// - a non-root declaration has at least two ancestry entries
/// - for a non-root declaration, the second ancestry entry matches `parent_id`
/// - the final ancestry entry is the advertised root
/// - the advertised root is the smallest `node_addr` in the ancestry
pub fn validate_semantics(&self) -> Result<(), TreeError> {
let entries = self.ancestry.entries();
let declared_node = *self.declaration.node_addr();
let declared_parent = *self.declaration.parent_id();
if entries[0].node_addr != declared_node {
return Err(TreeError::AncestryNodeMismatch {
declared: declared_node,
ancestry: entries[0].node_addr,
});
}
if self.declaration.is_root() {
if entries.len() != 1 {
return Err(TreeError::RootDeclarationMismatch);
}
} else {
let ancestry_parent = entries.get(1).ok_or(TreeError::AncestryTooShort)?.node_addr;
if ancestry_parent != declared_parent {
return Err(TreeError::AncestryParentMismatch {
declared: declared_parent,
ancestry: ancestry_parent,
});
}
}
let advertised_root = *self.ancestry.root_id();
let minimum = entries
.iter()
.map(|entry| entry.node_addr)
.min()
.expect("TreeCoordinate is never empty");
if advertised_root != minimum {
return Err(TreeError::AncestryRootNotMinimum {
advertised: advertised_root,
minimum,
});
}
Ok(())
}
/// Encode as link-layer plaintext (includes msg_type byte).
///
/// The declaration must be signed. The encoded format is:
@@ -166,8 +218,8 @@ impl TreeAnnounce {
let sig_bytes: [u8; 64] = payload[pos..pos + 64]
.try_into()
.map_err(|_| ProtocolError::Malformed("bad signature".into()))?;
let signature = Signature::from_slice(&sig_bytes)
.map_err(|_| ProtocolError::InvalidSignature)?;
let signature =
Signature::from_slice(&sig_bytes).map_err(|_| ProtocolError::InvalidSignature)?;
// The first entry's node_addr is the declaring node
if entries.is_empty() {
@@ -324,7 +376,10 @@ mod tests {
encoded[1] = 0xFF;
let result = TreeAnnounce::decode(&encoded[1..]);
assert!(matches!(result, Err(ProtocolError::UnsupportedVersion(0xFF))));
assert!(matches!(
result,
Err(ProtocolError::UnsupportedVersion(0xFF))
));
}
#[test]
@@ -365,10 +420,7 @@ mod tests {
encoded[35] = 0;
let result = TreeAnnounce::decode(&encoded[1..]);
assert!(matches!(
result,
Err(ProtocolError::MessageTooShort { .. })
));
assert!(matches!(result, Err(ProtocolError::MessageTooShort { .. })));
}
#[test]
@@ -381,4 +433,150 @@ mod tests {
let result = announce.encode();
assert!(matches!(result, Err(ProtocolError::InvalidSignature)));
}
/// Tests that a well-formed non-root ancestry is accepted.
#[test]
fn test_tree_announce_validate_semantics_accepts_valid_non_root() {
use crate::identity::Identity;
// Regenerate until the random identity's node_addr is numerically
// larger than both fixed parent (02:..) and root (01:..), so the
// root-minimum invariant holds deterministically.
let identity = loop {
let id = Identity::generate();
if id.node_addr().as_bytes()[0] > 1 {
break id;
}
};
let node_addr = *identity.node_addr();
let parent = make_node_addr(2);
let root = make_node_addr(1);
let mut decl = ParentDeclaration::new(node_addr, parent, 5, 1000);
decl.sign(&identity).unwrap();
let ancestry = TreeCoordinate::new(vec![
CoordEntry::new(node_addr, 5, 1000),
CoordEntry::new(parent, 4, 900),
CoordEntry::new(root, 3, 800),
])
.unwrap();
let announce = TreeAnnounce::new(decl, ancestry);
assert!(announce.validate_semantics().is_ok());
}
/// Tests that an ancestry is rejected if the final node_addr is not the smallest entry in the path.
#[test]
fn test_tree_announce_validate_semantics_rejects_non_minimal_root() {
use crate::identity::Identity;
let identity = Identity::generate();
let node_addr = *identity.node_addr();
let smaller = make_node_addr(0);
let advertised_root = make_node_addr(1);
let mut decl = ParentDeclaration::new(node_addr, smaller, 5, 1000);
decl.sign(&identity).unwrap();
let ancestry = TreeCoordinate::new(vec![
CoordEntry::new(node_addr, 5, 1000),
CoordEntry::new(smaller, 4, 900),
CoordEntry::new(advertised_root, 3, 800),
])
.unwrap();
let announce = TreeAnnounce::new(decl, ancestry);
assert!(matches!(
announce.validate_semantics(),
Err(TreeError::AncestryRootNotMinimum {
advertised,
minimum,
}) if advertised == advertised_root && minimum == smaller
));
}
/// Tests that an ancestry is rejected if the first ancestry hop does not match the signed parent_id.
#[test]
fn test_tree_announce_validate_semantics_rejects_parent_mismatch() {
use crate::identity::Identity;
let identity = Identity::generate();
let node_addr = *identity.node_addr();
let declared_parent = make_node_addr(2);
let ancestry_parent = make_node_addr(3);
let mut decl = ParentDeclaration::new(node_addr, declared_parent, 5, 1000);
decl.sign(&identity).unwrap();
let ancestry = TreeCoordinate::new(vec![
CoordEntry::new(node_addr, 5, 1000),
CoordEntry::new(ancestry_parent, 4, 900),
CoordEntry::new(make_node_addr(1), 3, 800),
])
.unwrap();
let announce = TreeAnnounce::new(decl, ancestry);
assert!(matches!(
announce.validate_semantics(),
Err(TreeError::AncestryParentMismatch {
declared,
ancestry,
}) if declared == declared_parent && ancestry == ancestry_parent
));
}
/// Tests that an ancestry is rejected if the first path entry does not match the signed sender node_addr.
#[test]
fn test_tree_announce_validate_semantics_rejects_sender_mismatch() {
use crate::identity::Identity;
let identity = Identity::generate();
let node_addr = *identity.node_addr();
let ancestry_sender = make_node_addr(9);
let parent = make_node_addr(2);
let mut decl = ParentDeclaration::new(node_addr, parent, 5, 1000);
decl.sign(&identity).unwrap();
let ancestry = TreeCoordinate::new(vec![
CoordEntry::new(ancestry_sender, 5, 1000),
CoordEntry::new(parent, 4, 900),
CoordEntry::new(make_node_addr(1), 3, 800),
])
.unwrap();
let announce = TreeAnnounce::new(decl, ancestry);
assert!(matches!(
announce.validate_semantics(),
Err(TreeError::AncestryNodeMismatch {
declared,
ancestry,
}) if declared == node_addr && ancestry == ancestry_sender
));
}
/// Tests that a self-root declaration is rejected if its ancestry contains extra ancestors.
#[test]
fn test_tree_announce_validate_semantics_rejects_root_with_ancestors() {
use crate::identity::Identity;
let identity = Identity::generate();
let node_addr = *identity.node_addr();
let mut decl = ParentDeclaration::self_root(node_addr, 5, 1000);
decl.sign(&identity).unwrap();
let ancestry = TreeCoordinate::new(vec![
CoordEntry::new(node_addr, 5, 1000),
CoordEntry::new(make_node_addr(0), 4, 900),
])
.unwrap();
let announce = TreeAnnounce::new(decl, ancestry);
assert!(matches!(
announce.validate_semantics(),
Err(TreeError::RootDeclarationMismatch)
));
}
}
+4 -9
View File
@@ -13,10 +13,8 @@ use super::{
TransportId, TransportState, TransportType,
};
use crate::config::EthernetConfig;
use discovery::{
build_beacon, parse_beacon, DiscoveryBuffer, FRAME_TYPE_BEACON, FRAME_TYPE_DATA,
};
use socket::{AsyncPacketSocket, PacketSocket, ETHERNET_BROADCAST};
use discovery::{DiscoveryBuffer, FRAME_TYPE_BEACON, FRAME_TYPE_DATA, build_beacon, parse_beacon};
use socket::{AsyncPacketSocket, ETHERNET_BROADCAST, PacketSocket};
use stats::EthernetStats;
use secp256k1::XOnlyPublicKey;
@@ -399,8 +397,7 @@ async fn ethernet_receive_loop(
trace!("Data frame too short ({len} bytes), ignoring");
continue;
}
let payload_len =
u16::from_le_bytes([buf[1], buf[2]]) as usize;
let payload_len = u16::from_le_bytes([buf[1], buf[2]]) as usize;
if payload_len > len - 3 {
trace!(
"Data frame length field ({payload_len}) exceeds \
@@ -431,9 +428,7 @@ async fn ethernet_receive_loop(
FRAME_TYPE_BEACON => {
stats.record_beacon_recv();
if discovery_enabled
&& let Some(pubkey) = parse_beacon(&buf[..len])
{
if discovery_enabled && let Some(pubkey) = parse_beacon(&buf[..len]) {
discovery_buffer.add_peer(src_mac, pubkey);
trace!(
transport_id = %transport_id,
+17 -8
View File
@@ -254,10 +254,7 @@ impl AsyncPacketSocket {
}
/// Receive a payload and source MAC address.
pub async fn recv_from(
&self,
buf: &mut [u8],
) -> Result<(usize, [u8; 6]), TransportError> {
pub async fn recv_from(&self, buf: &mut [u8]) -> Result<(usize, [u8; 6]), TransportError> {
loop {
let mut guard = self
.inner
@@ -308,7 +305,10 @@ fn get_mac_addr(fd: RawFd, if_index: i32) -> Result<[u8; 6], TransportError> {
// Use if_indextoname to get the name
let mut name_buf = [0u8; libc::IFNAMSIZ];
let ret = unsafe {
libc::if_indextoname(if_index as libc::c_uint, name_buf.as_mut_ptr() as *mut libc::c_char)
libc::if_indextoname(
if_index as libc::c_uint,
name_buf.as_mut_ptr() as *mut libc::c_char,
)
};
if ret.is_null() {
return Err(TransportError::StartFailed(format!(
@@ -319,7 +319,10 @@ fn get_mac_addr(fd: RawFd, if_index: i32) -> Result<[u8; 6], TransportError> {
}
// Copy name into ifreq
let name_len = name_buf.iter().position(|&b| b == 0).unwrap_or(name_buf.len());
let name_len = name_buf
.iter()
.position(|&b| b == 0)
.unwrap_or(name_buf.len());
let copy_len = name_len.min(libc::IFNAMSIZ - 1);
unsafe {
std::ptr::copy_nonoverlapping(
@@ -359,7 +362,10 @@ fn get_if_mtu(fd: RawFd, if_index: i32) -> Result<u16, TransportError> {
// Get the interface name from index
let mut name_buf = [0u8; libc::IFNAMSIZ];
let ret = unsafe {
libc::if_indextoname(if_index as libc::c_uint, name_buf.as_mut_ptr() as *mut libc::c_char)
libc::if_indextoname(
if_index as libc::c_uint,
name_buf.as_mut_ptr() as *mut libc::c_char,
)
};
if ret.is_null() {
return Err(TransportError::StartFailed(format!(
@@ -369,7 +375,10 @@ fn get_if_mtu(fd: RawFd, if_index: i32) -> Result<u16, TransportError> {
)));
}
let name_len = name_buf.iter().position(|&b| b == 0).unwrap_or(name_buf.len());
let name_len = name_buf
.iter()
.position(|&b| b == 0)
.unwrap_or(name_buf.len());
let copy_len = name_len.min(libc::IFNAMSIZ - 1);
unsafe {
std::ptr::copy_nonoverlapping(

Some files were not shown because too many files have changed in this diff Show More