Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15417f9623 |
@@ -5,23 +5,6 @@ junit = { path = "junit.xml" }
|
||||
# occasional msg1 under burst load even with the per-edge repair loop.
|
||||
# Allow a retry rather than failing the whole CI run on a single
|
||||
# dropped packet.
|
||||
#
|
||||
# Deliberately scoped to [profile.ci] and NOT applied locally, which makes
|
||||
# the two gates disagree: the hosted runner retries a flaky test twice, the
|
||||
# local sweep fails on the first failure. The asymmetry is intended and this
|
||||
# is the record of why, since an undocumented one is indistinguishable from
|
||||
# an oversight.
|
||||
#
|
||||
# It is here for shared-runner packet loss, a property of the hosted
|
||||
# environment and not of the code. Applying it locally would suppress a real
|
||||
# local flake, and a failure that only reproduces under load is a robustness
|
||||
# bug to fix rather than to retry past. Keeping the local sweep strict is
|
||||
# what makes it the sharper of the two gates.
|
||||
#
|
||||
# The cost, stated rather than hidden: a test that fails once and passes on
|
||||
# retry is reported green here with no separate signal, so a genuine
|
||||
# intermittent failure can be absorbed. If that starts mattering, the fix is
|
||||
# to surface retried-but-passed tests, not to drop the retries.
|
||||
retries = 2
|
||||
|
||||
[test-groups]
|
||||
|
||||
@@ -3,6 +3,3 @@
|
||||
|
||||
# rustfmt master-only code
|
||||
e9da598f8ab13de5dea3a1496531d675af6a0b94
|
||||
|
||||
# rustfmt noise-xx-only code
|
||||
fef3d011ab290743a13ae3f9b287ad4ebaa1713b
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Patch PKGBUILD-git b2sums for local assets
|
||||
run: |
|
||||
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
pacman -Sy --noconfirm --needed base-devel namcap git curl
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve package version
|
||||
id: ver
|
||||
@@ -176,7 +176,7 @@ jobs:
|
||||
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
|
||||
echo "pkgrel=${PKGREL}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ steps.tag.outputs.tag }}
|
||||
|
||||
|
||||
@@ -38,12 +38,12 @@ env:
|
||||
# unreliable on GitHub-hosted runners.
|
||||
# tor-directory — same; live Tor dependency.
|
||||
#
|
||||
# The two runners express the same work in different matrix shapes, and the
|
||||
# parity guard compares through that shape rather than around it: chaos legs
|
||||
# are compared per scenario (and per flag) via their `scenario:` field,
|
||||
# deb-install legs per distro. The one leg still compared at leg granularity
|
||||
# is dns-resolver — a single leg here, running all of its scenarios
|
||||
# internally, exactly as the local suite does.
|
||||
# Granularity-only differences (same coverage, different matrix shape —
|
||||
# NOT a divergence):
|
||||
# deb-install — split here into per-distro legs (debian12/debian13/
|
||||
# ubuntu22/ubuntu24/ubuntu26) for parallelism; local runs the
|
||||
# same distro set in one suite.
|
||||
# dns-resolver — single leg here; runs all scenarios (same as local).
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -52,34 +52,11 @@ env:
|
||||
# Builds on Linux x86_64, Linux aarch64, and macOS.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
jobs:
|
||||
ci-parity:
|
||||
name: CI parity
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Install Python deps
|
||||
run: pip3 install --quiet pyyaml
|
||||
- name: Check local and GitHub runners cover the same work
|
||||
run: bash testing/check-ci-parity.sh
|
||||
- name: Check test log matchers against the strings src/ emits
|
||||
run: python3 testing/check-log-strings.py
|
||||
- name: Check no tested function's exit status is a log call's
|
||||
run: python3 testing/check-trailing-log.py
|
||||
- name: Check nothing resolves the shared mutable test image
|
||||
run: bash testing/check-image-scoping.sh
|
||||
# Hermetic: synthetic ping functions, no containers, ~45s. Lives beside
|
||||
# the other two so both runners gate on it identically — putting it in
|
||||
# only one would create exactly the drift check-ci-parity.sh exists to
|
||||
# catch, and it is invisible to that checker either way since it is not
|
||||
# a matrix suite.
|
||||
- name: Run convergence-gate unit tests
|
||||
run: bash testing/lib/wait-converge-test.sh
|
||||
|
||||
fmt:
|
||||
name: Format check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
components: rustfmt
|
||||
@@ -91,7 +68,7 @@ jobs:
|
||||
name: Clippy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install system dependencies
|
||||
run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
@@ -100,7 +77,7 @@ jobs:
|
||||
cache: false
|
||||
rustflags: ''
|
||||
- name: Cache Cargo registry + build
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
@@ -110,59 +87,6 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-
|
||||
- run: cargo clippy --all-targets --all-features -- -D warnings
|
||||
# An optional feature means two source trees, and --all-features lints
|
||||
# only one of them. The default build is what ships, so lint it
|
||||
# explicitly: without this stage, code that compiles only with
|
||||
# `profiling` enabled would pass CI while breaking every release build.
|
||||
# Mirrored in testing/ci-local.sh — check-ci-parity.sh compares
|
||||
# integration suites only and will not catch a stage added to one runner
|
||||
# and not the other.
|
||||
- name: Clippy (default features)
|
||||
run: cargo clippy --all-targets -- -D warnings
|
||||
- name: Build with the tick-body profiler enabled
|
||||
run: cargo build --workspace --features profiling
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
# Android cross-check
|
||||
#
|
||||
# FIPS runs on Android as an embedded library — the host app owns the TUN
|
||||
# (an Android VpnService), so there are no daemon binaries to package, unlike
|
||||
# the desktop targets. This job only cross-compiles the library for the
|
||||
# android target to guard the android-only cfg paths (and the `not(android)`
|
||||
# exclusions) from silently bit-rotting; nothing else in CI compiles them.
|
||||
# cargo-ndk wires the NDK toolchain, which is required even for a check
|
||||
# because `ring` compiles C at build time.
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
android-check:
|
||||
name: Android cross-check (aarch64)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Install Rust toolchain (+ Android target)
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
target: aarch64-linux-android
|
||||
components: clippy
|
||||
cache: false
|
||||
rustflags: ''
|
||||
- name: Cache Cargo registry + build
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-android-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-
|
||||
- name: Install cargo-ndk
|
||||
uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: cargo-ndk
|
||||
- name: Clippy the library for Android
|
||||
run: |
|
||||
export ANDROID_NDK_HOME="${ANDROID_NDK_HOME:-$ANDROID_NDK_LATEST_HOME}"
|
||||
cargo ndk -t arm64-v8a clippy --lib -- -D warnings
|
||||
|
||||
build:
|
||||
name: Build (${{ matrix.os }})
|
||||
@@ -178,7 +102,7 @@ jobs:
|
||||
- os: windows-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set SOURCE_DATE_EPOCH from git (Unix)
|
||||
if: runner.os != 'Windows'
|
||||
@@ -206,7 +130,7 @@ jobs:
|
||||
rustflags: ''
|
||||
|
||||
- name: Cache Cargo registry + build
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
@@ -235,7 +159,7 @@ jobs:
|
||||
# Upload the Linux binary so integration jobs can use it without rebuilding
|
||||
- name: Upload Linux binary
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: fips-linux
|
||||
path: |
|
||||
@@ -256,7 +180,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set SOURCE_DATE_EPOCH from git
|
||||
run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV"
|
||||
@@ -271,7 +195,7 @@ jobs:
|
||||
rustflags: ''
|
||||
|
||||
- name: Cache Cargo registry + build
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
@@ -304,12 +228,6 @@ jobs:
|
||||
check_name: Unit Tests Summary
|
||||
fail_on_failure: false
|
||||
|
||||
# The `profiling` feature adds a module, a recorder and a writer thread
|
||||
# that the default-feature run above never compiles, so its own tests do
|
||||
# not execute there. Mirrored in testing/ci-local.sh.
|
||||
- name: Run library tests with the tick-body profiler enabled
|
||||
run: cargo test --lib --features profiling
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Job 2b – Unit tests (macOS)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -318,7 +236,7 @@ jobs:
|
||||
runs-on: macos-latest
|
||||
needs: [build]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set SOURCE_DATE_EPOCH from git
|
||||
run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV"
|
||||
@@ -330,7 +248,7 @@ jobs:
|
||||
rustflags: ''
|
||||
|
||||
- name: Cache Cargo registry + build
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
@@ -353,7 +271,7 @@ jobs:
|
||||
name: Unit tests (Windows)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
@@ -362,7 +280,7 @@ jobs:
|
||||
rustflags: ''
|
||||
|
||||
- name: Cache Cargo registry + build
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
@@ -391,7 +309,7 @@ jobs:
|
||||
name: PowerShell lint (Windows packaging)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Run PSScriptAnalyzer
|
||||
shell: pwsh
|
||||
@@ -433,6 +351,22 @@ jobs:
|
||||
- suite: static-chain
|
||||
type: static
|
||||
topology: chain
|
||||
# ── Rekey integration test ──────────────────────────────────────────
|
||||
- suite: rekey
|
||||
type: rekey
|
||||
topology: rekey
|
||||
- suite: rekey-accept-off
|
||||
type: rekey-accept-off
|
||||
topology: rekey-accept-off
|
||||
- suite: rekey-outbound-only
|
||||
type: rekey-outbound-only
|
||||
topology: rekey-outbound-only
|
||||
# ── Inbound max_peers admission-cap test ───────────────────────
|
||||
- suite: admission-cap
|
||||
type: admission-cap
|
||||
topology: mesh
|
||||
- suite: acl-allowlist
|
||||
type: acl-allowlist
|
||||
# ── Firewall baseline (fips0 nftables default-deny) ────────────
|
||||
- suite: firewall
|
||||
type: firewall
|
||||
@@ -441,6 +375,9 @@ jobs:
|
||||
type: gateway
|
||||
topology: gateway
|
||||
# ── Chaos / stochastic scenarios ───────────────────────────────────
|
||||
- suite: chaos-smoke-10
|
||||
type: chaos
|
||||
scenario: smoke-10
|
||||
- suite: churn-mixed-10
|
||||
type: chaos
|
||||
scenario: churn-mixed
|
||||
@@ -454,9 +391,30 @@ jobs:
|
||||
- suite: tcp-mesh
|
||||
type: chaos
|
||||
scenario: tcp-mesh
|
||||
- suite: bottleneck-parent
|
||||
type: chaos
|
||||
scenario: bottleneck-parent
|
||||
- suite: cost-avoidance
|
||||
type: chaos
|
||||
scenario: cost-avoidance
|
||||
- suite: cost-reeval
|
||||
type: chaos
|
||||
scenario: cost-reeval
|
||||
- suite: cost-stability
|
||||
type: chaos
|
||||
scenario: cost-stability
|
||||
- suite: depth-vs-cost
|
||||
type: chaos
|
||||
scenario: depth-vs-cost
|
||||
- suite: mixed-technology
|
||||
type: chaos
|
||||
scenario: mixed-technology
|
||||
- suite: congestion-stress
|
||||
type: chaos
|
||||
scenario: congestion-stress
|
||||
- suite: bloom-storm
|
||||
type: chaos
|
||||
scenario: bloom-storm
|
||||
# ── Sidecar deployment ──────────────────────────────────────────
|
||||
- suite: sidecar
|
||||
type: sidecar
|
||||
@@ -520,11 +478,11 @@ jobs:
|
||||
type: dns-resolver
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Fetch the pre-built Linux binary from job 1
|
||||
- name: Download Linux binary
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: fips-linux
|
||||
path: _bin
|
||||
@@ -569,6 +527,120 @@ jobs:
|
||||
docker compose -f testing/static/docker-compose.yml \
|
||||
--profile ${{ matrix.topology }} down --volumes --remove-orphans
|
||||
|
||||
# ── Rekey integration test ──────────────────────────────────────────────
|
||||
- name: Generate and inject configs (rekey)
|
||||
if: matrix.type == 'rekey'
|
||||
run: |
|
||||
bash testing/static/scripts/generate-configs.sh rekey
|
||||
bash testing/static/scripts/rekey-test.sh inject-config
|
||||
|
||||
- name: Start containers (rekey)
|
||||
if: matrix.type == 'rekey'
|
||||
run: |
|
||||
docker compose -f testing/static/docker-compose.yml \
|
||||
--profile rekey up -d
|
||||
|
||||
- name: Run rekey test
|
||||
if: matrix.type == 'rekey'
|
||||
run: bash testing/static/scripts/rekey-test.sh
|
||||
|
||||
- name: Collect logs on failure (rekey)
|
||||
if: matrix.type == 'rekey' && failure()
|
||||
run: |
|
||||
docker compose -f testing/static/docker-compose.yml \
|
||||
--profile rekey logs --no-color
|
||||
|
||||
- name: Stop containers (rekey)
|
||||
if: matrix.type == 'rekey' && always()
|
||||
run: |
|
||||
docker compose -f testing/static/docker-compose.yml \
|
||||
--profile rekey down --volumes --remove-orphans
|
||||
|
||||
# ── Rekey + accept_connections=false variant ──────────────────────────
|
||||
- name: Generate and inject configs (rekey-accept-off)
|
||||
if: matrix.type == 'rekey-accept-off'
|
||||
env:
|
||||
REKEY_TOPOLOGY: rekey-accept-off
|
||||
REKEY_ACCEPT_OFF_NODES: b
|
||||
run: |
|
||||
bash testing/static/scripts/generate-configs.sh rekey-accept-off
|
||||
bash testing/static/scripts/rekey-test.sh inject-config
|
||||
|
||||
- name: Start containers (rekey-accept-off)
|
||||
if: matrix.type == 'rekey-accept-off'
|
||||
run: |
|
||||
docker compose -f testing/static/docker-compose.yml \
|
||||
--profile rekey-accept-off up -d
|
||||
|
||||
- name: Run rekey test (accept-off variant)
|
||||
if: matrix.type == 'rekey-accept-off'
|
||||
env:
|
||||
REKEY_TOPOLOGY: rekey-accept-off
|
||||
REKEY_ACCEPT_OFF_NODES: b
|
||||
run: bash testing/static/scripts/rekey-test.sh
|
||||
|
||||
- name: Collect logs on failure (rekey-accept-off)
|
||||
if: matrix.type == 'rekey-accept-off' && failure()
|
||||
run: |
|
||||
docker compose -f testing/static/docker-compose.yml \
|
||||
--profile rekey-accept-off logs --no-color | tail -300
|
||||
|
||||
- name: Stop containers (rekey-accept-off)
|
||||
if: matrix.type == 'rekey-accept-off' && always()
|
||||
run: |
|
||||
docker compose -f testing/static/docker-compose.yml \
|
||||
--profile rekey-accept-off down --volumes --remove-orphans
|
||||
|
||||
# ── Rekey + udp.outbound_only=true variant ─────────────────────────────
|
||||
- name: Generate and inject configs (rekey-outbound-only)
|
||||
if: matrix.type == 'rekey-outbound-only'
|
||||
env:
|
||||
REKEY_TOPOLOGY: rekey-outbound-only
|
||||
REKEY_OUTBOUND_ONLY_NODES: b
|
||||
run: |
|
||||
bash testing/static/scripts/generate-configs.sh rekey-outbound-only
|
||||
bash testing/static/scripts/rekey-test.sh inject-config
|
||||
|
||||
- name: Start containers (rekey-outbound-only)
|
||||
if: matrix.type == 'rekey-outbound-only'
|
||||
run: |
|
||||
docker compose -f testing/static/docker-compose.yml \
|
||||
--profile rekey-outbound-only up -d
|
||||
|
||||
- name: Run rekey test (outbound-only variant)
|
||||
if: matrix.type == 'rekey-outbound-only'
|
||||
env:
|
||||
REKEY_TOPOLOGY: rekey-outbound-only
|
||||
REKEY_OUTBOUND_ONLY_NODES: b
|
||||
run: bash testing/static/scripts/rekey-test.sh
|
||||
|
||||
- name: Collect logs on failure (rekey-outbound-only)
|
||||
if: matrix.type == 'rekey-outbound-only' && failure()
|
||||
run: |
|
||||
docker compose -f testing/static/docker-compose.yml \
|
||||
--profile rekey-outbound-only logs --no-color | tail -300
|
||||
|
||||
- name: Stop containers (rekey-outbound-only)
|
||||
if: matrix.type == 'rekey-outbound-only' && always()
|
||||
run: |
|
||||
docker compose -f testing/static/docker-compose.yml \
|
||||
--profile rekey-outbound-only down --volumes --remove-orphans
|
||||
|
||||
# ── ACL allowlist integration test ─────────────────────────────────────
|
||||
- name: Run ACL allowlist integration test
|
||||
if: matrix.type == 'acl-allowlist'
|
||||
run: bash testing/acl-allowlist/test.sh --skip-build --keep-up
|
||||
|
||||
- name: Collect logs on failure (acl-allowlist)
|
||||
if: matrix.type == 'acl-allowlist' && failure()
|
||||
run: |
|
||||
docker compose -f testing/acl-allowlist/docker-compose.yml logs --no-color
|
||||
|
||||
- name: Stop containers (acl-allowlist)
|
||||
if: matrix.type == 'acl-allowlist' && always()
|
||||
run: |
|
||||
docker compose -f testing/acl-allowlist/docker-compose.yml down --volumes --remove-orphans
|
||||
|
||||
# ── Firewall baseline integration test ─────────────────────────────────
|
||||
- name: Run firewall baseline integration test
|
||||
if: matrix.type == 'firewall'
|
||||
@@ -596,7 +668,7 @@ jobs:
|
||||
|
||||
- name: Upload sim results on failure (chaos)
|
||||
if: matrix.type == 'chaos' && failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: sim-results-${{ matrix.scenario }}
|
||||
path: testing/chaos/sim-results/
|
||||
@@ -699,6 +771,42 @@ jobs:
|
||||
docker compose -f testing/static/docker-compose.yml \
|
||||
--profile gateway down --volumes --remove-orphans
|
||||
|
||||
# ── Inbound max_peers admission-cap integration test ────────────────
|
||||
# Lowers node.max_peers on one mesh node and asserts the inbound cap
|
||||
# holds under sustained retry pressure: denied peers keep retrying but
|
||||
# are never promoted to an active session. The admission-cap-test.sh
|
||||
# assertions are tailored per link-layer handshake variant; the leg
|
||||
# itself is uniform. Static-style harness on the shared mesh profile.
|
||||
- name: Generate configs (admission-cap)
|
||||
if: matrix.type == 'admission-cap'
|
||||
run: bash testing/static/scripts/generate-configs.sh mesh
|
||||
|
||||
- name: Inject admission-cap config (admission-cap)
|
||||
if: matrix.type == 'admission-cap'
|
||||
run: bash testing/static/scripts/admission-cap-test.sh inject-config
|
||||
|
||||
- name: Start containers (admission-cap)
|
||||
if: matrix.type == 'admission-cap'
|
||||
run: |
|
||||
docker compose -f testing/static/docker-compose.yml \
|
||||
--profile mesh up -d
|
||||
|
||||
- name: Run admission-cap test
|
||||
if: matrix.type == 'admission-cap'
|
||||
run: bash testing/static/scripts/admission-cap-test.sh
|
||||
|
||||
- name: Collect logs on failure (admission-cap)
|
||||
if: matrix.type == 'admission-cap' && failure()
|
||||
run: |
|
||||
docker compose -f testing/static/docker-compose.yml \
|
||||
--profile mesh logs --no-color | tail -300
|
||||
|
||||
- name: Stop containers (admission-cap)
|
||||
if: matrix.type == 'admission-cap' && always()
|
||||
run: |
|
||||
docker compose -f testing/static/docker-compose.yml \
|
||||
--profile mesh down --volumes --remove-orphans
|
||||
|
||||
# ── Real-deb install integration ────────────────────────────────────
|
||||
# The deb-install harness builds its own .deb from source in a
|
||||
# cargo-deb builder image; the pre-built Linux binary from the
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
outputs:
|
||||
linux_package_version: ${{ steps.linux_version.outputs.linux_package_version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
deb_arch: arm64
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
|
||||
- name: Cache Cargo registry + build
|
||||
if: ${{ env.ACT != 'true' }}
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
@@ -140,7 +140,7 @@ jobs:
|
||||
|
||||
- name: Upload artifact (GitHub only)
|
||||
if: ${{ env.ACT != 'true' }}
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: fips_${{ needs.determine-versioning.outputs.linux_package_version }}_${{ matrix.artifact_arch }}_linux
|
||||
path: |
|
||||
@@ -164,7 +164,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Download Linux artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
outputs:
|
||||
macos_package_version: ${{ steps.macos_version.outputs.macos_package_version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
target: x86_64-apple-darwin
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
rustflags: ''
|
||||
|
||||
- name: Cache Cargo registry + build
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
@@ -209,7 +209,7 @@ jobs:
|
||||
( cd "$(dirname "$PKG")" && shasum -a 256 "$(basename "$PKG")" | tee "$(basename "$PKG").sha256" )
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: fips_${{ needs.determine-versioning.outputs.macos_package_version }}_${{ matrix.arch }}_macos
|
||||
path: |
|
||||
@@ -229,7 +229,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Download macOS artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
@@ -283,7 +283,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Download macOS artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
@@ -24,10 +24,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
package_version: ${{ steps.version.outputs.package_version }}
|
||||
apk_version: ${{ steps.version.outputs.apk_version }}
|
||||
release_channel: ${{ steps.channel.outputs.release_channel }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -36,20 +35,13 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
: ${GITHUB_OUTPUT:=/tmp/github_output}
|
||||
# package_version is the human-readable label used in artifact
|
||||
# filenames; apk_version is the apk-tools-compatible string embedded
|
||||
# in the .apk metadata. apk_version is built directly from the same
|
||||
# structured inputs (tag, or commit height) — no reparse of the
|
||||
# flattened package_version. See packaging/openwrt-apk/apk-version.sh.
|
||||
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
|
||||
echo "package_version=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
|
||||
echo "apk_version=$(sh packaging/openwrt-apk/apk-version.sh tag "${GITHUB_REF_NAME}")" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
BRANCH=$(echo "$GITHUB_REF_NAME" | sed 's|/|-|g')
|
||||
HEIGHT=$(git rev-list --count HEAD)
|
||||
HASH=$(git rev-parse --short HEAD)
|
||||
echo "package_version=${BRANCH}.${HEIGHT}.${HASH}" >> "$GITHUB_OUTPUT"
|
||||
echo "apk_version=$(sh packaging/openwrt-apk/apk-version.sh dev "${HEIGHT}")" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Determine release channel
|
||||
@@ -72,8 +64,8 @@ jobs:
|
||||
echo "release_channel=dev" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
compile-binaries:
|
||||
name: Cross-compile (${{ matrix.openwrt_arch }})
|
||||
build:
|
||||
name: Build .ipk (${{ matrix.openwrt_arch }})
|
||||
runs-on: ubuntu-latest
|
||||
needs: determine-versioning
|
||||
|
||||
@@ -104,10 +96,18 @@ jobs:
|
||||
# x86 routers / VMs
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- 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: Initialize
|
||||
run: |
|
||||
PACKAGE_FILENAME=${{ env.PACKAGE_NAME }}_${{ needs.determine-versioning.outputs.package_version }}_${{ matrix.openwrt_arch }}.ipk
|
||||
echo "PACKAGE_FILENAME=$PACKAGE_FILENAME" >> $GITHUB_ENV
|
||||
|
||||
- name: Install Rust toolchain (stable)
|
||||
if: matrix.rust_channel == 'stable'
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
@@ -124,7 +124,7 @@ jobs:
|
||||
|
||||
- name: Cache Cargo registry + build
|
||||
if: ${{ env.ACT != 'true' }}
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
@@ -153,64 +153,6 @@ jobs:
|
||||
- name: Install llvm-strip
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends llvm
|
||||
|
||||
# Cross-compile + strip once; both the .ipk and .apk packagers consume
|
||||
# these artifacts via --bin-dir, so the Rust build runs a single time
|
||||
# per architecture instead of once per package format.
|
||||
- name: Cross-compile and strip binaries
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo zigbuild --release --target ${{ matrix.rust_target }} \
|
||||
--bin fips --bin fipsctl --bin fipstop --bin fips-gateway
|
||||
RELEASE_DIR="target/${{ matrix.rust_target }}/release"
|
||||
mkdir -p out
|
||||
for b in fips fipsctl fipstop fips-gateway; do
|
||||
llvm-strip "$RELEASE_DIR/$b" 2>/dev/null || true
|
||||
cp "$RELEASE_DIR/$b" "out/$b"
|
||||
done
|
||||
ls -lh out/
|
||||
|
||||
- name: Upload binaries artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: fips-bins-${{ matrix.openwrt_arch }}
|
||||
path: out/
|
||||
retention-days: 1
|
||||
|
||||
build:
|
||||
name: Build .ipk (${{ matrix.openwrt_arch }})
|
||||
runs-on: ubuntu-latest
|
||||
needs: [determine-versioning, compile-binaries]
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Must be a subset of compile-binaries' arches (this job consumes those
|
||||
# binary artifacts). Currently both ship aarch64 + x86_64.
|
||||
include:
|
||||
- build_arch: aarch64
|
||||
openwrt_arch: aarch64_cortex-a53
|
||||
- build_arch: x86_64
|
||||
openwrt_arch: x86_64
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set SOURCE_DATE_EPOCH from git
|
||||
run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Initialize
|
||||
run: |
|
||||
PACKAGE_FILENAME=${{ env.PACKAGE_NAME }}_${{ needs.determine-versioning.outputs.package_version }}_${{ matrix.openwrt_arch }}.ipk
|
||||
echo "PACKAGE_FILENAME=$PACKAGE_FILENAME" >> $GITHUB_ENV
|
||||
|
||||
- name: Download prebuilt binaries
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: fips-bins-${{ matrix.openwrt_arch }}
|
||||
path: bins
|
||||
|
||||
- name: Install nak
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -259,7 +201,8 @@ jobs:
|
||||
- name: Build .ipk
|
||||
env:
|
||||
PKG_VERSION: ${{ needs.determine-versioning.outputs.package_version }}
|
||||
run: ./packaging/openwrt-ipk/build-ipk.sh --arch ${{ matrix.build_arch }} --bin-dir "$GITHUB_WORKSPACE/bins"
|
||||
LLVM_STRIP: llvm-strip
|
||||
run: ./packaging/openwrt-ipk/build-ipk.sh --arch ${{ matrix.build_arch }}
|
||||
|
||||
- name: Install shellcheck (if missing)
|
||||
shell: bash
|
||||
@@ -285,8 +228,6 @@ jobs:
|
||||
"$FILES_DIR/etc/fips/firewall.sh"
|
||||
"$FILES_DIR/etc/hotplug.d/net/99-fips"
|
||||
"$FILES_DIR/etc/uci-defaults/90-fips-setup"
|
||||
"$FILES_DIR/usr/bin/fips-mesh-setup"
|
||||
"$FILES_DIR/usr/bin/fips-ap-setup"
|
||||
)
|
||||
fail=0
|
||||
for f in "${TARGETS[@]}"; do
|
||||
@@ -406,8 +347,6 @@ jobs:
|
||||
./usr/bin/fipsctl
|
||||
./usr/bin/fipstop
|
||||
./usr/bin/fips-gateway
|
||||
./usr/bin/fips-mesh-setup
|
||||
./usr/bin/fips-ap-setup
|
||||
./etc/init.d/fips
|
||||
./etc/init.d/fips-gateway
|
||||
./etc/fips/fips.yaml
|
||||
@@ -450,13 +389,13 @@ jobs:
|
||||
- name: SHA-256 hashes
|
||||
run: |
|
||||
echo "==> Binaries:"
|
||||
sha256sum bins/fips bins/fipsctl bins/fipstop
|
||||
sha256sum target/${{ matrix.rust_target }}/release/fips target/${{ matrix.rust_target }}/release/fipsctl target/${{ matrix.rust_target }}/release/fipstop
|
||||
echo "==> Package:"
|
||||
sha256sum dist/${{ env.PACKAGE_FILENAME }}
|
||||
|
||||
- name: Upload artifact (GitHub only)
|
||||
if: ${{ env.ACT != 'true' }}
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ env.PACKAGE_FILENAME }}
|
||||
path: dist/${{ env.PACKAGE_FILENAME }}
|
||||
@@ -464,7 +403,6 @@ jobs:
|
||||
|
||||
- name: Upload to Blossom
|
||||
id: blossom_upload
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
env:
|
||||
BLOSSOM_SERVER: "https://blossom.primal.net"
|
||||
@@ -472,30 +410,17 @@ jobs:
|
||||
run: |
|
||||
: ${GITHUB_OUTPUT:=/tmp/github_output}
|
||||
|
||||
FILE_HASH=""
|
||||
for attempt in 1 2 3; do
|
||||
if UPLOAD_RESPONSE=$(nak blossom upload \
|
||||
--server "$BLOSSOM_SERVER" \
|
||||
--sec "$NSEC" \
|
||||
"dist/${{ env.PACKAGE_FILENAME }}" < /dev/null); then
|
||||
echo "Upload response (attempt $attempt):"
|
||||
echo "$UPLOAD_RESPONSE"
|
||||
FILE_HASH=$(echo "$UPLOAD_RESPONSE" | jq -r '.sha256')
|
||||
if [ -n "$FILE_HASH" ] && [ "$FILE_HASH" != "null" ]; then
|
||||
break
|
||||
fi
|
||||
echo "Upload response had no sha256 (attempt $attempt)"
|
||||
else
|
||||
echo "Blossom upload timed out or failed (attempt $attempt)"
|
||||
fi
|
||||
FILE_HASH=""
|
||||
[ "$attempt" -lt 3 ] && sleep $((attempt * 10))
|
||||
done
|
||||
UPLOAD_RESPONSE=$(nak blossom upload \
|
||||
--server "$BLOSSOM_SERVER" \
|
||||
--sec "$NSEC" \
|
||||
"dist/${{ env.PACKAGE_FILENAME }}" < /dev/null)
|
||||
|
||||
echo "Upload response:"
|
||||
echo "$UPLOAD_RESPONSE"
|
||||
|
||||
FILE_HASH=$(echo "$UPLOAD_RESPONSE" | jq -r '.sha256')
|
||||
if [ -z "$FILE_HASH" ] || [ "$FILE_HASH" = "null" ]; then
|
||||
echo "Blossom upload did not succeed after 3 attempts; non-fatal."
|
||||
echo "The package still ships as a GitHub release artifact; only the"
|
||||
echo "supplementary Blossom/nostr distribution is skipped this run."
|
||||
echo "Failed to extract hash from upload response"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -506,7 +431,6 @@ jobs:
|
||||
|
||||
- name: Publish NIP-94 release event
|
||||
id: publish
|
||||
if: steps.blossom_upload.outcome == 'success'
|
||||
shell: bash
|
||||
env:
|
||||
RELAYS: "wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://offchain.pub"
|
||||
@@ -528,7 +452,6 @@ jobs:
|
||||
--tag A="${{ matrix.openwrt_arch }}" \
|
||||
--tag v="$VERSION" \
|
||||
--tag n="${{ env.PACKAGE_NAME }}" \
|
||||
--tag format="ipk" \
|
||||
--tag compression="none" \
|
||||
> event.json 2> event.err
|
||||
|
||||
@@ -586,352 +509,23 @@ jobs:
|
||||
echo " Release EventId: ${{ steps.publish.outputs.eventId }}"
|
||||
echo " Blossom URL: ${{ steps.blossom_upload.outputs.url }}"
|
||||
|
||||
build-apk:
|
||||
name: Build .apk (${{ matrix.openwrt_arch }})
|
||||
runs-on: ubuntu-latest
|
||||
needs: [determine-versioning, compile-binaries]
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Must be a subset of compile-binaries' arches.
|
||||
include:
|
||||
- build_arch: aarch64
|
||||
openwrt_arch: aarch64_cortex-a53
|
||||
# MT3000, MT6000, Flint 2, RPi 3/4/5 on OpenWrt 25+
|
||||
- build_arch: x86_64
|
||||
openwrt_arch: x86_64
|
||||
# x86 routers / VMs on OpenWrt 25+
|
||||
|
||||
env:
|
||||
# apk-tools commit OpenWrt pins for the .apk (ADB) format. Keep in sync
|
||||
# with package/system/apk/Makefile in the targeted OpenWrt release so the
|
||||
# packages we produce are readable by the apk on the device.
|
||||
APK_TOOLS_VERSION: "3.0.5"
|
||||
APK_TOOLS_COMMIT: "b5a31c0d865342ad80be10d68f1bb3d3ad9b0866"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set SOURCE_DATE_EPOCH from git
|
||||
run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Initialize
|
||||
run: |
|
||||
PACKAGE_FILENAME=${{ env.PACKAGE_NAME }}_${{ needs.determine-versioning.outputs.package_version }}_${{ matrix.openwrt_arch }}.apk
|
||||
echo "PACKAGE_FILENAME=$PACKAGE_FILENAME" >> $GITHUB_ENV
|
||||
|
||||
- name: Download prebuilt binaries
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: fips-bins-${{ matrix.openwrt_arch }}
|
||||
path: bins
|
||||
|
||||
- name: Install fakeroot
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends fakeroot
|
||||
|
||||
# apk mkpkg lives in apk-tools v3, which is not packaged for Ubuntu, so we
|
||||
# build the pinned release from source. This is the SDK-free equivalent of
|
||||
# how the .ipk path uses plain tar — one small C tool, no OpenWrt SDK.
|
||||
- name: Build apk-tools (${{ env.APK_TOOLS_VERSION }}) from source
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
git ca-certificates build-essential meson ninja-build pkg-config \
|
||||
zlib1g-dev libssl-dev libzstd-dev liblzma-dev lua5.4-dev scdoc
|
||||
git clone --quiet https://gitlab.alpinelinux.org/alpine/apk-tools.git /tmp/apk-tools
|
||||
cd /tmp/apk-tools
|
||||
git checkout --quiet "${APK_TOOLS_COMMIT}"
|
||||
meson setup build
|
||||
ninja -C build src/apk
|
||||
APK_BIN=/tmp/apk-tools/build/src/apk
|
||||
"$APK_BIN" --version 2>/dev/null || "$APK_BIN" version 2>/dev/null || true
|
||||
echo "APK_BIN=$APK_BIN" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build .apk
|
||||
env:
|
||||
PKG_VERSION: ${{ needs.determine-versioning.outputs.package_version }}
|
||||
APK_VERSION: ${{ needs.determine-versioning.outputs.apk_version }}
|
||||
run: ./packaging/openwrt-apk/build-apk.sh --arch ${{ matrix.build_arch }} --bin-dir "$GITHUB_WORKSPACE/bins"
|
||||
|
||||
- name: Verify apk structural integrity
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
APK="dist/${{ env.PACKAGE_FILENAME }}"
|
||||
if [ ! -s "$APK" ]; then
|
||||
echo "FAIL: produced apk not found or empty at $APK"
|
||||
exit 1
|
||||
fi
|
||||
echo "==> file type:"; file "$APK"
|
||||
|
||||
# apk v3 packages are ADB containers; dump the whole manifest with the
|
||||
# apk-tools we just built. Print it in full so the exact schema is
|
||||
# always visible in the log if an assertion needs adjusting.
|
||||
DUMP=$(mktemp)
|
||||
"$APK_BIN" adbdump "$APK" > "$DUMP" 2>/dev/null || {
|
||||
echo "FAIL: 'apk adbdump' could not read $APK"; exit 1; }
|
||||
echo "==> full adbdump:"; cat "$DUMP"
|
||||
|
||||
fail=0
|
||||
# Package metadata (flat keys under info:).
|
||||
for needle in "name: fips" "version: ${{ needs.determine-versioning.outputs.apk_version }}" "arch: ${{ matrix.openwrt_arch }}"; do
|
||||
if grep -qF "$needle" "$DUMP"; then
|
||||
echo " PASS meta: $needle"
|
||||
else
|
||||
echo " FAIL meta: missing '$needle'"; fail=1
|
||||
fi
|
||||
done
|
||||
|
||||
# installed-size reflects the bundled binaries (4 stripped Rust
|
||||
# binaries, several MB). A payload regression that drops them shows up
|
||||
# here regardless of how the path tree is formatted.
|
||||
SIZE=$(awk '/^[[:space:]]*installed-size:/ {print $2; exit}' "$DUMP")
|
||||
echo " installed-size: ${SIZE:-unknown}"
|
||||
if [ -z "${SIZE:-}" ] || [ "$SIZE" -lt 1000000 ]; then
|
||||
echo " FAIL: installed-size implausibly small (binaries missing?)"; fail=1
|
||||
else
|
||||
echo " PASS: installed-size >= 1MB"
|
||||
fi
|
||||
|
||||
# The adbdump paths: block is hierarchical. Each directory is a
|
||||
# top-level list item "- name: <full relative dir>"; its files are
|
||||
# "- name: <basename>" nested one indent level deeper under "files:".
|
||||
# (There are no "path:" keys.) Reconstruct full file paths by keying
|
||||
# off the indentation of the directory-level list items.
|
||||
RECON=$(awk '
|
||||
/^paths:/ {p=1; diri=-1; next}
|
||||
p && /^[^ #-]/ {p=0} # a new top-level key ends paths:
|
||||
!p {next}
|
||||
match($0, /^ *- /) {
|
||||
ind=RLENGTH; rest=substr($0, RLENGTH+1)
|
||||
if (diri==-1) diri=ind # first list item = directory indent
|
||||
if (ind==diri) { # directory entry (or the root acl: entry)
|
||||
if (rest ~ /^name: /) { dir=rest; sub(/^name: /,"",dir) } else dir=""
|
||||
next
|
||||
}
|
||||
if (rest ~ /^name: /) { # deeper item = a file under files:
|
||||
f=rest; sub(/^name: /,"",f); print (dir==""?f:dir"/"f)
|
||||
}
|
||||
}
|
||||
' "$DUMP")
|
||||
echo "==> reconstructed paths:"; printf '%s\n' "$RECON"
|
||||
|
||||
for path in \
|
||||
usr/bin/fips usr/bin/fipsctl usr/bin/fipstop usr/bin/fips-gateway \
|
||||
usr/bin/fips-mesh-setup usr/bin/fips-ap-setup \
|
||||
etc/init.d/fips etc/init.d/fips-gateway \
|
||||
etc/fips/fips.yaml etc/fips/firewall.sh etc/dnsmasq.d/fips.conf \
|
||||
etc/sysctl.d/fips-gateway.conf etc/sysctl.d/fips-bridge.conf \
|
||||
etc/hotplug.d/net/99-fips etc/uci-defaults/90-fips-setup \
|
||||
lib/upgrade/keep.d/fips; do
|
||||
if printf '%s\n' "$RECON" | grep -qxF "$path"; then
|
||||
echo " PASS path: $path"
|
||||
else
|
||||
echo " FAIL path: missing $path"; fail=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
echo "apk structural verification FAILED"
|
||||
exit 1
|
||||
fi
|
||||
echo "apk structural verification PASS"
|
||||
|
||||
- name: SHA-256 hashes
|
||||
run: |
|
||||
echo "==> Binaries:"
|
||||
sha256sum bins/fips bins/fipsctl bins/fipstop
|
||||
echo "==> Package:"
|
||||
sha256sum dist/${{ env.PACKAGE_FILENAME }}
|
||||
|
||||
- name: Upload artifact (GitHub only)
|
||||
if: ${{ env.ACT != 'true' }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ env.PACKAGE_FILENAME }}
|
||||
path: dist/${{ env.PACKAGE_FILENAME }}
|
||||
retention-days: 30
|
||||
|
||||
- name: Install nak
|
||||
shell: bash
|
||||
run: |
|
||||
NAK_VERSION="0.16.2"
|
||||
ARCH=$(uname -m)
|
||||
case "$ARCH" in
|
||||
x86_64|amd64) NAK_ARCH="amd64" ;;
|
||||
aarch64|arm64) NAK_ARCH="arm64" ;;
|
||||
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
|
||||
esac
|
||||
curl -fsSL "https://github.com/fiatjaf/nak/releases/download/v${NAK_VERSION}/nak-v${NAK_VERSION}-linux-${NAK_ARCH}" \
|
||||
-o /usr/local/bin/nak
|
||||
chmod +x /usr/local/bin/nak
|
||||
nak --version
|
||||
|
||||
- name: Install jq
|
||||
run: |
|
||||
if ! command -v jq &>/dev/null; then
|
||||
sudo apt-get update && sudo apt-get install -y jq
|
||||
fi
|
||||
|
||||
# Priority: HIVE_CI_NSEC from env (loom job) > repo secret > generate ephemeral
|
||||
- name: Resolve signing key
|
||||
id: keys
|
||||
shell: bash
|
||||
env:
|
||||
SECRET_NSEC: ${{ secrets.HIVE_CI_NSEC }}
|
||||
run: |
|
||||
: ${GITHUB_OUTPUT:=/tmp/github_output}
|
||||
if [ -n "${HIVE_CI_NSEC:-}" ]; then
|
||||
echo "Using HIVE_CI_NSEC from loom job environment"
|
||||
NSEC="$HIVE_CI_NSEC"
|
||||
elif [ -n "$SECRET_NSEC" ]; then
|
||||
echo "Using HIVE_CI_NSEC from repository secrets"
|
||||
NSEC="$SECRET_NSEC"
|
||||
else
|
||||
echo "No nsec provided -- generating ephemeral keypair"
|
||||
NSEC=$(nak key generate)
|
||||
fi
|
||||
|
||||
PUBKEY=$(echo "$NSEC" | nak key public)
|
||||
echo "::add-mask::$NSEC"
|
||||
echo "nsec=$NSEC" >> "$GITHUB_OUTPUT"
|
||||
echo "pubkey=$PUBKEY" >> "$GITHUB_OUTPUT"
|
||||
echo "Publisher pubkey (hex): $PUBKEY"
|
||||
|
||||
- name: Upload to Blossom
|
||||
id: blossom_upload
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
env:
|
||||
BLOSSOM_SERVER: "https://blossom.primal.net"
|
||||
NSEC: ${{ steps.keys.outputs.nsec }}
|
||||
run: |
|
||||
: ${GITHUB_OUTPUT:=/tmp/github_output}
|
||||
|
||||
FILE_HASH=""
|
||||
for attempt in 1 2 3; do
|
||||
if UPLOAD_RESPONSE=$(nak blossom upload \
|
||||
--server "$BLOSSOM_SERVER" \
|
||||
--sec "$NSEC" \
|
||||
"dist/${{ env.PACKAGE_FILENAME }}" < /dev/null); then
|
||||
echo "Upload response (attempt $attempt):"
|
||||
echo "$UPLOAD_RESPONSE"
|
||||
FILE_HASH=$(echo "$UPLOAD_RESPONSE" | jq -r '.sha256')
|
||||
if [ -n "$FILE_HASH" ] && [ "$FILE_HASH" != "null" ]; then
|
||||
break
|
||||
fi
|
||||
echo "Upload response had no sha256 (attempt $attempt)"
|
||||
else
|
||||
echo "Blossom upload timed out or failed (attempt $attempt)"
|
||||
fi
|
||||
FILE_HASH=""
|
||||
[ "$attempt" -lt 3 ] && sleep $((attempt * 10))
|
||||
done
|
||||
|
||||
if [ -z "$FILE_HASH" ] || [ "$FILE_HASH" = "null" ]; then
|
||||
echo "Blossom upload did not succeed after 3 attempts; non-fatal."
|
||||
echo "The package still ships as a GitHub release artifact; only the"
|
||||
echo "supplementary Blossom/nostr distribution is skipped this run."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BLOSSOM_URL="${BLOSSOM_SERVER}/${FILE_HASH}"
|
||||
echo "url=$BLOSSOM_URL" >> "$GITHUB_OUTPUT"
|
||||
echo "hash=$FILE_HASH" >> "$GITHUB_OUTPUT"
|
||||
echo "Uploaded to Blossom: $BLOSSOM_URL"
|
||||
|
||||
- name: Publish NIP-94 release event
|
||||
id: publish
|
||||
if: steps.blossom_upload.outcome == 'success'
|
||||
shell: bash
|
||||
env:
|
||||
RELAYS: "wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://offchain.pub"
|
||||
NSEC: ${{ steps.keys.outputs.nsec }}
|
||||
run: |
|
||||
: ${GITHUB_OUTPUT:=/tmp/github_output}
|
||||
set -e
|
||||
|
||||
VERSION="${{ needs.determine-versioning.outputs.package_version }}"
|
||||
CHANNEL="${{ needs.determine-versioning.outputs.release_channel }}"
|
||||
|
||||
nak event --sec "$NSEC" -k 1063 \
|
||||
-c "FIPS Package: ${{ env.PACKAGE_NAME }} for ${{ matrix.openwrt_arch }} (apk)" \
|
||||
--tag url="${{ steps.blossom_upload.outputs.url }}" \
|
||||
--tag m="application/octet-stream" \
|
||||
--tag x="${{ steps.blossom_upload.outputs.hash }}" \
|
||||
--tag ox="${{ steps.blossom_upload.outputs.hash }}" \
|
||||
--tag filename="${{ env.PACKAGE_FILENAME }}" \
|
||||
--tag A="${{ matrix.openwrt_arch }}" \
|
||||
--tag v="$VERSION" \
|
||||
--tag n="${{ env.PACKAGE_NAME }}" \
|
||||
--tag format="apk" \
|
||||
--tag compression="none" \
|
||||
> event.json 2> event.err
|
||||
|
||||
if [ ! -s event.json ]; then
|
||||
echo "Failed to create event"
|
||||
cat event.err 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Event JSON ==="
|
||||
cat event.json
|
||||
echo "=================="
|
||||
|
||||
EVENT_ID=$(jq -r '.id' event.json)
|
||||
if [ -z "$EVENT_ID" ] || [ "$EVENT_ID" = "null" ]; then
|
||||
echo "Failed to extract event ID"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Publish to relays
|
||||
cat event.json | nak event $RELAYS 2>&1
|
||||
|
||||
echo "eventId=$EVENT_ID" >> "$GITHUB_OUTPUT"
|
||||
echo "Published NIP-94 event: $EVENT_ID"
|
||||
|
||||
- name: Build Summary
|
||||
run: |
|
||||
echo "Build Summary for ${{ matrix.openwrt_arch }} (apk):"
|
||||
echo " Package: ${{ env.PACKAGE_FILENAME }}"
|
||||
echo " apk version: ${{ needs.determine-versioning.outputs.apk_version }}"
|
||||
echo " Release EventId: ${{ steps.publish.outputs.eventId }}"
|
||||
echo " Blossom URL: ${{ steps.blossom_upload.outputs.url }}"
|
||||
|
||||
release:
|
||||
name: Publish GitHub Release (github only)
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build, build-apk]
|
||||
needs: build
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Download package artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
- name: Download all .ipk artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
# Only the .ipk/.apk packages (named fips_<ver>_<arch>.*), not the
|
||||
# fips-bins-* raw-binary artifacts shared between the build jobs.
|
||||
pattern: fips_*
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: Generate OpenWrt release checksums
|
||||
run: |
|
||||
cd dist
|
||||
find . -maxdepth 1 -type f \( -name '*.ipk' -o -name '*.apk' \) -printf '%P\n' \
|
||||
| LC_ALL=C sort \
|
||||
| xargs sha256sum \
|
||||
> checksums-openwrt.txt
|
||||
|
||||
- name: Create release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
dist/*.ipk
|
||||
dist/*.apk
|
||||
dist/checksums-openwrt.txt
|
||||
files: dist/*.ipk
|
||||
generate_release_notes: true
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
outputs:
|
||||
package_version: ${{ steps.version.outputs.package_version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
needs: determine-versioning
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
rustflags: ''
|
||||
|
||||
- name: Cache Cargo registry + build
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
@@ -146,7 +146,7 @@ jobs:
|
||||
}
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: fips_${{ needs.determine-versioning.outputs.package_version }}_x86_64_windows
|
||||
path: deploy/fips-*-windows-*.zip
|
||||
@@ -170,7 +170,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Download Windows artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
@@ -33,11 +33,6 @@ __pycache__/
|
||||
*.egg-info/
|
||||
*.egg
|
||||
|
||||
# Per-run build contexts created by testing/ci-local.sh. Its teardown normally
|
||||
# removes them, but the CI worker's SIGKILL runs no trap, so one can survive a
|
||||
# preempted run; ci-cleanup.sh sweeps the survivors.
|
||||
/testing/docker-*/
|
||||
|
||||
# Runtime artifacts from running fips in-tree during local testing.
|
||||
# Root-anchored so legitimately-tracked fips.yaml under packaging/ and
|
||||
# examples/ stays included.
|
||||
|
||||
@@ -42,10 +42,6 @@ and fail without it. BLE-capable builds additionally need `bluez`,
|
||||
`libdbus-1-dev`, and `pkg-config` installed; the default build picks
|
||||
up BLE if those are present and skips it cleanly if not.
|
||||
|
||||
On Nix, `nix develop` provides the pinned toolchain and all of these
|
||||
build prerequisites without any manual install; see the Nix / NixOS
|
||||
section of [packaging/README.md](packaging/README.md).
|
||||
|
||||
For multi-node integration runs, Docker is required. The harness
|
||||
under [testing/](testing/) starts containerized topologies and
|
||||
exercises real mesh behavior; see [testing/README.md](testing/README.md)
|
||||
|
||||
@@ -1074,7 +1074,7 @@ checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5"
|
||||
|
||||
[[package]]
|
||||
name = "fips"
|
||||
version = "0.6.0-dev"
|
||||
version = "0.4.0-rc.1"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"bech32",
|
||||
@@ -1087,7 +1087,6 @@ dependencies = [
|
||||
"hex",
|
||||
"hkdf",
|
||||
"libc",
|
||||
"libm",
|
||||
"mdns-sd",
|
||||
"nostr",
|
||||
"nostr-sdk",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "fips"
|
||||
version = "0.6.0-dev"
|
||||
version = "0.4.0-rc.1"
|
||||
edition = "2024"
|
||||
description = "A distributed, decentralized network routing protocol for mesh nodes connecting over arbitrary transports"
|
||||
license = "MIT"
|
||||
@@ -11,21 +11,12 @@ readme = "README.md"
|
||||
keywords = ["mesh", "p2p", "decentralized", "overlay-network", "nostr"]
|
||||
categories = ["network-programming", "command-line-utilities", "cryptography"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Tick-body profiler (`src/instr`). Off by default: enabling it edits 26 call
|
||||
# sites in the rx loop's hot tick arm, and only a compile-time gate makes the
|
||||
# default build's neutrality a property of the generated code rather than of a
|
||||
# runtime check. Build with `--features profiling` for a measurement run.
|
||||
profiling = []
|
||||
|
||||
[dependencies]
|
||||
ratatui = "0.30"
|
||||
secp256k1 = { version = "0.30", features = ["rand", "global-context"] }
|
||||
sha2 = "0.10"
|
||||
hkdf = "0.12"
|
||||
ring = "0.17"
|
||||
libm = "0.2"
|
||||
rand = "0.10.1"
|
||||
crossbeam-channel = "0.5"
|
||||
thiserror = "2.0"
|
||||
@@ -117,8 +108,3 @@ path = "src/bin/fips-gateway.rs"
|
||||
[[bin]]
|
||||
name = "fipstop"
|
||||
path = "src/bin/fipstop/main.rs"
|
||||
|
||||
[[bench]]
|
||||
name = "routing_next_hop"
|
||||
path = "benches/routing_next_hop.rs"
|
||||
harness = false
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||

|
||||
[](LICENSE)
|
||||
[](https://www.rust-lang.org/)
|
||||
[](#status--roadmap)
|
||||
[](#status--roadmap)
|
||||
|
||||
A self-organizing encrypted mesh network built on Nostr identities,
|
||||
capable of operating over arbitrary transports without central
|
||||
@@ -43,9 +43,9 @@ same way it would on a local network.
|
||||
- **Multi-transport.** UDP, TCP, Ethernet, Tor, Nym, and Bluetooth
|
||||
(BLE L2CAP) ship today; transports compose on a single mesh and a
|
||||
node may run several at once.
|
||||
- **Two-layer encryption.** Noise XX both hop-by-hop (peer links)
|
||||
and end-to-end (mesh sessions), with periodic rekey for forward
|
||||
secrecy and protocol negotiation in the handshake.
|
||||
- **Two-layer encryption.** Noise IK between peers (hop-by-hop) and
|
||||
Noise XK between mesh endpoints (independent end-to-end), with
|
||||
periodic rekey for forward secrecy.
|
||||
- **Nostr-native identity.** secp256k1 / schnorr keypairs as node
|
||||
addresses; self-generated, no registration, no central authority.
|
||||
- **IPv6 adapter.** A TUN interface maps each remote npub to an
|
||||
@@ -98,9 +98,9 @@ This installs the daemon, CLI tools (`fipsctl`, `fipstop`), the
|
||||
optional `fips-gateway` service, systemd units, and a default
|
||||
`/etc/fips/fips.yaml` you can edit before starting.
|
||||
|
||||
For macOS, Windows, OpenWrt, the systemd tarball, a Nix flake, or a
|
||||
from-source build, see [docs/getting-started.md](docs/getting-started.md)
|
||||
for the full multi-platform installation guide.
|
||||
For macOS, Windows, OpenWrt, the systemd tarball, or a from-source
|
||||
build, see [docs/getting-started.md](docs/getting-started.md) for
|
||||
the full multi-platform installation guide.
|
||||
|
||||
To join a live mesh and reach your first peer, follow the new-user
|
||||
tutorial progression starting at
|
||||
@@ -112,19 +112,17 @@ tutorial progression starting at
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
Requires Rust 1.94.1+ (edition 2024). Linux, macOS, and Windows run as
|
||||
standalone daemons; Android is supported as an embedded library (the host
|
||||
app owns the TUN, e.g. a `VpnService`). Transport availability varies by
|
||||
platform.
|
||||
Requires Rust 1.94.1+ (edition 2024). Linux, macOS, and Windows are
|
||||
supported; transport availability varies by platform.
|
||||
|
||||
| Transport | Linux | macOS | Windows | Android | OpenWrt |
|
||||
|-----------|:-----:|:-----:|:-------:|:-------:|:-------:|
|
||||
| UDP | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| TCP | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Ethernet | ✅ | ✅ | ❌ | ❌ | ✅ |
|
||||
| Tor | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
| Nym | ✅ | ✅ | ✅ | ❌ | ❌ |
|
||||
| BLE | ✅ | ❌ | ❌ | ❌ | ❌ |
|
||||
| Transport | Linux | macOS | Windows | OpenWrt |
|
||||
|-----------|:-----:|:-----:|:-------:|:-------:|
|
||||
| UDP | ✅ | ✅ | ✅ | ✅ |
|
||||
| TCP | ✅ | ✅ | ✅ | ✅ |
|
||||
| Ethernet | ✅ | ✅ | ❌ | ✅ |
|
||||
| Tor | ✅ | ✅ | ✅ | ✅ |
|
||||
| Nym | ✅ | ✅ | ✅ | ❌ |
|
||||
| BLE | ✅ | ❌ | ❌ | ❌ |
|
||||
|
||||
On Linux, a source build requires `libclang` — the LAN gateway's
|
||||
nftables bindings are generated by `bindgen` at build time, which
|
||||
@@ -145,12 +143,6 @@ Nym (mixnet) transport builds on all desktop platforms. The OpenWrt
|
||||
availability on the target; it will flip to ✅ only if confirmed
|
||||
buildable there.
|
||||
|
||||
Alternatively, the repo ships a [Nix flake](flake.nix): `nix develop`
|
||||
drops you into a shell with the pinned toolchain and every build
|
||||
prerequisite (libclang, dbus, pkg-config) already provided, and
|
||||
`nix build .#fips` builds all four binaries with no host setup. See the
|
||||
Nix / NixOS section of [packaging/README.md](packaging/README.md).
|
||||
|
||||
## Documentation
|
||||
|
||||
`docs/` is organised by reader purpose:
|
||||
@@ -212,26 +204,22 @@ testing/ Docker-based integration test harnesses + chaos simulation
|
||||
|
||||
## Status & roadmap
|
||||
|
||||
FIPS is at **v0.6.0-dev** on the `next` branch.
|
||||
[v0.4.1](https://github.com/jmcorgan/fips/releases/tag/v0.4.1)
|
||||
has shipped from `master`; this development line carries
|
||||
wire-format-breaking work for v0.6.0 — unified Noise XX handshake
|
||||
at both layers, FMP node profiles, slimmer MMP reports, and an
|
||||
extensible bloom-filter encoding — that will not interoperate with
|
||||
v0.2.x, v0.3.x, or v0.4.x peers. The core protocol works end-to-end over
|
||||
FIPS is at **v0.4.0**. The core protocol works end-to-end over
|
||||
UDP, TCP, Ethernet, Tor, Nym, and Bluetooth on a global, public test
|
||||
mesh of thousands of nodes. See the CHANGELOG `## Breaking` section for the
|
||||
full list of v0.6.0 wire-format changes in flight.
|
||||
mesh of thousands of nodes. v0.4.0 builds on the v0.3.0 testing-and-polishing
|
||||
track, adding the Nym mixnet transport and mDNS LAN discovery
|
||||
alongside the existing Nostr-mediated peer discovery, UDP NAT
|
||||
traversal, peer ACL, and packaging hardening. New wire-format work
|
||||
continues to be staged on the `next` branch for the subsequent
|
||||
release line.
|
||||
|
||||
### What works today
|
||||
|
||||
- Spanning-tree construction with greedy coordinate routing.
|
||||
- Bloom-filter-guided destination discovery (no flooding,
|
||||
single-path with retry).
|
||||
- Two-layer Noise XX encryption (hop-by-hop at the link layer and
|
||||
end-to-end at the session layer) with periodic hitless rekey for
|
||||
forward secrecy at both layers and protocol negotiation in the
|
||||
handshake.
|
||||
- Two-layer Noise encryption (IK at the link, XK at the session)
|
||||
with periodic hitless rekey for forward secrecy at both layers.
|
||||
- Persistent or ephemeral node identity with key-file management.
|
||||
- IPv6 TUN adapter with built-in `.fips` DNS resolver and
|
||||
multi-backend auto-configuration (systemd dns-delegate,
|
||||
|
||||
@@ -1,136 +1,262 @@
|
||||
# FIPS v0.4.1
|
||||
# FIPS v0.4.0
|
||||
|
||||
**Released**: 2026-07-19
|
||||
**Released**: 2026-06-DD (provisional)
|
||||
|
||||
v0.4.1 is a maintenance release on the v0.4.x line. It raises the default
|
||||
antipoison cap on inbound bloom filter announcements, removes a redundant
|
||||
spanning-tree metric counter, fixes two convergence and path-MTU bugs, and
|
||||
cuts per-packet CPU in the bloom and identity paths. There is no wire
|
||||
format change and no new feature surface.
|
||||
v0.4.0 is the throughput-and-observability release on the v0.3.x wire
|
||||
format. It adds two new ways for nodes to find and reach each other (the
|
||||
Nym mixnet transport and opt-in mDNS LAN discovery), overhauls the data
|
||||
plane for higher single-node throughput and lower per-packet CPU, moves
|
||||
the entire operator read surface off the data-plane hot path so
|
||||
observability stays responsive under load, ships a reworked `fipstop`
|
||||
TUI, and hardens FMP and FSP rekey to be hitless under packet loss in
|
||||
both directions. It also folds in the accumulated mesh-convergence,
|
||||
admission-control, and packaging fixes from the maintenance line.
|
||||
|
||||
v0.4.1 is wire-compatible with v0.4.0. Nodes can be upgraded one at a time
|
||||
with no coordinated restart, though one behavior change below is worth
|
||||
reading before you start a rolling upgrade.
|
||||
v0.4.0 is wire-compatible with v0.3.0. Mixed meshes interoperate; there
|
||||
is no flag-day upgrade. A deployed v0.3.0 node and an upgraded v0.4.0
|
||||
node peer, rekey, and route normally, so you can roll the upgrade out
|
||||
across a mesh in any order.
|
||||
|
||||
## At a glance
|
||||
|
||||
- `node.bloom.max_inbound_fpr` default moves from `0.10` to `0.20`.
|
||||
- The `parent_switched` metric counter is gone. Use `parent_switches`.
|
||||
- Spanning tree no longer serves stale coordinates after a parent link is
|
||||
lost through peer removal.
|
||||
- Discovery no longer loosens a path MTU clamp it had correctly tightened.
|
||||
- Bloom probing and identity operations do measurably less work per call,
|
||||
with identical results.
|
||||
- New outbound Nym mixnet transport with a single-container demo and a
|
||||
new mixnet-relay example.
|
||||
- Opt-in mDNS / DNS-SD discovery on the local link.
|
||||
- Data-plane overhaul: off-task encrypt and decrypt worker pools, GSO,
|
||||
connected-UDP send path, copy-avoidance on receive, batched macOS
|
||||
receive.
|
||||
- The full `show_*` read surface now serves off the receive loop, so
|
||||
`fipsctl` and `fipstop` stay responsive on loaded nodes; a new
|
||||
counter-only `show_metrics` query enables a Prometheus scraper at no
|
||||
hot-path cost.
|
||||
- Reworked `fipstop` TUI on a machine-verified render-snapshot base.
|
||||
- Rekey is now hitless under loss and reordering in both directions.
|
||||
|
||||
## What's new
|
||||
|
||||
### Nym mixnet transport
|
||||
|
||||
FIPS can now peer over the [Nym](https://nymtech.net/) mixnet for
|
||||
metadata-resistant connectivity. The new `transports.nym` transport
|
||||
makes outbound connections through a `nym-socks5-client` SOCKS5 proxy
|
||||
that you run alongside the daemon (for example as a service running
|
||||
alongside the fips daemon, or as a sidecar container). The transport
|
||||
waits at startup for the nym-socks5-client to become ready before giving
|
||||
up.
|
||||
|
||||
This is a privacy and anonymity deployment mode chosen for its own
|
||||
properties. It mixes your FIPS traffic into the Nym cover-traffic
|
||||
network so that link-level observers cannot correlate which mesh peers
|
||||
are talking. A new `examples/sidecar-nostr-mixnet-relay/` demonstrates a
|
||||
FIPS-reachable Nostr relay peered across the mixnet end to end, and a
|
||||
single-container demo ships with the transport.
|
||||
|
||||
Enable it by adding a `transports.nym` instance and pointing it at your
|
||||
running nym-socks5-client. See the transports reference for the field
|
||||
set.
|
||||
|
||||
### mDNS LAN discovery
|
||||
|
||||
Nodes on a shared local link can now find each other with zero address
|
||||
configuration. The opt-in `node.discovery.lan` path runs an mDNS /
|
||||
DNS-SD responder and browser: each node advertises a FIPS service record
|
||||
on the link and adopts the peers it discovers. This complements the
|
||||
existing Nostr-mediated overlay discovery for the common case where the
|
||||
peers are simply on the same LAN.
|
||||
|
||||
Turn it on with `node.discovery.lan.enabled: true`. `service_type` and
|
||||
`scope` tune the advertised service record and which interfaces
|
||||
participate. Discovery on the local link needs no relay and no STUN.
|
||||
|
||||
### Data-plane throughput overhaul
|
||||
|
||||
The receive and send paths were reworked for higher single-node
|
||||
throughput and lower per-packet CPU, building on the v0.3.0
|
||||
crypto-backend swap:
|
||||
|
||||
- **Off-task encrypt and decrypt.** Per-peer encrypt and decrypt now run
|
||||
on dedicated worker tasks rather than inline on the receive loop, so a
|
||||
single busy peer no longer serializes the whole node's crypto.
|
||||
- **GSO and connected-UDP send.** The Linux send path uses generic
|
||||
segmentation offload and a connected-UDP socket where available,
|
||||
cutting syscall overhead on bulk flows.
|
||||
- **Copy-avoidance on receive.** The receive hot path avoids buffer
|
||||
copies it previously made per packet.
|
||||
- **Batched macOS receive.** macOS gains a `recvmsg_x` batched receive,
|
||||
mirroring the Linux `recvmmsg` batching from v0.3.0.
|
||||
- **Shared immutable-state context and an atomic metric registry.**
|
||||
Immutable per-node state moved into a single shared context, and
|
||||
counters live in an atomic metric registry that the new `show_metrics`
|
||||
query reads without touching the hot path.
|
||||
|
||||
These are all internal to the data plane and require no operator action.
|
||||
|
||||
### Observability off the hot path
|
||||
|
||||
Every read-only control query now renders from a snapshot published once
|
||||
per tick into a lock-free `ArcSwap`, served from the control accept task
|
||||
instead of round-tripping the data-plane receive loop. This covers
|
||||
`show_status`, `show_stats_*`, `show_peers`, `show_sessions`,
|
||||
`show_links`, `show_connections`, `show_transports`, `show_mmp`,
|
||||
`show_tree`, `show_bloom`, `show_cache`, `show_routing`,
|
||||
`show_identity_cache`, `show_acl`, `show_listening_sockets`, and the new
|
||||
`show_metrics`. Only the mutating `connect` and `disconnect` commands
|
||||
still reach the loop.
|
||||
|
||||
The practical effect: on a loaded node where the receive loop was busy,
|
||||
`fipsctl` and `fipstop` queries previously stalled or timed out (the
|
||||
five-second query pattern operators saw). They now answer promptly
|
||||
regardless of data-plane load. Per-entity snapshots reuse unchanged rows
|
||||
by pointer, so the per-tick publish cost stays bounded as peer and
|
||||
session counts grow.
|
||||
|
||||
A new **`show_metrics`** query (surfaced as `fipsctl stats metrics`)
|
||||
returns a counter-only snapshot of every metric family. It is the
|
||||
enabler for a Prometheus scraper that pulls node counters at no hot-path
|
||||
cost.
|
||||
|
||||
### Reworked fipstop TUI
|
||||
|
||||
`fipstop` gets a rendering, navigation, and read-surface overhaul on a
|
||||
machine-verified base: a render-snapshot harness asserts the exact text
|
||||
grid and per-cell style of every view against canned control-socket
|
||||
output. New daemon-resolved fields surface through the snapshots,
|
||||
including effective persistence, root and is-root state, a
|
||||
per-transport-type peer-count map, per-peer effective depth, the root
|
||||
npub, and the last-sent uptree filter fill ratio with the subtree size
|
||||
estimate.
|
||||
|
||||
A separate fix clears a garbled-screen problem on startup and stray
|
||||
bytes on quit, most visible over SSH and inside tmux: startup now forces
|
||||
a full repaint before the first draw, and quit stops and joins the
|
||||
stdin-poll thread before restoring the terminal, so post-raw-mode
|
||||
keystrokes no longer echo onto the restored screen.
|
||||
|
||||
### Rekey reliability
|
||||
|
||||
FMP and FSP session rekey are now hitless under packet loss and
|
||||
reordering in both directions:
|
||||
|
||||
- Inbound frames are authenticated against the pending session before
|
||||
the K-bit cutover promotes it, so a spoofed or stale frame cannot
|
||||
derail a rekey in progress.
|
||||
- Rekey message-1 retransmission is bounded, and the link-dead heartbeat
|
||||
is rekey-aware so an in-flight rekey is not mistaken for a dead link.
|
||||
- FSP session rekey holds connectivity across the rekey window under
|
||||
loss and reordering.
|
||||
- Dual-initiation races (both peers starting a rekey at once on a
|
||||
high-latency link) are desynchronized with symmetric jitter so the two
|
||||
sides converge on one session rather than fighting.
|
||||
- An exhausted retransmission-budget abort, an expected and self-limiting
|
||||
outcome on lossy or high-latency links, is logged at debug rather than
|
||||
warn.
|
||||
|
||||
The net operator takeaway: rekey completes cleanly without dropping
|
||||
traffic, even on lossy or high-latency links, and the log no longer
|
||||
cries wolf when a rekey gives up and retries.
|
||||
|
||||
## Behavior changes worth flagging
|
||||
|
||||
### The inbound filter FPR cap default doubles again
|
||||
These affect operators on upgrade.
|
||||
|
||||
`node.bloom.max_inbound_fpr` goes from `0.10` to `0.20`. The cap rejects
|
||||
inbound `FilterAnnounce` frames whose advertised false positive rate
|
||||
exceeds it. On the fixed 1 KB, k=5 filter, `0.10` corresponds to a fill of
|
||||
0.631 and roughly 1,630 reachable entries, and the busiest nodes'
|
||||
aggregates had started reaching that ceiling as the mesh grew. `0.20`
|
||||
corresponds to a fill of 0.7248 and roughly 2,114 entries.
|
||||
|
||||
Be aware that this is the second time in two releases that this default
|
||||
has doubled, for the same reason both times. That is worth stating plainly
|
||||
rather than repeating the previous release's framing: raising the cap buys
|
||||
headroom, it does not fix anything. The real constraint is the fixed 1 KB
|
||||
filter size, which is a protocol constant. The structural remedy is the v2
|
||||
filter work, where filter capacity scales with the mesh instead of being
|
||||
pinned. This release is an interim step to keep legitimate aggregates from
|
||||
being rejected until that lands. It is not the start of a pattern of
|
||||
raising the cap once per release, and if you are sizing capacity planning
|
||||
around this number, plan against the v2 work rather than against a third
|
||||
raise.
|
||||
|
||||
The antipoison property the cap exists for is preserved. A saturated or
|
||||
deliberately poisoned filter still presents an FPR near 100% and is still
|
||||
rejected.
|
||||
|
||||
**This matters during a rolling upgrade.** A v0.4.1 node accepts a
|
||||
`FilterAnnounce` with a derived FPR between 0.10 and 0.20; a v0.4.0 node
|
||||
drops the same frame, and the drop is silent on the wire with no NACK. The
|
||||
cap also gates the mesh size estimator, which declines to produce a value
|
||||
when any contributing filter is over the cap. So while a mesh is partly
|
||||
upgraded, upgraded and not-yet-upgraded nodes can legitimately report
|
||||
different mesh sizes, or one can report a size while the other reports
|
||||
unknown. This resolves once every node is on v0.4.1. If you want to avoid
|
||||
the window entirely, set `node.bloom.max_inbound_fpr: 0.10` explicitly in
|
||||
your config before upgrading and remove it after the last node is done.
|
||||
|
||||
### The `parent_switched` counter is removed
|
||||
|
||||
`parent_switched` was incremented on the line immediately before
|
||||
`parent_switches` at every site and never independently, so the two
|
||||
counters always held the same value. `parent_switched` is now gone from
|
||||
the tree metrics, the control socket snapshot, and the `fipstop` tree
|
||||
view. `parent_switches` remains and is unchanged.
|
||||
|
||||
If you scrape the control socket, or have dashboards or alerts referencing
|
||||
`parent_switched`, point them at `parent_switches`. Anything still asking
|
||||
for `parent_switched` will find nothing rather than a zero.
|
||||
- **Bloom filter antipoison cap raised.** `node.bloom.max_inbound_fpr`
|
||||
moves from 0.05 to 0.10, accepting filters with a higher derived
|
||||
false-positive rate before rejecting them. This reduces spurious
|
||||
filter rejections on larger meshes while keeping the antipoison
|
||||
protection in place.
|
||||
- **TCP inbound cap honors `max_connections`.** The TCP inbound accept
|
||||
ceiling now resolves from explicit per-transport
|
||||
`max_inbound_connections`, then node-wide
|
||||
`node.limits.max_connections`, then the built-in default of 256.
|
||||
Previously the TCP inbound ceiling was hardwired to 256 and ignored
|
||||
`max_connections`, so raising it had no effect on inbound TCP.
|
||||
- **Static host aliases hot-reload.** `/etc/fips/hosts` now reloads on
|
||||
mtime change once per tick rather than only at startup, so display
|
||||
names in `fipsctl` and `fipstop` reflect edits without a daemon
|
||||
restart. The peer ACL reloads through the same lock-free snapshot
|
||||
mechanism.
|
||||
- **Quieter logs on busy public-mesh nodes.** Routine per-peer
|
||||
connection-lifecycle and capacity-cap events, no-route session-datagram
|
||||
drops, and exhausted rekey-budget aborts are demoted to debug, so
|
||||
genuinely notable info and warn lines are no longer drowned out.
|
||||
- **More visible drops.** Receive-path silent rejections now flow
|
||||
through typed reject-reason counters, and discovery counts requests
|
||||
dropped when the dedup cache is full (`req_dedup_cache_full`, visible
|
||||
via `show_routing`). Drops that were previously silent are now
|
||||
countable.
|
||||
- **Tor connect-refused accounting.** The Tor transport increments its
|
||||
`connect_refused` statistic (the "Refused" line in `fipstop`) on an
|
||||
actively-refused SOCKS5 connect, instead of recording every connect
|
||||
failure as a generic SOCKS5 error.
|
||||
|
||||
## Notable bug fixes
|
||||
|
||||
### Stale coordinates after losing a parent through peer removal
|
||||
The CHANGELOG has the exhaustive list. This is the operator-relevant
|
||||
subset of fixes for behavior that shipped in v0.3.0.
|
||||
|
||||
When a node's parent link dropped via peer removal, the node correctly
|
||||
reparented or self-rooted, but skipped the coordinate cache invalidation
|
||||
that every other position-change path performs. Cached entries for
|
||||
downstream destinations kept the node's old coordinate prefix. This did
|
||||
not self-correct the way a stale cache entry normally would: routing
|
||||
access refreshes an entry's TTL, so an entry that was actively being
|
||||
routed through never expired, and was only fixed by an unrelated fresh
|
||||
insert. Both invalidation classes now run on this path, matching the
|
||||
loop-detection branch.
|
||||
|
||||
### Discovery could loosen a tightened path MTU clamp
|
||||
|
||||
An originator handling a `LookupResponse` overwrote its cached path MTU
|
||||
unconditionally. If a reactive `MtuExceeded` or `PathMtuNotification` had
|
||||
already taught it a tighter value, a later, looser discovery estimate
|
||||
would clobber that and re-loosen the clamp, risking a return to dropped
|
||||
oversized packets. The cached and received values are now compared and the
|
||||
tighter one is kept.
|
||||
- **Symmetric peer teardown on manual disconnect.** A manual
|
||||
`fipsctl disconnect` now sends the peer a scoped Disconnect so both
|
||||
ends tear down and re-handshake cleanly. Previously a manual
|
||||
disconnect tore down only the local side, leaving the peer with a
|
||||
stale session that was never re-adopted as a child and whose bloom
|
||||
filter was never re-recorded.
|
||||
- **Gateway holds long-lived and DNS-cached mappings.** `fips-gateway`
|
||||
no longer drops a virtual-IP mapping while traffic is still flowing.
|
||||
The mapping TTL clock previously advanced only on DNS re-query, so a
|
||||
busy long-lived or DNS-cached client could have its mapping reclaimed
|
||||
mid-flow. The tick now refreshes the mapping whenever conntrack reports
|
||||
active sessions and recovers a draining mapping to active when traffic
|
||||
resumes; only genuinely idle mappings drain.
|
||||
- **Accurate mesh-size estimate under filter overlap.** The mesh-size
|
||||
estimator now estimates the cardinality of the OR-union of self plus
|
||||
every connected peer's inbound filter, instead of summing per-filter
|
||||
cardinalities of tree peers. Summing assumed the filters were disjoint,
|
||||
so a stale or oversized parent filter or a routing loop inflated the
|
||||
reported mesh size and a tree rebalance flapped the count. OR-union
|
||||
deduplicates overlap, equals the old result in the disjoint case, and
|
||||
removes the estimate's dependence on tree-declaration cache freshness.
|
||||
- **Single-uplink node reattaches within a round-trip.** A node with one
|
||||
tree peer, which has periodic parent re-evaluation disabled, was left
|
||||
self-rooted and unreachable if its one-shot attaching TreeAnnounce was
|
||||
lost, until the next periodic re-broadcast. Tree-position exchange is
|
||||
now self-healing on the receive path: a node that hears an announce
|
||||
advertising a strictly worse root echoes its own declaration back,
|
||||
provoking the better-rooted peer to re-push its real position
|
||||
immediately.
|
||||
|
||||
## Upgrade notes
|
||||
|
||||
This is a drop-in upgrade from v0.4.0 with no wire format change, no
|
||||
config migration, and no coordinated restart. Upgrade nodes in whatever
|
||||
order you like.
|
||||
Operator-actionable items moving from v0.3.0 to v0.4.0:
|
||||
|
||||
Two things to do rather than assume:
|
||||
- **Wire-compatible, no flag day.** v0.4.0 peers with v0.3.0. Upgrade
|
||||
nodes in any order. During a rolling upgrade you may see some log lines
|
||||
on the upgraded side as it interacts with not-yet-upgraded peers;
|
||||
behavior is correct, log noise only.
|
||||
- **Bloom antipoison cap default changed.** `node.bloom.max_inbound_fpr`
|
||||
now defaults to 0.10 (was 0.05). If you set this explicitly, review
|
||||
whether you still want the old value.
|
||||
- **New optional config surfaces.** `transports.nym` (outbound Nym
|
||||
mixnet) and `node.discovery.lan` (mDNS LAN discovery) are both opt-in
|
||||
and off by default. Adding them is the only way to turn the new paths
|
||||
on.
|
||||
- **TCP inbound cap.** If you relied on the old hardwired 256 inbound-TCP
|
||||
ceiling, note it now honors `max_inbound_connections` then
|
||||
`node.limits.max_connections` then 256.
|
||||
- **New observability query.** `fipsctl stats metrics` (the
|
||||
`show_metrics` control query) returns a counter-only snapshot suitable
|
||||
for a scraper.
|
||||
|
||||
1. If you monitor `parent_switched`, move to `parent_switches` before
|
||||
upgrading, or your dashboards will go blank rather than error.
|
||||
2. During the rolling window, expect upgraded and not-yet-upgraded nodes
|
||||
to potentially disagree about mesh size, per the FPR cap section above.
|
||||
This is expected and self-resolves. Do not chase it as a bug unless it
|
||||
persists after every node reports `0.4.1`.
|
||||
|
||||
If you have pinned `node.bloom.max_inbound_fpr` explicitly in your config,
|
||||
your setting is honored and nothing changes for you. The change only
|
||||
affects nodes taking the default.
|
||||
|
||||
Downgrading to v0.4.0 is supported and needs no special handling.
|
||||
|
||||
## Getting v0.4.1
|
||||
## Getting v0.4.0
|
||||
|
||||
- **Linux x86_64 / aarch64**: `.deb` and tarball at the
|
||||
[v0.4.1 release page](https://github.com/jmcorgan/fips/releases/tag/v0.4.1).
|
||||
[v0.4.0 release page](https://github.com/jmcorgan/fips/releases/tag/v0.4.0).
|
||||
- **Arch Linux**: `fips` from the AUR.
|
||||
- **macOS**: `.pkg` at the v0.4.1 release page.
|
||||
- **Windows**: ZIP at the v0.4.1 release page.
|
||||
- **OpenWrt**: `.ipk` (OpenWrt 24.x and earlier) or `.apk` (OpenWrt 25+)
|
||||
at the v0.4.1 release page.
|
||||
- **From source**: `cargo build --release` from a checkout of the v0.4.1
|
||||
- **macOS**: `.pkg` at the v0.4.0 release page.
|
||||
- **Windows**: ZIP at the v0.4.0 release page.
|
||||
- **OpenWrt**: `.ipk` at the v0.4.0 release page.
|
||||
- **From source**: `cargo build --release` from a checkout of the v0.4.0
|
||||
tag (Rust 1.94.1 per `rust-toolchain.toml`; `libclang-dev` is a
|
||||
required Linux build prerequisite).
|
||||
- **Nix / NixOS**: `nix build .#fips` from a checkout of the v0.4.1 tag
|
||||
builds the binaries from source with the pinned toolchain and no manual
|
||||
prerequisites (see the Nix section of `packaging/README.md`).
|
||||
|
||||
The full per-commit changelog lives in
|
||||
[`CHANGELOG.md`](../../CHANGELOG.md). Issues and discussion at
|
||||
@@ -141,6 +267,13 @@ The full per-commit changelog lives in
|
||||
Thanks to everyone who contributed code, packaging work, bug reports, or
|
||||
reviews to this release.
|
||||
|
||||
- [@jcorgan](https://github.com/jmcorgan): release shepherd, spanning-tree
|
||||
and discovery fixes, bloom and identity performance work, antipoison cap
|
||||
change, and testing.
|
||||
- [@jcorgan](https://github.com/jmcorgan): release shepherd, high-level
|
||||
design, control read plane, rekey hardening, admission, bug fixes,
|
||||
testing, packaging, PR coordination, and issue resolution.
|
||||
- [@mmalmi](https://github.com/mmalmi): opt-in mDNS LAN discovery and
|
||||
data-plane performance work.
|
||||
- [@Origami74](https://github.com/Origami74): macOS packaging and
|
||||
website coordination.
|
||||
- [@dskvr](https://github.com/dskvr): AUR packaging.
|
||||
- [@oleksky](https://github.com/oleksky): Nym mixnet transport and the
|
||||
single-container mixnet demo.
|
||||
|
||||
@@ -1,365 +0,0 @@
|
||||
//! Micro-benchmark quantifying the per-forwarded-packet heap-allocation cost
|
||||
//! of the routing next-hop candidate-assembly path.
|
||||
//!
|
||||
//! `find_next_hop` runs once per forwarded data packet. Its sans-IO core
|
||||
//! assembles a `Vec<Candidate>` by enumerating every peer through the
|
||||
//! `RoutingView` seam: `peer_addrs()` materializes a `Vec<NodeAddr>` of all
|
||||
//! peers, the survivors are snapshotted (each cloning its `TreeCoordinate`),
|
||||
//! and the result is collected into a second `Vec`. This bench measures that
|
||||
//! per-call allocation against a fused zero-alloc reference that iterates the
|
||||
//! peer map directly and borrows coordinates instead of cloning.
|
||||
//!
|
||||
//! Visibility caveat: the production `routing_candidates` / `select_best_candidate`
|
||||
//! / `RoutingView` / `Candidate` are `pub(crate)` (src/proto/routing/core.rs)
|
||||
//! and are not re-exported at the crate root, so an external bench crate cannot
|
||||
//! name them. Rather than change production visibility, this file reproduces
|
||||
//! that path verbatim over the real public `NodeAddr` / `TreeCoordinate` /
|
||||
//! `CoordEntry` / `BloomFilter` types with the same iterator chain and the same
|
||||
//! `HashMap`-backed view the shell uses (src/node/mod.rs NodeRoutingView). The
|
||||
//! allocation behavior is therefore identical to production by construction;
|
||||
//! only the symbol identity differs.
|
||||
|
||||
use std::alloc::{GlobalAlloc, Layout, System};
|
||||
use std::collections::HashMap;
|
||||
use std::hint::black_box;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main};
|
||||
use fips::{BloomFilter, NodeAddr, TreeCoordinate};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Counting global allocator: bumps a process-global counter on every heap
|
||||
// allocation operation (alloc / alloc_zeroed / realloc). Sampled tightly and
|
||||
// single-threaded in `report_allocs` so no unrelated allocations are captured.
|
||||
// ---------------------------------------------------------------------------
|
||||
struct CountingAlloc;
|
||||
|
||||
static ALLOCS: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
unsafe impl GlobalAlloc for CountingAlloc {
|
||||
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
|
||||
ALLOCS.fetch_add(1, Ordering::Relaxed);
|
||||
unsafe { System.alloc(layout) }
|
||||
}
|
||||
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
|
||||
unsafe { System.dealloc(ptr, layout) }
|
||||
}
|
||||
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
|
||||
ALLOCS.fetch_add(1, Ordering::Relaxed);
|
||||
unsafe { System.alloc_zeroed(layout) }
|
||||
}
|
||||
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
|
||||
ALLOCS.fetch_add(1, Ordering::Relaxed);
|
||||
unsafe { System.realloc(ptr, layout, new_size) }
|
||||
}
|
||||
}
|
||||
|
||||
#[global_allocator]
|
||||
static GLOBAL: CountingAlloc = CountingAlloc;
|
||||
|
||||
const PEER_COUNTS: [usize; 4] = [8, 32, 128, 256];
|
||||
/// Fraction of peers whose bloom filter reports the destination reachable.
|
||||
const REACH_NUMERATOR: usize = 1;
|
||||
const REACH_DENOMINATOR: usize = 2;
|
||||
/// Tree depth for synthetic coordinates (self..root), a realistic mesh depth.
|
||||
const COORD_DEPTH: usize = 8;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reproduction of the pub(crate) routing seam (src/proto/routing/core.rs).
|
||||
// ---------------------------------------------------------------------------
|
||||
trait RoutingView {
|
||||
fn peer_addrs(&self) -> Vec<NodeAddr>;
|
||||
fn peer_may_reach(&self, peer: &NodeAddr, dest: &NodeAddr) -> bool;
|
||||
fn peer_can_send(&self, peer: &NodeAddr) -> bool;
|
||||
fn peer_link_cost(&self, peer: &NodeAddr) -> f64;
|
||||
fn peer_coords(&self, peer: &NodeAddr) -> Option<TreeCoordinate>;
|
||||
}
|
||||
|
||||
struct Candidate {
|
||||
addr: NodeAddr,
|
||||
can_send: bool,
|
||||
link_cost: f64,
|
||||
coords: Option<TreeCoordinate>,
|
||||
}
|
||||
|
||||
/// Verbatim from `routing::routing_candidates` (core.rs). Allocates the
|
||||
/// `peer_addrs` Vec, clones each survivor's coords, and collects into a Vec.
|
||||
fn routing_candidates(rv: &impl RoutingView, dest: &NodeAddr) -> Vec<Candidate> {
|
||||
rv.peer_addrs()
|
||||
.into_iter()
|
||||
.filter(|peer| rv.peer_may_reach(peer, dest))
|
||||
.map(|peer| Candidate {
|
||||
can_send: rv.peer_can_send(&peer),
|
||||
link_cost: rv.peer_link_cost(&peer),
|
||||
coords: rv.peer_coords(&peer),
|
||||
addr: peer,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Verbatim from `routing::select_best_candidate` (core.rs). Pure, no alloc.
|
||||
fn select_best_candidate(
|
||||
candidates: &[Candidate],
|
||||
dest_coords: &TreeCoordinate,
|
||||
my_coords: &TreeCoordinate,
|
||||
) -> Option<NodeAddr> {
|
||||
let my_distance = my_coords.distance_to(dest_coords);
|
||||
let mut best: Option<(&Candidate, f64, usize)> = None;
|
||||
for candidate in candidates {
|
||||
if !candidate.can_send {
|
||||
continue;
|
||||
}
|
||||
let cost = candidate.link_cost;
|
||||
let dist = candidate
|
||||
.coords
|
||||
.as_ref()
|
||||
.map(|pc| pc.distance_to(dest_coords))
|
||||
.unwrap_or(usize::MAX);
|
||||
if dist >= my_distance {
|
||||
continue;
|
||||
}
|
||||
let dominated = match &best {
|
||||
None => true,
|
||||
Some((_, best_cost, best_dist)) => {
|
||||
cost < *best_cost
|
||||
|| (cost == *best_cost && dist < *best_dist)
|
||||
|| (cost == *best_cost
|
||||
&& dist == *best_dist
|
||||
&& candidate.addr < best.as_ref().unwrap().0.addr)
|
||||
}
|
||||
};
|
||||
if dominated {
|
||||
best = Some((candidate, cost, dist));
|
||||
}
|
||||
}
|
||||
best.map(|(candidate, _, _)| candidate.addr)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bench-local view, HashMap-backed exactly like src/node/mod.rs NodeRoutingView.
|
||||
// ---------------------------------------------------------------------------
|
||||
struct BenchPeer {
|
||||
bloom: BloomFilter,
|
||||
can_send: bool,
|
||||
link_cost: f64,
|
||||
}
|
||||
|
||||
struct BenchView {
|
||||
peers: HashMap<NodeAddr, BenchPeer>,
|
||||
coords: HashMap<NodeAddr, TreeCoordinate>,
|
||||
}
|
||||
|
||||
impl RoutingView for BenchView {
|
||||
fn peer_addrs(&self) -> Vec<NodeAddr> {
|
||||
self.peers.keys().copied().collect()
|
||||
}
|
||||
fn peer_may_reach(&self, peer: &NodeAddr, dest: &NodeAddr) -> bool {
|
||||
self.peers.get(peer).is_some_and(|p| p.bloom.contains(dest))
|
||||
}
|
||||
fn peer_can_send(&self, peer: &NodeAddr) -> bool {
|
||||
self.peers.get(peer).is_some_and(|p| p.can_send)
|
||||
}
|
||||
fn peer_link_cost(&self, peer: &NodeAddr) -> f64 {
|
||||
self.peers.get(peer).map_or(f64::INFINITY, |p| p.link_cost)
|
||||
}
|
||||
fn peer_coords(&self, peer: &NodeAddr) -> Option<TreeCoordinate> {
|
||||
self.coords.get(peer).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
/// Zero-alloc reference: what an iterator/visitor seam would do. Iterates the
|
||||
/// peer map directly, fuses the may_reach + can_send filters, borrows coords
|
||||
/// instead of cloning, and tracks the best hop inline. No Vec, no coord clone.
|
||||
fn resolve_next_hop_zeroalloc(
|
||||
view: &BenchView,
|
||||
dest: &NodeAddr,
|
||||
dest_coords: &TreeCoordinate,
|
||||
my_coords: &TreeCoordinate,
|
||||
) -> Option<NodeAddr> {
|
||||
let my_distance = my_coords.distance_to(dest_coords);
|
||||
let mut best: Option<(NodeAddr, f64, usize)> = None;
|
||||
for (addr, peer) in &view.peers {
|
||||
if !peer.bloom.contains(dest) {
|
||||
continue;
|
||||
}
|
||||
if !peer.can_send {
|
||||
continue;
|
||||
}
|
||||
let cost = peer.link_cost;
|
||||
let dist = view
|
||||
.coords
|
||||
.get(addr)
|
||||
.map(|pc| pc.distance_to(dest_coords))
|
||||
.unwrap_or(usize::MAX);
|
||||
if dist >= my_distance {
|
||||
continue;
|
||||
}
|
||||
let dominated = match &best {
|
||||
None => true,
|
||||
Some((best_addr, best_cost, best_dist)) => {
|
||||
cost < *best_cost
|
||||
|| (cost == *best_cost && dist < *best_dist)
|
||||
|| (cost == *best_cost && dist == *best_dist && *addr < *best_addr)
|
||||
}
|
||||
};
|
||||
if dominated {
|
||||
best = Some((*addr, cost, dist));
|
||||
}
|
||||
}
|
||||
best.map(|(addr, _, _)| addr)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario construction.
|
||||
// ---------------------------------------------------------------------------
|
||||
fn addr(tag: u8, i: u16) -> NodeAddr {
|
||||
let mut b = [0u8; 16];
|
||||
b[0] = tag;
|
||||
b[1..3].copy_from_slice(&i.to_le_bytes());
|
||||
NodeAddr::from_bytes(b)
|
||||
}
|
||||
|
||||
/// A depth-`COORD_DEPTH` coordinate whose leaf is `leaf`, sharing a fixed
|
||||
/// interior path and root with `shared_tag`. Peers built with the dest's
|
||||
/// shared_tag sit close to the destination (distance 2); a distinct shared_tag
|
||||
/// sits far (near the root), modeling our own position.
|
||||
fn coord(leaf: NodeAddr, shared_tag: u8) -> TreeCoordinate {
|
||||
let mut path = Vec::with_capacity(COORD_DEPTH);
|
||||
path.push(leaf);
|
||||
for level in 1..(COORD_DEPTH - 1) {
|
||||
path.push(addr(shared_tag, level as u16));
|
||||
}
|
||||
path.push(addr(9, 0)); // common root
|
||||
TreeCoordinate::from_addrs(path).expect("valid coord path")
|
||||
}
|
||||
|
||||
struct Scenario {
|
||||
view: BenchView,
|
||||
dest: NodeAddr,
|
||||
dest_coords: TreeCoordinate,
|
||||
my_coords: TreeCoordinate,
|
||||
}
|
||||
|
||||
impl Scenario {
|
||||
fn new(n: usize) -> Self {
|
||||
let dest = addr(2, 0);
|
||||
// Destination path uses interior tag 4; peers reuse tag 4 so survivors
|
||||
// are close to the destination. Our own coords use tag 5 (far).
|
||||
let dest_coords = coord(dest, 4);
|
||||
let my_coords = coord(addr(6, 0), 5);
|
||||
|
||||
let mut peers = HashMap::new();
|
||||
let mut coords = HashMap::new();
|
||||
for i in 0..n {
|
||||
let paddr = addr(1, i as u16);
|
||||
let mut bloom = BloomFilter::new();
|
||||
// Realistic fill: a handful of unrelated reachable addrs.
|
||||
for f in 0..4u16 {
|
||||
bloom.insert(&addr(7, i as u16 * 4 + f));
|
||||
}
|
||||
// A controlled fraction advertise the destination as reachable.
|
||||
if (i % REACH_DENOMINATOR) < REACH_NUMERATOR {
|
||||
bloom.insert(&dest);
|
||||
}
|
||||
peers.insert(
|
||||
paddr,
|
||||
BenchPeer {
|
||||
bloom,
|
||||
can_send: true,
|
||||
link_cost: 1.0 + (i as f64) * 0.01,
|
||||
},
|
||||
);
|
||||
// Peers share the destination's interior path (tag 4) → close.
|
||||
coords.insert(paddr, coord(paddr, 4));
|
||||
}
|
||||
|
||||
Self {
|
||||
view: BenchView { peers, coords },
|
||||
dest,
|
||||
dest_coords,
|
||||
my_coords,
|
||||
}
|
||||
}
|
||||
|
||||
fn survivors(&self) -> usize {
|
||||
self.view
|
||||
.peers
|
||||
.values()
|
||||
.filter(|p| p.bloom.contains(&self.dest))
|
||||
.count()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Allocation-per-call report (printed once, before criterion timing).
|
||||
// ---------------------------------------------------------------------------
|
||||
fn count_allocs<T>(iters: usize, mut f: impl FnMut() -> T) -> f64 {
|
||||
for _ in 0..8 {
|
||||
black_box(f());
|
||||
}
|
||||
let start = ALLOCS.load(Ordering::Relaxed);
|
||||
for _ in 0..iters {
|
||||
black_box(f());
|
||||
}
|
||||
let end = ALLOCS.load(Ordering::Relaxed);
|
||||
(end - start) as f64 / iters as f64
|
||||
}
|
||||
|
||||
fn report_allocs() {
|
||||
const ITERS: usize = 2000;
|
||||
println!("\n=== allocations per call (heap alloc ops: alloc+alloc_zeroed+realloc) ===");
|
||||
println!(
|
||||
"{:>6} {:>10} {:>16} {:>16}",
|
||||
"peers", "survivors", "current/call", "zero-alloc/call"
|
||||
);
|
||||
for &n in &PEER_COUNTS {
|
||||
let s = Scenario::new(n);
|
||||
let survivors = s.survivors();
|
||||
let current = count_allocs(ITERS, || {
|
||||
let cands = routing_candidates(&s.view, &s.dest);
|
||||
select_best_candidate(&cands, &s.dest_coords, &s.my_coords)
|
||||
});
|
||||
let zero = count_allocs(ITERS, || {
|
||||
resolve_next_hop_zeroalloc(&s.view, &s.dest, &s.dest_coords, &s.my_coords)
|
||||
});
|
||||
println!("{n:>6} {survivors:>10} {current:>16.2} {zero:>16.2}");
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
fn bench_next_hop(c: &mut Criterion) {
|
||||
report_allocs();
|
||||
|
||||
let mut group = c.benchmark_group("find_next_hop");
|
||||
for &n in &PEER_COUNTS {
|
||||
let scenario = Scenario::new(n);
|
||||
group.bench_with_input(BenchmarkId::new("current_alloc", n), &n, |b, _| {
|
||||
b.iter(|| {
|
||||
let cands = routing_candidates(&scenario.view, &scenario.dest);
|
||||
black_box(select_best_candidate(
|
||||
&cands,
|
||||
&scenario.dest_coords,
|
||||
&scenario.my_coords,
|
||||
))
|
||||
});
|
||||
});
|
||||
group.bench_with_input(BenchmarkId::new("zero_alloc_ref", n), &n, |b, _| {
|
||||
b.iter(|| {
|
||||
black_box(resolve_next_hop_zeroalloc(
|
||||
&scenario.view,
|
||||
&scenario.dest,
|
||||
&scenario.dest_coords,
|
||||
&scenario.my_coords,
|
||||
))
|
||||
});
|
||||
});
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group! {
|
||||
name = benches;
|
||||
config = Criterion::default().sample_size(50);
|
||||
targets = bench_next_hop
|
||||
}
|
||||
criterion_main!(benches);
|
||||
@@ -44,7 +44,7 @@ See [fips-transport-layer.md](fips-transport-layer.md) for the
|
||||
transport layer specification.
|
||||
|
||||
**FIPS Mesh Protocol (FMP)**: Manages peer connections, authenticates
|
||||
peers via Noise XX handshakes, and encrypts all traffic on each link.
|
||||
peers via Noise IK handshakes, and encrypts all traffic on each link.
|
||||
FMP is where the mesh organizes itself — nodes exchange spanning tree
|
||||
announcements and bloom filters with their direct peers, and FMP
|
||||
makes forwarding decisions for transit traffic. FMP provides
|
||||
@@ -116,8 +116,8 @@ three are deterministically derived from the same keypair.
|
||||
|
||||

|
||||
|
||||
The pubkey is the node's cryptographic identity, used in Noise XX
|
||||
handshakes for both link encryption and session encryption.
|
||||
The pubkey is the node's cryptographic identity, used in Noise
|
||||
handshakes for both link encryption (IK) and session encryption (XK).
|
||||
It is never exposed beyond the endpoints of an encrypted channel. The node_addr, a one-way
|
||||
SHA-256 hash truncated to 16 bytes, serves as the routing identifier
|
||||
in packet headers and bloom filters. Intermediate routers see only
|
||||
@@ -156,29 +156,31 @@ FIPS uses independent encryption at two protocol layers:
|
||||
|
||||
| Layer | Scope | Pattern | Purpose |
|
||||
| ----- | ----- | ------- | ------- |
|
||||
| **FMP (Mesh)** | Hop-by-hop | Noise XX | Encrypt all traffic on each peer link |
|
||||
| **FSP (Session)** | End-to-end | Noise XX | Encrypt application payload between endpoints |
|
||||
| **FMP (Mesh)** | Hop-by-hop | Noise IK | Encrypt all traffic on each peer link |
|
||||
| **FSP (Session)** | End-to-end | Noise XK | Encrypt application payload between endpoints |
|
||||
|
||||
### Link Layer (Hop-by-Hop)
|
||||
|
||||
When two nodes establish a direct connection, they perform a [Noise
|
||||
XX](https://noiseprotocol.org/) handshake. This authenticates both
|
||||
IK](https://noiseprotocol.org/) handshake. This authenticates both
|
||||
parties and establishes symmetric keys for encrypting all traffic on
|
||||
that link. Every packet between direct peers is encrypted — gossip
|
||||
messages, routing queries, and forwarded session datagrams alike.
|
||||
Neither side requires prior knowledge of the other's static key —
|
||||
both identities are revealed during the three-message handshake,
|
||||
along with a protocol negotiation payload that enables rolling
|
||||
upgrades.
|
||||
|
||||
The IK pattern is used because outbound connections know the peer's
|
||||
npub from configuration, while inbound connections learn the
|
||||
initiator's identity from the first handshake message.
|
||||
|
||||
### Session Layer (End-to-End)
|
||||
|
||||
FIPS establishes end-to-end encrypted sessions between any two
|
||||
communicating nodes using Noise XX, regardless of how many hops
|
||||
separate them. The same three-message XX pattern is used at both
|
||||
layers — neither side reveals its identity until msg2 (responder) or
|
||||
msg3 (initiator), providing mutual identity protection for traffic
|
||||
traversing untrusted intermediate nodes.
|
||||
communicating nodes using Noise XK, regardless of how many hops
|
||||
separate them. The initiator knows the destination's npub (required
|
||||
for XK's pre-message); the responder learns the initiator's identity
|
||||
from the third handshake message. Unlike the link-layer IK pattern
|
||||
where the initiator's identity is revealed in msg1, XK delays
|
||||
identity disclosure until msg3, providing stronger initiator identity
|
||||
protection for traffic traversing untrusted intermediate nodes.
|
||||
|
||||
A packet from A to D through intermediate nodes B and C:
|
||||
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
# FIPS Bloom Filters
|
||||
|
||||
> **Status (2026-07-12):** This document still describes the **abandoned v1.5**
|
||||
> bloom design. The code on `next` is presently plain **v1** — the v1.5
|
||||
> implementation was removed during the sans-IO refactor and replaced with the
|
||||
> v1 filter. The target design is **v2** (per the settled v2 bloom protocol
|
||||
> specification), which is **pending implementation**. Until v2 lands, treat any
|
||||
> "Implemented" wording below as the retired v1.5 work, not the shipped code.
|
||||
|
||||
This document describes the bloom filter data structures, parameters, and
|
||||
mathematical properties used by FIPS for reachability-based candidate
|
||||
selection. It is a supporting reference — for how bloom filters fit into
|
||||
@@ -262,30 +255,29 @@ for i in 0..hash_count:
|
||||
return true // Maybe present (possible false positive)
|
||||
```
|
||||
|
||||
Where `filter_bits = 8 × (512 << size_class)` — 8,192 for size_class 1 (default).
|
||||
Where `filter_bits = 8 × (512 << size_class)` — 8,192 for v1.
|
||||
|
||||
## Wire Format
|
||||
|
||||
The FilterAnnounce byte layout (`msg_type 0x20`, flags, sequence,
|
||||
base_seq, size_class, compressed payload) lives in
|
||||
The FilterAnnounce byte layout (`msg_type 0x20`, sequence, hash_count,
|
||||
size_class, filter_bits) lives in
|
||||
[../reference/wire-formats.md](../reference/wire-formats.md). The
|
||||
v2 payload uses RLE-compressed full filters and XOR deltas
|
||||
(`[count:2 LE][word:8 LE]` runs) so steady-state announcements are
|
||||
typically a few dozen bytes; a `FilterNack` (msg_type 0x21) requests
|
||||
full retransmission when a sequence gap is detected. Link encryption
|
||||
adds 36 bytes of FMP framing (16-byte outer header + 4-byte inner
|
||||
timestamp + 16-byte AEAD tag) on top of the compressed payload.
|
||||
v1 plaintext payload is 1,035 bytes (11-byte header + 1,024-byte
|
||||
filter); link encryption adds 36 bytes of FMP framing (16-byte outer
|
||||
header + 4-byte inner timestamp + 16-byte AEAD tag), bringing the
|
||||
on-the-wire size to roughly 1,071 bytes before the underlying
|
||||
transport's per-packet overhead.
|
||||
|
||||
## Scale and Size Classes
|
||||
|
||||
### Scale Limits
|
||||
### v1 Scale Limits
|
||||
|
||||
Coordinate-based tree distance checking ensures correct routing decisions
|
||||
at all network sizes — bloom filters are an optimization that narrows the
|
||||
set of peers considered, not a correctness requirement. As filters
|
||||
saturate, routing still works; it just evaluates more candidates per hop.
|
||||
|
||||
With the default 1 KB filter (size_class 1):
|
||||
With the v1 mandatory 1 KB filter (size_class 1):
|
||||
|
||||
- **Small networks (< 1,000 nodes)**: Both upward and downward filters
|
||||
are highly accurate (worst-case FPR < 1%). Filters effectively narrow
|
||||
@@ -306,29 +298,33 @@ With the default 1 KB filter (size_class 1):
|
||||
|
||||
### Size Class Table
|
||||
|
||||
| size_class | Bytes | Bits | Notes |
|
||||
| ---------- | ----- | ---- | ----- |
|
||||
| 0 | 512 | 4,096 | Minimum |
|
||||
| 1 | 1,024 | 8,192 | Default |
|
||||
| 2 | 2,048 | 16,384 | |
|
||||
| 3 | 4,096 | 32,768 | |
|
||||
| 4 | 8,192 | 65,536 | |
|
||||
| 5 | 16,384 | 131,072 | |
|
||||
| 6 | 32,768 | 262,144 | Maximum |
|
||||
| size_class | Bytes | Bits | Status |
|
||||
| ---------- | ----- | ---- | ------ |
|
||||
| 0 | 512 | 4,096 | Reserved |
|
||||
| 1 | 1,024 | 8,192 | **v1 (MUST use)** |
|
||||
| 2 | 2,048 | 16,384 | Reserved |
|
||||
| 3 | 4,096 | 32,768 | Reserved |
|
||||
|
||||
### Adaptive Sizing
|
||||
FMP v1 mandates size_class = 1. Nodes MUST use size_class = 1 and MUST
|
||||
reject FilterAnnounce messages with any other size_class. The size_class
|
||||
field is reserved in the wire format to support future protocol versions
|
||||
with larger default filter sizes.
|
||||
|
||||
Filter size is a node property, not a link property. Each node selects
|
||||
its own size class based on outgoing filter fill ratio: step up above
|
||||
~20%, step down below ~5%, with hysteresis to prevent oscillation.
|
||||
Nodes near the root of the spanning tree — which carry larger combined
|
||||
filters — naturally upsize, while leaf and edge nodes stay small.
|
||||
### Scaling Strategy
|
||||
|
||||
When a node receives a filter at a different size class than its own,
|
||||
it converts on receipt: larger filters are folded down, smaller filters
|
||||
are expanded via bit duplication. Routing queries use the peer's filter
|
||||
at its native (advertised) size for full resolution; conversion happens
|
||||
only when building the node's own outgoing filter.
|
||||
The 1 KB filter becomes a practical limitation beyond ~2,000 nodes. The
|
||||
size class mechanism provides the path forward: future FMP versions may
|
||||
use larger default filters (size_class 2 or 3) to support larger networks
|
||||
while remaining compatible with constrained nodes through folding.
|
||||
Size_class 2 (2 KB, 16,384 bits) would roughly double the practical
|
||||
network size limit.
|
||||
|
||||
The envisioned approach is that hub nodes near the root — which carry the
|
||||
largest downward filters — would use larger size classes, while leaf nodes
|
||||
and resource-constrained nodes continue with smaller filters. A node
|
||||
receiving a filter larger than its own size class folds it down locally.
|
||||
The mechanism by which heterogeneous filter sizes propagate through
|
||||
the tree is a future design direction not specified in v1.
|
||||
|
||||
### Folding
|
||||
|
||||
@@ -364,14 +360,14 @@ control socket and `fipstop` dashboard. (See `compute_mesh_size()` in
|
||||
|
||||
The estimator refuses to produce a value when any contributing filter
|
||||
is above the antipoison FPR cap (`node.bloom.max_inbound_fpr`,
|
||||
default `0.20`); a partial aggregate would silently underestimate.
|
||||
default `0.10`); a partial aggregate would silently underestimate.
|
||||
Consumers handle the resulting `None` by displaying an "unknown"
|
||||
state rather than a misleading number.
|
||||
|
||||
## Antipoison: Inbound FPR Cap
|
||||
|
||||
Inbound `FilterAnnounce` payloads are checked against
|
||||
`node.bloom.max_inbound_fpr` (default `0.20`). Filters whose
|
||||
`node.bloom.max_inbound_fpr` (default `0.10`). Filters whose
|
||||
estimated false positive rate exceeds the cap are dropped silently
|
||||
(no NACK on the wire) — they would otherwise inflate downstream
|
||||
candidate evaluation cost without contributing useful discrimination.
|
||||
@@ -382,7 +378,7 @@ as described above.
|
||||
|
||||
| Feature | Status |
|
||||
| ------- | ------ |
|
||||
| Variable-size bloom filters (512 B – 32 KB) | **Implemented** |
|
||||
| 1 KB bloom filter (size_class 1) | **Implemented** |
|
||||
| 5 hash functions | **Implemented** |
|
||||
| Split-horizon filter computation | **Implemented** |
|
||||
| Tree-only merge propagation | **Implemented** |
|
||||
@@ -390,10 +386,6 @@ as described above.
|
||||
| Per-peer filter maintenance | **Implemented** |
|
||||
| Event-driven updates | **Implemented** |
|
||||
| 500ms rate limiting | **Implemented** |
|
||||
| Delta compression (XOR diff + RLE) | **Implemented** |
|
||||
| FilterNack sequence recovery | **Implemented** |
|
||||
| Adaptive sizing (fill-ratio heuristic) | **Implemented** |
|
||||
| Fold/duplicate size conversion | **Implemented** |
|
||||
| FilterAnnounce gossip (all peers) | **Implemented** |
|
||||
| Filter cardinality logging | **Implemented** |
|
||||
| Mesh size estimation (OR-union of peer filters) | **Implemented** |
|
||||
|
||||
@@ -302,34 +302,6 @@ alternative — running under a dedicated unprivileged service
|
||||
account with the capability granted on the binary — see
|
||||
[../how-to/run-as-unprivileged-user.md](../how-to/run-as-unprivileged-user.md).
|
||||
|
||||
### App-Owned TUN (embedded hosts)
|
||||
|
||||
On platforms where FIPS is embedded rather than run as a daemon — notably
|
||||
Android, where the `VpnService` owns the TUN fd and the app has no
|
||||
`CAP_NET_ADMIN` — FIPS does not create `fips0` itself. Instead the embedder owns
|
||||
the fd and exchanges IPv6 packet bytes with FIPS over channels.
|
||||
|
||||
`Node::enable_app_owned_tun()` sets this up. It is called after `Node::new` and
|
||||
before `start()` (and before the node is moved into a background task), mirroring
|
||||
`control_read_handle()`, and returns two app-side channel ends:
|
||||
|
||||
- **app → mesh** — the embedder pushes IPv6 packets read from its fd into
|
||||
`app_outbound_tx`. These are drained by `run_rx_loop` into `handle_tun_outbound`
|
||||
and routed exactly as the Reader Thread's output would be.
|
||||
- **mesh → app** — inbound mesh traffic on port 256 is reconstructed and written
|
||||
to the node's `tun_tx` (the same sink the Writer Thread reads); the embedder
|
||||
pulls from `app_inbound_rx` and writes to its fd.
|
||||
|
||||
With the channels installed, `start()` skips system-TUN creation (it gates on
|
||||
`tun_tx` being unset), so FIPS does no `CAP_NET_ADMIN` operations.
|
||||
|
||||
Because packets enter via `app_outbound_tx` rather than the Reader Thread, they
|
||||
**bypass `handle_tun_packet`** — the `fd00::/8` destination filter, the ICMPv6
|
||||
Destination Unreachable for off-mesh dests (see [Reader Thread](#reader-thread)),
|
||||
and the [TUN-Side TCP MSS Clamping](#tun-side-tcp-mss-clamping). The embedder is
|
||||
therefore responsible for routing only `fd00::/8` to its TUN (so only mesh-bound
|
||||
packets arrive) and for clamping TCP MSS on outbound SYNs.
|
||||
|
||||
## Implementation Status
|
||||
|
||||
| Feature | Status |
|
||||
|
||||
@@ -9,7 +9,7 @@ the mesh self-organizes, and where forwarding decisions are made.
|
||||
|
||||
FMP manages direct peer connections over transports. When a transport delivers
|
||||
a datagram from an unknown address, FMP authenticates the sender through a
|
||||
Noise XX handshake, establishing a cryptographic link. Once authenticated, the
|
||||
Noise IK handshake, establishing a cryptographic link. Once authenticated, the
|
||||
link carries all inter-peer communication: spanning tree gossip, bloom filter
|
||||
updates, coordinate discovery, and forwarded session datagrams — all encrypted
|
||||
per-hop.
|
||||
@@ -82,7 +82,7 @@ for the encrypted frame wrapper: 16-byte outer header + 5-byte inner header +
|
||||
### Connection Lifecycle
|
||||
|
||||
For connection-oriented transports, the transport must establish the underlying
|
||||
connection before FMP can begin the Noise XX handshake. For connectionless
|
||||
connection before FMP can begin the Noise IK handshake. For connectionless
|
||||
transports, datagrams can flow immediately.
|
||||
|
||||
### Endpoint Discovery (Optional)
|
||||
@@ -94,52 +94,46 @@ through configuration.
|
||||
|
||||
## Peer Authentication
|
||||
|
||||
### Noise XX Handshake
|
||||
### Noise IK Handshake
|
||||
|
||||
Every peer connection begins with a Noise XX handshake that mutually
|
||||
Every peer connection begins with a Noise IK handshake that mutually
|
||||
authenticates both parties and establishes symmetric keys for link encryption.
|
||||
|
||||
The XX pattern is chosen because:
|
||||
The IK pattern is chosen because:
|
||||
|
||||
- **Neither side** requires prior knowledge of the other's static public key
|
||||
- The **responder** reveals its identity in msg2; the **initiator** reveals
|
||||
its identity in msg3
|
||||
- A protocol negotiation payload (version range, feature bitfield, TLV
|
||||
extensions) is appended to msg2 and msg3, enabling rolling protocol
|
||||
upgrades without additional round-trips
|
||||
- The **initiator** knows the responder's static public key from configuration
|
||||
or discovery, and sends their own static key encrypted in the first message
|
||||
- The **responder** learns the initiator's identity from the first message,
|
||||
then responds with their own ephemeral key
|
||||
|
||||
After the three-message handshake completes, both parties share symmetric
|
||||
session keys derived from three DH operations (ee, es, se). The handshake
|
||||
provides mutual authentication, forward secrecy, and identity hiding for
|
||||
both parties until they choose to reveal.
|
||||
After the two-message handshake completes, both parties share symmetric
|
||||
session keys derived from four DH operations (es, ss, ee, se). The handshake
|
||||
provides mutual authentication, forward secrecy, and identity hiding for the
|
||||
initiator.
|
||||
|
||||
### Epoch Exchange and Peer Restart Detection
|
||||
|
||||
XX handshake messages msg2 and msg3 carry an encrypted epoch payload — an
|
||||
8-byte random value generated once at node startup:
|
||||
Both IK handshake messages carry an encrypted epoch payload — an 8-byte
|
||||
random value generated once at node startup:
|
||||
|
||||
- **msg1**: Ephemeral key only (33 bytes) — no identity or epoch
|
||||
- **msg2**: Ephemeral key (33 bytes) + encrypted static key (49 bytes) +
|
||||
encrypted epoch (24 bytes) = 106 bytes base, plus negotiation payload
|
||||
- **msg3**: Encrypted static key (49 bytes) + encrypted epoch (24 bytes)
|
||||
= 73 bytes base, plus negotiation payload
|
||||
- **msg1**: Ephemeral key (33 bytes) + encrypted static key (49 bytes) +
|
||||
encrypted epoch (24 bytes) = 106 bytes total
|
||||
- **msg2**: Ephemeral key (33 bytes) + encrypted epoch (24 bytes) = 57 bytes
|
||||
total
|
||||
|
||||
The encrypted epoch (EPOCH_ENCRYPTED_SIZE = 24 bytes) consists of the
|
||||
8-byte epoch value plus a 16-byte AEAD tag.
|
||||
|
||||
Because msg1 carries no identity, restart detection is deferred: the
|
||||
initiator checks the responder's epoch after msg2, and the responder
|
||||
checks the initiator's epoch after msg3. An epoch mismatch indicates the
|
||||
peer has restarted, triggering full link re-establishment rather than
|
||||
treating the handshake as a simple reconnection. This prevents stale
|
||||
session state from persisting across restarts.
|
||||
On reconnection, each peer compares the received epoch with the previously
|
||||
stored epoch for that peer. An epoch mismatch indicates the peer has
|
||||
restarted (generated a new epoch), triggering full link re-establishment
|
||||
rather than treating the handshake as a simple reconnection. This prevents
|
||||
stale session state from persisting across restarts.
|
||||
|
||||
### Identity Binding
|
||||
|
||||
The Noise handshake binds the link to the peer's cryptographic identity.
|
||||
With XX, identity confirmation happens at different points: the initiator
|
||||
learns the responder's identity from msg2, and the responder learns the
|
||||
initiator's identity from msg3. After handshake completion:
|
||||
The Noise handshake binds the link to the peer's cryptographic identity. After
|
||||
handshake completion:
|
||||
|
||||
- The peer's public key (FIPS identity) is confirmed
|
||||
- The node_addr is computed from the public key (SHA-256, truncated to 128 bits)
|
||||
@@ -149,12 +143,8 @@ initiator's identity from msg3. After handshake completion:
|
||||
|
||||
### Reconnection
|
||||
|
||||
When a Noise XX msg1 arrives from an address that already has an
|
||||
authenticated link, FMP accepts the new handshake alongside the existing
|
||||
session. With XX, the peer's identity is not known at msg1 time — FMP can
|
||||
only detect the duplicate by transport address. Identity-based checks
|
||||
(restart detection, rekey recognition, cross-connection resolution) are
|
||||
deferred to msg3 when the initiator's identity is revealed. If the new
|
||||
When a Noise IK msg1 arrives from a peer that already has an authenticated
|
||||
link, FMP accepts the new handshake alongside the existing session. If the new
|
||||
handshake completes successfully, it replaces the old session. This handles
|
||||
legitimate reconnection (network change, process restart, NAT rebinding)
|
||||
without disrupting ongoing traffic until the new session is confirmed.
|
||||
@@ -173,7 +163,7 @@ The auto-reconnect path:
|
||||
3. If eligible, the peer is fed into the retry system with unlimited retries
|
||||
and exponential backoff (same base interval and max backoff as startup
|
||||
retries, configured via `node.retry.*`)
|
||||
4. On each retry tick, a fresh Noise XX handshake is initiated toward the
|
||||
4. On each retry tick, a fresh Noise IK handshake is initiated toward the
|
||||
peer's configured transport addresses
|
||||
|
||||
Auto-reconnect only applies to peers in the static peer list with
|
||||
@@ -183,8 +173,8 @@ config) is responsible for re-establishing the link.
|
||||
|
||||
### Handshake Message Retry
|
||||
|
||||
Both link-layer (Noise XX msg1/msg2/msg3) and session-layer (SessionSetup/
|
||||
SessionAck/SessionMsg3) handshakes use message-level retry with exponential backoff
|
||||
Both link-layer (Noise IK msg1/msg2) and session-layer (SessionSetup/
|
||||
SessionAck) handshakes use message-level retry with exponential backoff
|
||||
within the handshake timeout window. This handles packet loss on the
|
||||
underlying transport without waiting for the full handshake timeout to
|
||||
expire.
|
||||
@@ -390,14 +380,13 @@ configuration tree is documented in
|
||||
|
||||
### Mechanism
|
||||
|
||||
A rekey reuses the Noise XX pattern of the initial handshake, but the
|
||||
three messages travel over the existing link as ordinary encrypted FMP
|
||||
frames rather than as plaintext bootstrap packets. The initiator builds
|
||||
a fresh `HandshakeState`, generates msg1, and sends it through the
|
||||
current session; the responder consumes msg1, builds msg2, and replies;
|
||||
the initiator then sends msg3. After both sides have exchanged messages
|
||||
and finalised the new keys, traffic transitions from the old session to
|
||||
the new one.
|
||||
A rekey reuses the Noise IK pattern of the initial handshake, but the two
|
||||
messages travel over the existing link as ordinary encrypted FMP frames
|
||||
rather than as plaintext bootstrap packets. The initiator builds a fresh
|
||||
`HandshakeState`, generates msg1, and sends it through the current session;
|
||||
the responder consumes msg1, builds msg2, and replies. After both sides
|
||||
have exchanged messages and finalised the new keys, traffic transitions
|
||||
from the old session to the new one.
|
||||
|
||||
Cutover is signalled in-band by the **K-bit** in the FMP flags byte. Each
|
||||
side starts emitting frames under the new session with K set; on receipt
|
||||
@@ -558,9 +547,7 @@ an attacker sends invalid packets to elicit responses.
|
||||
|
||||
| Feature | Status |
|
||||
| ------- | ------ |
|
||||
| Noise XX handshake (with epoch and negotiation) | **Implemented** |
|
||||
| Protocol negotiation (version + features + TLV) | **Implemented** |
|
||||
| Node profiles (Full, NonRouting, Leaf) | **Implemented** |
|
||||
| Noise IK handshake (with epoch) | **Implemented** |
|
||||
| Peer restart detection (epoch mismatch) | **Implemented** |
|
||||
| Link encryption (ChaCha20-Poly1305) | **Implemented** |
|
||||
| Index-based session dispatch | **Implemented** |
|
||||
|
||||
@@ -182,8 +182,8 @@ The source creates a LookupRequest containing:
|
||||
- **request_id**: Unique identifier for deduplication
|
||||
- **target**: The node_addr being sought
|
||||
- **origin**: The requester's node_addr
|
||||
- **min_mtu**: Minimum transport MTU the origin requires (transit nodes
|
||||
skip peers whose link MTU is below this)
|
||||
- **origin_coords**: The requester's current tree coordinates (so the
|
||||
response can route back)
|
||||
- **TTL**: Bounds the forwarding radius
|
||||
|
||||
### Bloom-Guided Tree Routing
|
||||
@@ -269,8 +269,8 @@ the primary mechanism: each transit node looks up the `request_id` in its
|
||||
`recent_requests` table to find the peer that forwarded the original request,
|
||||
and sends the response back through that peer. This ensures the response
|
||||
follows the same path as the request. Greedy tree routing toward the
|
||||
greedy tree routing toward the origin's coordinates is used only as a
|
||||
fallback if the reverse-path entry has expired.
|
||||
`origin_coords` is used only as a fallback if the reverse-path entry has
|
||||
expired.
|
||||
|
||||
**Response-forwarded flag**: Each `recent_requests` entry tracks whether a
|
||||
response has already been forwarded for that `request_id`. If a second
|
||||
@@ -472,23 +472,16 @@ When traffic resumes:
|
||||
3. Coordinates: discovery may be needed if cache has expired
|
||||
4. SessionSetup re-warms transit caches on the new path
|
||||
|
||||
## Node Profiles
|
||||
## Leaf-Only Operation *(under development)*
|
||||
|
||||
Nodes advertise a profile during FMP negotiation (bits 0-2 of the feature
|
||||
bitfield): **Full** (default), **NonRouting**, or **Leaf**. At least one
|
||||
side of a link must be Full. Config mapping: `disable_routing: true` →
|
||||
NonRouting, `leaf_only: true` → Leaf.
|
||||
Leaf-only operation is an optimization for resource-constrained nodes
|
||||
(sensors, battery-powered devices). The core infrastructure exists (config
|
||||
flag, node constructor, bloom filter support) but is not yet enabled in
|
||||
normal operation.
|
||||
|
||||
### Non-Routing Nodes
|
||||
### Concept
|
||||
|
||||
A non-routing node participates in the spanning tree but does not forward
|
||||
transit traffic or send bloom filters. Its full peer inserts the
|
||||
non-routing node's identity as a leaf dependent. MMP report flow is gated
|
||||
by wants/provides bits negotiated during the handshake.
|
||||
|
||||
### Leaf Nodes
|
||||
|
||||
A leaf node connects to a single upstream peer that handles all routing
|
||||
A leaf-only node connects to a single upstream peer that handles all routing
|
||||
on its behalf:
|
||||
|
||||
- **No bloom filter storage or processing**: The upstream peer includes the
|
||||
@@ -511,7 +504,7 @@ The upstream peer:
|
||||
|
||||
Even as a leaf-only node, it still:
|
||||
|
||||
- Maintains its own Noise XX link session with the upstream peer (FMP layer)
|
||||
- Maintains its own Noise IK link session with the upstream peer (FMP layer)
|
||||
- Can establish end-to-end FSP sessions with arbitrary destinations
|
||||
- Has its own identity (npub, node_addr)
|
||||
|
||||
@@ -571,7 +564,7 @@ recovery).
|
||||
| Discovery originator backoff | **Implemented** |
|
||||
| Discovery transit-side rate limiting | **Implemented** |
|
||||
| Discovery response-forwarded dedup | **Implemented** |
|
||||
| Node profiles (Full, NonRouting, Leaf) | **Implemented** |
|
||||
| Leaf-only operation | Under development |
|
||||
| Link cost in parent selection (ETX) | **Implemented** |
|
||||
| Link cost in candidate ranking | **Implemented** |
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ MMP supports three modes:
|
||||
| ---- | ----------------- | ----------------- |
|
||||
| **Full** (default) | SenderReport + ReceiverReport | All metrics including RTT, loss, jitter, goodput, OWD trend |
|
||||
| **Lightweight** | ReceiverReport only | Loss (from counter gaps), jitter, OWD trend. No RTT. |
|
||||
| **Minimal** | None | CE echo flags only. No computed metrics. |
|
||||
| **Minimal** | None | Spin bit and CE echo flags only. No computed metrics. |
|
||||
|
||||
The mode is configured per layer (`node.mmp.mode` and
|
||||
`node.session_mmp.mode`).
|
||||
@@ -88,18 +88,28 @@ The session-layer bounds are higher because session reports are
|
||||
encrypted and forwarded through every transit link, so bandwidth cost
|
||||
is proportional to path length.
|
||||
|
||||
## RTT Measurement
|
||||
## Spin Bit and RTT
|
||||
|
||||
SRTT is derived exclusively from timestamp-echo in ReceiverReports
|
||||
with dwell-time compensation, applied via the Jacobson/Karels
|
||||
algorithm (RFC 6298, α = 1/8). This is the sole SRTT source at both
|
||||
layers.
|
||||
The SP (spin bit) flag in the FMP inner header follows the QUIC spin
|
||||
bit pattern: reflected on receive, toggled on send when the reflected
|
||||
value matches the last sent value. The spin bit state machine runs
|
||||
for TX reflection, but **RTT samples from the spin bit are
|
||||
discarded**. In a mesh protocol where frames are sent irregularly
|
||||
(tree announces, bloom filters, MMP reports on different timers),
|
||||
inter-frame processing delays inflate spin bit RTT measurements
|
||||
unpredictably. Timestamp-echo from ReceiverReports (with dwell-time
|
||||
compensation) is the sole SRTT source.
|
||||
|
||||
Duplicate or regressed ReceiverReports are ignored before any RTT, loss,
|
||||
goodput, or ETX update. If receiver-side dwell time exceeds the wire
|
||||
field, the report keeps its counters but sends a zero timestamp echo so
|
||||
the sender cannot form an invalid RTT sample.
|
||||
|
||||
The spin bit lives in the link-layer FMP inner header, so this
|
||||
mechanism applies to link-layer MMP only. Session-layer MMP carries
|
||||
its spin bit in the FSP encrypted inner header but uses it the same
|
||||
way: reflected for diagnostic visibility, not used for SRTT.
|
||||
|
||||
## ECN Congestion Signaling
|
||||
|
||||
The CE (Congestion Experienced) flag (bit 1 in the FMP flags byte)
|
||||
|
||||
@@ -273,7 +273,7 @@ carrying the session id, the punch socket, and the learned remote
|
||||
address. `adopt_established_traversal()` in the node lifecycle takes
|
||||
the socket, registers it with the UDP transport layer as a new
|
||||
transport instance, and calls `initiate_connection()` with the peer's
|
||||
FIPS identity as the expected remote. FMP's Noise XX handshake runs on
|
||||
FIPS identity as the expected remote. FMP's Noise IK handshake runs on
|
||||
the same socket — there is no "promote link" step between punch and
|
||||
handshake; the punch socket *is* the FMP socket.
|
||||
|
||||
@@ -382,7 +382,7 @@ semaphore and replay-cache layers downstream.
|
||||
non-default `app` value to scope visibility.
|
||||
- **Nothing about discovery bypasses FMP.** A successful punch yields
|
||||
a UDP socket with a claimed remote identity. That identity is not
|
||||
trusted until FMP's Noise XX handshake completes. A peer whose
|
||||
trusted until FMP's Noise IK handshake completes. A peer whose
|
||||
advert says "I am npub X at 1.2.3.4:5678" but whose FMP handshake
|
||||
presents a different static key is rejected at the mesh layer.
|
||||
|
||||
@@ -477,7 +477,7 @@ The TXT record carries three keys (`src/discovery/lan/mod.rs:47-55`):
|
||||
|
||||
Once per node tick, the node drains browser events and acts on them in
|
||||
`poll_lan_discovery()` (`src/node/lifecycle.rs:907`, called from
|
||||
`src/node/dataplane/rx_loop.rs:266`). For each discovered peer it finds
|
||||
`src/node/handlers/rx_loop.rs:266`). For each discovered peer it finds
|
||||
a UDP transport whose family matches the peer address, parses the
|
||||
`npub` into a `PeerIdentity`, skips peers it is already connected to or
|
||||
currently connecting to, and otherwise initiates a connection.
|
||||
@@ -584,7 +584,7 @@ beyond the shared scope fallback.
|
||||
- [fips-transport-layer.md](fips-transport-layer.md) — UDP, TCP, and
|
||||
Tor transport mechanics; the punch socket is adopted as a normal
|
||||
UDP transport after handoff.
|
||||
- [fips-mesh-layer.md](fips-mesh-layer.md) — FMP Noise XX handshake
|
||||
- [fips-mesh-layer.md](fips-mesh-layer.md) — FMP Noise IK handshake
|
||||
that runs on the adopted socket.
|
||||
- [port-advertisement-and-nat-traversal.md](port-advertisement-and-nat-traversal.md)
|
||||
— generic protocol reference (event tags, NIP usage, on-the-wire
|
||||
|
||||
@@ -80,24 +80,27 @@ patterns.
|
||||
## Noise Protocol Framework
|
||||
|
||||
FIPS uses the [Noise Protocol Framework](https://noiseprotocol.org/)
|
||||
at both protocol layers with the **Noise XX** handshake pattern. XX
|
||||
requires no prior knowledge of the peer's static key — both
|
||||
identities are revealed during a three-message handshake (responder
|
||||
in msg2, initiator in msg3). This enables anonymous peer discovery on
|
||||
shared-media transports and allows a protocol negotiation payload to
|
||||
be exchanged alongside the handshake, supporting rolling protocol
|
||||
upgrades without extra round trips.
|
||||
[WireGuard](https://www.wireguard.com/) uses the related IK pattern
|
||||
for VPN tunnels;
|
||||
[Lightning Network](https://github.com/lightning/bolts/blob/master/08-transport.md)
|
||||
uses XK for transport encryption.
|
||||
at both protocol layers, with different handshake patterns chosen for
|
||||
each layer's threat model. FMP link encryption uses **Noise IK**,
|
||||
providing mutual authentication with a single round trip where the
|
||||
initiator knows the responder's static key in advance.
|
||||
[WireGuard](https://www.wireguard.com/) uses the same IK base pattern
|
||||
(extended with a pre-shared key as IKpsk2) for VPN tunnels. FSP
|
||||
session encryption uses **Noise XK**, the same pattern used by the
|
||||
[Lightning Network](https://github.com/lightning/bolts/blob/master/08-transport.md),
|
||||
where the initiator's static key is transmitted in a third message
|
||||
rather than the first. XK provides stronger initiator identity hiding
|
||||
at the cost of an additional round trip — a worthwhile tradeoff for
|
||||
session-layer traffic that traverses untrusted intermediate nodes. At
|
||||
the link layer, where both peers are configured and directly
|
||||
connected, IK's single round trip is preferred.
|
||||
|
||||
Specific Noise references and adapted constructions:
|
||||
|
||||
- Perrin, T. ["The Noise Protocol Framework"](https://noiseprotocol.org/noise.html).
|
||||
Revision 34, 2018. *Framework for building crypto protocols using
|
||||
Diffie-Hellman key agreement and AEAD ciphers. FIPS uses the XX
|
||||
handshake pattern at both layers.*
|
||||
Diffie-Hellman key agreement and AEAD ciphers. FSP uses the XK
|
||||
handshake pattern.*
|
||||
|
||||
- Donenfeld, J.A. ["WireGuard: Next Generation Kernel Network Tunnel"](https://www.wireguard.com/papers/wireguard.pdf).
|
||||
NDSS 2017. *Transport-independent cryptographic sessions bound to
|
||||
@@ -153,6 +156,14 @@ computation used in TCP for retransmission timeout calculation since
|
||||
1988. MMP derives RTT from timestamp-echo in ReceiverReports with
|
||||
dwell-time compensation, rather than from packet round-trips.
|
||||
|
||||
The spin bit in the FMP frame header follows the
|
||||
[QUIC](https://www.rfc-editor.org/rfc/rfc9000) spin bit
|
||||
([RFC 9312](https://www.rfc-editor.org/rfc/rfc9312)) — a single bit
|
||||
that alternates each round trip, enabling passive latency measurement.
|
||||
FIPS implements the spin bit state machine but relies on
|
||||
timestamp-echo for SRTT, as irregular mesh traffic makes spin bit RTT
|
||||
unreliable.
|
||||
|
||||
The Expected Transmission Count (ETX) metric, computed from
|
||||
bidirectional delivery ratios, was introduced by
|
||||
[De Couto et al. (2003)](https://pdos.csail.mit.edu/papers/grid:mobicom03/paper.pdf)
|
||||
@@ -314,10 +325,11 @@ The protocol builds on these foundations and adds several new elements:
|
||||
| [HIP](https://en.wikipedia.org/wiki/Host_Identity_Protocol) | identity-as-address |
|
||||
| [Babel](https://www.irif.fr/~jch/software/babel/) | split-horizon, ETX |
|
||||
| [RIP](https://en.wikipedia.org/wiki/Routing_Information_Protocol) | split-horizon |
|
||||
| [Noise Framework](https://noiseprotocol.org/) | FMP and FSP XX handshakes |
|
||||
| [WireGuard](https://www.wireguard.com/) | receiver-index dispatch, identity-bound sessions |
|
||||
| [Lightning BOLT #8](https://github.com/lightning/bolts/blob/master/08-transport.md) | comparison reference |
|
||||
| [QUIC (RFC 9000)](https://www.rfc-editor.org/rfc/rfc9000) | transport design |
|
||||
| [Noise Framework](https://noiseprotocol.org/) | FMP IK, FSP XK |
|
||||
| [WireGuard](https://www.wireguard.com/) | IK pattern, receiver-index dispatch, identity-bound sessions |
|
||||
| [Lightning BOLT #8](https://github.com/lightning/bolts/blob/master/08-transport.md) | XK pattern |
|
||||
| [QUIC (RFC 9000)](https://www.rfc-editor.org/rfc/rfc9000) | spin bit, transport design |
|
||||
| [QUIC Spin Bit (RFC 9312)](https://www.rfc-editor.org/rfc/rfc9312) | passive RTT measurement |
|
||||
| [RTCP (RFC 3550)](https://www.rfc-editor.org/rfc/rfc3550) | sender/receiver report structure, jitter algorithm |
|
||||
| [TCP SRTT/RTO (RFC 6298)](https://www.rfc-editor.org/rfc/rfc6298) | Jacobson/Karels SRTT |
|
||||
| [ECN (RFC 3168)](https://www.rfc-editor.org/rfc/rfc3168) | CE echo |
|
||||
|
||||
@@ -17,8 +17,8 @@ you can deliver packets to your `fips0` address — your direct peers
|
||||
forward traffic from non-peer mesh nodes onto your `fips0` the same
|
||||
way any router forwards transit traffic. Identity on the mesh is the
|
||||
originating node's npub — the FMP link layer authenticates direct
|
||||
peers with Noise XX and the FSP session layer authenticates session
|
||||
endpoints with Noise XX — but identity is **not** authorization.
|
||||
peers with Noise IK and the FSP session layer authenticates session
|
||||
endpoints with Noise XK — but identity is **not** authorization.
|
||||
Knowing who sent a packet does not, by itself, decide whether the
|
||||
local host should accept it.
|
||||
|
||||
@@ -149,7 +149,7 @@ explicitly not:
|
||||
originating mesh node's npub is allowed to use that service. That
|
||||
is the application's responsibility (e.g., an `authorized_keys`
|
||||
file for SSH, an ACL in the application's configuration).
|
||||
- **ACL on the mesh handshake.** The FMP Noise XX handshake
|
||||
- **ACL on the mesh handshake.** The FMP Noise IK handshake
|
||||
authenticates the peer's npub and, on both inbound and outbound
|
||||
paths, consults the peer ACL (`peers.allow` / `peers.deny`) before
|
||||
promoting the connection. The ACL evaluates in TCP-Wrappers order:
|
||||
|
||||
@@ -120,29 +120,25 @@ node, FMP delivers it to FSP for session-layer processing.
|
||||
Sessions are established on demand when the first datagram needs to be sent to
|
||||
a destination with no existing session.
|
||||
|
||||
FSP uses Noise XX for session key agreement (Noise Protocol Framework;
|
||||
Perrin 2018). Neither side requires prior knowledge of the other's
|
||||
static key — both identities are revealed during the handshake
|
||||
(responder in msg2, initiator in msg3). An optional protocol negotiation
|
||||
payload may be appended to msg2/msg3 (omitted for rekey handshakes).
|
||||
FSP uses Noise XK for session key agreement (Noise Protocol Framework;
|
||||
Perrin 2018). The initiator knows the destination's npub (required for
|
||||
XK's pre-message `s` token); the responder learns the initiator's
|
||||
identity from msg3 (not msg1, unlike IK at the link layer). This
|
||||
provides stronger initiator identity hiding — the initiator's static
|
||||
key is encrypted under the established shared secret rather than under
|
||||
only the responder's static key.
|
||||
|
||||
The handshake is a three-message flow carried in SessionSetup, SessionAck,
|
||||
and SessionMsg3:
|
||||
|
||||
1. **Initiator** sends SessionSetup containing Noise XX msg1 (ephemeral key
|
||||
1. **Initiator** sends SessionSetup containing Noise XK msg1 (ephemeral key
|
||||
only) and both parties' tree coordinates
|
||||
2. **Responder** processes msg1, sends SessionAck containing Noise XX msg2
|
||||
(ephemeral key + encrypted static key + encrypted epoch) and both
|
||||
parties' tree coordinates. The responder transitions to AwaitingMsg3
|
||||
state.
|
||||
3. **Initiator** processes msg2 (learning the responder's identity), sends
|
||||
SessionMsg3 containing its encrypted static key and encrypted epoch.
|
||||
The responder learns the initiator's identity from msg3. Both parties
|
||||
derive identical symmetric session keys and the session is established.
|
||||
|
||||
Post-handshake identity verification uses x-only key comparison
|
||||
(parity-independent) to confirm the revealed identity matches the
|
||||
expected npub.
|
||||
2. **Responder** processes msg1, sends SessionAck containing Noise XK msg2
|
||||
(ephemeral key + encrypted epoch) and both parties' tree coordinates.
|
||||
The responder transitions to AwaitingMsg3 state.
|
||||
3. **Initiator** processes msg2, sends SessionMsg3 containing the encrypted
|
||||
static key and encrypted epoch. Both parties derive identical symmetric
|
||||
session keys and the session is established.
|
||||
|
||||
Each side's epoch (an 8-byte random value generated at startup) is
|
||||
exchanged encrypted in msg2 and msg3. On subsequent handshakes, an epoch
|
||||
@@ -223,30 +219,29 @@ than network addresses. A session survives:
|
||||
|
||||
## End-to-End Encryption
|
||||
|
||||
### Noise XX Pattern
|
||||
### Noise XK Pattern
|
||||
|
||||
FSP uses the same Noise XX pattern as the link layer (FMP). The full
|
||||
Noise descriptor is `Noise_XX_secp256k1_ChaChaPoly_SHA256`.
|
||||
FSP uses Noise XK for session encryption, distinct from the Noise IK
|
||||
pattern used at the link layer. The full Noise descriptor is
|
||||
`Noise_XK_secp256k1_ChaChaPoly_SHA256`.
|
||||
|
||||
The XX pattern (no pre-message):
|
||||
The XK pattern (pre-message: `← s`):
|
||||
|
||||
- **msg1** (`→ e`): Initiator sends ephemeral key only. No identity
|
||||
disclosed, no DH with static keys.
|
||||
- **msg2** (`← e, ee, s, es`): Responder sends ephemeral key, encrypted
|
||||
static key, and encrypted epoch. The initiator learns the responder's
|
||||
identity.
|
||||
- **msg1** (`→ e, es`): Initiator sends ephemeral key only. The initiator's
|
||||
static identity is not revealed in this message.
|
||||
- **msg2** (`← e, ee`): Responder sends ephemeral key and encrypted epoch.
|
||||
- **msg3** (`→ s, se`): Initiator sends encrypted static key and encrypted
|
||||
epoch. The responder learns the initiator's identity. Both parties now
|
||||
share identical session keys.
|
||||
epoch. Both parties now share identical session keys.
|
||||
|
||||
After the handshake, Noise produces two directional symmetric keys
|
||||
(`send_key`, `recv_key`) used with ChaCha20-Poly1305 for all subsequent
|
||||
data.
|
||||
(`send_key`, `recv_key`) used with ChaCha20-Poly1305 for all subsequent data.
|
||||
|
||||
XX requires no prior knowledge of the peer's static key. The initiator
|
||||
still needs the destination's npub to address the SessionSetup, but the
|
||||
Noise handshake itself does not depend on it — identity is verified
|
||||
post-handshake by comparing the revealed key against the expected npub.
|
||||
The XK pattern requires the initiator to know the responder's static key
|
||||
in advance (the `← s` pre-message), which is satisfied by the discovery
|
||||
or DNS lookup that precedes session establishment. In exchange, XK
|
||||
provides stronger initiator identity protection than IK — the initiator's
|
||||
static key is encrypted under the full shared secret (after three DH
|
||||
operations) rather than under only the responder's static key.
|
||||
|
||||
### Cryptographic Primitives
|
||||
|
||||
@@ -256,27 +251,26 @@ primitive table shared with the link layer.
|
||||
|
||||
### secp256k1 Parity Normalization
|
||||
|
||||
Nostr npubs encode x-only public keys (32 bytes, no y-coordinate parity).
|
||||
When the Noise XX handshake reveals a peer's static key via
|
||||
`public_key().serialize()`, the key has its actual parity (0x02 or 0x03
|
||||
prefix). The default secp256k1 ECDH hash also includes a parity-dependent
|
||||
version byte.
|
||||
Nostr npubs encode x-only public keys (32 bytes, no y-coordinate parity). The
|
||||
Noise XK pre-message mixes the responder's static key as a 33-byte compressed
|
||||
key, and the default secp256k1 ECDH hash includes a parity-dependent version
|
||||
byte.
|
||||
|
||||
Both operations are normalized to be parity-independent: ECDH hashes only
|
||||
the x-coordinate of the result point, and post-handshake identity
|
||||
verification uses `x_only_public_key()` to strip parity before comparing
|
||||
against the expected npub. This ensures handshakes and identity checks
|
||||
succeed regardless of key parity.
|
||||
Both operations are normalized to be parity-independent: the pre-message hash
|
||||
uses even parity (`0x02` prefix), and ECDH hashes only the x-coordinate of the
|
||||
result point. This ensures handshakes succeed regardless of the responder's
|
||||
actual key parity.
|
||||
|
||||
### Privacy Note
|
||||
|
||||
Noise XX provides mutual identity protection — both the initiator's and
|
||||
responder's static keys are encrypted under the evolving shared secret
|
||||
(derived from DH operations completed in earlier messages). An attacker
|
||||
who compromises only one side's nsec cannot decrypt the other side's
|
||||
identity from captured handshake messages without also obtaining the
|
||||
corresponding ephemeral key. Since session-layer traffic traverses
|
||||
untrusted intermediate nodes, this mutual identity hiding is valuable.
|
||||
Noise XK provides stronger initiator identity protection than IK. In XK, the
|
||||
initiator's static key is encrypted in msg3 under the full shared secret
|
||||
(derived from three DH operations), so an attacker who compromises only the
|
||||
responder's nsec cannot decrypt the initiator's identity from captured
|
||||
handshake messages (they would also need the responder's ephemeral key).
|
||||
This is the primary reason FSP uses XK rather than IK — session-layer
|
||||
traffic traverses untrusted intermediate nodes, making initiator identity
|
||||
protection more valuable than at the link layer.
|
||||
|
||||
### Data Packet Authentication
|
||||
|
||||
@@ -467,7 +461,7 @@ reactive PMTUD mechanism.
|
||||
|
||||
| Feature | Status |
|
||||
| ------- | ------ |
|
||||
| Session establishment (Noise XX) | **Implemented** |
|
||||
| Session establishment (Noise XK) | **Implemented** |
|
||||
| Peer restart detection (epoch exchange) | **Implemented** |
|
||||
| MtuExceeded handling | **Implemented** |
|
||||
| End-to-end encryption (ChaCha20-Poly1305) | **Implemented** |
|
||||
@@ -503,7 +497,7 @@ reactive PMTUD mechanism.
|
||||
- [fips-mmp.md](fips-mmp.md) — Metrics Measurement Protocol (link + session)
|
||||
- [fips-mtu.md](fips-mtu.md) — Path MTU model (PathMtuNotification,
|
||||
MtuExceeded, hysteresis)
|
||||
- [fips-prior-work.md](fips-prior-work.md) — Noise XX, WireGuard,
|
||||
- [fips-prior-work.md](fips-prior-work.md) — Noise XK, WireGuard,
|
||||
DTLS replay window, IKEv2 simultaneous initiation, hybrid coordinate
|
||||
warmup citations
|
||||
- [../reference/wire-formats.md](../reference/wire-formats.md) — Wire
|
||||
@@ -513,23 +507,6 @@ reactive PMTUD mechanism.
|
||||
|
||||
### External References
|
||||
|
||||
- Perrin, T. ["The Noise Protocol Framework"](https://noiseprotocol.org/noise.html).
|
||||
Revision 34, 2018. *Framework for building crypto protocols using Diffie-Hellman
|
||||
key agreement and AEAD ciphers. FSP uses the XX handshake pattern.*
|
||||
|
||||
- Donenfeld, J.A. ["WireGuard: Next Generation Kernel Network Tunnel"](https://www.wireguard.com/papers/wireguard.pdf).
|
||||
NDSS 2017. *Transport-independent cryptographic sessions bound to identity keys
|
||||
rather than network addresses; AEAD-only authentication model.*
|
||||
|
||||
- Rescorla, E., Modadugu, N. [RFC 6347](https://datatracker.ietf.org/doc/html/rfc6347):
|
||||
"Datagram Transport Layer Security Version 1.2". 2012. *Explicit sequence numbers
|
||||
with sliding bitmap window for replay protection over unreliable transports.*
|
||||
|
||||
- Kaufman, C., Hoffman, P., Nir, Y., Eronen, P., Kivinen, T.
|
||||
[RFC 7296](https://datatracker.ietf.org/doc/html/rfc7296):
|
||||
"Internet Key Exchange Protocol Version 2 (IKEv2)". 2014. *Simultaneous
|
||||
initiation resolution (§2.8) and INITIAL_CONTACT peer restart detection (§2.4).*
|
||||
|
||||
- Mogul, J., Deering, S. [RFC 1191](https://datatracker.ietf.org/doc/html/rfc1191):
|
||||
"Path MTU Discovery". 1990. *End-to-end path MTU discovery; FSP adapts this for
|
||||
overlay networks using transit-node min() propagation.*
|
||||
|
||||
@@ -171,7 +171,7 @@ When a node receives a TreeAnnounce from peer P:
|
||||
|
||||
1. **Validate version**: Reject if version ≠ 0x01
|
||||
2. **Verify signature**: Check P's declaration signature using P's known
|
||||
public key (established during Noise XX handshake)
|
||||
public key (established during Noise IK handshake)
|
||||
3. **Verify identity**: Confirm the declaration's node_addr matches the
|
||||
sender's known identity
|
||||
4. **Check freshness**: If `sequence ≤ stored sequence for P`, discard
|
||||
|
||||
@@ -18,7 +18,7 @@ to the FIPS Mesh Protocol (FMP) above.
|
||||
The transport layer deals exclusively in **transport addresses** — IP:port
|
||||
or hostname:port addresses, MAC addresses, .onion identifiers, radio device addresses. These are
|
||||
opaque to every layer above FMP. The mapping from transport address to FIPS
|
||||
identity happens at the link layer after the Noise XX link handshake completes.
|
||||
identity happens at the link layer after the Noise IK link handshake completes.
|
||||
The word "peer" belongs to the link layer and above; the transport layer
|
||||
knows only about remote endpoints identified by transport addresses.
|
||||
|
||||
@@ -71,7 +71,7 @@ forwarding and LookupResponse transit annotation.
|
||||
|
||||
For connection-oriented transports, manage the underlying connection: TCP
|
||||
handshake, Tor circuit establishment, BLE pairing. FMP cannot begin
|
||||
the Noise XX link handshake until the transport-layer connection is
|
||||
the Noise IK link handshake until the transport-layer connection is
|
||||
established.
|
||||
|
||||
Connection-oriented transports expose a non-blocking connect interface.
|
||||
@@ -154,7 +154,7 @@ and duplication at the routing layer.
|
||||
|
||||
**Connection model**: Connectionless transports (UDP, raw Ethernet) allow
|
||||
immediate datagram exchange. Connection-oriented transports (TCP, Tor, BLE)
|
||||
require connection setup before FMP can begin the Noise XX link handshake,
|
||||
require connection setup before FMP can begin the Noise IK link handshake,
|
||||
adding startup latency.
|
||||
|
||||
**Stream vs. datagram**: Datagram transports have natural packet boundaries.
|
||||
@@ -262,7 +262,7 @@ UDP (1500 vs 1472 MTU).
|
||||
- **No IP dependency**: Operates below the IP layer. Nodes on the same
|
||||
Ethernet segment can communicate without IP addresses or routing
|
||||
infrastructure
|
||||
- **Broadcast neighbor detection**: Nodes discover each other via periodic beacon
|
||||
- **Broadcast discovery**: Nodes discover each other via periodic beacon
|
||||
broadcasts on the shared medium, with no static peer configuration required
|
||||
- **Higher MTU**: Standard Ethernet frames carry 1500 bytes of payload,
|
||||
yielding an effective FIPS MTU of 1499 after the frame type prefix
|
||||
@@ -276,30 +276,32 @@ EtherType 0x2121. SOCK_DGRAM mode
|
||||
lets the kernel handle Ethernet header construction and parsing — the
|
||||
transport deals only with payloads and MAC addresses.
|
||||
|
||||
All frames use a unified 4-byte header: `[type:1][flags:1][length:2 LE]`.
|
||||
The length field allows the receiver to trim Ethernet minimum-frame padding
|
||||
that would otherwise corrupt AEAD verification. Frame types: `0x00` (data),
|
||||
`0x01` (beacon). Beacons and data share the same EtherType and socket.
|
||||
Data frames use a 3-byte header: a 1-byte frame type (`0x00`) followed by
|
||||
a 2-byte little-endian payload length. The length field allows the receiver
|
||||
to trim Ethernet minimum-frame padding that would otherwise corrupt AEAD
|
||||
verification. Beacon frames (`0x01`) use only the 1-byte type prefix
|
||||
(fixed 34-byte payload). Beacons and data share the same EtherType and
|
||||
socket.
|
||||
|
||||
| Property | Value |
|
||||
| -------- | ----- |
|
||||
| EtherType | 0x2121 |
|
||||
| Socket type | AF_PACKET SOCK_DGRAM |
|
||||
| Frame header | `[type:1][flags:1][length:2 LE][payload]` |
|
||||
| Effective MTU | Interface MTU - 4 (typically 1496) |
|
||||
| Data frame header | `[type:1][length:2 LE][payload]` |
|
||||
| Beacon frame header | `[type:1][payload]` (fixed 34 bytes) |
|
||||
| Effective MTU | Interface MTU - 3 (typically 1497) |
|
||||
| Addressing | 6-byte MAC address |
|
||||
| Platform | Linux only (`CAP_NET_RAW` required) |
|
||||
|
||||
### Neighbor Beacons
|
||||
### Beacon Discovery
|
||||
|
||||
Ethernet nodes discover peers via broadcast beacons sent to
|
||||
ff:ff:ff:ff:ff:ff. Beacons are minimal 5-byte frames (4-byte header +
|
||||
1-byte beacon type) — no public key is included. The peer's identity is
|
||||
learned from the Noise XX handshake after the connection is established.
|
||||
Receiving nodes extract the MAC source address from the frame and report
|
||||
the discovered address to FMP.
|
||||
ff:ff:ff:ff:ff:ff. Each beacon is a 34-byte frame containing the sender's
|
||||
x-only public key. Receiving nodes extract the MAC source address from the
|
||||
frame and the public key from the payload, then report the discovered peer
|
||||
to FMP.
|
||||
|
||||
Four configuration flags control neighbor behavior — `listen`
|
||||
Four configuration flags control discovery behavior — `discovery`
|
||||
(listen for beacons), `announce` (broadcast beacons), `auto_connect`
|
||||
(initiate handshakes to discovered peers), and `accept_connections`
|
||||
(accept inbound handshakes). The flag table and per-flag defaults
|
||||
@@ -308,13 +310,13 @@ under `transports.ethernet.*`.
|
||||
|
||||
A typical discoverable node sets `announce`, `auto_connect`, and
|
||||
`accept_connections` all true. A passive listener uses just
|
||||
`listen: true` to observe the network without announcing itself.
|
||||
`discovery: true` to observe the network without announcing itself.
|
||||
|
||||
### WiFi Compatibility
|
||||
|
||||
WiFi interfaces in infrastructure (managed) mode work transparently for
|
||||
unicast — the mac80211 subsystem handles frame translation between 802.11
|
||||
and 802.3. Broadcast neighbor detection is unreliable in managed mode because
|
||||
and 802.3. Broadcast beacon discovery is unreliable in managed mode because
|
||||
access points commonly isolate clients from each other's broadcast traffic.
|
||||
|
||||
Startup logging:
|
||||
@@ -382,7 +384,7 @@ every tick, `poll_pending_connects()` calls `connection_state(addr)` to
|
||||
check progress. When the transport reports `Connected`, the completed
|
||||
connection is promoted to the established pool (stream split into
|
||||
read/write halves, per-connection receive task spawned), and the node
|
||||
initiates the Noise XX link handshake. If the transport reports `Failed`,
|
||||
initiates the Noise IK link handshake. If the transport reports `Failed`,
|
||||
the node schedules a retry with exponential backoff.
|
||||
|
||||
As a fallback, `send(addr, data)` still performs synchronous
|
||||
@@ -420,7 +422,7 @@ socket is created).
|
||||
The Tor transport routes FIPS traffic through the Tor network, hiding
|
||||
a node's IP address from its peers. A node behind Tor connects outbound
|
||||
through a local Tor SOCKS5 proxy; the remote peer sees the Tor exit
|
||||
node's IP, not the initiator's. After the Noise XX handshake, the remote
|
||||
node's IP, not the initiator's. After the Noise IK handshake, the remote
|
||||
peer knows the initiator's FIPS identity (npub) but not its network
|
||||
location.
|
||||
|
||||
@@ -501,7 +503,7 @@ The inbound accept loop mirrors the TCP transport's pattern: accept
|
||||
connection, configure socket (TCP_NODELAY, keepalive), spawn a
|
||||
per-connection receive loop using the shared FMP stream reader. Inbound
|
||||
connections arrive from `127.0.0.1` (Tor daemon's local forwarding); peer
|
||||
identity is resolved during the Noise XX handshake, not from the transport
|
||||
identity is resolved during the Noise IK handshake, not from the transport
|
||||
address.
|
||||
|
||||
Configuration requires coordinating `torrc` and `fips.yaml`. The
|
||||
@@ -893,7 +895,7 @@ transitions through `Starting` to `Up` (operational). `stop()` moves to
|
||||
| --------- | ------ | ----- |
|
||||
| UDP/IP | **Implemented** | Primary transport, AsyncFd/recvmsg, SO_RXQ_OVFL kernel drop detection |
|
||||
| TCP/IP | **Implemented** | FMP header-based framing, non-blocking connect, per-connection MSS MTU |
|
||||
| Ethernet | **Implemented** | AF_PACKET SOCK_DGRAM, EtherType 0x2121, neighbor beacons, Linux only |
|
||||
| Ethernet | **Implemented** | AF_PACKET SOCK_DGRAM, EtherType 0x2121, beacon discovery, Linux only |
|
||||
| WiFi | **Implemented** (via Ethernet transport, infrastructure mode) | mac80211 translates 802.11↔802.3; broadcast beacons unreliable through APs |
|
||||
| Tor | **Implemented** | Outbound SOCKS5, inbound via onion service, .onion and clearnet addressing |
|
||||
| Nym | **Implemented** | Outbound-only SOCKS5 through nym-socks5-client, mixnet anonymity, IP/hostname addressing |
|
||||
|
||||
@@ -457,7 +457,7 @@ authentication or encryption**. The application layer is responsible
|
||||
for establishing its own security on the punched channel — for
|
||||
example, a Noise Protocol handshake keyed from the Nostr identity,
|
||||
or an application-specific authenticated-encryption layer. FIPS
|
||||
runs its FMP Noise XX handshake immediately after adoption; the
|
||||
runs its FMP Noise IK handshake immediately after adoption; the
|
||||
identity proven by the Noise handshake is the same Nostr pubkey
|
||||
that signed the inner offer/answer rumour, so a man-in-the-middle on
|
||||
the relay cannot impersonate the responder.
|
||||
|
||||
@@ -88,23 +88,6 @@ See [packaging/README.md](../packaging/README.md) for per-format
|
||||
build details, cross-target options, and the full `make` target
|
||||
list.
|
||||
|
||||
### With Nix (flake)
|
||||
|
||||
On Nix/NixOS, a [flake](../flake.nix) at the project root builds the
|
||||
binaries from source with the pinned toolchain and no manual
|
||||
prerequisite install:
|
||||
|
||||
```sh
|
||||
nix build .#fips # all four binaries, into ./result/bin
|
||||
nix develop # dev shell with the toolchain + build deps
|
||||
```
|
||||
|
||||
This path produces binaries only — it does not run the installer, so
|
||||
there are no systemd units, no `fips` group, and no default `fips.yaml`.
|
||||
On NixOS, wire the daemon in through your system configuration using the
|
||||
flake's `packages.<system>.fips` output instead. See the Nix / NixOS
|
||||
section of [packaging/README.md](../packaging/README.md).
|
||||
|
||||
## What's installed and running
|
||||
|
||||
Here's what the installer leaves on your machine, what's
|
||||
|
||||
@@ -25,6 +25,4 @@ X" to "X is done".
|
||||
| [persistent-identity.md](persistent-identity.md) | Provision a stable Nostr keypair so the node keeps the same npub across restarts |
|
||||
| [host-aliases.md](host-aliases.md) | Use shortnames (`test-us01.fips`, `my-laptop.fips`) instead of full npubs by editing `/etc/fips/hosts` or setting peer aliases |
|
||||
| [set-up-bluetooth-peer.md](set-up-bluetooth-peer.md) | Configure a Bluetooth Low Energy peer link |
|
||||
| [set-up-80211s-mesh-backhaul.md](set-up-80211s-mesh-backhaul.md) | Link OpenWrt FIPS routers over an open 802.11s radio backhaul (FIPS provides encryption, authentication, and routing) |
|
||||
| [set-up-open-access-ssid.md](set-up-open-access-ssid.md) | Broadcast the open `!FIPS` access SSID so phones and laptops roam onto the mesh (one ESS: save once, roam every FIPS router) |
|
||||
| [diagnose-mtu-issues.md](diagnose-mtu-issues.md) | Triage MTU-shaped failures and rule out their imposters (bufferbloat, transport saturation) |
|
||||
|
||||
@@ -197,7 +197,7 @@ What this achieves: the node publishes a `udp:nat` endpoint plus its
|
||||
signaling relays in the advert. When either side initiates, an
|
||||
encrypted offer is sealed to the peer's npub, a matching answer
|
||||
comes back, and both sides punch at the negotiated time. On success,
|
||||
the punch socket is adopted as an FMP UDP transport and Noise XX
|
||||
the punch socket is adopted as an FMP UDP transport and Noise IK
|
||||
proceeds normally.
|
||||
|
||||
> **Validation:** `advertise_on_nostr: true` with `public: false` on
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
# Set Up an 802.11s Mesh Backhaul (OpenWrt)
|
||||
|
||||
Link FIPS routers over radio — no cables, no APs, no shared
|
||||
infrastructure — by running the Ethernet transport on an open 802.11s
|
||||
mesh interface. The radio layer provides nothing but L2 frames to
|
||||
direct neighbors; FIPS provides everything else: encryption and
|
||||
authentication (the Noise handshake), peer discovery (Ethernet beacons), and
|
||||
routing (the spanning tree).
|
||||
|
||||
For the transport design, see
|
||||
[../design/fips-transport-layer.md](../design/fips-transport-layer.md).
|
||||
For all `transports.ethernet.*` configuration keys, see
|
||||
[../reference/configuration.md](../reference/configuration.md).
|
||||
|
||||
## Why open, why forwarding off
|
||||
|
||||
Two deliberate choices distinguish this from a stock 802.11s setup:
|
||||
|
||||
- **`encryption none`** — the mesh is open on purpose. Every FIPS peer
|
||||
link is already authenticated and encrypted by the Noise
|
||||
handshake, so SAE at L2 would duplicate that work, add a shared
|
||||
credential to provision across routers, and (on ath10k) force the
|
||||
firmware into its slower raw Tx/Rx mode. A stranger can form an
|
||||
802.11s peering with your router *and* a FIPS peer link on top of it —
|
||||
the same open model as mDNS and BLE discovery, where the advert is
|
||||
only a hint and the handshake authenticates each link (no
|
||||
impersonation, no MITM) rather than gating who may peer. Admission is
|
||||
open up to the daemon's max-peers cap. What you concede: any nearby
|
||||
radio can peer and reach the FIPS overlay surface; L2 metadata (MAC
|
||||
addresses, frame sizes) is visible in the air; a hostile radio can
|
||||
burn airtime — all inherent to an open radio link.
|
||||
- **`mesh_fwding 0`** — disables 802.11s's own HWMP routing so each
|
||||
mesh link is a plain neighbor link. FIPS is the routing layer; two
|
||||
routing layers would fight, and broadcast discovery beacons would
|
||||
flood the whole mesh instead of reaching direct neighbors only.
|
||||
|
||||
The interface is **not** bridged into `br-lan` — the FIPS Ethernet
|
||||
transport binds it directly.
|
||||
|
||||
## When to use
|
||||
|
||||
- Two or more OpenWrt FIPS routers within radio range of each other,
|
||||
where running cable is impractical.
|
||||
- You want the mesh segment to keep working with zero shared
|
||||
credentials or per-site configuration ("flash and drop in").
|
||||
|
||||
It is **not** for connecting phones or laptops — client devices
|
||||
cannot join an 802.11s mesh. They enter the mesh through a normal AP
|
||||
on the same router (see constraints below), or over BLE.
|
||||
|
||||
## Requirements
|
||||
|
||||
- OpenWrt 22.03+ with the FIPS package installed.
|
||||
- A radio whose driver supports mesh point interfaces. Check with:
|
||||
|
||||
```sh
|
||||
iw list | grep -A 10 "Supported interface modes" | grep "mesh point"
|
||||
```
|
||||
|
||||
The mainstream OpenWrt chips (ath9k, ath10k, mt76) all qualify.
|
||||
- Ideally a dual- or tri-band router, so one band can be dedicated to
|
||||
the backhaul (see constraints).
|
||||
|
||||
## Step 1 — create the mesh interface(s)
|
||||
|
||||
On **each** router, run the helper once per radio you want in the
|
||||
backhaul:
|
||||
|
||||
```sh
|
||||
fips-mesh-setup radio1
|
||||
```
|
||||
|
||||
This creates an open 802.11s interface with mesh ID `fips-mesh` and
|
||||
HWMP forwarding off, attaches it to an unmanaged netifd interface (no
|
||||
IP configuration — none is needed), uncomments the matching `meshN`
|
||||
transport entry in `/etc/fips/fips.yaml` (see Step 2), and reloads the
|
||||
radio. Interfaces are named by radio index: `radio0` → `fips-mesh0`,
|
||||
`radio1` → `fips-mesh1`. Pass a second argument to use a different
|
||||
mesh ID.
|
||||
|
||||
Note: the helper runs `wifi reload`, which re-applies the whole
|
||||
wireless config and so briefly drops every client AP on all radios for
|
||||
a few seconds. `fips-mesh-setup remove` reloads the same way. Expect
|
||||
the blip if clients are connected.
|
||||
|
||||
On dual-band routers, meshing **both** bands is worth it: 2.4 GHz
|
||||
reaches further at lower rates, 5 GHz carries more over shorter
|
||||
links. Note this is **failover, not multipath**: FIPS keeps one
|
||||
active link per peer, so traffic uses one band at a time — the other
|
||||
is a standby that re-establishes the peer if the active link dies
|
||||
(detection via keepalive timeout, so a cutover takes seconds, not
|
||||
milliseconds):
|
||||
|
||||
```sh
|
||||
fips-mesh-setup radio0
|
||||
fips-mesh-setup radio1
|
||||
```
|
||||
|
||||
**Pin the same channel on every backhaul router, per band.** Mesh
|
||||
points only peer on the same channel, and the mesh inherits whatever
|
||||
the radio is set to — with `channel 'auto'` (the default on many
|
||||
devices) each router picks its own and the mesh silently never forms.
|
||||
The script prints the radio's current band and channel and warns on
|
||||
`auto`:
|
||||
|
||||
```sh
|
||||
uci set wireless.radio1.channel='36'
|
||||
uci commit wireless && wifi reload
|
||||
```
|
||||
|
||||
Prefer a non-DFS channel (36–48 on 5 GHz): on DFS channels the radio
|
||||
must wait ~60 s in CAC before transmitting after every reload.
|
||||
|
||||
Equivalent manual UCI (per radio), if you prefer to see what it does:
|
||||
|
||||
```sh
|
||||
uci batch <<'EOF'
|
||||
set wireless.fips_mesh_radio1=wifi-iface
|
||||
set wireless.fips_mesh_radio1.device='radio1'
|
||||
set wireless.fips_mesh_radio1.mode='mesh'
|
||||
set wireless.fips_mesh_radio1.mesh_id='fips-mesh'
|
||||
set wireless.fips_mesh_radio1.encryption='none'
|
||||
set wireless.fips_mesh_radio1.mesh_fwding='0'
|
||||
set wireless.fips_mesh_radio1.ifname='fips-mesh1'
|
||||
set wireless.fips_mesh_radio1.network='fips_mesh_radio1'
|
||||
set network.fips_mesh_radio1=interface
|
||||
set network.fips_mesh_radio1.proto='none'
|
||||
EOF
|
||||
uci commit
|
||||
wifi reload
|
||||
```
|
||||
|
||||
## Step 2 — check the FIPS transport binding
|
||||
|
||||
The `fips.yaml` shipped in the OpenWrt package carries one transport
|
||||
entry per radio, but **commented out** — so a stock install that never
|
||||
runs this helper logs no per-boot "interface missing" warning.
|
||||
`fips-mesh-setup` uncommented the matching `meshN` entry in Step 1, so
|
||||
there is normally nothing to do here. If you maintain your own config
|
||||
(or ran the manual UCI above instead of the helper), make sure the
|
||||
entries are present and uncommented:
|
||||
|
||||
```yaml
|
||||
transports:
|
||||
ethernet:
|
||||
mesh0:
|
||||
interface: "fips-mesh0"
|
||||
discovery: true
|
||||
announce: true
|
||||
auto_connect: true
|
||||
accept_connections: true
|
||||
mesh1:
|
||||
interface: "fips-mesh1"
|
||||
discovery: true
|
||||
announce: true
|
||||
auto_connect: true
|
||||
accept_connections: true
|
||||
```
|
||||
|
||||
## Step 3 — restart the daemon (order matters)
|
||||
|
||||
```sh
|
||||
/etc/init.d/fips restart
|
||||
```
|
||||
|
||||
Restart fips **after** the mesh interface is up. A transport whose
|
||||
interface is missing at startup is logged and skipped, not retried —
|
||||
so if the daemon comes up before the radio, the mesh transport stays
|
||||
dead until the next restart. (An interface that *vanishes and
|
||||
returns* after startup is recovered automatically; only the missing-
|
||||
at-startup case needs this ordering.)
|
||||
|
||||
## Verify
|
||||
|
||||
L2 first — the 802.11s peering, with a second configured router in
|
||||
range:
|
||||
|
||||
```sh
|
||||
iw dev fips-mesh0 station dump
|
||||
```
|
||||
|
||||
You should see one station entry per neighbor router, with signal
|
||||
levels. No entries means a radio problem, not a FIPS problem — triage
|
||||
in this order:
|
||||
|
||||
1. **Channel mismatch** (the most common cause): compare
|
||||
`iw dev fips-mesh0 info` on both routers — mesh ID *and* channel
|
||||
must match exactly.
|
||||
2. **The mesh interface never joined** — `iw dev fips-meshX info`
|
||||
shows `type mesh point` but **no channel line**, and `station dump`
|
||||
is empty. Usual cause: a client (`sta`) interface on the same
|
||||
radio. A STA must follow its upstream AP's channel, the whole
|
||||
radio follows the STA, and a mesh pinned to a different channel
|
||||
silently stays down. Check for a STA sharing the radio
|
||||
(`iw dev`, look for `type managed` on the same phy), compare
|
||||
`iw dev <sta-iface> info | grep channel`, and re-pin the mesh
|
||||
channel to match — on every backhaul router.
|
||||
3. **Is the other router transmitting at all?**
|
||||
|
||||
```sh
|
||||
iw dev fips-mesh0 scan | grep -i -B4 "MESH ID"
|
||||
```
|
||||
|
||||
Its mesh ID visible → transmission works, peering is failing
|
||||
(mesh ID typo, or one side has encryption set). Nothing visible →
|
||||
check `wifi status` on the other router, remember the ~60 s DFS
|
||||
CAC wait, and confirm the country code is set
|
||||
(`uci get wireless.radio1.country`) — an unset regdomain can
|
||||
block channels entirely.
|
||||
4. `logread | grep -iE "mesh|fips-mesh0"` on both sides.
|
||||
|
||||
Then the FIPS layer on top:
|
||||
|
||||
```sh
|
||||
logread | grep -i beacon # beacons flowing on the new transport
|
||||
fipsctl show peers # neighbor authenticated and connected
|
||||
fipsctl show links # link on the 'ethernet' transport
|
||||
```
|
||||
|
||||
Discovery is automatic: each node beacons its pubkey every few
|
||||
seconds, and `auto_connect` initiates the Noise handshake on first
|
||||
sight.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Airtime is shared per radio.** All virtual interfaces on one
|
||||
radio (AP + mesh) share one channel, and multi-hop forwarding on a
|
||||
single radio roughly halves throughput per hop. On dual/tri-band
|
||||
hardware, dedicate one band to `fips-mesh0` and serve clients on
|
||||
the others.
|
||||
- **AP + mesh coexistence is driver-dependent.** It works on the
|
||||
mainstream chips (this is the standard Freifunk/Gluon setup), but
|
||||
check `iw list` under "valid interface combinations" for your
|
||||
hardware.
|
||||
- **Clients can't join.** Phones and laptops reach the mesh through
|
||||
the router's normal AP or via BLE — never through the 802.11s
|
||||
interface.
|
||||
- **Radio links are lossy.** A neighbor at the edge of range will
|
||||
form an 802.11s peering yet deliver a fraction of its frames.
|
||||
Expect link-quality effects that don't exist on wired Ethernet.
|
||||
- **A client (STA) uplink on the same radio owns the channel.** The
|
||||
STA must follow whatever channel its upstream AP uses; every other
|
||||
interface on that radio follows the STA. A mesh pinned to a
|
||||
different channel silently never joins, and it does **not** recover
|
||||
when the STA disconnects — a `wifi reload` (plus a fips restart) is
|
||||
needed. A *roaming* uplink (travel-router / hotspot-chasing setups)
|
||||
is fundamentally incompatible with a fixed-channel mesh on the same
|
||||
radio: dedicate the mesh to the radio the STA never uses, and treat
|
||||
any mesh sharing a STA radio as best-effort.
|
||||
@@ -1,267 +0,0 @@
|
||||
# Set Up the Open FIPS Access SSID (OpenWrt)
|
||||
|
||||
Give phones and laptops a way in: every FIPS router broadcasts the
|
||||
same open SSID — `!FIPS` — from its access radio. Same SSID + unique
|
||||
BSSIDs is one standard ESS, so a client saves the network once and
|
||||
roams between all FIPS routers natively, with no per-router setup and
|
||||
no shared credentials (the Freifunk model). The leading `!` sorts the
|
||||
network to the top of alphabetically ordered pickers (iOS, desktop
|
||||
OSes — Android sorts by signal strength) and is part of the name:
|
||||
SSIDs match byte-for-byte or not at all. The radio layer provides
|
||||
nothing but open L2 to the nearest router; FIPS provides everything
|
||||
else: encryption and authentication (Noise IK), discovery
|
||||
(mDNS/Ethernet beacons), and mobility (the overlay identity survives
|
||||
roaming, so no 802.11r or L2 tricks are needed).
|
||||
|
||||
This is the *access* layer — how clients reach FIPS routers. For the
|
||||
router-to-router *backhaul*, see
|
||||
[set-up-80211s-mesh-backhaul.md](set-up-80211s-mesh-backhaul.md).
|
||||
For all `transports.ethernet.*` configuration keys, see
|
||||
[../reference/configuration.md](../reference/configuration.md).
|
||||
|
||||
## Why open, why this addressing
|
||||
|
||||
Three deliberate choices distinguish this from a stock guest network:
|
||||
|
||||
- **`encryption none`** — the SSID is open on purpose, and it *must*
|
||||
be. Clients key a saved network on SSID **plus security type**: if
|
||||
one router used a PSK and another OWE, the same `FIPS` name would be
|
||||
three different saved networks and roaming would break. Open is the
|
||||
only security type that needs zero provisioning, and OWE is left out
|
||||
for now for exactly this uniformity reason (OWE-transition mode is
|
||||
inconsistent across client vendors). Every FIPS peer link is already
|
||||
authenticated and encrypted by the Noise IK handshake. A stranger
|
||||
can associate *and* form a FIPS peer link — that is the point of open
|
||||
access; the handshake authenticates each link (no impersonation, no
|
||||
MITM) but does not gate who may peer, and admission is open up to the
|
||||
daemon's max-peers cap. What confines a hostile peer is the isolated
|
||||
`fips_ap` zone (no path to br-lan or the WAN — see below), not the
|
||||
handshake. What you concede: any nearby device can reach the FIPS
|
||||
overlay surface (handshake, discovery, lookup, routing) and peer with
|
||||
the router; L2 metadata is visible in the air; a hostile radio can
|
||||
burn airtime — all inherent to an open radio link.
|
||||
- **DHCPv4 from a fixed subnet, plus IPv6 router advertisements.**
|
||||
dnsmasq leases IPv4 out of `10.21.<N>.0/24` (`N` = the radio index;
|
||||
the prefix echoes FIPS port 2121). The subnet is deliberately
|
||||
**identical on every router**: a roaming phone keeps its lease
|
||||
across routers, and dnsmasq's authoritative mode (the OpenWrt
|
||||
default, pinned by the helper) ACKs a renew the new router never
|
||||
issued. odhcpd additionally announces a ULA prefix (`fd..`-range)
|
||||
for stateless SLAAC; DHCPv6 stays off. FIPS itself only needs
|
||||
link-local + mDNS, but Android's provisioning check requires an RA
|
||||
or a DHCP offer and *disconnects* with neither, and plain laptops
|
||||
expect a real IPv4 address. Works with or without an upstream —
|
||||
nothing here depends on the WAN. The IPv6 side stays per-router and
|
||||
disposable; in all cases the FIPS overlay identity, not the IP, is
|
||||
the mobility anchor.
|
||||
- **Isolated interface** — its own network and firewall zone, with no
|
||||
path to `br-lan` and no forwarding to the WAN. Inbound traffic is
|
||||
rejected except DHCPv4, ICMPv6 (SLAAC itself), mDNS, and the FIPS
|
||||
transport ports; the raw-Ethernet transport (EtherType 0x2121) is
|
||||
not IP and never traverses the firewall. AP client isolation is on, so clients
|
||||
cannot reach each other at L2 — two FIPS phones on one router still
|
||||
reach each other through the router at the overlay layer.
|
||||
|
||||
## The "no internet" behavior (expected, one-time acceptance)
|
||||
|
||||
The network intentionally provides **no internet**. On first connect,
|
||||
a phone's validation probe fails and it asks whether to stay on a
|
||||
network without internet access — choose **stay connected** and
|
||||
**don't ask again**. That choice is stored per SSID, so accepting it
|
||||
once covers every FIPS router anywhere.
|
||||
|
||||
After that, the network is marked "connected, no internet"
|
||||
(unvalidated) and the phone keeps **cellular as its default route**
|
||||
while staying associated — normal apps never notice the FIPS network
|
||||
exists. FIPS apps bind their sockets to the Wi-Fi network explicitly,
|
||||
so mesh traffic flows over Wi-Fi while everything else uses cellular.
|
||||
|
||||
## When to use
|
||||
|
||||
- Any FIPS router that should serve phones and laptops directly, not
|
||||
just peer with other routers.
|
||||
- You want clients to roam between FIPS routers with zero per-router
|
||||
or per-site configuration.
|
||||
|
||||
It is the complement of the 802.11s backhaul: the backhaul links
|
||||
routers (clients cannot join it), the access SSID admits clients.
|
||||
Both can share a radio, at an airtime cost (see constraints).
|
||||
|
||||
## Requirements
|
||||
|
||||
- OpenWrt 22.03+ with the FIPS package installed (fw4; dnsmasq and
|
||||
odhcpd are part of the default images).
|
||||
- Any radio — AP mode needs no special driver support.
|
||||
|
||||
## Step 1 — create the access point(s)
|
||||
|
||||
On **each** router, run the helper once per radio that should serve
|
||||
clients:
|
||||
|
||||
```sh
|
||||
fips-ap-setup radio0
|
||||
```
|
||||
|
||||
This creates an open AP with SSID `!FIPS` and client isolation, an
|
||||
isolated network with `10.21.<N>.1/24` and a static ULA `/64`, a
|
||||
DHCPv4 + RA dhcp config (dnsmasq leases, SLAAC, no DHCPv6), and a
|
||||
locked-down `fips_ap` firewall zone — then reloads the radio. Interfaces are named by radio index: `radio0` →
|
||||
`fips-ap0`, `radio1` → `fips-ap1`. Pass a second argument to use a
|
||||
different SSID — but the SSID, like the security type, must be
|
||||
identical on **all** routers or clients will treat them as separate
|
||||
networks and stop roaming.
|
||||
|
||||
On dual-band routers, run it for both radios so clients can pick
|
||||
either band:
|
||||
|
||||
```sh
|
||||
fips-ap-setup radio0
|
||||
fips-ap-setup radio1
|
||||
```
|
||||
|
||||
**Channels are free per router.** Unlike the mesh backhaul, there is
|
||||
no same-channel constraint — clients scan when they roam — so leave
|
||||
each router on whatever channel suits its RF environment.
|
||||
|
||||
Equivalent manual UCI (per radio), if you prefer to see what it does
|
||||
(`fdxx:...` stands for a `/64` out of the router's ULA prefix):
|
||||
|
||||
```sh
|
||||
uci batch <<'EOF'
|
||||
set wireless.fips_ap_radio0=wifi-iface
|
||||
set wireless.fips_ap_radio0.device='radio0'
|
||||
set wireless.fips_ap_radio0.mode='ap'
|
||||
set wireless.fips_ap_radio0.ssid='!FIPS'
|
||||
set wireless.fips_ap_radio0.encryption='none'
|
||||
set wireless.fips_ap_radio0.isolate='1'
|
||||
set wireless.fips_ap_radio0.ifname='fips-ap0'
|
||||
set wireless.fips_ap_radio0.network='fips_ap_radio0'
|
||||
set network.fips_ap_radio0=interface
|
||||
set network.fips_ap_radio0.proto='static'
|
||||
set network.fips_ap_radio0.ipaddr='10.21.0.1'
|
||||
set network.fips_ap_radio0.netmask='255.255.255.0'
|
||||
set network.fips_ap_radio0.ip6addr='fdxx:xxxx:xxxx:fa00::1/64'
|
||||
set dhcp.fips_ap_radio0=dhcp
|
||||
set dhcp.fips_ap_radio0.interface='fips_ap_radio0'
|
||||
set dhcp.fips_ap_radio0.ra='server'
|
||||
set dhcp.fips_ap_radio0.ra_default='2'
|
||||
set dhcp.fips_ap_radio0.dhcpv6='disabled'
|
||||
set dhcp.fips_ap_radio0.dhcpv4='server'
|
||||
set dhcp.fips_ap_radio0.start='10'
|
||||
set dhcp.fips_ap_radio0.limit='200'
|
||||
EOF
|
||||
uci commit
|
||||
wifi reload
|
||||
```
|
||||
|
||||
plus the `fips_ap` firewall zone (input/forward REJECT, no
|
||||
forwardings, ACCEPT rules for DHCPv4/UDP 67, ICMPv6, UDP 5353/2121,
|
||||
TCP 8443).
|
||||
|
||||
## Step 2 — check the FIPS transport binding
|
||||
|
||||
The `fips.yaml` shipped in the OpenWrt package carries one transport
|
||||
entry per access interface, but **commented out** — so a stock install
|
||||
that never runs this helper logs no per-boot "interface missing"
|
||||
warning. `fips-ap-setup` uncommented the matching `apN` entry in Step 1,
|
||||
and also enabled `node.rendezvous.lan` (the daemon's mDNS/DNS-SD
|
||||
rendezvous — phone FIPS apps cannot see raw-Ethernet beacons, so mDNS
|
||||
is how they find the daemon; the switch is daemon-wide and stays on if
|
||||
you later remove the AP). So there is normally nothing to do here. If
|
||||
you maintain your own config (or ran the manual UCI above instead of
|
||||
the helper), make sure both are present and uncommented:
|
||||
|
||||
```yaml
|
||||
node:
|
||||
rendezvous:
|
||||
lan:
|
||||
enabled: true
|
||||
```
|
||||
|
||||
```yaml
|
||||
transports:
|
||||
ethernet:
|
||||
ap0:
|
||||
interface: "fips-ap0"
|
||||
discovery: true
|
||||
announce: true
|
||||
auto_connect: true
|
||||
accept_connections: true
|
||||
ap1:
|
||||
interface: "fips-ap1"
|
||||
discovery: true
|
||||
announce: true
|
||||
auto_connect: true
|
||||
accept_connections: true
|
||||
```
|
||||
|
||||
## Step 3 — restart the daemon (order matters)
|
||||
|
||||
```sh
|
||||
/etc/init.d/fips restart
|
||||
```
|
||||
|
||||
Restart fips **after** the AP interface is up. A transport whose
|
||||
interface is missing at startup is logged and skipped, not retried —
|
||||
so if the daemon comes up before the radio, the access transport
|
||||
stays dead until the next restart. (An interface that *vanishes and
|
||||
returns* after startup is recovered automatically; only the missing-
|
||||
at-startup case needs this ordering.)
|
||||
|
||||
## Verify
|
||||
|
||||
L2 and addressing first, with a phone or laptop connected to `!FIPS`:
|
||||
|
||||
```sh
|
||||
iw dev fips-ap0 station dump # one entry per associated client
|
||||
ip addr show dev fips-ap0 # 10.21.0.1/24 and the fd..::1/64
|
||||
cat /tmp/dhcp.leases # one lease per connected client
|
||||
```
|
||||
|
||||
No station entries means a radio problem; an association that drops
|
||||
after ~30 s usually means the client never got an address — check
|
||||
`logread | grep -e dnsmasq -e odhcpd` and that the
|
||||
`dhcp.fips_ap_radio0` section survived
|
||||
(`uci show dhcp | grep fips_ap`).
|
||||
|
||||
Then the FIPS layer on top, for a client running FIPS:
|
||||
|
||||
```sh
|
||||
logread | grep -i beacon # beacons flowing on the new transport
|
||||
fipsctl show peers # client authenticated and connected
|
||||
```
|
||||
|
||||
On the phone itself: the network shows "connected, no internet" and
|
||||
stays associated — that is the designed steady state, not an error.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **SSID and security type must be uniform across ALL routers.**
|
||||
One router with a PSK (or OWE) under the same name splits the ESS
|
||||
into different saved networks and silently breaks roaming. Never
|
||||
"harden" a single router.
|
||||
- **Airtime is shared per radio.** An access AP and a mesh backhaul
|
||||
on the same radio share one channel. On dual/tri-band hardware,
|
||||
dedicate a band to the backhaul and serve clients on the others.
|
||||
- **Strangers can associate and peer — by design.** Open access means
|
||||
any nearby device can complete the Noise handshake and become a FIPS
|
||||
peer (up to the max-peers cap); the handshake authenticates each link,
|
||||
it does not restrict who joins. They reach only the FIPS overlay
|
||||
surface — the isolated zone gives no path to br-lan or the WAN. Do not
|
||||
add forwardings to the `fips_ap` zone: that would turn the open SSID
|
||||
into a hotspot and hand the isolation away.
|
||||
- **Roaming is client-driven.** Clients decide when to hop BSSIDs
|
||||
(standard ESS behavior); the IPv4 lease survives the hop (same
|
||||
subnet everywhere), the SLAAC address renumbers, and FIPS sessions
|
||||
ride through because the overlay identity is the anchor. Expect a
|
||||
brief L2 gap during the hop, as on any ESS without 802.11r.
|
||||
- **The `10.21.<N>.0/24` convention must hold everywhere.** Lease
|
||||
survival depends on every router serving the same subnet from the
|
||||
same radio index — the helper guarantees this; don't hand-pick
|
||||
per-router subnets. Two routers can lease the same address to two
|
||||
different clients; after a roam the conflict is caught (dnsmasq
|
||||
NAKs a renew for an address in use) and the client re-DHCPs. If a
|
||||
laptop is *also* wired to a LAN that really uses `10.21.<N>.0/24`,
|
||||
its routing table will conflict — a corner case worth knowing, not
|
||||
designing around: the zone forwards nowhere, so the FIPS side never
|
||||
reaches beyond the router either way.
|
||||
@@ -28,7 +28,7 @@ controlled through the standard service control manager.
|
||||
| Flag | Argument | Description |
|
||||
| ---- | -------- | ----------- |
|
||||
| `-c`, `--config` | `FILE` | Use `FILE` as the configuration. Skips the default search paths. |
|
||||
| `-V` | — | Print the short version (e.g. `0.5.0-dev (rev abcdef1)`). |
|
||||
| `-V` | — | Print the short version (e.g. `0.4.0 (rev abcdef1)`). |
|
||||
| `--version` | — | Print the long version: short version plus build target triple. |
|
||||
| `-h`, `--help` | — | Print usage and exit. |
|
||||
| `--install-service` | — | (Windows only) Install `fips` as a Windows service. Requires Administrator. |
|
||||
|
||||
@@ -115,58 +115,6 @@ Tell the daemon to drop a peer link.
|
||||
| -------- | ----------- |
|
||||
| `peer` | npub (bech32) or hostname from `/etc/fips/hosts`. |
|
||||
|
||||
### `profile tick <on|off|status>`
|
||||
|
||||
> **Reading the output.** Step durations are wall clock measured across `await`
|
||||
> points, not CPU time: a step that waits on I/O accrues that wait, and other
|
||||
> tasks may run inside the span. That is the intended measure for head-of-line
|
||||
> delay, and it means a large step is not necessarily an expensive one.
|
||||
> `arm_starvation` is measured directly as the entry time minus the deadline
|
||||
> the interval scheduled that tick for. It is not derived from
|
||||
> `tick_entry_gap`, which carries no starvation signal on its own: under a
|
||||
> steady delay every gap is exactly one tick period.
|
||||
|
||||
Start, stop and inspect a capture of the rx-loop tick body. **Present
|
||||
only when both `fipsctl` and the daemon are built with
|
||||
`--features profiling`**; the feature is off by default, so a stock
|
||||
package does not carry this subcommand and a stock daemon reports
|
||||
`profile_tick_*` as an unknown command.
|
||||
|
||||
| Subcommand | Control-socket command | Description |
|
||||
| ---------- | ---------------------- | ----------- |
|
||||
| `profile tick on` | `profile_tick_on` | Create the capture file and start recording. Fails if a capture is already running (naming the active file) or if the directory cannot be written. |
|
||||
| `profile tick off` | `profile_tick_off` | Stop the capture. The writer is woken immediately, drains once more and is joined, so the command returns promptly. Succeeds, reporting nothing active, when no capture is running. |
|
||||
| `profile tick status` | `profile_tick_status` | Report `idle`, `running`, `stopped_by_cap` or `stopped_by_error`, plus the active path, bytes written, flush interval and byte cap. |
|
||||
|
||||
`profile tick on` options:
|
||||
|
||||
| Flag | Argument | Default | Description |
|
||||
| ---- | -------- | ------- | ----------- |
|
||||
| `--dir` | directory path | `/var/log/fips` | Where to write the capture. Created if absent. Use it to profile a non-root `cargo run`, or on a platform whose log root differs. |
|
||||
|
||||
One file is written per capture, named `profile-<UTC timestamp>.tsv`.
|
||||
It opens with a `#`-prefixed header block (node npub, build version,
|
||||
platform, configured tick period, flush interval, byte cap, start
|
||||
time), then a tab-separated column header, then one row per measured
|
||||
step per flush interval:
|
||||
|
||||
```text
|
||||
ts_unix kind domain name count max total unit
|
||||
```
|
||||
|
||||
`kind` is `step` for a timed span and `gauge` for a sampled scalar, so
|
||||
a gauge value never lands under a duration column; `unit` names the
|
||||
unit of `max` and `total` for that row. Every step present in the build
|
||||
gets a row every interval, including zero-count rows. Gauges cover
|
||||
ticks per interval, peer count, the wall gap between successive
|
||||
tick-arm entries, and the arm-starvation delay, which is measured
|
||||
against the deadline the tick was scheduled for rather than derived
|
||||
from the gap.
|
||||
|
||||
A capture stops itself on reaching 32 MB, appending a `#` line saying
|
||||
so; `profile tick status` then reports `stopped_by_cap` until the next
|
||||
`on` or `off` clears it.
|
||||
|
||||
## Exit Codes
|
||||
|
||||
| Code | Meaning |
|
||||
|
||||
@@ -104,8 +104,7 @@ to the highest-priority config file for operator visibility, even in ephemeral m
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `node.disable_routing` | bool | `false` | Non-routing mode: participates in spanning tree but does not forward transit traffic or send bloom filters |
|
||||
| `node.leaf_only` | bool | `false` | Leaf mode: single upstream peer, no tree/bloom/transit participation. Implies `disable_routing: true` |
|
||||
| `node.leaf_only` | bool | `false` | Leaf-only mode: node does not forward traffic or participate in routing |
|
||||
| `node.tick_interval_secs` | u64 | `1` | Periodic maintenance tick interval (retry checks, timeout cleanup, tree refresh) |
|
||||
| `node.base_rtt_ms` | u64 | `100` | Initial RTT estimate for new links before measurements converge |
|
||||
| `node.heartbeat_interval_secs` | u64 | `10` | Heartbeat send interval per peer for liveness detection |
|
||||
@@ -125,7 +124,7 @@ Controls capacity for connections, peers, and links.
|
||||
|
||||
### Rate Limiting (`node.rate_limit.*`)
|
||||
|
||||
Handshake rate limiting protects against DoS on the Noise XX handshake path.
|
||||
Handshake rate limiting protects against DoS on the Noise IK handshake path.
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
@@ -278,7 +277,7 @@ Controls tree construction and parent selection.
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `node.bloom.update_debounce_ms` | u64 | `500` | Debounce interval for filter update propagation |
|
||||
| `node.bloom.max_inbound_fpr` | f64 | `0.20` | Antipoison cap: reject inbound `FilterAnnounce` frames whose advertised false-positive rate exceeds this value. Valid range `(0.0, 1.0)`. The default `0.20` corresponds to fill 0.7248 at k=5 (≈2,114 entries on the 1 KB filter); a saturated/poisoned filter is still ~100% FPR and rejected |
|
||||
| `node.bloom.max_inbound_fpr` | f64 | `0.10` | Antipoison cap: reject inbound `FilterAnnounce` frames whose advertised false-positive rate exceeds this value. Valid range `(0.0, 1.0)`. The default `0.10` corresponds to fill 0.631 at k=5 (≈1,630 entries on the 1 KB filter); a saturated/poisoned filter is still ~100% FPR and rejected |
|
||||
|
||||
Bloom filter size (1 KB), hash count (5), and size classes are protocol
|
||||
constants and not configurable.
|
||||
@@ -305,7 +304,7 @@ stays set for all subsequent hops to the destination.
|
||||
### Rekey (`node.rekey.*`)
|
||||
|
||||
Controls periodic Noise rekey for forward secrecy. When enabled, both FMP
|
||||
(link-layer XX) and FSP (session-layer XX) sessions perform fresh Diffie-Hellman
|
||||
(link-layer IK) and FSP (session-layer XK) sessions perform fresh Diffie-Hellman
|
||||
key exchanges after a time or message count threshold, whichever comes first.
|
||||
A 10-second drain window keeps the old session active for decryption during
|
||||
cutover.
|
||||
@@ -339,7 +338,7 @@ Metrics Measurement Protocol for per-peer link measurement. See
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `node.mmp.mode` | string | `"full"` | Operating mode: `full` (sender + receiver reports), `lightweight` (receiver reports only), or `minimal` (CE echo only, no reports) |
|
||||
| `node.mmp.mode` | string | `"full"` | Operating mode: `full` (sender + receiver reports), `lightweight` (receiver reports only), or `minimal` (spin bit + CE echo only, no reports) |
|
||||
| `node.mmp.log_interval_secs` | u64 | `30` | Periodic operator log interval for link metrics |
|
||||
| `node.mmp.owd_window_size` | usize | `32` | One-way delay trend ring buffer size |
|
||||
|
||||
@@ -437,7 +436,7 @@ Requires `CAP_NET_RAW` or running as root. Linux only.
|
||||
| `mtu` | u16 | *(auto)* | Override MTU. Default: interface MTU minus 3 (for frame type + length prefix) |
|
||||
| `recv_buf_size` | usize | `2097152` | Socket receive buffer size in bytes (2 MB) |
|
||||
| `send_buf_size` | usize | `2097152` | Socket send buffer size in bytes (2 MB) |
|
||||
| `listen` | bool | `true` | Listen for neighbor beacons from other nodes |
|
||||
| `discovery` | bool | `true` | Listen for discovery beacons from other nodes |
|
||||
| `announce` | bool | `false` | Broadcast announcement beacons on the LAN |
|
||||
| `auto_connect` | bool | `false` | Auto-connect to discovered peers |
|
||||
| `accept_connections` | bool | `false` | Accept incoming connection attempts from discovered peers |
|
||||
@@ -451,7 +450,7 @@ transports:
|
||||
ethernet:
|
||||
lan:
|
||||
interface: "eth0"
|
||||
listen: true
|
||||
discovery: true
|
||||
announce: true
|
||||
backbone:
|
||||
interface: "eth1"
|
||||
@@ -459,7 +458,7 @@ transports:
|
||||
```
|
||||
|
||||
Each named instance operates independently with its own socket and
|
||||
neighbor state. The instance name is used in log messages and the
|
||||
discovery state. The instance name is used in log messages and the
|
||||
`name()` method on the Transport trait.
|
||||
|
||||
### TCP (`transports.tcp.*`)
|
||||
@@ -841,7 +840,7 @@ peers:
|
||||
### Mixed UDP + Ethernet Example
|
||||
|
||||
A node bridging internet peers (UDP) and a local Ethernet segment with
|
||||
neighbor beacons:
|
||||
beacon discovery:
|
||||
|
||||
```yaml
|
||||
node:
|
||||
@@ -857,7 +856,7 @@ transports:
|
||||
mtu: 1472
|
||||
ethernet:
|
||||
interface: "eth0"
|
||||
listen: true
|
||||
discovery: true
|
||||
announce: true
|
||||
auto_connect: true
|
||||
accept_connections: true
|
||||
@@ -900,7 +899,6 @@ node:
|
||||
identity:
|
||||
nsec: null # secret key in nsec or hex (null = depends on persistent)
|
||||
persistent: false # true = load/save fips.key; false = ephemeral each start
|
||||
disable_routing: false
|
||||
leaf_only: false
|
||||
tick_interval_secs: 1
|
||||
base_rtt_ms: 100
|
||||
@@ -946,7 +944,7 @@ node:
|
||||
flap_dampening_secs: 120 # extended hold-down on flap
|
||||
bloom:
|
||||
update_debounce_ms: 500
|
||||
max_inbound_fpr: 0.20 # antipoison cap on inbound FilterAnnounce FPR
|
||||
max_inbound_fpr: 0.10 # antipoison cap on inbound FilterAnnounce FPR
|
||||
session:
|
||||
default_ttl: 64
|
||||
pending_packets_per_dest: 16
|
||||
@@ -1001,7 +999,7 @@ transports:
|
||||
# mtu: null # null = interface MTU - 3 (typically 1497)
|
||||
# recv_buf_size: 2097152 # 2 MB
|
||||
# send_buf_size: 2097152 # 2 MB
|
||||
# listen: true # listen for beacons
|
||||
# discovery: true # listen for beacons
|
||||
# announce: false # broadcast beacons
|
||||
# auto_connect: false # connect to discovered peers
|
||||
# accept_connections: false # accept inbound handshakes
|
||||
|
||||
@@ -159,21 +159,6 @@ not reproduced here to avoid duplicating the source.
|
||||
Both commands run on the daemon's main task and may block briefly
|
||||
while the node mutates its state.
|
||||
|
||||
#### Profiler toggle (`--features profiling` builds only)
|
||||
|
||||
| Command | Params | Behaviour |
|
||||
| ------- | ------ | --------- |
|
||||
| `profile_tick_on` | `dir` (optional directory path; default `/var/log/fips`) | Creates the capture file, publishes its path, and starts the writer thread. `data`: `state`, `path`, `interval_secs`, `byte_cap`. Errors if a capture is already running (naming the active file) or the directory is unwritable. |
|
||||
| `profile_tick_off` | — | Stops the capture, drains once more, joins the writer. `data`: `state`, `stopped`, `stopped_by_cap`, `stopped_by_error`, `path`, `bytes`. |
|
||||
| `profile_tick_status` | — | `data`: `state` (`idle` / `running` / `stopped_by_cap` / `stopped_by_error`), `path`, `bytes`, `byte_cap`, `interval_secs`. |
|
||||
|
||||
Unlike `connect` and `disconnect`, these three are served in the
|
||||
control accept task rather than on the daemon's main task. All of their
|
||||
state is process statics and none of them needs `&mut Node`, so
|
||||
routing them through the main loop would only make the toggle queue
|
||||
behind the tick body it exists to measure. They are absent from a
|
||||
default build, where the daemon answers them as unknown commands.
|
||||
|
||||
## Gateway Command Catalog
|
||||
|
||||
`fips-gateway` exposes a separate control socket with its own command
|
||||
|
||||
@@ -1,52 +1,43 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 580 374" font-family="monospace" font-size="13">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 580 270" font-family="monospace" font-size="13">
|
||||
<!-- Background -->
|
||||
<rect width="580" height="374" fill="#1a1a2e" rx="4"/>
|
||||
<rect width="580" height="270" fill="#1a1a2e" rx="4"/>
|
||||
|
||||
<!-- Title -->
|
||||
<text x="310" y="26" fill="#e0e0e0" text-anchor="middle" font-size="14" font-weight="bold">FilterAnnounce (0x20) — 19-byte header + RLE payload</text>
|
||||
<text x="310" y="26" fill="#e0e0e0" text-anchor="middle" font-size="14" font-weight="bold">FilterAnnounce (0x20) — 11 + filter bytes</text>
|
||||
|
||||
<!-- Row 0 (0-1): msg_type + flags -->
|
||||
<text x="50" y="62" fill="#666" font-size="10" text-anchor="end">0–1</text>
|
||||
<!-- Row 0 (0–3): msg_type(1) + sequence starts -->
|
||||
<text x="50" y="62" fill="#666" font-size="10" text-anchor="end">0–3</text>
|
||||
|
||||
<rect x="55" y="36" width="130" height="52" fill="#2d4a7a" stroke="#4a90d9" stroke-width="1.5" rx="3"/>
|
||||
<text x="120" y="56" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">msg_type</text>
|
||||
<text x="120" y="74" fill="#8ab4f8" text-anchor="middle" font-size="10">0x20</text>
|
||||
<text x="120" y="60" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">msg_type</text>
|
||||
<text x="120" y="78" fill="#8ab4f8" text-anchor="middle" font-size="10">0x20</text>
|
||||
|
||||
<rect x="185" y="36" width="390" height="52" fill="#2d5a4a" stroke="#4ad99a" stroke-width="1.5" rx="3"/>
|
||||
<text x="380" y="56" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">flags</text>
|
||||
<text x="380" y="74" fill="#8af8c8" text-anchor="middle" font-size="10">1 byte — bit 0: delta (XOR diff)</text>
|
||||
<rect x="185" y="36" width="390" height="52" fill="#5a3d2d" stroke="#d9904a" stroke-width="1.5" rx="3"/>
|
||||
<text x="380" y="66" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">sequence</text>
|
||||
|
||||
<!-- Row 1 (2-9): sequence -->
|
||||
<text x="50" y="114" fill="#666" font-size="10" text-anchor="end">2–9</text>
|
||||
<!-- Row 1 (4–8): sequence continued -->
|
||||
<text x="50" y="114" fill="#666" font-size="10" text-anchor="end">4–8</text>
|
||||
|
||||
<rect x="55" y="88" width="520" height="52" fill="#5a3d2d" stroke="#d9904a" stroke-width="1.5" rx="3"/>
|
||||
<text x="315" y="110" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">sequence</text>
|
||||
<text x="315" y="128" fill="#f8c88a" text-anchor="middle" font-size="10">8 bytes LE — per-peer monotonic counter</text>
|
||||
<text x="315" y="118" fill="#f8c88a" text-anchor="middle" font-size="10">8 bytes LE — monotonic counter</text>
|
||||
|
||||
<!-- Row 2 (10-17): base_seq -->
|
||||
<text x="50" y="166" fill="#666" font-size="10" text-anchor="end">10–17</text>
|
||||
<!-- Row 2 (9–10): hash_count + size_class -->
|
||||
<text x="50" y="166" fill="#666" font-size="10" text-anchor="end">9–10</text>
|
||||
|
||||
<rect x="55" y="140" width="520" height="52" fill="#4a2d5a" stroke="#b04ad9" stroke-width="1.5" rx="3"/>
|
||||
<text x="315" y="162" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">base_seq</text>
|
||||
<text x="315" y="180" fill="#d8a0f8" text-anchor="middle" font-size="10">8 bytes LE — reference sequence for delta (0 if full)</text>
|
||||
<rect x="55" y="140" width="260" height="52" fill="#2d5a4a" stroke="#4ad99a" stroke-width="1.5" rx="3"/>
|
||||
<text x="185" y="162" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">hash_count</text>
|
||||
<text x="185" y="180" fill="#8af8c8" text-anchor="middle" font-size="10">1 byte</text>
|
||||
|
||||
<!-- Row 3 (18): size_class -->
|
||||
<text x="50" y="218" fill="#666" font-size="10" text-anchor="end">18</text>
|
||||
<rect x="315" y="140" width="260" height="52" fill="#4a2d5a" stroke="#b04ad9" stroke-width="1.5" rx="3"/>
|
||||
<text x="445" y="162" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">size_class</text>
|
||||
<text x="445" y="180" fill="#d8a0f8" text-anchor="middle" font-size="10">1 byte</text>
|
||||
|
||||
<rect x="55" y="192" width="520" height="52" fill="#2d5a5a" stroke="#4ad9d9" stroke-width="1.5" rx="3"/>
|
||||
<text x="315" y="214" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">size_class</text>
|
||||
<text x="315" y="232" fill="#8af8f8" text-anchor="middle" font-size="10">1 byte — filter size = 512 « size_class bytes</text>
|
||||
<!-- Row 3 (11–): filter_bits -->
|
||||
<text x="50" y="218" fill="#666" font-size="10" text-anchor="end">11–</text>
|
||||
|
||||
<!-- Row 4 (19-): compressed_payload -->
|
||||
<text x="50" y="270" fill="#666" font-size="10" text-anchor="end">19–</text>
|
||||
|
||||
<rect x="55" y="250" width="520" height="40" fill="#1f1f3a" stroke="#4a9090" stroke-width="1" stroke-dasharray="4,3" rx="3"/>
|
||||
<text x="315" y="274" fill="#8ad8d8" text-anchor="middle" font-size="11">compressed_payload (RLE: [count:2 LE][word:8 LE] per run)</text>
|
||||
|
||||
<!-- Explanation -->
|
||||
<rect x="55" y="304" width="520" height="32" fill="#1f1f3a" stroke="#444" stroke-width="1" rx="3"/>
|
||||
<text x="315" y="324" fill="#888" text-anchor="middle" font-size="10">delta: XOR diff of current vs last-sent filter — full: raw filter words</text>
|
||||
<rect x="55" y="198" width="520" height="40" fill="#1f1f3a" stroke="#4ad9d9" stroke-width="1" stroke-dasharray="4,3" rx="3"/>
|
||||
<text x="315" y="222" fill="#8af8f8" text-anchor="middle" font-size="11">filter_bits (variable, 512 << size_class bytes)</text>
|
||||
|
||||
<!-- Total -->
|
||||
<text x="310" y="362" fill="#777" font-size="10" text-anchor="middle">19-byte header + variable compressed payload</text>
|
||||
<text x="310" y="258" fill="#777" font-size="10" text-anchor="middle">v1 payload: 1,035 bytes (11 header + 1,024 filter)</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 2.6 KiB |
@@ -1,23 +1,26 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 620 620" font-family="monospace" font-size="13">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 620 500" font-family="monospace" font-size="13">
|
||||
<defs>
|
||||
<marker id="arrowR" markerWidth="10" markerHeight="8" refX="9" refY="4" orient="auto" markerUnits="userSpaceOnUse">
|
||||
<polygon points="0,0 10,4 0,8" fill="#e0e0e0"/>
|
||||
</marker>
|
||||
<marker id="arrowL" markerWidth="10" markerHeight="8" refX="1" refY="4" orient="auto" markerUnits="userSpaceOnUse">
|
||||
<polygon points="10,0 0,4 10,8" fill="#e0e0e0"/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="620" height="620" fill="#1a1a2e" rx="4"/>
|
||||
<rect width="620" height="500" fill="#1a1a2e" rx="4"/>
|
||||
|
||||
<!-- Title -->
|
||||
<text x="310" y="26" fill="#e0e0e0" text-anchor="middle" font-size="14" font-weight="bold">FMP Handshake Flow (Noise XX)</text>
|
||||
<text x="310" y="26" fill="#e0e0e0" text-anchor="middle" font-size="14" font-weight="bold">FMP Handshake Flow (Noise IK)</text>
|
||||
|
||||
<!-- Column headers -->
|
||||
<text x="100" y="54" fill="#8ab4f8" text-anchor="middle" font-size="12" font-weight="bold">Initiator</text>
|
||||
<text x="520" y="54" fill="#8ab4f8" text-anchor="middle" font-size="12" font-weight="bold">Responder</text>
|
||||
|
||||
<!-- Lifelines -->
|
||||
<line x1="100" y1="62" x2="100" y2="600" stroke="#333" stroke-width="1" stroke-dasharray="4,4"/>
|
||||
<line x1="520" y1="62" x2="520" y2="600" stroke="#333" stroke-width="1" stroke-dasharray="4,4"/>
|
||||
<line x1="100" y1="62" x2="100" y2="480" stroke="#333" stroke-width="1" stroke-dasharray="4,4"/>
|
||||
<line x1="520" y1="62" x2="520" y2="480" stroke="#333" stroke-width="1" stroke-dasharray="4,4"/>
|
||||
|
||||
<!-- Step 1: Initiator prepares -->
|
||||
<text x="20" y="88" fill="#8af8c8" font-size="10">generates sender_idx</text>
|
||||
@@ -25,51 +28,39 @@
|
||||
|
||||
<!-- Arrow 1: msg1 (initiator -> responder) -->
|
||||
<line x1="100" y1="120" x2="520" y2="120" stroke="#4a90d9" stroke-width="1.5" marker-end="url(#arrowR)"/>
|
||||
<rect x="145" y="128" width="330" height="24" fill="#2d4a7a" stroke="#4a90d9" stroke-width="1" rx="3"/>
|
||||
<text x="310" y="144" fill="#e0e0e0" text-anchor="middle" font-size="10">[0x11|flags=0|len] | sender_idx | noise_msg1</text>
|
||||
<text x="310" y="166" fill="#666" text-anchor="middle" font-size="9">phase 0x1 — 41 bytes — pattern: → e</text>
|
||||
<rect x="130" y="128" width="360" height="24" fill="#2d4a7a" stroke="#4a90d9" stroke-width="1" rx="3"/>
|
||||
<text x="310" y="144" fill="#e0e0e0" text-anchor="middle" font-size="10">[0x01|flags=0|len] | sender_idx | noise_msg1</text>
|
||||
<text x="310" y="166" fill="#666" text-anchor="middle" font-size="9">phase 0x1 — 114 bytes</text>
|
||||
|
||||
<!-- Step 2: Responder processes msg1 -->
|
||||
<text x="600" y="192" fill="#8af8c8" font-size="10" text-anchor="end">validates msg1 (ephemeral only)</text>
|
||||
<text x="600" y="206" fill="#8af8c8" font-size="10" text-anchor="end">generates sender_idx</text>
|
||||
<text x="600" y="220" fill="#8af8c8" font-size="10" text-anchor="end">generates ephemeral keypair</text>
|
||||
<!-- Step 2: Responder processes (right-aligned to stay in bounds) -->
|
||||
<text x="600" y="192" fill="#8af8c8" font-size="10" text-anchor="end">validates msg1</text>
|
||||
<text x="600" y="206" fill="#8af8c8" font-size="10" text-anchor="end">learns initiator's static key</text>
|
||||
<text x="600" y="220" fill="#8af8c8" font-size="10" text-anchor="end">generates sender_idx</text>
|
||||
<text x="600" y="234" fill="#8af8c8" font-size="10" text-anchor="end">generates ephemeral keypair</text>
|
||||
|
||||
<!-- Arrow 2: msg2 (responder -> initiator) -->
|
||||
<line x1="520" y1="244" x2="100" y2="244" stroke="#4ad99a" stroke-width="1.5" marker-end="url(#arrowR)"/>
|
||||
<rect x="115" y="252" width="390" height="24" fill="#2d5a4a" stroke="#4ad99a" stroke-width="1" rx="3"/>
|
||||
<text x="310" y="268" fill="#e0e0e0" text-anchor="middle" font-size="9">[0x12|flags=0|len] | sender_idx | receiver_idx | noise_msg2 + negotiation</text>
|
||||
<text x="310" y="288" fill="#666" text-anchor="middle" font-size="9">phase 0x2 — 144 bytes — pattern: ← e, ee, s, es</text>
|
||||
<text x="600" y="300" fill="#d8a0f8" font-size="9" text-anchor="end">responder identity revealed</text>
|
||||
<!-- Draw line right-to-left so orient="auto" points the arrowhead left -->
|
||||
<line x1="520" y1="256" x2="100" y2="256" stroke="#4ad99a" stroke-width="1.5" marker-end="url(#arrowR)"/>
|
||||
<rect x="130" y="264" width="360" height="24" fill="#2d5a4a" stroke="#4ad99a" stroke-width="1" rx="3"/>
|
||||
<text x="310" y="280" fill="#e0e0e0" text-anchor="middle" font-size="9">[0x02|flags=0|len] | sender_idx | receiver_idx | noise_msg2</text>
|
||||
<text x="310" y="300" fill="#666" text-anchor="middle" font-size="9">phase 0x2 — 69 bytes</text>
|
||||
|
||||
<!-- Step 3: Initiator processes msg2, learns responder identity -->
|
||||
<text x="20" y="322" fill="#8af8c8" font-size="10">validates msg2</text>
|
||||
<text x="20" y="336" fill="#8af8c8" font-size="10">learns responder identity</text>
|
||||
<text x="20" y="350" fill="#8af8c8" font-size="10">checks epoch (restart detection)</text>
|
||||
|
||||
<!-- Arrow 3: msg3 (initiator -> responder) -->
|
||||
<line x1="100" y1="368" x2="520" y2="368" stroke="#d94a6a" stroke-width="1.5" marker-end="url(#arrowR)"/>
|
||||
<rect x="115" y="376" width="390" height="24" fill="#5a2d3d" stroke="#d94a6a" stroke-width="1" rx="3"/>
|
||||
<text x="310" y="392" fill="#e0e0e0" text-anchor="middle" font-size="9">[0x13|flags=0|len] | sender_idx | receiver_idx | noise_msg3 + neg</text>
|
||||
<text x="310" y="412" fill="#666" text-anchor="middle" font-size="9">phase 0x3 — 111 bytes — pattern: → s, se</text>
|
||||
<text x="20" y="424" fill="#d8a0f8" font-size="9">initiator identity revealed</text>
|
||||
|
||||
<!-- Step 4: Responder processes msg3, learns initiator identity -->
|
||||
<text x="600" y="440" fill="#8af8c8" font-size="10" text-anchor="end">validates msg3</text>
|
||||
<text x="600" y="454" fill="#8af8c8" font-size="10" text-anchor="end">learns initiator identity</text>
|
||||
<text x="600" y="468" fill="#8af8c8" font-size="10" text-anchor="end">checks epoch, derives session keys</text>
|
||||
<!-- Step 3: Initiator completes -->
|
||||
<text x="20" y="324" fill="#8af8c8" font-size="10">validates msg2</text>
|
||||
<text x="20" y="338" fill="#8af8c8" font-size="10">derives session keys</text>
|
||||
|
||||
<!-- Handshake complete separator -->
|
||||
<line x1="30" y1="488" x2="230" y2="488" stroke="#d9904a" stroke-width="1.5"/>
|
||||
<text x="310" y="492" fill="#d9904a" text-anchor="middle" font-size="11" font-weight="bold">HANDSHAKE COMPLETE</text>
|
||||
<line x1="390" y1="488" x2="590" y2="488" stroke="#d9904a" stroke-width="1.5"/>
|
||||
<line x1="30" y1="360" x2="230" y2="360" stroke="#d9904a" stroke-width="1.5"/>
|
||||
<text x="310" y="364" fill="#d9904a" text-anchor="middle" font-size="11" font-weight="bold">HANDSHAKE COMPLETE</text>
|
||||
<line x1="390" y1="360" x2="590" y2="360" stroke="#d9904a" stroke-width="1.5"/>
|
||||
|
||||
<!-- Arrow 4: first encrypted frame -->
|
||||
<text x="20" y="516" fill="#777" font-size="10">first encrypted frame:</text>
|
||||
<line x1="100" y1="530" x2="520" y2="530" stroke="#d9b04a" stroke-width="1.5" marker-end="url(#arrowR)"/>
|
||||
<rect x="115" y="538" width="390" height="24" fill="#4a3d2d" stroke="#d9b04a" stroke-width="1" rx="3"/>
|
||||
<text x="310" y="554" fill="#e0e0e0" text-anchor="middle" font-size="9">[0x00|flags|len] | receiver_idx | counter=0 | ciphertext+tag</text>
|
||||
<text x="310" y="574" fill="#666" text-anchor="middle" font-size="9">phase 0x0 — established frame</text>
|
||||
<!-- Arrow 3: first encrypted frame -->
|
||||
<text x="20" y="392" fill="#777" font-size="10">first encrypted frame:</text>
|
||||
<line x1="100" y1="406" x2="520" y2="406" stroke="#d94a6a" stroke-width="1.5" marker-end="url(#arrowR)"/>
|
||||
<rect x="115" y="414" width="390" height="24" fill="#5a2d3d" stroke="#d94a6a" stroke-width="1" rx="3"/>
|
||||
<text x="310" y="430" fill="#e0e0e0" text-anchor="middle" font-size="9">[0x00|flags|len] | receiver_idx | counter=0 | ciphertext+tag</text>
|
||||
<text x="310" y="450" fill="#666" text-anchor="middle" font-size="9">phase 0x0 — established frame</text>
|
||||
|
||||
<!-- Legend -->
|
||||
<text x="310" y="600" fill="#555" text-anchor="middle" font-size="9">Both parties hold identical symmetric keys. Epoch + negotiation exchanged in msg2/msg3.</text>
|
||||
<text x="310" y="478" fill="#555" text-anchor="middle" font-size="9">Both parties hold identical symmetric keys. Epoch exchange enables restart detection.</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,67 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 580 426" font-family="monospace" font-size="13">
|
||||
<!-- Background -->
|
||||
<rect width="580" height="426" fill="#1a1a2e" rx="4"/>
|
||||
|
||||
<!-- Title -->
|
||||
<text x="310" y="26" fill="#e0e0e0" text-anchor="middle" font-size="14" font-weight="bold">Noise IK Message 1 — phase 0x1 (114 bytes)</text>
|
||||
|
||||
<!-- Row 0: Common prefix (bytes 0-3) -->
|
||||
<text x="50" y="62" fill="#666" font-size="10" text-anchor="end">0–3</text>
|
||||
|
||||
<rect x="55" y="36" width="95" height="52" fill="#2d4a7a" stroke="#4a90d9" stroke-width="1.5" rx="3"/>
|
||||
<text x="102" y="60" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">ver</text>
|
||||
<text x="102" y="78" fill="#8ab4f8" text-anchor="middle" font-size="10">4 bits</text>
|
||||
|
||||
<rect x="150" y="36" width="95" height="52" fill="#2d5a4a" stroke="#4ad99a" stroke-width="1.5" rx="3"/>
|
||||
<text x="197" y="60" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">phase</text>
|
||||
<text x="197" y="78" fill="#8af8c8" text-anchor="middle" font-size="10">4 bits</text>
|
||||
|
||||
<rect x="245" y="36" width="130" height="52" fill="#5a3d2d" stroke="#d9904a" stroke-width="1.5" rx="3"/>
|
||||
<text x="310" y="60" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">flags</text>
|
||||
<text x="310" y="78" fill="#f8c88a" text-anchor="middle" font-size="10">1 byte</text>
|
||||
|
||||
<rect x="375" y="36" width="200" height="52" fill="#4a2d5a" stroke="#b04ad9" stroke-width="1.5" rx="3"/>
|
||||
<text x="475" y="60" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">payload_len</text>
|
||||
<text x="475" y="78" fill="#d8a0f8" text-anchor="middle" font-size="10">2 bytes LE</text>
|
||||
|
||||
<!-- Row 1: sender_idx (bytes 4-7) -->
|
||||
<text x="50" y="114" fill="#666" font-size="10" text-anchor="end">4–7</text>
|
||||
|
||||
<rect x="55" y="88" width="520" height="52" fill="#2d5a5a" stroke="#4ad9d9" stroke-width="1.5" rx="3"/>
|
||||
<text x="315" y="112" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">sender_idx</text>
|
||||
<text x="315" y="130" fill="#8af8f8" text-anchor="middle" font-size="10">4 bytes LE</text>
|
||||
|
||||
<!-- Noise IK msg1 label -->
|
||||
<text x="10" y="250" fill="#777" font-size="10" text-anchor="middle" transform="rotate(-90, 10, 250)">Noise IK msg1 (106 bytes)</text>
|
||||
<line x1="18" y1="140" x2="18" y2="348" stroke="#555" stroke-width="1"/>
|
||||
<line x1="18" y1="140" x2="23" y2="140" stroke="#555" stroke-width="1"/>
|
||||
<line x1="18" y1="348" x2="23" y2="348" stroke="#555" stroke-width="1"/>
|
||||
|
||||
<!-- Rows 2-3: ephemeral_pubkey (bytes 8-40, 33 bytes) -->
|
||||
<text x="50" y="166" fill="#666" font-size="10" text-anchor="end">8–40</text>
|
||||
|
||||
<rect x="55" y="140" width="520" height="52" fill="#5a3d2d" stroke="#d9904a" stroke-width="1.5" rx="3"/>
|
||||
<text x="315" y="170" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">ephemeral_pubkey</text>
|
||||
|
||||
<rect x="55" y="192" width="520" height="52" fill="#5a3d2d" stroke="#d9904a" stroke-width="1.5" rx="3"/>
|
||||
<text x="315" y="222" fill="#f8c88a" text-anchor="middle" font-size="10">33 bytes (compressed secp256k1)</text>
|
||||
|
||||
<!-- Rows 4-5: encrypted_static (bytes 41-89, 49 bytes) -->
|
||||
<text x="50" y="270" fill="#666" font-size="10" text-anchor="end">41–89</text>
|
||||
|
||||
<rect x="55" y="244" width="520" height="52" fill="#5a2d3d" stroke="#d94a6a" stroke-width="1.5" rx="3"/>
|
||||
<text x="315" y="274" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">encrypted_static</text>
|
||||
|
||||
<rect x="55" y="296" width="520" height="52" fill="#5a2d3d" stroke="#d94a6a" stroke-width="1.5" rx="3"/>
|
||||
<text x="315" y="326" fill="#f88aaa" text-anchor="middle" font-size="10">49 bytes (static key 33 + AEAD tag 16)</text>
|
||||
|
||||
<!-- Row 6: encrypted_epoch (bytes 90-113, 24 bytes) -->
|
||||
<text x="50" y="374" fill="#666" font-size="10" text-anchor="end">90–113</text>
|
||||
|
||||
<rect x="55" y="348" width="520" height="52" fill="#4a2d5a" stroke="#b04ad9" stroke-width="1.5" rx="3"/>
|
||||
<text x="315" y="372" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">encrypted_epoch</text>
|
||||
<text x="315" y="390" fill="#d8a0f8" text-anchor="middle" font-size="10">24 bytes (epoch 8 + AEAD tag 16)</text>
|
||||
|
||||
<!-- Total -->
|
||||
<text x="310" y="418" fill="#777" font-size="10" text-anchor="middle">total: 114 bytes · payload_len = 110 · pattern: e, es, s, ss</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
@@ -1,12 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 580 218" font-family="monospace" font-size="13">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 580 374" font-family="monospace" font-size="13">
|
||||
<!-- Background -->
|
||||
<rect width="580" height="218" fill="#1a1a2e" rx="4"/>
|
||||
<rect width="580" height="374" fill="#1a1a2e" rx="4"/>
|
||||
|
||||
<!-- Title -->
|
||||
<text x="310" y="26" fill="#e0e0e0" text-anchor="middle" font-size="14" font-weight="bold">Noise XX Message 1 — phase 0x1 (41 bytes)</text>
|
||||
<text x="310" y="26" fill="#e0e0e0" text-anchor="middle" font-size="14" font-weight="bold">Noise IK Message 2 — phase 0x2 (69 bytes)</text>
|
||||
|
||||
<!-- Row 0: Common prefix (bytes 0-3) -->
|
||||
<text x="50" y="62" fill="#666" font-size="10" text-anchor="end">0–3</text>
|
||||
<text x="50" y="62" fill="#666" font-size="10" text-anchor="end">0–3</text>
|
||||
|
||||
<rect x="55" y="36" width="95" height="52" fill="#2d4a7a" stroke="#4a90d9" stroke-width="1.5" rx="3"/>
|
||||
<text x="102" y="60" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">ver</text>
|
||||
@@ -25,19 +25,41 @@
|
||||
<text x="475" y="78" fill="#d8a0f8" text-anchor="middle" font-size="10">2 bytes LE</text>
|
||||
|
||||
<!-- Row 1: sender_idx (bytes 4-7) -->
|
||||
<text x="50" y="114" fill="#666" font-size="10" text-anchor="end">4–7</text>
|
||||
<text x="50" y="114" fill="#666" font-size="10" text-anchor="end">4–7</text>
|
||||
|
||||
<rect x="55" y="88" width="520" height="52" fill="#2d5a5a" stroke="#4ad9d9" stroke-width="1.5" rx="3"/>
|
||||
<text x="315" y="112" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">sender_idx</text>
|
||||
<text x="315" y="130" fill="#8af8f8" text-anchor="middle" font-size="10">4 bytes LE</text>
|
||||
|
||||
<!-- Row 2: ephemeral_pubkey (bytes 8-40, 33 bytes) -->
|
||||
<text x="50" y="166" fill="#666" font-size="10" text-anchor="end">8–40</text>
|
||||
<!-- Row 2: receiver_idx (bytes 8-11) -->
|
||||
<text x="50" y="166" fill="#666" font-size="10" text-anchor="end">8–11</text>
|
||||
|
||||
<rect x="55" y="140" width="520" height="52" fill="#5a3d2d" stroke="#d9904a" stroke-width="1.5" rx="3"/>
|
||||
<text x="315" y="164" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">ephemeral_pubkey</text>
|
||||
<text x="315" y="182" fill="#f8c88a" text-anchor="middle" font-size="10">33 bytes (compressed secp256k1)</text>
|
||||
<rect x="55" y="140" width="520" height="52" fill="#2d5a4a" stroke="#4ad99a" stroke-width="1.5" rx="3"/>
|
||||
<text x="315" y="164" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">receiver_idx</text>
|
||||
<text x="315" y="182" fill="#8af8c8" text-anchor="middle" font-size="10">4 bytes LE</text>
|
||||
|
||||
<!-- Noise IK msg2 bracket -->
|
||||
<text x="10" y="270" fill="#777" font-size="10" text-anchor="middle" transform="rotate(-90, 10, 270)">Noise IK msg2 (57 bytes)</text>
|
||||
<line x1="18" y1="192" x2="18" y2="348" stroke="#555" stroke-width="1"/>
|
||||
<line x1="18" y1="192" x2="23" y2="192" stroke="#555" stroke-width="1"/>
|
||||
<line x1="18" y1="348" x2="23" y2="348" stroke="#555" stroke-width="1"/>
|
||||
|
||||
<!-- Rows 3-4: ephemeral_pubkey (bytes 12-44, 33 bytes) -->
|
||||
<text x="50" y="218" fill="#666" font-size="10" text-anchor="end">12–44</text>
|
||||
|
||||
<rect x="55" y="192" width="520" height="52" fill="#5a3d2d" stroke="#d9904a" stroke-width="1.5" rx="3"/>
|
||||
<text x="315" y="222" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">ephemeral_pubkey</text>
|
||||
|
||||
<rect x="55" y="244" width="520" height="52" fill="#5a3d2d" stroke="#d9904a" stroke-width="1.5" rx="3"/>
|
||||
<text x="315" y="274" fill="#f8c88a" text-anchor="middle" font-size="10">33 bytes (compressed secp256k1)</text>
|
||||
|
||||
<!-- Row 5: encrypted_epoch (bytes 45-68, 24 bytes) -->
|
||||
<text x="50" y="322" fill="#666" font-size="10" text-anchor="end">45–68</text>
|
||||
|
||||
<rect x="55" y="296" width="520" height="52" fill="#4a2d5a" stroke="#b04ad9" stroke-width="1.5" rx="3"/>
|
||||
<text x="315" y="320" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">encrypted_epoch</text>
|
||||
<text x="315" y="338" fill="#d8a0f8" text-anchor="middle" font-size="10">24 bytes (epoch 8 + AEAD tag 16)</text>
|
||||
|
||||
<!-- Total -->
|
||||
<text x="310" y="210" fill="#777" font-size="10" text-anchor="middle">total: 41 bytes · payload_len = 37 · pattern: → e</text>
|
||||
<text x="310" y="366" fill="#777" font-size="10" text-anchor="middle">total: 69 bytes · payload_len = 65 · pattern: e, ee, se</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 4.1 KiB |
@@ -1,82 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 580 478" font-family="monospace" font-size="13">
|
||||
<!-- Background -->
|
||||
<rect width="580" height="478" fill="#1a1a2e" rx="4"/>
|
||||
|
||||
<!-- Title -->
|
||||
<text x="310" y="26" fill="#e0e0e0" text-anchor="middle" font-size="14" font-weight="bold">Noise XX Message 2 — phase 0x2 (118–144 bytes)</text>
|
||||
|
||||
<!-- Row 0: Common prefix (bytes 0-3) -->
|
||||
<text x="50" y="62" fill="#666" font-size="10" text-anchor="end">0–3</text>
|
||||
|
||||
<rect x="55" y="36" width="95" height="52" fill="#2d4a7a" stroke="#4a90d9" stroke-width="1.5" rx="3"/>
|
||||
<text x="102" y="60" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">ver</text>
|
||||
<text x="102" y="78" fill="#8ab4f8" text-anchor="middle" font-size="10">4 bits</text>
|
||||
|
||||
<rect x="150" y="36" width="95" height="52" fill="#2d5a4a" stroke="#4ad99a" stroke-width="1.5" rx="3"/>
|
||||
<text x="197" y="60" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">phase</text>
|
||||
<text x="197" y="78" fill="#8af8c8" text-anchor="middle" font-size="10">4 bits</text>
|
||||
|
||||
<rect x="245" y="36" width="130" height="52" fill="#5a3d2d" stroke="#d9904a" stroke-width="1.5" rx="3"/>
|
||||
<text x="310" y="60" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">flags</text>
|
||||
<text x="310" y="78" fill="#f8c88a" text-anchor="middle" font-size="10">1 byte</text>
|
||||
|
||||
<rect x="375" y="36" width="200" height="52" fill="#4a2d5a" stroke="#b04ad9" stroke-width="1.5" rx="3"/>
|
||||
<text x="475" y="60" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">payload_len</text>
|
||||
<text x="475" y="78" fill="#d8a0f8" text-anchor="middle" font-size="10">2 bytes LE</text>
|
||||
|
||||
<!-- Row 1: sender_idx (bytes 4-7) -->
|
||||
<text x="50" y="114" fill="#666" font-size="10" text-anchor="end">4–7</text>
|
||||
|
||||
<rect x="55" y="88" width="260" height="52" fill="#2d5a5a" stroke="#4ad9d9" stroke-width="1.5" rx="3"/>
|
||||
<text x="185" y="112" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">sender_idx</text>
|
||||
<text x="185" y="130" fill="#8af8f8" text-anchor="middle" font-size="10">4 bytes LE</text>
|
||||
|
||||
<!-- receiver_idx (bytes 8-11) -->
|
||||
<text x="50" y="114" fill="#666" font-size="10" text-anchor="end"/>
|
||||
|
||||
<rect x="315" y="88" width="260" height="52" fill="#2d5a4a" stroke="#4ad99a" stroke-width="1.5" rx="3"/>
|
||||
<text x="445" y="112" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">receiver_idx</text>
|
||||
<text x="445" y="130" fill="#8af8c8" text-anchor="middle" font-size="10">4 bytes LE</text>
|
||||
|
||||
<!-- Noise XX msg2 bracket -->
|
||||
<text x="10" y="290" fill="#777" font-size="10" text-anchor="middle" transform="rotate(-90, 10, 290)">Noise XX msg2 (106 bytes base)</text>
|
||||
<line x1="18" y1="140" x2="18" y2="400" stroke="#555" stroke-width="1"/>
|
||||
<line x1="18" y1="140" x2="23" y2="140" stroke="#555" stroke-width="1"/>
|
||||
<line x1="18" y1="400" x2="23" y2="400" stroke="#555" stroke-width="1"/>
|
||||
|
||||
<!-- Row 2: ephemeral_pubkey (bytes 12-44, 33 bytes) -->
|
||||
<text x="50" y="166" fill="#666" font-size="10" text-anchor="end">12–44</text>
|
||||
|
||||
<rect x="55" y="140" width="520" height="52" fill="#5a3d2d" stroke="#d9904a" stroke-width="1.5" rx="3"/>
|
||||
<text x="315" y="164" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">ephemeral_pubkey</text>
|
||||
<text x="315" y="182" fill="#f8c88a" text-anchor="middle" font-size="10">33 bytes (compressed secp256k1)</text>
|
||||
|
||||
<!-- Row 3: encrypted_static (bytes 45-93, 49 bytes) -->
|
||||
<text x="50" y="218" fill="#666" font-size="10" text-anchor="end">45–93</text>
|
||||
|
||||
<rect x="55" y="192" width="520" height="52" fill="#5a2d3d" stroke="#d94a6a" stroke-width="1.5" rx="3"/>
|
||||
<text x="315" y="216" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">encrypted_static</text>
|
||||
<text x="315" y="234" fill="#f88aaa" text-anchor="middle" font-size="10">49 bytes (static key 33 + AEAD tag 16)</text>
|
||||
|
||||
<!-- Row 4: encrypted_epoch (bytes 94-117, 24 bytes) -->
|
||||
<text x="50" y="270" fill="#666" font-size="10" text-anchor="end">94–117</text>
|
||||
|
||||
<rect x="55" y="244" width="520" height="52" fill="#4a2d5a" stroke="#b04ad9" stroke-width="1.5" rx="3"/>
|
||||
<text x="315" y="268" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">encrypted_epoch</text>
|
||||
<text x="315" y="286" fill="#d8a0f8" text-anchor="middle" font-size="10">24 bytes (epoch 8 + AEAD tag 16)</text>
|
||||
|
||||
<!-- Row 5: negotiation payload (variable, optional) -->
|
||||
<text x="50" y="322" fill="#666" font-size="10" text-anchor="end">118+</text>
|
||||
|
||||
<rect x="55" y="296" width="520" height="52" fill="#2d4a4a" stroke="#4a9090" stroke-width="1.5" rx="3" stroke-dasharray="4,3"/>
|
||||
<text x="315" y="320" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">encrypted negotiation payload</text>
|
||||
<text x="315" y="338" fill="#8ad8d8" text-anchor="middle" font-size="10">26 bytes typical (10 payload + 16 AEAD tag)</text>
|
||||
|
||||
<!-- Row 6: AEAD explanation -->
|
||||
<rect x="55" y="362" width="520" height="38" fill="#1f1f3a" stroke="#444" stroke-width="1" rx="3"/>
|
||||
<text x="315" y="386" fill="#888" text-anchor="middle" font-size="10">negotiation appended via encrypt_payload() — extends Noise hash chain</text>
|
||||
|
||||
<!-- Total -->
|
||||
<text x="310" y="425" fill="#777" font-size="10" text-anchor="middle">base: 118 bytes · with FMP negotiation: 144 bytes</text>
|
||||
<text x="310" y="442" fill="#777" font-size="10" text-anchor="middle">pattern: ← e, ee, s, es</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.4 KiB |
@@ -1,72 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 580 426" font-family="monospace" font-size="13">
|
||||
<!-- Background -->
|
||||
<rect width="580" height="426" fill="#1a1a2e" rx="4"/>
|
||||
|
||||
<!-- Title -->
|
||||
<text x="310" y="26" fill="#e0e0e0" text-anchor="middle" font-size="14" font-weight="bold">Noise XX Message 3 — phase 0x3 (85–111 bytes)</text>
|
||||
|
||||
<!-- Row 0: Common prefix (bytes 0-3) -->
|
||||
<text x="50" y="62" fill="#666" font-size="10" text-anchor="end">0–3</text>
|
||||
|
||||
<rect x="55" y="36" width="95" height="52" fill="#2d4a7a" stroke="#4a90d9" stroke-width="1.5" rx="3"/>
|
||||
<text x="102" y="60" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">ver</text>
|
||||
<text x="102" y="78" fill="#8ab4f8" text-anchor="middle" font-size="10">4 bits</text>
|
||||
|
||||
<rect x="150" y="36" width="95" height="52" fill="#2d5a4a" stroke="#4ad99a" stroke-width="1.5" rx="3"/>
|
||||
<text x="197" y="60" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">phase</text>
|
||||
<text x="197" y="78" fill="#8af8c8" text-anchor="middle" font-size="10">4 bits</text>
|
||||
|
||||
<rect x="245" y="36" width="130" height="52" fill="#5a3d2d" stroke="#d9904a" stroke-width="1.5" rx="3"/>
|
||||
<text x="310" y="60" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">flags</text>
|
||||
<text x="310" y="78" fill="#f8c88a" text-anchor="middle" font-size="10">1 byte</text>
|
||||
|
||||
<rect x="375" y="36" width="200" height="52" fill="#4a2d5a" stroke="#b04ad9" stroke-width="1.5" rx="3"/>
|
||||
<text x="475" y="60" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">payload_len</text>
|
||||
<text x="475" y="78" fill="#d8a0f8" text-anchor="middle" font-size="10">2 bytes LE</text>
|
||||
|
||||
<!-- Row 1: sender_idx + receiver_idx (bytes 4-11) -->
|
||||
<text x="50" y="114" fill="#666" font-size="10" text-anchor="end">4–11</text>
|
||||
|
||||
<rect x="55" y="88" width="260" height="52" fill="#2d5a5a" stroke="#4ad9d9" stroke-width="1.5" rx="3"/>
|
||||
<text x="185" y="112" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">sender_idx</text>
|
||||
<text x="185" y="130" fill="#8af8f8" text-anchor="middle" font-size="10">4 bytes LE</text>
|
||||
|
||||
<rect x="315" y="88" width="260" height="52" fill="#2d5a4a" stroke="#4ad99a" stroke-width="1.5" rx="3"/>
|
||||
<text x="445" y="112" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">receiver_idx</text>
|
||||
<text x="445" y="130" fill="#8af8c8" text-anchor="middle" font-size="10">4 bytes LE</text>
|
||||
|
||||
<!-- Noise XX msg3 bracket -->
|
||||
<text x="10" y="240" fill="#777" font-size="10" text-anchor="middle" transform="rotate(-90, 10, 240)">Noise XX msg3 (73 bytes base)</text>
|
||||
<line x1="18" y1="140" x2="18" y2="348" stroke="#555" stroke-width="1"/>
|
||||
<line x1="18" y1="140" x2="23" y2="140" stroke="#555" stroke-width="1"/>
|
||||
<line x1="18" y1="348" x2="23" y2="348" stroke="#555" stroke-width="1"/>
|
||||
|
||||
<!-- Row 2: encrypted_static (bytes 12-60, 49 bytes) -->
|
||||
<text x="50" y="166" fill="#666" font-size="10" text-anchor="end">12–60</text>
|
||||
|
||||
<rect x="55" y="140" width="520" height="52" fill="#5a2d3d" stroke="#d94a6a" stroke-width="1.5" rx="3"/>
|
||||
<text x="315" y="164" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">encrypted_static</text>
|
||||
<text x="315" y="182" fill="#f88aaa" text-anchor="middle" font-size="10">49 bytes (static key 33 + AEAD tag 16)</text>
|
||||
|
||||
<!-- Row 3: encrypted_epoch (bytes 61-84, 24 bytes) -->
|
||||
<text x="50" y="218" fill="#666" font-size="10" text-anchor="end">61–84</text>
|
||||
|
||||
<rect x="55" y="192" width="520" height="52" fill="#4a2d5a" stroke="#b04ad9" stroke-width="1.5" rx="3"/>
|
||||
<text x="315" y="216" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">encrypted_epoch</text>
|
||||
<text x="315" y="234" fill="#d8a0f8" text-anchor="middle" font-size="10">24 bytes (epoch 8 + AEAD tag 16)</text>
|
||||
|
||||
<!-- Row 4: negotiation payload (variable, optional) -->
|
||||
<text x="50" y="270" fill="#666" font-size="10" text-anchor="end">85+</text>
|
||||
|
||||
<rect x="55" y="244" width="520" height="52" fill="#2d4a4a" stroke="#4a9090" stroke-width="1.5" rx="3" stroke-dasharray="4,3"/>
|
||||
<text x="315" y="268" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">encrypted negotiation payload</text>
|
||||
<text x="315" y="286" fill="#8ad8d8" text-anchor="middle" font-size="10">26 bytes typical (10 payload + 16 AEAD tag)</text>
|
||||
|
||||
<!-- Row 5: explanation -->
|
||||
<rect x="55" y="310" width="520" height="38" fill="#1f1f3a" stroke="#444" stroke-width="1" rx="3"/>
|
||||
<text x="315" y="334" fill="#888" text-anchor="middle" font-size="10">negotiation appended via encrypt_payload() — extends Noise hash chain</text>
|
||||
|
||||
<!-- Total -->
|
||||
<text x="310" y="378" fill="#777" font-size="10" text-anchor="middle">base: 85 bytes · with FMP negotiation: 111 bytes</text>
|
||||
<text x="310" y="395" fill="#777" font-size="10" text-anchor="middle">pattern: → s, se</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.8 KiB |
@@ -3,95 +3,104 @@
|
||||
<rect width="580" height="634" fill="#1a1a2e" rx="4"/>
|
||||
|
||||
<!-- Title -->
|
||||
<text x="310" y="26" fill="#e0e0e0" text-anchor="middle" font-size="14" font-weight="bold">ReceiverReport (0x02) — 54 bytes</text>
|
||||
<text x="310" y="26" fill="#e0e0e0" text-anchor="middle" font-size="14" font-weight="bold">ReceiverReport (0x02) — 68 bytes</text>
|
||||
|
||||
<!-- Row 0 (0-3): msg_type + format_version + total_length -->
|
||||
<text x="50" y="62" fill="#666" font-size="10" text-anchor="end">0–3</text>
|
||||
<!-- Row 0 (0–3): msg_type + reserved -->
|
||||
<text x="50" y="62" fill="#666" font-size="10" text-anchor="end">0–3</text>
|
||||
|
||||
<rect x="55" y="36" width="130" height="52" fill="#2d4a7a" stroke="#4a90d9" stroke-width="1.5" rx="3"/>
|
||||
<text x="120" y="56" fill="#e0e0e0" text-anchor="middle" font-size="11" font-weight="bold">msg_type</text>
|
||||
<text x="120" y="74" fill="#8ab4f8" text-anchor="middle" font-size="10">0x02</text>
|
||||
<text x="120" y="60" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">msg_type</text>
|
||||
<text x="120" y="78" fill="#8ab4f8" text-anchor="middle" font-size="10">0x02</text>
|
||||
|
||||
<rect x="185" y="36" width="130" height="52" fill="#2d5a4a" stroke="#4ad99a" stroke-width="1.5" rx="3"/>
|
||||
<text x="250" y="56" fill="#e0e0e0" text-anchor="middle" font-size="11" font-weight="bold">fmt_ver</text>
|
||||
<text x="250" y="74" fill="#8af8c8" text-anchor="middle" font-size="10">1 byte</text>
|
||||
<rect x="185" y="36" width="390" height="52" fill="#1f1f3a" stroke="#444" stroke-width="1" rx="3"/>
|
||||
<text x="380" y="66" fill="#666" text-anchor="middle" font-size="12">reserved (3 bytes zero)</text>
|
||||
|
||||
<rect x="315" y="36" width="260" height="52" fill="#4a2d5a" stroke="#b04ad9" stroke-width="1.5" rx="3"/>
|
||||
<text x="445" y="56" fill="#e0e0e0" text-anchor="middle" font-size="11" font-weight="bold">total_length</text>
|
||||
<text x="445" y="74" fill="#d8a0f8" text-anchor="middle" font-size="10">2 bytes LE (= 50)</text>
|
||||
|
||||
<!-- Row 1 (4-7): timestamp_echo -->
|
||||
<text x="50" y="114" fill="#666" font-size="10" text-anchor="end">4–7</text>
|
||||
<!-- Row 1 (4–11): highest_counter -->
|
||||
<text x="50" y="114" fill="#666" font-size="10" text-anchor="end">4–11</text>
|
||||
|
||||
<rect x="55" y="88" width="520" height="52" fill="#5a3d2d" stroke="#d9904a" stroke-width="1.5" rx="3"/>
|
||||
<text x="315" y="110" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">timestamp_echo</text>
|
||||
<text x="315" y="128" fill="#f8c88a" text-anchor="middle" font-size="10">4 bytes LE (echoed sender timestamp for RTT)</text>
|
||||
<text x="315" y="110" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">highest_counter</text>
|
||||
<text x="315" y="128" fill="#f8c88a" text-anchor="middle" font-size="10">8 bytes LE</text>
|
||||
|
||||
<!-- Row 2 (8-9): dwell_time -->
|
||||
<text x="50" y="166" fill="#666" font-size="10" text-anchor="end">8–9</text>
|
||||
<!-- Row 2 (12–19): cumulative_packets_recv -->
|
||||
<text x="50" y="166" fill="#666" font-size="10" text-anchor="end">12–19</text>
|
||||
|
||||
<rect x="55" y="140" width="520" height="52" fill="#2d5a5a" stroke="#4ad9d9" stroke-width="1.5" rx="3"/>
|
||||
<text x="315" y="162" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">dwell_time</text>
|
||||
<text x="315" y="180" fill="#8af8f8" text-anchor="middle" font-size="10">2 bytes LE (ms between receive and echo)</text>
|
||||
<rect x="55" y="140" width="520" height="52" fill="#2d5a4a" stroke="#4ad99a" stroke-width="1.5" rx="3"/>
|
||||
<text x="315" y="162" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">cumulative_packets_recv</text>
|
||||
<text x="315" y="180" fill="#8af8c8" text-anchor="middle" font-size="10">8 bytes LE</text>
|
||||
|
||||
<!-- Row 3 (10-17): highest_counter -->
|
||||
<text x="50" y="218" fill="#666" font-size="10" text-anchor="end">10–17</text>
|
||||
<!-- Row 3 (20–27): cumulative_bytes_recv -->
|
||||
<text x="50" y="218" fill="#666" font-size="10" text-anchor="end">20–27</text>
|
||||
|
||||
<rect x="55" y="192" width="520" height="52" fill="#2d5a4a" stroke="#4ad99a" stroke-width="1.5" rx="3"/>
|
||||
<text x="315" y="214" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">highest_counter</text>
|
||||
<text x="315" y="214" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">cumulative_bytes_recv</text>
|
||||
<text x="315" y="232" fill="#8af8c8" text-anchor="middle" font-size="10">8 bytes LE</text>
|
||||
|
||||
<!-- Row 4 (18-25): cumulative_packets_recv -->
|
||||
<text x="50" y="270" fill="#666" font-size="10" text-anchor="end">18–25</text>
|
||||
<!-- Row 4 (28–33): timestamp_echo + dwell_time -->
|
||||
<text x="50" y="270" fill="#666" font-size="10" text-anchor="end">28–33</text>
|
||||
|
||||
<rect x="55" y="244" width="520" height="52" fill="#5a3d2d" stroke="#d9904a" stroke-width="1.5" rx="3"/>
|
||||
<text x="315" y="266" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">cumulative_packets_recv</text>
|
||||
<text x="315" y="284" fill="#f8c88a" text-anchor="middle" font-size="10">8 bytes LE</text>
|
||||
<rect x="55" y="244" width="260" height="52" fill="#4a2d5a" stroke="#b04ad9" stroke-width="1.5" rx="3"/>
|
||||
<text x="185" y="266" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">timestamp_echo</text>
|
||||
<text x="185" y="284" fill="#d8a0f8" text-anchor="middle" font-size="10">4 bytes LE</text>
|
||||
|
||||
<!-- Row 5 (26-33): cumulative_bytes_recv -->
|
||||
<text x="50" y="322" fill="#666" font-size="10" text-anchor="end">26–33</text>
|
||||
<rect x="315" y="244" width="260" height="52" fill="#2d5a5a" stroke="#4ad9d9" stroke-width="1.5" rx="3"/>
|
||||
<text x="445" y="266" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">dwell_time</text>
|
||||
<text x="445" y="284" fill="#8af8f8" text-anchor="middle" font-size="10">2 bytes LE ms</text>
|
||||
|
||||
<rect x="55" y="296" width="520" height="52" fill="#2d5a5a" stroke="#4ad9d9" stroke-width="1.5" rx="3"/>
|
||||
<text x="315" y="318" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">cumulative_bytes_recv</text>
|
||||
<text x="315" y="336" fill="#8af8f8" text-anchor="middle" font-size="10">8 bytes LE</text>
|
||||
<!-- Row 5 (34–39): max_burst_loss + mean_burst_loss + reserved -->
|
||||
<text x="50" y="322" fill="#666" font-size="10" text-anchor="end">34–39</text>
|
||||
|
||||
<!-- Row 6 (34-41): jitter + ecn_ce_count -->
|
||||
<text x="50" y="374" fill="#666" font-size="10" text-anchor="end">34–41</text>
|
||||
<rect x="55" y="296" width="173" height="52" fill="#5a2d3d" stroke="#d94a6a" stroke-width="1.5" rx="3"/>
|
||||
<text x="141" y="318" fill="#e0e0e0" text-anchor="middle" font-size="11" font-weight="bold">max_burst</text>
|
||||
<text x="141" y="336" fill="#f88aaa" text-anchor="middle" font-size="10">2 bytes LE</text>
|
||||
|
||||
<rect x="55" y="348" width="260" height="52" fill="#4a2d5a" stroke="#b04ad9" stroke-width="1.5" rx="3"/>
|
||||
<rect x="228" y="296" width="174" height="52" fill="#5a2d3d" stroke="#d94a6a" stroke-width="1.5" rx="3"/>
|
||||
<text x="315" y="318" fill="#e0e0e0" text-anchor="middle" font-size="11" font-weight="bold">mean_burst</text>
|
||||
<text x="315" y="336" fill="#f88aaa" text-anchor="middle" font-size="10">2 bytes u8.8</text>
|
||||
|
||||
<rect x="402" y="296" width="173" height="52" fill="#1f1f3a" stroke="#444" stroke-width="1" rx="3"/>
|
||||
<text x="488" y="322" fill="#666" text-anchor="middle" font-size="10">reserved</text>
|
||||
|
||||
<!-- Row 6 (40–47): jitter + ecn_ce_count -->
|
||||
<text x="50" y="374" fill="#666" font-size="10" text-anchor="end">40–47</text>
|
||||
|
||||
<rect x="55" y="348" width="260" height="52" fill="#4a3d2d" stroke="#d9b04a" stroke-width="1.5" rx="3"/>
|
||||
<text x="185" y="370" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">jitter</text>
|
||||
<text x="185" y="388" fill="#d8a0f8" text-anchor="middle" font-size="10">4 bytes LE (µs)</text>
|
||||
<text x="185" y="388" fill="#f8d88a" text-anchor="middle" font-size="10">4 bytes LE µs</text>
|
||||
|
||||
<rect x="315" y="348" width="260" height="52" fill="#2d5a4a" stroke="#4ad99a" stroke-width="1.5" rx="3"/>
|
||||
<rect x="315" y="348" width="260" height="52" fill="#4a3d2d" stroke="#d9b04a" stroke-width="1.5" rx="3"/>
|
||||
<text x="445" y="370" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">ecn_ce_count</text>
|
||||
<text x="445" y="388" fill="#8af8c8" text-anchor="middle" font-size="10">4 bytes LE</text>
|
||||
<text x="445" y="388" fill="#f8d88a" text-anchor="middle" font-size="10">4 bytes LE</text>
|
||||
|
||||
<!-- Row 7 (42-49): owd_trend + burst_loss_count -->
|
||||
<text x="50" y="426" fill="#666" font-size="10" text-anchor="end">42–49</text>
|
||||
<!-- Row 7 (48–55): owd_trend + burst_loss_count -->
|
||||
<text x="50" y="426" fill="#666" font-size="10" text-anchor="end">48–55</text>
|
||||
|
||||
<rect x="55" y="400" width="260" height="52" fill="#5a3d2d" stroke="#d9904a" stroke-width="1.5" rx="3"/>
|
||||
<rect x="55" y="400" width="260" height="52" fill="#3d4a2d" stroke="#8ad94a" stroke-width="1.5" rx="3"/>
|
||||
<text x="185" y="422" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">owd_trend</text>
|
||||
<text x="185" y="440" fill="#f8c88a" text-anchor="middle" font-size="10">i32 LE (µs/s)</text>
|
||||
<text x="185" y="440" fill="#c8f88a" text-anchor="middle" font-size="10">4 bytes i32 LE µs/s</text>
|
||||
|
||||
<rect x="315" y="400" width="260" height="52" fill="#2d5a5a" stroke="#4ad9d9" stroke-width="1.5" rx="3"/>
|
||||
<rect x="315" y="400" width="260" height="52" fill="#3d4a2d" stroke="#8ad94a" stroke-width="1.5" rx="3"/>
|
||||
<text x="445" y="422" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">burst_loss_count</text>
|
||||
<text x="445" y="440" fill="#8af8f8" text-anchor="middle" font-size="10">4 bytes LE</text>
|
||||
<text x="445" y="440" fill="#c8f88a" text-anchor="middle" font-size="10">4 bytes LE</text>
|
||||
|
||||
<!-- Row 8 (50-53): cumulative_reorder_count -->
|
||||
<text x="50" y="478" fill="#666" font-size="10" text-anchor="end">50–53</text>
|
||||
<!-- Row 8 (56–63): cumulative_reorder_count + interval_packets_recv -->
|
||||
<text x="50" y="478" fill="#666" font-size="10" text-anchor="end">56–63</text>
|
||||
|
||||
<rect x="55" y="452" width="520" height="52" fill="#4a2d5a" stroke="#b04ad9" stroke-width="1.5" rx="3"/>
|
||||
<text x="315" y="474" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">cumulative_reorder_count</text>
|
||||
<text x="315" y="492" fill="#d8a0f8" text-anchor="middle" font-size="10">4 bytes LE</text>
|
||||
<rect x="55" y="452" width="260" height="52" fill="#2d5a5a" stroke="#4ad9d9" stroke-width="1.5" rx="3"/>
|
||||
<text x="185" y="474" fill="#e0e0e0" text-anchor="middle" font-size="11" font-weight="bold">reorder_count</text>
|
||||
<text x="185" y="492" fill="#8af8f8" text-anchor="middle" font-size="10">4 bytes LE cumulative</text>
|
||||
|
||||
<!-- Forward compat note -->
|
||||
<rect x="55" y="518" width="520" height="32" fill="#1f1f3a" stroke="#444" stroke-width="1" rx="3"/>
|
||||
<text x="315" y="538" fill="#888" text-anchor="middle" font-size="10">decoders skip trailing bytes beyond total_length (forward compatibility)</text>
|
||||
<rect x="315" y="452" width="260" height="52" fill="#2d5a5a" stroke="#4ad9d9" stroke-width="1.5" rx="3"/>
|
||||
<text x="445" y="474" fill="#e0e0e0" text-anchor="middle" font-size="11" font-weight="bold">interval_pkts_recv</text>
|
||||
<text x="445" y="492" fill="#8af8f8" text-anchor="middle" font-size="10">4 bytes LE</text>
|
||||
|
||||
<!-- Removed fields note -->
|
||||
<rect x="55" y="558" width="520" height="32" fill="#1f1f3a" stroke="#444" stroke-width="1" rx="3"/>
|
||||
<text x="315" y="578" fill="#666" text-anchor="middle" font-size="10">removed from v0.2: max_burst_loss, mean_burst_loss, interval_packets/bytes_recv</text>
|
||||
<!-- Row 9 (64–67): interval_bytes_recv -->
|
||||
<text x="50" y="530" fill="#666" font-size="10" text-anchor="end">64–67</text>
|
||||
|
||||
<rect x="55" y="504" width="260" height="52" fill="#2d5a5a" stroke="#4ad9d9" stroke-width="1.5" rx="3"/>
|
||||
<text x="185" y="526" fill="#e0e0e0" text-anchor="middle" font-size="11" font-weight="bold">interval_bytes_recv</text>
|
||||
<text x="185" y="544" fill="#8af8f8" text-anchor="middle" font-size="10">4 bytes LE</text>
|
||||
|
||||
<!-- Total -->
|
||||
<text x="310" y="618" fill="#777" font-size="10" text-anchor="middle">total: 54 bytes · format_version = 0 · total_length = 50</text>
|
||||
<text x="310" y="586" fill="#777" font-size="10" text-anchor="middle">total: 68 bytes</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.8 KiB |
@@ -1,50 +1,66 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 580 322" font-family="monospace" font-size="13">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 580 478" font-family="monospace" font-size="13">
|
||||
<!-- Background -->
|
||||
<rect width="580" height="322" fill="#1a1a2e" rx="4"/>
|
||||
<rect width="580" height="478" fill="#1a1a2e" rx="4"/>
|
||||
|
||||
<!-- Title -->
|
||||
<text x="310" y="26" fill="#e0e0e0" text-anchor="middle" font-size="14" font-weight="bold">SenderReport (0x01) — 20 bytes</text>
|
||||
<text x="310" y="26" fill="#e0e0e0" text-anchor="middle" font-size="14" font-weight="bold">SenderReport (0x01) — 48 bytes</text>
|
||||
|
||||
<!-- Row 0 (0-3): msg_type + format_version + total_length -->
|
||||
<text x="50" y="62" fill="#666" font-size="10" text-anchor="end">0–3</text>
|
||||
<!-- Row 0 (0–3): msg_type + reserved -->
|
||||
<text x="50" y="62" fill="#666" font-size="10" text-anchor="end">0–3</text>
|
||||
|
||||
<rect x="55" y="36" width="130" height="52" fill="#2d4a7a" stroke="#4a90d9" stroke-width="1.5" rx="3"/>
|
||||
<text x="120" y="56" fill="#e0e0e0" text-anchor="middle" font-size="11" font-weight="bold">msg_type</text>
|
||||
<text x="120" y="74" fill="#8ab4f8" text-anchor="middle" font-size="10">0x01</text>
|
||||
<text x="120" y="60" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">msg_type</text>
|
||||
<text x="120" y="78" fill="#8ab4f8" text-anchor="middle" font-size="10">0x01</text>
|
||||
|
||||
<rect x="185" y="36" width="130" height="52" fill="#2d5a4a" stroke="#4ad99a" stroke-width="1.5" rx="3"/>
|
||||
<text x="250" y="56" fill="#e0e0e0" text-anchor="middle" font-size="11" font-weight="bold">fmt_ver</text>
|
||||
<text x="250" y="74" fill="#8af8c8" text-anchor="middle" font-size="10">1 byte</text>
|
||||
<rect x="185" y="36" width="390" height="52" fill="#1f1f3a" stroke="#444" stroke-width="1" rx="3"/>
|
||||
<text x="380" y="66" fill="#666" text-anchor="middle" font-size="12">reserved (3 bytes zero)</text>
|
||||
|
||||
<rect x="315" y="36" width="260" height="52" fill="#4a2d5a" stroke="#b04ad9" stroke-width="1.5" rx="3"/>
|
||||
<text x="445" y="56" fill="#e0e0e0" text-anchor="middle" font-size="11" font-weight="bold">total_length</text>
|
||||
<text x="445" y="74" fill="#d8a0f8" text-anchor="middle" font-size="10">2 bytes LE (= 16)</text>
|
||||
|
||||
<!-- Row 1 (4-7): interval_packets_sent -->
|
||||
<text x="50" y="114" fill="#666" font-size="10" text-anchor="end">4–7</text>
|
||||
<!-- Row 1 (4–11): interval_start_counter -->
|
||||
<text x="50" y="114" fill="#666" font-size="10" text-anchor="end">4–11</text>
|
||||
|
||||
<rect x="55" y="88" width="520" height="52" fill="#5a3d2d" stroke="#d9904a" stroke-width="1.5" rx="3"/>
|
||||
<text x="315" y="110" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">interval_packets_sent</text>
|
||||
<text x="315" y="128" fill="#f8c88a" text-anchor="middle" font-size="10">4 bytes LE</text>
|
||||
<text x="315" y="110" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">interval_start_counter</text>
|
||||
<text x="315" y="128" fill="#f8c88a" text-anchor="middle" font-size="10">8 bytes LE</text>
|
||||
|
||||
<!-- Row 2 (8-11): interval_bytes_sent -->
|
||||
<text x="50" y="166" fill="#666" font-size="10" text-anchor="end">8–11</text>
|
||||
<!-- Row 2 (12–19): interval_end_counter -->
|
||||
<text x="50" y="166" fill="#666" font-size="10" text-anchor="end">12–19</text>
|
||||
|
||||
<rect x="55" y="140" width="520" height="52" fill="#2d5a5a" stroke="#4ad9d9" stroke-width="1.5" rx="3"/>
|
||||
<text x="315" y="162" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">interval_bytes_sent</text>
|
||||
<text x="315" y="180" fill="#8af8f8" text-anchor="middle" font-size="10">4 bytes LE</text>
|
||||
<rect x="55" y="140" width="520" height="52" fill="#5a3d2d" stroke="#d9904a" stroke-width="1.5" rx="3"/>
|
||||
<text x="315" y="162" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">interval_end_counter</text>
|
||||
<text x="315" y="180" fill="#f8c88a" text-anchor="middle" font-size="10">8 bytes LE</text>
|
||||
|
||||
<!-- Row 3 (12-19): cumulative_packets_sent -->
|
||||
<text x="50" y="218" fill="#666" font-size="10" text-anchor="end">12–19</text>
|
||||
<!-- Row 3 (20–27): interval_start_timestamp + interval_end_timestamp -->
|
||||
<text x="50" y="218" fill="#666" font-size="10" text-anchor="end">20–27</text>
|
||||
|
||||
<rect x="55" y="192" width="520" height="52" fill="#2d5a4a" stroke="#4ad99a" stroke-width="1.5" rx="3"/>
|
||||
<text x="315" y="214" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">cumulative_packets_sent</text>
|
||||
<text x="315" y="232" fill="#8af8c8" text-anchor="middle" font-size="10">8 bytes LE</text>
|
||||
<rect x="55" y="192" width="260" height="52" fill="#4a2d5a" stroke="#b04ad9" stroke-width="1.5" rx="3"/>
|
||||
<text x="185" y="214" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">interval_start_ts</text>
|
||||
<text x="185" y="232" fill="#d8a0f8" text-anchor="middle" font-size="10">4 bytes LE</text>
|
||||
|
||||
<!-- Forward compat note -->
|
||||
<rect x="55" y="258" width="520" height="32" fill="#1f1f3a" stroke="#444" stroke-width="1" rx="3"/>
|
||||
<text x="315" y="278" fill="#888" text-anchor="middle" font-size="10">decoders skip trailing bytes beyond total_length (forward compatibility)</text>
|
||||
<rect x="315" y="192" width="260" height="52" fill="#4a2d5a" stroke="#b04ad9" stroke-width="1.5" rx="3"/>
|
||||
<text x="445" y="214" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">interval_end_ts</text>
|
||||
<text x="445" y="232" fill="#d8a0f8" text-anchor="middle" font-size="10">4 bytes LE</text>
|
||||
|
||||
<!-- Row 4 (28–31): interval_bytes_sent -->
|
||||
<text x="50" y="270" fill="#666" font-size="10" text-anchor="end">28–31</text>
|
||||
|
||||
<rect x="55" y="244" width="520" height="52" fill="#2d5a5a" stroke="#4ad9d9" stroke-width="1.5" rx="3"/>
|
||||
<text x="315" y="266" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">interval_bytes_sent</text>
|
||||
<text x="315" y="284" fill="#8af8f8" text-anchor="middle" font-size="10">4 bytes LE</text>
|
||||
|
||||
<!-- Row 5 (32–39): cumulative_packets_sent -->
|
||||
<text x="50" y="322" fill="#666" font-size="10" text-anchor="end">32–39</text>
|
||||
|
||||
<rect x="55" y="296" width="520" height="52" fill="#2d5a4a" stroke="#4ad99a" stroke-width="1.5" rx="3"/>
|
||||
<text x="315" y="318" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">cumulative_packets_sent</text>
|
||||
<text x="315" y="336" fill="#8af8c8" text-anchor="middle" font-size="10">8 bytes LE</text>
|
||||
|
||||
<!-- Row 6 (40–47): cumulative_bytes_sent -->
|
||||
<text x="50" y="374" fill="#666" font-size="10" text-anchor="end">40–47</text>
|
||||
|
||||
<rect x="55" y="348" width="520" height="52" fill="#2d5a4a" stroke="#4ad99a" stroke-width="1.5" rx="3"/>
|
||||
<text x="315" y="370" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">cumulative_bytes_sent</text>
|
||||
<text x="315" y="388" fill="#8af8c8" text-anchor="middle" font-size="10">8 bytes LE</text>
|
||||
|
||||
<!-- Total -->
|
||||
<text x="310" y="314" fill="#777" font-size="10" text-anchor="middle">total: 20 bytes · format_version = 0 · total_length = 16</text>
|
||||
<text x="310" y="422" fill="#777" font-size="10" text-anchor="middle">total: 48 bytes</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 4.1 KiB |
@@ -3,7 +3,7 @@
|
||||
<rect width="580" height="322" fill="#1a1a2e" rx="4"/>
|
||||
|
||||
<!-- Title -->
|
||||
<text x="310" y="26" fill="#e0e0e0" text-anchor="middle" font-size="14" font-weight="bold">SessionAck (phase 0x2) — Noise XX msg2</text>
|
||||
<text x="310" y="26" fill="#e0e0e0" text-anchor="middle" font-size="14" font-weight="bold">SessionAck (phase 0x2) — Noise XK msg2</text>
|
||||
|
||||
<!-- Row 0 (0–3): FSP prefix -->
|
||||
<text x="50" y="62" fill="#666" font-size="10" text-anchor="end">0–3</text>
|
||||
@@ -40,8 +40,8 @@
|
||||
|
||||
<rect x="185" y="232" width="390" height="52" fill="#2d5a4a" stroke="#4ad99a" stroke-width="1.5" rx="3"/>
|
||||
<text x="380" y="254" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">handshake_payload</text>
|
||||
<text x="380" y="272" fill="#8af8c8" text-anchor="middle" font-size="10">Noise XX msg2 (106+ bytes — ephemeral + static + epoch)</text>
|
||||
<text x="380" y="272" fill="#8af8c8" text-anchor="middle" font-size="10">Noise XK msg2 (57 bytes — ephemeral + epoch)</text>
|
||||
|
||||
<!-- Total -->
|
||||
<text x="310" y="308" fill="#777" font-size="10" text-anchor="middle">typical ~240 bytes (depth-dependent, carries both endpoints' coords)</text>
|
||||
<text x="310" y="308" fill="#777" font-size="10" text-anchor="middle">typical ~190 bytes (depth-dependent, carries both endpoints' coords)</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
@@ -3,7 +3,7 @@
|
||||
<rect width="580" height="244" fill="#1a1a2e" rx="4"/>
|
||||
|
||||
<!-- Title -->
|
||||
<text x="310" y="26" fill="#e0e0e0" text-anchor="middle" font-size="14" font-weight="bold">SessionMsg3 (phase 0x3) — Noise XX msg3</text>
|
||||
<text x="310" y="26" fill="#e0e0e0" text-anchor="middle" font-size="14" font-weight="bold">SessionMsg3 (phase 0x3) — Noise XK msg3</text>
|
||||
|
||||
<!-- Row 0 (0–3): FSP prefix -->
|
||||
<text x="50" y="62" fill="#666" font-size="10" text-anchor="end">0–3</text>
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
<rect x="55" y="140" width="520" height="52" fill="#2d5a4a" stroke="#4ad99a" stroke-width="1.5" rx="3"/>
|
||||
<text x="315" y="162" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">handshake_payload</text>
|
||||
<text x="315" y="180" fill="#8af8c8" text-anchor="middle" font-size="10">Noise XX msg3 (73+ bytes — encrypted static + epoch + optional negotiation)</text>
|
||||
<text x="315" y="180" fill="#8af8c8" text-anchor="middle" font-size="10">Noise XK msg3 (73 bytes — encrypted static + encrypted epoch)</text>
|
||||
|
||||
<!-- Total -->
|
||||
<text x="310" y="218" fill="#777" font-size="10" text-anchor="middle">~80 bytes · no coordinates (both endpoints already have them)</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
@@ -3,7 +3,7 @@
|
||||
<rect width="580" height="322" fill="#1a1a2e" rx="4"/>
|
||||
|
||||
<!-- Title -->
|
||||
<text x="310" y="26" fill="#e0e0e0" text-anchor="middle" font-size="14" font-weight="bold">SessionSetup (phase 0x1) — Noise XX msg1</text>
|
||||
<text x="310" y="26" fill="#e0e0e0" text-anchor="middle" font-size="14" font-weight="bold">SessionSetup (phase 0x1) — Noise XK msg1</text>
|
||||
|
||||
<!-- Row 0 (0–3): FSP prefix -->
|
||||
<text x="50" y="62" fill="#666" font-size="10" text-anchor="end">0–3</text>
|
||||
@@ -40,7 +40,7 @@
|
||||
|
||||
<rect x="185" y="232" width="390" height="52" fill="#2d5a4a" stroke="#4ad99a" stroke-width="1.5" rx="3"/>
|
||||
<text x="380" y="254" fill="#e0e0e0" text-anchor="middle" font-size="12" font-weight="bold">handshake_payload</text>
|
||||
<text x="380" y="272" fill="#8af8c8" text-anchor="middle" font-size="10">Noise XX msg1 (33 bytes — ephemeral key)</text>
|
||||
<text x="380" y="272" fill="#8af8c8" text-anchor="middle" font-size="10">Noise XK msg1 (33 bytes — ephemeral key)</text>
|
||||
|
||||
<!-- Total -->
|
||||
<text x="310" y="308" fill="#777" font-size="10" text-anchor="middle">typical ~170 bytes (depth-dependent)</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
@@ -55,14 +55,14 @@ idempotent).
|
||||
|
||||
| Component | Choice | Where Used |
|
||||
| --------- | ------ | ---------- |
|
||||
| Curve | secp256k1 | FMP XX, FSP XX, Schnorr signatures |
|
||||
| Diffie-Hellman | ECDH on secp256k1 (x-only normalized) | Noise XX (both layers) |
|
||||
| Curve | secp256k1 | FMP IK, FSP XK, Schnorr signatures |
|
||||
| Diffie-Hellman | ECDH on secp256k1 (x-only normalized) | Noise IK, Noise XK |
|
||||
| AEAD | ChaCha20-Poly1305 | FMP link encryption, FSP session encryption |
|
||||
| Hash | SHA-256 | NodeAddr derivation, Noise transcript |
|
||||
| Key derivation | HKDF-SHA256 | Noise key schedule |
|
||||
| Signatures | secp256k1 Schnorr | TreeAnnounce, LookupResponse proof, Nostr adverts |
|
||||
| Noise pattern (link) | `Noise_XX_secp256k1_ChaChaPoly_SHA256` | FMP link layer (XX with negotiation payload) |
|
||||
| Noise pattern (session) | `Noise_XX_secp256k1_ChaChaPoly_SHA256` | FSP session layer (XX with negotiation payload) |
|
||||
| Noise pattern (link) | `Noise_IK_secp256k1_ChaChaPoly_SHA256` | FMP link layer (IK with epoch payload) |
|
||||
| Noise pattern (session) | `Noise_XK_secp256k1_ChaChaPoly_SHA256` | FSP session layer (XK with epoch payload) |
|
||||
|
||||
These choices align with the Nostr cryptographic stack
|
||||
(secp256k1 + ChaCha20-Poly1305 + SHA-256) and the NIP-44 encrypted
|
||||
@@ -101,7 +101,7 @@ and FSP layers.
|
||||
|
||||
Mesh-level ACL files at `/etc/fips/peers.allow` and
|
||||
`/etc/fips/peers.deny` give the operator allowlist/blocklist control
|
||||
over which npubs may complete the FMP Noise XX link handshake.
|
||||
over which npubs may complete the FMP Noise IK link handshake.
|
||||
|
||||
File format:
|
||||
|
||||
@@ -209,7 +209,7 @@ for the metadata-privacy model and the rejection of onion routing.
|
||||
| --------- | --------------- | ------------ | ------ |
|
||||
| UDP | None until `bind_addr` set | `0.0.0.0:2121` typical | Operator sets `transports.udp.bind_addr` |
|
||||
| TCP | None until `bind_addr` set | None — outbound-only without bind | Operator sets `transports.tcp.bind_addr` |
|
||||
| Ethernet | Listens on configured interface (raw `AF_PACKET`) | EtherType 0x2121 on selected interface | Per-flag `listen`, `announce`, `auto_connect`, `accept_connections` |
|
||||
| Ethernet | Listens on configured interface (raw `AF_PACKET`) | EtherType 0x2121 on selected interface | Per-flag `discovery`, `announce`, `auto_connect`, `accept_connections` |
|
||||
| Tor | None until `directory_service` configured | `127.0.0.1:8443` (loopback only) | Operator sets `transports.tor.directory_service` and configures `HiddenServiceDir` in `torrc` |
|
||||
| BLE | Off by default | n/a | Operator enables `transports.ble.*` |
|
||||
| Nostr discovery | Off by default | n/a (relay client, not a listener) | Operator sets `node.discovery.nostr.enabled: true` |
|
||||
@@ -228,7 +228,7 @@ way to restrict inbound traffic on `fips0`. See
|
||||
- [../design/fips-mesh-layer.md](../design/fips-mesh-layer.md) — FMP
|
||||
link encryption, replay protection, rate limiting
|
||||
- [../design/fips-session-layer.md](../design/fips-session-layer.md)
|
||||
— FSP end-to-end encryption, Noise XX, replay window
|
||||
— FSP end-to-end encryption, Noise XK, replay window
|
||||
- [../how-to/enable-mesh-firewall.md](../how-to/enable-mesh-firewall.md)
|
||||
— operator activation and drop-in recipes
|
||||
- [configuration.md](configuration.md) — full `node.rekey.*`,
|
||||
|
||||
@@ -24,7 +24,7 @@ The FMP link layer defines the following message types, dispatched by the
|
||||
|
||||
Handshake messages travel before encryption is established and are identified
|
||||
by the FMP common-prefix `phase` field rather than a `msg_type` byte
|
||||
(phase 0x1 = Noise XX msg1, phase 0x2 = Noise XX msg2, phase 0x3 = Noise XX msg3).
|
||||
(phase 0x1 = Noise IK msg1, phase 0x2 = Noise IK msg2).
|
||||
|
||||
## Packet Type Summary
|
||||
|
||||
@@ -33,8 +33,8 @@ A higher-level summary that includes typical sizes and forwarding category:
|
||||
| Message | Typical Size | When | Forwarded? |
|
||||
| ------- | ------------ | ---- | ---------- |
|
||||
| TreeAnnounce | Variable (depth-dependent) | Topology changes | No (peer-to-peer) |
|
||||
| FilterAnnounce | variable (RLE compressed) | Topology changes | No (peer-to-peer) |
|
||||
| LookupRequest | 44 bytes + TLV | First contact, recovery | Yes (bloom-guided tree) |
|
||||
| FilterAnnounce | ~1 KB | Topology changes | No (peer-to-peer) |
|
||||
| LookupRequest | ~300 bytes | First contact, recovery | Yes (bloom-guided tree) |
|
||||
| LookupResponse | ~400 bytes | Response to discovery | Yes (reverse-path) |
|
||||
| SessionDatagram + SessionSetup | ~232–402 bytes | Session establishment | Yes (routed) |
|
||||
| SessionDatagram + SessionAck | ~170 bytes | Session confirmation | Yes (routed) |
|
||||
@@ -63,14 +63,14 @@ stream; the common prefix `payload_len` field provides this framing
|
||||
directly. TCP and Tor share a common stream reader (`tcp/stream.rs`)
|
||||
that implements this framing.
|
||||
|
||||
**Ethernet frame header.** The Ethernet transport prepends a 4-byte
|
||||
unified header before the payload: `[type:1][flags:1][length:2 LE]`.
|
||||
The length field allows the receiver to trim Ethernet minimum-frame
|
||||
padding that would otherwise corrupt AEAD verification. Frame types:
|
||||
`0x00` (data), `0x01` (beacon). Beacons are 5 bytes total (4-byte
|
||||
header + 1-byte beacon type). These bytes are consumed by the
|
||||
transport layer and are not visible to FMP. The effective MTU for FMP
|
||||
is the interface MTU minus four bytes (typically 1496).
|
||||
**Ethernet data frame header.** The Ethernet transport prepends a 3-byte
|
||||
header before the FMP payload on data frames: a 1-byte frame type
|
||||
(`0x00`) followed by a 2-byte little-endian payload length. The length
|
||||
field allows the receiver to trim Ethernet minimum-frame padding that
|
||||
would otherwise corrupt AEAD verification. Beacon frames (`0x01`) have
|
||||
no length field (fixed 34-byte payload). These bytes are consumed by the
|
||||
transport layer and are not visible to FMP. The effective MTU for FMP is
|
||||
the interface MTU minus three bytes (typically 1497).
|
||||
|
||||
## Link-Layer Formats
|
||||
|
||||
@@ -84,7 +84,7 @@ length.
|
||||
|
||||
| Field | Size | Description |
|
||||
| ----- | ---- | ----------- |
|
||||
| version | 4 bits (high) | Protocol version. Currently 0x1 |
|
||||
| version | 4 bits (high) | Protocol version. Currently 0x0 |
|
||||
| phase | 4 bits (low) | Session lifecycle phase (see table) |
|
||||
| flags | 1 byte | Per-packet signal flags (zero during handshake) |
|
||||
| payload_len | 2 bytes LE | Length of payload after phase-specific header, excluding AEAD tag |
|
||||
@@ -94,9 +94,8 @@ length.
|
||||
| Phase | Type | Description |
|
||||
| ----- | ---- | ----------- |
|
||||
| 0x0 | Established frame | Post-handshake encrypted traffic |
|
||||
| 0x1 | Noise XX msg1 | Handshake initiation (ephemeral only) |
|
||||
| 0x2 | Noise XX msg2 | Handshake response (responder identity + negotiation) |
|
||||
| 0x3 | Noise XX msg3 | Handshake completion (initiator identity + negotiation) |
|
||||
| 0x1 | Noise IK msg1 | Handshake initiation |
|
||||
| 0x2 | Noise IK msg2 | Handshake response |
|
||||
|
||||
### Flags (Established Phase Only)
|
||||
|
||||
@@ -104,9 +103,10 @@ length.
|
||||
| --- | ---- | ----------- |
|
||||
| 0 | K (key epoch) | Selects active key during rekeying |
|
||||
| 1 | CE | Congestion Experienced echo |
|
||||
| 2-7 | — | Reserved (must be zero) |
|
||||
| 2 | SP (spin bit) | RTT measurement |
|
||||
| 3-7 | — | Reserved (must be zero) |
|
||||
|
||||
Flags must be zero in handshake packets (phase 0x1, 0x2, and 0x3).
|
||||
Flags must be zero in handshake packets (phase 0x1 and 0x2).
|
||||
|
||||
### Established Frame (phase 0x0)
|
||||
|
||||
@@ -119,7 +119,7 @@ encrypted link-layer message.
|
||||
|
||||
| Field | Size | Description |
|
||||
| ----- | ---- | ----------- |
|
||||
| common prefix | 4 bytes | ver=1, phase=0, flags, payload_len |
|
||||
| common prefix | 4 bytes | ver=0, phase=0, flags, payload_len |
|
||||
| receiver_idx | 4 bytes LE | Session index for O(1) lookup |
|
||||
| counter | 8 bytes LE | Monotonic nonce, used as AEAD nonce and for replay detection |
|
||||
|
||||
@@ -147,141 +147,67 @@ the 1-byte message type and message-specific fields.
|
||||
| Type | Message | Description |
|
||||
| ---- | ------- | ----------- |
|
||||
| 0x00 | SessionDatagram | Encapsulated session-layer payload for forwarding |
|
||||
| 0x01 | SenderReport | MMP sender-side metrics report (20 bytes) |
|
||||
| 0x02 | ReceiverReport | MMP receiver-side metrics report (54 bytes) |
|
||||
| 0x01 | SenderReport | MMP sender-side metrics report (48 bytes) |
|
||||
| 0x02 | ReceiverReport | MMP receiver-side metrics report (68 bytes) |
|
||||
| 0x10 | TreeAnnounce | Spanning tree state announcement |
|
||||
| 0x20 | FilterAnnounce | Bloom filter reachability update |
|
||||
| 0x21 | FilterNack | Bloom filter delta NACK (retransmit request) |
|
||||
| 0x30 | LookupRequest | Coordinate discovery request |
|
||||
| 0x31 | LookupResponse | Coordinate discovery response |
|
||||
| 0x50 | Disconnect | Orderly link teardown |
|
||||
| 0x51 | Heartbeat | Link liveness probe |
|
||||
|
||||
### Noise XX Message 1 (phase 0x1)
|
||||
### Noise IK Message 1 (phase 0x1)
|
||||
|
||||
Handshake initiation from connecting party. The initiator sends only its
|
||||
ephemeral key — neither side's static identity is revealed in msg1.
|
||||
Handshake initiation from connecting party.
|
||||
|
||||

|
||||

|
||||
|
||||
Common prefix: ver=1, phase=0x1, flags=0, payload_len=37 (4 + 33).
|
||||
Common prefix: ver=0, phase=0x1, flags=0, payload_len=110 (4 + 106).
|
||||
|
||||
| Field | Size | Description |
|
||||
| ----- | ---- | ----------- |
|
||||
| common prefix | 4 bytes | ver=1, phase=1, flags=0, payload_len |
|
||||
| common prefix | 4 bytes | ver=0, phase=1, flags=0, payload_len |
|
||||
| sender_idx | 4 bytes LE | Initiator's session index (becomes receiver's `receiver_idx`) |
|
||||
| noise_msg1 | 33 bytes | Noise XX first message |
|
||||
| noise_msg1 | 106 bytes | Noise IK first message |
|
||||
|
||||
**Noise msg1 breakdown** (33 bytes):
|
||||
**Noise msg1 breakdown** (106 bytes):
|
||||
|
||||
| Offset | Field | Size | Description |
|
||||
| ------ | ----- | ---- | ----------- |
|
||||
| 0 | ephemeral_pubkey | 33 bytes | Initiator's ephemeral key (compressed secp256k1) |
|
||||
| 33 | encrypted_static | 49 bytes | Initiator's static key (33) + AEAD tag (16) |
|
||||
| 82 | encrypted_epoch | 24 bytes | Startup epoch (8) + AEAD tag (16) |
|
||||
|
||||
Noise pattern: `-> e`
|
||||
Noise pattern: `-> e, es, s, ss` with epoch payload
|
||||
|
||||
**Total wire size**: 41 bytes (4 prefix + 4 sender_idx + 33 noise).
|
||||
### Noise IK Message 2 (phase 0x2)
|
||||
|
||||
### Noise XX Message 2 (phase 0x2)
|
||||
Handshake response from responder.
|
||||
|
||||
Handshake response from responder. The responder reveals its identity
|
||||
(ephemeral key, encrypted static key, and encrypted epoch) plus an
|
||||
optional protocol negotiation payload.
|
||||

|
||||
|
||||

|
||||
|
||||
Common prefix: ver=1, phase=0x2, flags=0, payload_len varies.
|
||||
Common prefix: ver=0, phase=0x2, flags=0, payload_len=65 (4 + 4 + 57).
|
||||
|
||||
| Field | Size | Description |
|
||||
| ----- | ---- | ----------- |
|
||||
| common prefix | 4 bytes | ver=1, phase=2, flags=0, payload_len |
|
||||
| common prefix | 4 bytes | ver=0, phase=2, flags=0, payload_len |
|
||||
| sender_idx | 4 bytes LE | Responder's session index |
|
||||
| receiver_idx | 4 bytes LE | Echo of initiator's sender_idx from msg1 |
|
||||
| noise_msg2 | 106+ bytes | Noise XX second message (variable with negotiation) |
|
||||
| noise_msg2 | 57 bytes | Noise IK second message |
|
||||
|
||||
**Noise msg2 breakdown** (106 bytes base):
|
||||
**Noise msg2 breakdown** (57 bytes):
|
||||
|
||||
| Offset | Field | Size | Description |
|
||||
| ------ | ----- | ---- | ----------- |
|
||||
| 0 | ephemeral_pubkey | 33 bytes | Responder's ephemeral key (compressed secp256k1) |
|
||||
| 33 | encrypted_static | 49 bytes | Responder's static key (33) + AEAD tag (16) |
|
||||
| 82 | encrypted_epoch | 24 bytes | Startup epoch (8) + AEAD tag (16) |
|
||||
| 33 | encrypted_epoch | 24 bytes | Startup epoch (8) + AEAD tag (16) |
|
||||
|
||||
Noise pattern: `<- e, ee, s, es` with epoch and negotiation payload
|
||||
Noise pattern: `<- e, ee, se` with epoch payload
|
||||
|
||||
**Negotiation payload** (variable, appended via `encrypt_payload()`):
|
||||
encrypted negotiation bytes + AEAD tag. Minimum 26 bytes (10 payload +
|
||||
16 tag) when present. See [Protocol Negotiation Payload](#protocol-negotiation-payload).
|
||||
|
||||
**Total wire size**: 118 bytes minimum (without negotiation), 144 bytes
|
||||
typical (with FMP negotiation: 118 + 26).
|
||||
|
||||
After msg2, the responder's identity is known to the initiator.
|
||||
|
||||
### Noise XX Message 3 (phase 0x3)
|
||||
|
||||
Handshake completion from initiator. The initiator reveals its encrypted
|
||||
static identity and epoch, plus an optional negotiation payload.
|
||||
|
||||

|
||||
|
||||
| Field | Size | Description |
|
||||
| ----- | ---- | ----------- |
|
||||
| common prefix | 4 bytes | ver=1, phase=3, flags=0, payload_len |
|
||||
| sender_idx | 4 bytes LE | Echo of initiator's sender_idx |
|
||||
| receiver_idx | 4 bytes LE | Echo of responder's sender_idx from msg2 |
|
||||
| noise_msg3 | 73+ bytes | Noise XX third message (variable with negotiation) |
|
||||
|
||||
**Noise msg3 breakdown** (73 bytes base):
|
||||
|
||||
| Offset | Field | Size | Description |
|
||||
| ------ | ----- | ---- | ----------- |
|
||||
| 0 | encrypted_static | 49 bytes | Initiator's static key (33) + AEAD tag (16) |
|
||||
| 49 | encrypted_epoch | 24 bytes | Startup epoch (8) + AEAD tag (16) |
|
||||
|
||||
Noise pattern: `-> s, se` with epoch and negotiation payload
|
||||
|
||||
**Negotiation payload** (variable, appended via `encrypt_payload()`):
|
||||
same format as msg2. Minimum 26 bytes when present.
|
||||
|
||||
**Total wire size**: 85 bytes minimum (without negotiation), 111 bytes
|
||||
typical (with FMP negotiation: 85 + 26).
|
||||
|
||||
After msg3, both parties derive identical symmetric session keys and
|
||||
have exchanged negotiation payloads. The encrypted epoch in msg2 and
|
||||
msg3 enables peer restart detection — if a peer's epoch changes, the
|
||||
other side knows it restarted and must re-establish the link.
|
||||
|
||||
### Protocol Negotiation Payload
|
||||
|
||||
Appended to XX msg2 and msg3 via Noise `encrypt_payload()` — extends
|
||||
the Noise hash chain so negotiation data is authenticated alongside the
|
||||
handshake transcript.
|
||||
|
||||
**Wire format**: `[format:1][versions:1][features:8][TLV entries...]`
|
||||
|
||||
| Offset | Field | Size | Description |
|
||||
| ------ | ----- | ---- | ----------- |
|
||||
| 0 | format | 1 byte | Must be 0 |
|
||||
| 1 | versions | 1 byte | High nibble = version_min, low nibble = version_max |
|
||||
| 2 | features | 8 bytes LE | 64-bit feature bitfield |
|
||||
| 10 | tlv_entries | variable | TLV extensions: `[field_num:2 LE][length:2 LE][value:N]` per entry |
|
||||
|
||||
**Minimum size**: 10 bytes (no TLV entries). With AEAD tag: 26 bytes on
|
||||
the wire.
|
||||
|
||||
**FMP feature bits**:
|
||||
|
||||
| Bits | Name | Description |
|
||||
| ---- | ---- | ----------- |
|
||||
| 0-2 | Node profile | Full (0), NonRouting (1), Leaf (2) |
|
||||
| 3-6 | MMP wants/provides | MMP capability negotiation |
|
||||
| 7 | Bloom | Bloom filter capability |
|
||||
| 8-63 | — | Reserved |
|
||||
|
||||
**Critical**: `decrypt_payload()` MUST be called even if the result is
|
||||
discarded, to maintain hash chain consistency between the two Noise
|
||||
transport channels.
|
||||
After msg2, both parties derive identical symmetric session keys. The
|
||||
encrypted epoch in msg1 and msg2 enables peer restart detection — if a
|
||||
peer's epoch changes, the other side knows it restarted and must
|
||||
re-establish the link.
|
||||
|
||||
### Index Semantics
|
||||
|
||||
@@ -343,49 +269,28 @@ includes self)
|
||||
### FilterAnnounce (0x20)
|
||||
|
||||
Bloom filter reachability update, exchanged between direct peers only.
|
||||
Supports both full sends and delta (XOR diff) updates with RLE
|
||||
compression.
|
||||
|
||||

|
||||
|
||||
| Offset | Field | Size | Description |
|
||||
| ------ | ----- | ---- | ----------- |
|
||||
| 0 | msg_type | 1 byte | 0x20 |
|
||||
| 1 | flags | 1 byte | Bit 0: delta (XOR diff), bits 1-7 reserved |
|
||||
| 2 | sequence | 8 bytes LE | Monotonic counter, per-peer (only increments on actual send) |
|
||||
| 10 | base_seq | 8 bytes LE | Sequence of reference filter for delta (0 if full send) |
|
||||
| 18 | size_class | 1 byte | Filter size: `512 << size_class` bytes |
|
||||
| 19 | compressed_payload | variable | RLE-encoded filter or XOR diff |
|
||||
|
||||
**RLE format**: each run = `[count:2 LE][word:8 LE]` (10 bytes per run).
|
||||
Sparse XOR diffs compress to very few runs.
|
||||
| 1 | sequence | 8 bytes LE | Monotonic counter for freshness |
|
||||
| 9 | hash_count | 1 byte | Number of hash functions (5 in v1) |
|
||||
| 10 | size_class | 1 byte | Filter size: `512 << size_class` bytes |
|
||||
| 11 | filter_bits | variable | Bloom filter bit array |
|
||||
|
||||
**Size class table**:
|
||||
|
||||
| size_class | Bytes | Bits | Notes |
|
||||
| ---------- | ----- | ---- | ----- |
|
||||
| 0 | 512 | 4,096 | Minimum |
|
||||
| 1 | 1,024 | 8,192 | Default |
|
||||
| 2 | 2,048 | 16,384 | |
|
||||
| 3 | 4,096 | 32,768 | |
|
||||
| 4 | 8,192 | 65,536 | |
|
||||
| 5 | 16,384 | 131,072 | |
|
||||
| 6 | 32,768 | 262,144 | Maximum |
|
||||
| size_class | Bytes | Bits | Status |
|
||||
| ---------- | ----- | ---- | ------ |
|
||||
| 0 | 512 | 4,096 | Reserved |
|
||||
| 1 | 1,024 | 8,192 | **v1 (MUST use)** |
|
||||
| 2 | 2,048 | 16,384 | Reserved |
|
||||
| 3 | 4,096 | 32,768 | Reserved |
|
||||
|
||||
**Size**: variable (19-byte header + RLE-compressed payload).
|
||||
|
||||
### FilterNack (0x21)
|
||||
|
||||
Request full filter retransmission. Sent when a node receives an
|
||||
out-of-sequence delta update (sequence gap detected), triggering a
|
||||
full retransmit from the sender.
|
||||
|
||||
| Offset | Field | Size | Description |
|
||||
| ------ | ----- | ---- | ----------- |
|
||||
| 0 | msg_type | 1 byte | 0x21 |
|
||||
| 1 | expected_seq | 8 bytes LE | Sequence number the receiver expected |
|
||||
|
||||
**Total**: 9 bytes.
|
||||
**v1 payload**: 1,035 bytes (11 header + 1,024 filter).
|
||||
With link overhead: 1,072 bytes.
|
||||
|
||||
### LookupRequest (0x30)
|
||||
|
||||
@@ -405,10 +310,16 @@ restructuring.
|
||||
| 25 | origin | 16 bytes | Requester's NodeAddr |
|
||||
| 41 | ttl | 1 byte | Remaining hops (default 64) |
|
||||
| 42 | min_mtu | 2 bytes LE | Minimum transport MTU the origin requires (0 = no requirement) |
|
||||
| 44 | tlv_entries | variable | TLV extensions, forwarded verbatim by transit |
|
||||
| 44 | origin_coords_cnt | 2 bytes LE | Number of coordinate entries |
|
||||
| 46 | origin_coords | 16 x n bytes | Requester's ancestry (NodeAddr only) |
|
||||
|
||||
**Size**: 44 bytes fixed + TLV entries. TLV format:
|
||||
`[field_num:2 LE][length:2 LE][value:N]` per entry.
|
||||
**Size**: `46 + (n x 16)` bytes, where n = origin depth + 1
|
||||
|
||||
| Origin Depth | Payload |
|
||||
| ------------ | ------- |
|
||||
| 3 | 110 bytes |
|
||||
| 5 | 142 bytes |
|
||||
| 10 | 222 bytes |
|
||||
|
||||
### LookupResponse (0x31)
|
||||
|
||||
@@ -426,12 +337,11 @@ requester via the transit nodes that forwarded the request.
|
||||
| 27 | target_coords_cnt | 2 bytes LE | Number of coordinate entries |
|
||||
| 29 | target_coords | 16 x n bytes | Target's ancestry (NodeAddr only) |
|
||||
| 29 + 16n | proof | 64 bytes | Schnorr signature over `(request_id \|\| target \|\| target_coords)` |
|
||||
| 93 + 16n | tlv_entries | variable | TLV extensions, forwarded verbatim by transit |
|
||||
|
||||
**Size**: `93 + (n x 16)` bytes + TLV entries
|
||||
**Size**: `93 + (n x 16)` bytes
|
||||
|
||||
| Target Depth | Payload (no TLV) |
|
||||
| ------------ | ---------------- |
|
||||
| Target Depth | Payload |
|
||||
| ------------ | ------- |
|
||||
| 3 | 141 bytes |
|
||||
| 5 | 173 bytes |
|
||||
| 10 | 253 bytes |
|
||||
@@ -504,16 +414,16 @@ Sent by the frame sender to provide interval-based transmission statistics.
|
||||
| Offset | Field | Size | Encoding |
|
||||
| ------ | ----- | ---- | -------- |
|
||||
| 0 | msg_type | 1 | `0x01` |
|
||||
| 1 | format_version | 1 | 0 (current) |
|
||||
| 2 | total_length | 2 | u16 LE — 16 (v0 payload size) |
|
||||
| 4 | interval_packets_sent | 4 | u32 LE — packets sent in interval |
|
||||
| 8 | interval_bytes_sent | 4 | u32 LE — payload bytes sent in interval |
|
||||
| 12 | cumulative_packets_sent | 8 | u64 LE — total packets sent on this link |
|
||||
| 1 | reserved | 3 | Zero |
|
||||
| 4 | interval_start_counter | 8 | u64 LE — first counter in this interval |
|
||||
| 12 | interval_end_counter | 8 | u64 LE — last counter in this interval |
|
||||
| 20 | interval_start_timestamp | 4 | u32 LE — timestamp at interval start |
|
||||
| 24 | interval_end_timestamp | 4 | u32 LE — timestamp at interval end |
|
||||
| 28 | interval_bytes_sent | 4 | u32 LE — payload bytes sent in interval |
|
||||
| 32 | cumulative_packets_sent | 8 | u64 LE — total packets sent on this link |
|
||||
| 40 | cumulative_bytes_sent | 8 | u64 LE — total bytes sent on this link |
|
||||
|
||||
**Total: 20 bytes.**
|
||||
|
||||
Decoders skip trailing bytes beyond `total_length` for forward
|
||||
compatibility with future format versions.
|
||||
**Total: 48 bytes.**
|
||||
|
||||
### ReceiverReport (0x02)
|
||||
|
||||
@@ -524,26 +434,24 @@ Sent by the frame receiver to provide loss, jitter, and timing feedback.
|
||||
| Offset | Field | Size | Encoding |
|
||||
| ------ | ----- | ---- | -------- |
|
||||
| 0 | msg_type | 1 | `0x02` |
|
||||
| 1 | format_version | 1 | 0 (current) |
|
||||
| 2 | total_length | 2 | u16 LE — 50 (v0 payload size) |
|
||||
| 4 | timestamp_echo | 4 | u32 LE — echoed sender timestamp for RTT |
|
||||
| 8 | dwell_time | 2 | u16 LE — time between receive and echo (ms) |
|
||||
| 10 | highest_counter | 8 | u64 LE — highest counter value received |
|
||||
| 18 | cumulative_packets_recv | 8 | u64 LE — total packets received |
|
||||
| 26 | cumulative_bytes_recv | 8 | u64 LE — total bytes received |
|
||||
| 34 | jitter | 4 | u32 LE — interarrival jitter (microseconds) |
|
||||
| 38 | ecn_ce_count | 4 | u32 LE — cumulative ECN-CE marked packets |
|
||||
| 42 | owd_trend | 4 | i32 LE — one-way delay trend (µs/s, signed) |
|
||||
| 46 | burst_loss_count | 4 | u32 LE — number of loss bursts in interval |
|
||||
| 50 | cumulative_reorder_count | 4 | u32 LE — total reordered packets |
|
||||
| 1 | reserved | 3 | Zero |
|
||||
| 4 | highest_counter | 8 | u64 LE — highest counter value received |
|
||||
| 12 | cumulative_packets_recv | 8 | u64 LE — total packets received |
|
||||
| 20 | cumulative_bytes_recv | 8 | u64 LE — total bytes received |
|
||||
| 28 | timestamp_echo | 4 | u32 LE — echoed sender timestamp for RTT |
|
||||
| 32 | dwell_time | 2 | u16 LE — time between receive and echo (ms) |
|
||||
| 34 | max_burst_loss | 2 | u16 LE — largest loss burst in interval |
|
||||
| 36 | mean_burst_loss | 2 | u16 LE — mean burst length (u8.8 fixed-point) |
|
||||
| 38 | reserved | 2 | Zero |
|
||||
| 40 | jitter | 4 | u32 LE — interarrival jitter (microseconds) |
|
||||
| 44 | ecn_ce_count | 4 | u32 LE — cumulative ECN-CE marked packets |
|
||||
| 48 | owd_trend | 4 | i32 LE — one-way delay trend (µs/s, signed) |
|
||||
| 52 | burst_loss_count | 4 | u32 LE — number of loss bursts in interval |
|
||||
| 56 | cumulative_reorder_count | 4 | u32 LE — total reordered packets |
|
||||
| 60 | interval_packets_recv | 4 | u32 LE — packets received in interval |
|
||||
| 64 | interval_bytes_recv | 4 | u32 LE — bytes received in interval |
|
||||
|
||||
**Total: 54 bytes.**
|
||||
|
||||
Fields removed from v0.2: `max_burst_loss`, `mean_burst_loss`,
|
||||
`interval_packets_recv`, `interval_bytes_recv`.
|
||||
|
||||
Decoders skip trailing bytes beyond `total_length` for forward
|
||||
compatibility with future format versions.
|
||||
**Total: 68 bytes.**
|
||||
|
||||
## Session-Layer Message Formats
|
||||
|
||||
@@ -565,9 +473,9 @@ protocol version, session lifecycle phase, per-packet flags, and payload length.
|
||||
| Phase | Type | Description |
|
||||
| ----- | ---- | ----------- |
|
||||
| 0x0 | Established | Post-handshake encrypted traffic or plaintext error signals |
|
||||
| 0x1 | Handshake msg1 | SessionSetup (Noise XX msg1) |
|
||||
| 0x2 | Handshake msg2 | SessionAck (Noise XX msg2) |
|
||||
| 0x3 | Handshake msg3 | SessionMsg3 (Noise XX msg3) |
|
||||
| 0x1 | Handshake msg1 | SessionSetup (Noise XK msg1) |
|
||||
| 0x2 | Handshake msg2 | SessionAck (Noise XK msg2) |
|
||||
| 0x3 | Handshake msg3 | SessionMsg3 (Noise XK msg3) |
|
||||
|
||||
### FSP Flags (Established Phase Only)
|
||||
|
||||
@@ -610,7 +518,7 @@ Transit nodes parse the CP flag and extract coordinates without decryption.
|
||||
| ----- | ---- | ----------- |
|
||||
| timestamp | 4 bytes LE | Session-relative milliseconds (u32) |
|
||||
| msg_type | 1 byte | Session-layer message type |
|
||||
| inner_flags | 1 byte | Reserved (must be zero) |
|
||||
| inner_flags | 1 byte | Bit 0: SP (spin bit for RTT measurement) |
|
||||
|
||||
After the inner header, the remaining plaintext is the message-type-specific
|
||||
body.
|
||||
@@ -655,8 +563,8 @@ encrypted inner header.
|
||||
### SessionSetup (phase 0x1)
|
||||
|
||||
Establishes a session and warms transit coordinate caches. Contains the
|
||||
first message of the Noise XX handshake (ephemeral key only — neither
|
||||
side's static identity is revealed until msg2/msg3).
|
||||
first message of the Noise XK handshake (ephemeral key only — the
|
||||
initiator's static identity is not revealed until msg3).
|
||||
|
||||
SessionSetup, SessionAck, and SessionMsg3 are identified by the **phase**
|
||||
field in the FSP common prefix (0x1, 0x2, 0x3), not by a message type
|
||||
@@ -677,13 +585,12 @@ Encoded with FSP prefix: ver=0, phase=0x1, flags=0, payload_len.
|
||||
| ... | dest_coords_count | 2 bytes LE | Number of dest coordinate entries |
|
||||
| ... | dest_coords | 16 x m bytes | Destination's ancestry |
|
||||
| ... | handshake_len | 2 bytes LE | Noise payload length |
|
||||
| ... | handshake_payload | variable | Noise XX msg1 (33 bytes — ephemeral key only) |
|
||||
| ... | handshake_payload | variable | Noise XK msg1 (33 bytes — ephemeral key only) |
|
||||
|
||||
### SessionAck (phase 0x2)
|
||||
|
||||
Second message of the Noise XX handshake. The responder reveals its
|
||||
identity (ephemeral key, encrypted static key, and encrypted epoch).
|
||||
Optional FSP negotiation payload may be appended (omitted for rekey).
|
||||
Second message of the Noise XK handshake. The responder sends its
|
||||
ephemeral key and encrypted epoch.
|
||||
Encoded with FSP prefix: ver=0, phase=0x2, flags=0, payload_len.
|
||||
|
||||

|
||||
@@ -698,13 +605,12 @@ Encoded with FSP prefix: ver=0, phase=0x2, flags=0, payload_len.
|
||||
| ... | dest_coords_count | 2 bytes LE | Number of initiator coordinate entries |
|
||||
| ... | dest_coords | 16 x m bytes | Initiator's ancestry (for return-path cache warming) |
|
||||
| ... | handshake_len | 2 bytes LE | Noise payload length |
|
||||
| ... | handshake_payload | variable | Noise XX msg2 (106+ bytes — ephemeral + encrypted static + encrypted epoch + optional negotiation) |
|
||||
| ... | handshake_payload | variable | Noise XK msg2 (57 bytes — ephemeral key + encrypted epoch) |
|
||||
|
||||
### SessionMsg3 (phase 0x3)
|
||||
|
||||
Third and final message of the Noise XX handshake. The initiator reveals
|
||||
its encrypted static identity and epoch. Optional FSP negotiation payload
|
||||
may be appended (omitted for rekey). After msg3, both parties derive
|
||||
Third and final message of the Noise XK handshake. The initiator reveals
|
||||
its encrypted static identity and epoch. After msg3, both parties derive
|
||||
identical symmetric session keys and the session is established.
|
||||
Encoded with FSP prefix: ver=0, phase=0x3, flags=0, payload_len.
|
||||
|
||||
@@ -716,9 +622,9 @@ Encoded with FSP prefix: ver=0, phase=0x3, flags=0, payload_len.
|
||||
| ------ | ----- | ---- | ----------- |
|
||||
| 0 | flags | 1 byte | Reserved |
|
||||
| 1 | handshake_len | 2 bytes LE | Noise payload length |
|
||||
| 3 | handshake_payload | variable | Noise XX msg3 (73+ bytes — encrypted static + encrypted epoch + optional negotiation) |
|
||||
| 3 | handshake_payload | variable | Noise XK msg3 (73 bytes — encrypted static + encrypted epoch) |
|
||||
|
||||
**Noise XX msg3 breakdown** (73 bytes base):
|
||||
**Noise XK msg3 breakdown** (73 bytes):
|
||||
|
||||
| Offset | Field | Size | Description |
|
||||
| ------ | ----- | ---- | ----------- |
|
||||
@@ -967,33 +873,29 @@ endpoint session keys).
|
||||
|
||||
## Size Summary
|
||||
|
||||
### FMP Handshake Messages (Noise XX)
|
||||
### FMP Handshake Messages (Noise IK)
|
||||
|
||||
| Message | Raw Noise | Wire Frame |
|
||||
| ------- | --------- | ---------- |
|
||||
| XX msg1 (ephemeral only) | 33 bytes | 41 bytes |
|
||||
| XX msg2 (ephemeral + encrypted static + epoch) | 106 bytes | 144 bytes (with negotiation) |
|
||||
| XX msg3 (encrypted static + epoch) | 73 bytes | 111 bytes (with negotiation) |
|
||||
| IK msg1 (ephemeral + encrypted static + encrypted epoch) | 106 bytes | 114 bytes |
|
||||
| IK msg2 (ephemeral + encrypted epoch) | 57 bytes | 69 bytes |
|
||||
|
||||
### FSP Handshake Messages (Noise XX)
|
||||
### FSP Handshake Messages (Noise XK)
|
||||
|
||||
| Message | Raw Noise | Notes |
|
||||
| ------- | --------- | ----- |
|
||||
| XX msg1 (ephemeral only) | 33 bytes | Carried in SessionSetup |
|
||||
| XX msg2 (ephemeral + encrypted static + epoch) | 106 bytes | Carried in SessionAck |
|
||||
| XX msg3 (encrypted static + epoch) | 73 bytes | Carried in SessionMsg3 |
|
||||
| XK msg1 (ephemeral only) | 33 bytes | Carried in SessionSetup |
|
||||
| XK msg2 (ephemeral + encrypted epoch) | 57 bytes | Carried in SessionAck |
|
||||
| XK msg3 (encrypted static + encrypted epoch) | 73 bytes | Carried in SessionMsg3 |
|
||||
|
||||
### Link-Layer Messages (inside encrypted frame)
|
||||
|
||||
| Message | Size | Notes |
|
||||
| ------- | ---- | ----- |
|
||||
| TreeAnnounce | 100 + 32n bytes | n = depth + 1 |
|
||||
| FilterAnnounce | variable | 19-byte header + RLE-compressed payload |
|
||||
| FilterNack | 9 bytes | |
|
||||
| LookupRequest | 44 bytes + TLV | Fixed (no longer depth-dependent) |
|
||||
| LookupResponse | 93 + 16n bytes + TLV | n = target depth + 1 |
|
||||
| SenderReport | 20 bytes | Extensibility header |
|
||||
| ReceiverReport | 54 bytes | Extensibility header |
|
||||
| FilterAnnounce | 1,035 bytes | v1 (1KB filter) |
|
||||
| LookupRequest | 46 + 16n bytes | n = origin depth + 1 |
|
||||
| LookupResponse | 93 + 16n bytes | n = target depth + 1 |
|
||||
| SessionDatagram | 36 + payload bytes | Fixed 36-byte header |
|
||||
| Disconnect | 2 bytes | |
|
||||
|
||||
@@ -1001,13 +903,13 @@ endpoint session keys).
|
||||
|
||||
| Message | Typical Size | Notes |
|
||||
| ------- | ------------ | ----- |
|
||||
| SessionSetup | ~170 bytes | Depth-dependent (XX msg1 = 33 bytes) |
|
||||
| SessionAck | ~240 bytes | Depth-dependent, carries both endpoints' coords (XX msg2 = 106+ bytes) |
|
||||
| SessionMsg3 | ~80 bytes | Fixed (XX msg3 = 73+ bytes, no coords) |
|
||||
| SessionSetup | ~170 bytes | Depth-dependent (XK msg1 = 33 bytes) |
|
||||
| SessionAck | ~190 bytes | Depth-dependent, carries both endpoints' coords (XK msg2 = 57 bytes) |
|
||||
| SessionMsg3 | ~80 bytes | Fixed (XK msg3 = 73 bytes, no coords) |
|
||||
| Data (minimal) | 12 + 6 + 4 + payload + 16 bytes | Steady state (port header included) |
|
||||
| Data (with coords) | 12 + ~130 + 6 + 4 + payload + 16 bytes | Warmup/recovery (port header included) |
|
||||
| SenderReport | 12 + 6 + 20 + 16 bytes | MMP metrics |
|
||||
| ReceiverReport | 12 + 6 + 54 + 16 bytes | MMP metrics |
|
||||
| SenderReport | 12 + 6 + 46 + 16 bytes | MMP metrics |
|
||||
| ReceiverReport | 12 + 6 + 66 + 16 bytes | MMP metrics |
|
||||
| PathMtuNotification | 12 + 6 + 2 + 16 bytes | MTU signal |
|
||||
| CoordsWarmup | 12 + coords + 6 + 16 bytes | Standalone warmup (empty body) |
|
||||
| CoordsRequired | 38 bytes | Fixed (prefix + msg_type + body) |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# FIPS v0.4.0
|
||||
|
||||
**Released**: 2026-06-27
|
||||
**Released**: 2026-06-DD (provisional)
|
||||
|
||||
v0.4.0 is the throughput-and-observability release on the v0.3.x wire
|
||||
format. It adds two new ways for nodes to find and reach each other (the
|
||||
@@ -31,11 +31,6 @@ across a mesh in any order.
|
||||
hot-path cost.
|
||||
- Reworked `fipstop` TUI on a machine-verified render-snapshot base.
|
||||
- Rekey is now hitless under loss and reordering in both directions.
|
||||
- New packaging targets: an OpenWrt `.apk` for OpenWrt 25+ and a Nix
|
||||
flake for reproducible from-source builds on Nix/NixOS.
|
||||
- Six route-class transit counters partition forwarded traffic by its
|
||||
tree relationship to the next hop, visible via `show_routing` and
|
||||
`show_status`.
|
||||
|
||||
## What's new
|
||||
|
||||
@@ -120,14 +115,6 @@ returns a counter-only snapshot of every metric family. It is the
|
||||
enabler for a Prometheus scraper that pulls node counters at no hot-path
|
||||
cost.
|
||||
|
||||
Six **route-class transit counters** partition transit-forwarded packets
|
||||
by their tree relationship to the chosen next hop — tree-up, tree-down,
|
||||
tree-down-cross, cross-link descend, cross-link ascend, and direct-peer
|
||||
— and the six classes sum to `forwarded_packets`. They surface through
|
||||
`show_routing` and `show_status`, and the `fipstop` routing tab is
|
||||
reorganized so its two columns separate own/endpoint traffic from
|
||||
forwarded/transit traffic with the tree-down-cross line visually flagged.
|
||||
|
||||
### Reworked fipstop TUI
|
||||
|
||||
`fipstop` gets a rendering, navigation, and read-surface overhaul on a
|
||||
@@ -168,22 +155,6 @@ The net operator takeaway: rekey completes cleanly without dropping
|
||||
traffic, even on lossy or high-latency links, and the log no longer
|
||||
cries wolf when a rekey gives up and retries.
|
||||
|
||||
### New packaging targets
|
||||
|
||||
- **OpenWrt `.apk`.** A new `.apk` package targets OpenWrt 25+, where
|
||||
apk-tools is the mandatory package manager; the existing `.ipk`
|
||||
continues to cover OpenWrt 24.x and earlier. It is built SDK-free,
|
||||
reusing the `.ipk` cross-compile and installed-filesystem payload, and
|
||||
releases publish `.apk` artifacts and checksums alongside `.ipk`. Like
|
||||
the `.ipk`, the package is unsigned and installed with
|
||||
`apk add --allow-untrusted`.
|
||||
- **Nix flake.** A `flake.nix` at the project root builds all four
|
||||
binaries (`fips`, `fipsctl`, `fips-gateway`, `fipstop`) from source on
|
||||
Nix/NixOS, pinning the exact toolchain and wiring the native build
|
||||
dependencies so no host setup is needed beyond Nix with flakes
|
||||
enabled. It exposes `nix build`, `nix run`, a `nix develop` dev shell,
|
||||
and `nix flake check`, with `flake.lock` committed for reproducibility.
|
||||
|
||||
## Behavior changes worth flagging
|
||||
|
||||
These affect operators on upgrade.
|
||||
@@ -252,15 +223,6 @@ subset of fixes for behavior that shipped in v0.3.0.
|
||||
advertising a strictly worse root echoes its own declaration back,
|
||||
provoking the better-rooted peer to re-push its real position
|
||||
immediately.
|
||||
- **macOS self-connections work end to end (#117).** Traffic a macOS
|
||||
node sends to its own `<npub>.fips` address is now delivered locally
|
||||
for full TCP/UDP, not just `ping6`. The point-to-point `utun` egresses
|
||||
self-addressed packets into the daemon with an unfinished transport
|
||||
checksum (macOS offloads it on the `lo0` loopback route), so
|
||||
re-injecting them verbatim made the local stack drop every segment the
|
||||
MSS-clamp rewrite did not happen to fix and self-connections
|
||||
half-opened and hung. The hairpin path now recomputes the TCP/UDP
|
||||
checksum before re-injection. Linux was unaffected.
|
||||
|
||||
## Upgrade notes
|
||||
|
||||
@@ -291,14 +253,10 @@ Operator-actionable items moving from v0.3.0 to v0.4.0:
|
||||
- **Arch Linux**: `fips` from the AUR.
|
||||
- **macOS**: `.pkg` at the v0.4.0 release page.
|
||||
- **Windows**: ZIP at the v0.4.0 release page.
|
||||
- **OpenWrt**: `.ipk` (OpenWrt 24.x and earlier) or `.apk` (OpenWrt 25+)
|
||||
at the v0.4.0 release page.
|
||||
- **OpenWrt**: `.ipk` at the v0.4.0 release page.
|
||||
- **From source**: `cargo build --release` from a checkout of the v0.4.0
|
||||
tag (Rust 1.94.1 per `rust-toolchain.toml`; `libclang-dev` is a
|
||||
required Linux build prerequisite).
|
||||
- **Nix / NixOS**: `nix build .#fips` from a checkout of the v0.4.0 tag
|
||||
builds the binaries from source with the pinned toolchain and no manual
|
||||
prerequisites (see the Nix section of `packaging/README.md`).
|
||||
|
||||
The full per-commit changelog lives in
|
||||
[`CHANGELOG.md`](../../CHANGELOG.md). Issues and discussion at
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
# FIPS v0.4.1
|
||||
|
||||
**Released**: 2026-07-19
|
||||
|
||||
v0.4.1 is a maintenance release on the v0.4.x line. It raises the default
|
||||
antipoison cap on inbound bloom filter announcements, removes a redundant
|
||||
spanning-tree metric counter, fixes two convergence and path-MTU bugs, and
|
||||
cuts per-packet CPU in the bloom and identity paths. There is no wire
|
||||
format change and no new feature surface.
|
||||
|
||||
v0.4.1 is wire-compatible with v0.4.0. Nodes can be upgraded one at a time
|
||||
with no coordinated restart, though one behavior change below is worth
|
||||
reading before you start a rolling upgrade.
|
||||
|
||||
## At a glance
|
||||
|
||||
- `node.bloom.max_inbound_fpr` default moves from `0.10` to `0.20`.
|
||||
- The `parent_switched` metric counter is gone. Use `parent_switches`.
|
||||
- Spanning tree no longer serves stale coordinates after a parent link is
|
||||
lost through peer removal.
|
||||
- Discovery no longer loosens a path MTU clamp it had correctly tightened.
|
||||
- Bloom probing and identity operations do measurably less work per call,
|
||||
with identical results.
|
||||
|
||||
## Behavior changes worth flagging
|
||||
|
||||
### The inbound filter FPR cap default doubles again
|
||||
|
||||
`node.bloom.max_inbound_fpr` goes from `0.10` to `0.20`. The cap rejects
|
||||
inbound `FilterAnnounce` frames whose advertised false positive rate
|
||||
exceeds it. On the fixed 1 KB, k=5 filter, `0.10` corresponds to a fill of
|
||||
0.631 and roughly 1,630 reachable entries, and the busiest nodes'
|
||||
aggregates had started reaching that ceiling as the mesh grew. `0.20`
|
||||
corresponds to a fill of 0.7248 and roughly 2,114 entries.
|
||||
|
||||
Be aware that this is the second time in two releases that this default
|
||||
has doubled, for the same reason both times. That is worth stating plainly
|
||||
rather than repeating the previous release's framing: raising the cap buys
|
||||
headroom, it does not fix anything. The real constraint is the fixed 1 KB
|
||||
filter size, which is a protocol constant. The structural remedy is the v2
|
||||
filter work, where filter capacity scales with the mesh instead of being
|
||||
pinned. This release is an interim step to keep legitimate aggregates from
|
||||
being rejected until that lands. It is not the start of a pattern of
|
||||
raising the cap once per release, and if you are sizing capacity planning
|
||||
around this number, plan against the v2 work rather than against a third
|
||||
raise.
|
||||
|
||||
The antipoison property the cap exists for is preserved. A saturated or
|
||||
deliberately poisoned filter still presents an FPR near 100% and is still
|
||||
rejected.
|
||||
|
||||
**This matters during a rolling upgrade.** A v0.4.1 node accepts a
|
||||
`FilterAnnounce` with a derived FPR between 0.10 and 0.20; a v0.4.0 node
|
||||
drops the same frame, and the drop is silent on the wire with no NACK. The
|
||||
cap also gates the mesh size estimator, which declines to produce a value
|
||||
when any contributing filter is over the cap. So while a mesh is partly
|
||||
upgraded, upgraded and not-yet-upgraded nodes can legitimately report
|
||||
different mesh sizes, or one can report a size while the other reports
|
||||
unknown. This resolves once every node is on v0.4.1. If you want to avoid
|
||||
the window entirely, set `node.bloom.max_inbound_fpr: 0.10` explicitly in
|
||||
your config before upgrading and remove it after the last node is done.
|
||||
|
||||
### The `parent_switched` counter is removed
|
||||
|
||||
`parent_switched` was incremented on the line immediately before
|
||||
`parent_switches` at every site and never independently, so the two
|
||||
counters always held the same value. `parent_switched` is now gone from
|
||||
the tree metrics, the control socket snapshot, and the `fipstop` tree
|
||||
view. `parent_switches` remains and is unchanged.
|
||||
|
||||
If you scrape the control socket, or have dashboards or alerts referencing
|
||||
`parent_switched`, point them at `parent_switches`. Anything still asking
|
||||
for `parent_switched` will find nothing rather than a zero.
|
||||
|
||||
## Notable bug fixes
|
||||
|
||||
### Stale coordinates after losing a parent through peer removal
|
||||
|
||||
When a node's parent link dropped via peer removal, the node correctly
|
||||
reparented or self-rooted, but skipped the coordinate cache invalidation
|
||||
that every other position-change path performs. Cached entries for
|
||||
downstream destinations kept the node's old coordinate prefix. This did
|
||||
not self-correct the way a stale cache entry normally would: routing
|
||||
access refreshes an entry's TTL, so an entry that was actively being
|
||||
routed through never expired, and was only fixed by an unrelated fresh
|
||||
insert. Both invalidation classes now run on this path, matching the
|
||||
loop-detection branch.
|
||||
|
||||
### Discovery could loosen a tightened path MTU clamp
|
||||
|
||||
An originator handling a `LookupResponse` overwrote its cached path MTU
|
||||
unconditionally. If a reactive `MtuExceeded` or `PathMtuNotification` had
|
||||
already taught it a tighter value, a later, looser discovery estimate
|
||||
would clobber that and re-loosen the clamp, risking a return to dropped
|
||||
oversized packets. The cached and received values are now compared and the
|
||||
tighter one is kept.
|
||||
|
||||
## Upgrade notes
|
||||
|
||||
This is a drop-in upgrade from v0.4.0 with no wire format change, no
|
||||
config migration, and no coordinated restart. Upgrade nodes in whatever
|
||||
order you like.
|
||||
|
||||
Two things to do rather than assume:
|
||||
|
||||
1. If you monitor `parent_switched`, move to `parent_switches` before
|
||||
upgrading, or your dashboards will go blank rather than error.
|
||||
2. During the rolling window, expect upgraded and not-yet-upgraded nodes
|
||||
to potentially disagree about mesh size, per the FPR cap section above.
|
||||
This is expected and self-resolves. Do not chase it as a bug unless it
|
||||
persists after every node reports `0.4.1`.
|
||||
|
||||
If you have pinned `node.bloom.max_inbound_fpr` explicitly in your config,
|
||||
your setting is honored and nothing changes for you. The change only
|
||||
affects nodes taking the default.
|
||||
|
||||
Downgrading to v0.4.0 is supported and needs no special handling.
|
||||
|
||||
## Getting v0.4.1
|
||||
|
||||
- **Linux x86_64 / aarch64**: `.deb` and tarball at the
|
||||
[v0.4.1 release page](https://github.com/jmcorgan/fips/releases/tag/v0.4.1).
|
||||
- **Arch Linux**: `fips` from the AUR.
|
||||
- **macOS**: `.pkg` at the v0.4.1 release page.
|
||||
- **Windows**: ZIP at the v0.4.1 release page.
|
||||
- **OpenWrt**: `.ipk` (OpenWrt 24.x and earlier) or `.apk` (OpenWrt 25+)
|
||||
at the v0.4.1 release page.
|
||||
- **From source**: `cargo build --release` from a checkout of the v0.4.1
|
||||
tag (Rust 1.94.1 per `rust-toolchain.toml`; `libclang-dev` is a
|
||||
required Linux build prerequisite).
|
||||
- **Nix / NixOS**: `nix build .#fips` from a checkout of the v0.4.1 tag
|
||||
builds the binaries from source with the pinned toolchain and no manual
|
||||
prerequisites (see the Nix section of `packaging/README.md`).
|
||||
|
||||
The full per-commit changelog lives in
|
||||
[`CHANGELOG.md`](../../CHANGELOG.md). Issues and discussion at
|
||||
[github.com/jmcorgan/fips](https://github.com/jmcorgan/fips).
|
||||
|
||||
## Contributors
|
||||
|
||||
Thanks to everyone who contributed code, packaging work, bug reports, or
|
||||
reviews to this release.
|
||||
|
||||
- [@jcorgan](https://github.com/jmcorgan): release shepherd, spanning-tree
|
||||
and discovery fixes, bloom and identity performance work, antipoison cap
|
||||
change, and testing.
|
||||
@@ -282,7 +282,7 @@ an entry for `test-us03` (the open-discovery test mesh node).
|
||||
It will have `connectivity` active and its own
|
||||
`transport_addr`. This peering appeared without you
|
||||
configuring anything — the test-mesh open-discovery node saw
|
||||
your advert, dialed the endpoint, and Noise XX established
|
||||
your advert, dialed the endpoint, and Noise IK established
|
||||
the link.
|
||||
|
||||
If no inbound peers appear, that's not necessarily a failure
|
||||
|
||||
@@ -47,7 +47,7 @@ Two machines, each running `fips`, joined by a physical Ethernet
|
||||
link. After the worked example:
|
||||
|
||||
- The two daemons have discovered each other via L2 beacons on
|
||||
the link, peered over Noise XX, and brought up an FMP link.
|
||||
the link, peered over Noise IK, and brought up an FMP link.
|
||||
- Each `fips0` adapter has a routable mesh address; each can
|
||||
ping the other by `<npub>.fips`.
|
||||
- Nothing between the two machines speaks IP. The link carries
|
||||
@@ -71,7 +71,7 @@ supplies the rest:
|
||||
- **Addressing**: the `fips0` adapter takes an `fd97:...` ULA
|
||||
derived from the npub. No DHCP. No SLAAC. The address is
|
||||
cryptographically tied to the identity.
|
||||
- **Neighbor detection**: each daemon broadcasts a small beacon on the
|
||||
- **Discovery**: each daemon broadcasts a small beacon on the
|
||||
link advertising its npub; the other daemon's listener picks
|
||||
it up and dials in over the same link.
|
||||
- **Routing**: the FIPS mesh layer builds its own spanning tree
|
||||
@@ -177,7 +177,7 @@ different interface names — that is normal.
|
||||
|
||||
Edit `/etc/fips/fips.yaml` on **both** nodes. Under
|
||||
`transports:`, add an `ethernet:` block. The key settings are
|
||||
the four neighbor flags — both nodes must opt in to all four,
|
||||
the four discovery flags — both nodes must opt in to all four,
|
||||
and they default to off:
|
||||
|
||||
```yaml
|
||||
@@ -185,7 +185,7 @@ transports:
|
||||
ethernet:
|
||||
interface: "<eth>" # the name from Step 1
|
||||
announce: true # broadcast our beacon on the link
|
||||
listen: true # listen for beacons (default; shown for clarity)
|
||||
discovery: true # listen for beacons (default; shown for clarity)
|
||||
auto_connect: true # dial peers we discover
|
||||
accept_connections: true # accept dial-ins from peers we discover
|
||||
```
|
||||
@@ -194,7 +194,7 @@ Each flag does one thing:
|
||||
|
||||
- `announce: true` — emit a small beacon every
|
||||
`beacon_interval_secs` (default 30s) carrying our npub.
|
||||
- `listen: true` — listen for incoming beacons; populate a
|
||||
- `discovery: true` — listen for incoming beacons; populate a
|
||||
candidate-peer list keyed by source MAC and observed npub.
|
||||
- `auto_connect: true` — when we see a beacon from an npub
|
||||
we have not yet peered with, initiate the outbound Noise
|
||||
@@ -218,7 +218,7 @@ is "all four flags on both ends."
|
||||
> lan:
|
||||
> interface: "eth0"
|
||||
> announce: true
|
||||
> listen: true
|
||||
> discovery: true
|
||||
> auto_connect: true
|
||||
> accept_connections: true
|
||||
> dongle:
|
||||
@@ -227,7 +227,7 @@ is "all four flags on both ends."
|
||||
> # ...
|
||||
> ```
|
||||
>
|
||||
> Each named instance runs its own socket and neighbor state.
|
||||
> Each named instance runs its own socket and discovery state.
|
||||
> A single ground-up link only needs the flat form shown
|
||||
> first; named instances become useful when the same node
|
||||
> bridges multiple physical segments.
|
||||
@@ -390,7 +390,7 @@ What you do need on the AP side:
|
||||
networks and "secure" enterprise APs ship with it on.
|
||||
When client isolation is on, the AP refuses to forward
|
||||
station-to-station frames — the broadcast beacons never
|
||||
arrive at the other node, and neighbor detection fails silently.
|
||||
arrive at the other node, and discovery fails silently.
|
||||
If beacons aren't crossing, this is the first thing to
|
||||
check.
|
||||
|
||||
@@ -401,7 +401,7 @@ adapter name.
|
||||
### Bluetooth LE (experimental but works)
|
||||
|
||||
BLE is a separate transport (`transports.ble.*`) with its own
|
||||
neighbor-detection model — L2CAP advertisements rather than raw L2
|
||||
discovery model — L2CAP advertisements rather than raw L2
|
||||
broadcasts. The shape of the tutorial is the same (advertise +
|
||||
scan + auto-connect + accept), but the prerequisites are
|
||||
different: BlueZ, `bluetoothd`, an HCI adapter, and the
|
||||
@@ -424,7 +424,7 @@ Windows builds skip it.
|
||||
a radio link), `CAP_NET_RAW`, and a few config flags on each
|
||||
end are sufficient. The mesh supplies its own identity,
|
||||
addressing, discovery, and routing.
|
||||
- **Neighbor detection is a four-flag opt-in.** `announce`, `listen`,
|
||||
- **Discovery is a four-flag opt-in.** `announce`, `discovery`,
|
||||
`auto_connect`, and `accept_connections` each control one
|
||||
thing; both ends must agree before a link will form.
|
||||
- **The two modes coexist.** Overlay peers and ground-up peers
|
||||
|
||||
@@ -206,8 +206,8 @@ metrics" to "I understand why each one moves the way it does":
|
||||
in `show sessions` means: the proactive forward-path field, the
|
||||
reactive `MtuExceeded` mechanism, the hysteresis on increase.
|
||||
- [../design/fips-architecture.md](../design/fips-architecture.md)
|
||||
— the two-layer encryption model: link-layer Noise XX over
|
||||
each hop, end-to-end Noise XX over the session.
|
||||
— the two-layer encryption model: link-layer Noise IK over
|
||||
each hop, end-to-end Noise XK over the session.
|
||||
|
||||
## What you've learned
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ UDP/2121, and is reachable from any network that permits arbitrary
|
||||
outbound UDP.
|
||||
|
||||
> **Peer vs. node.** In FIPS terminology, a *peer* is a node
|
||||
> you have a direct link to — same Noise XX handshake, same
|
||||
> you have a direct link to — same Noise IK handshake, same
|
||||
> transport socket. A *node* is any participant on the mesh,
|
||||
> whether you peer with it directly or reach it through one or
|
||||
> more hops via your peer's connections. Peering is a local
|
||||
@@ -85,7 +85,7 @@ peers:
|
||||
What each field does:
|
||||
|
||||
- `npub` — the canonical Nostr public key of `test-us01`. This is
|
||||
who your daemon will mutually authenticate with over Noise XX.
|
||||
who your daemon will mutually authenticate with over Noise IK.
|
||||
- `alias` — a short name your daemon will use when referring to
|
||||
this peer in logs and `fipsctl show peers` output. Optional.
|
||||
- `addresses` — one or more transport endpoints. UDP on the
|
||||
@@ -111,7 +111,7 @@ Within a few seconds you should see lines mentioning:
|
||||
|
||||
- An outbound connection attempt to `test-us01` or
|
||||
`test-us01.fips.network:2121`
|
||||
- A handshake completion (a "Noise XX link handshake complete"
|
||||
- A handshake completion (a "Noise IK link handshake complete"
|
||||
style line, or "peer authenticated" with the test-us01 npub)
|
||||
- An MMP / link metrics entry naming `test-us01`
|
||||
|
||||
@@ -203,7 +203,7 @@ public test mesh, with reach to every node that mesh routes you
|
||||
to. You have seen:
|
||||
|
||||
- **Identity.** Your daemon's ephemeral keypair authenticated to
|
||||
`test-us01` over Noise XX without either side trusting anyone in
|
||||
`test-us01` over Noise IK without either side trusting anyone in
|
||||
advance.
|
||||
- **Transports.** A UDP socket on your host carries
|
||||
authenticated, encrypted mesh frames to your peer. No central
|
||||
@@ -317,8 +317,8 @@ For "what just happened, in detail":
|
||||
- [../design/fips-architecture.md](../design/fips-architecture.md) —
|
||||
the protocol stack and the two-layer encryption model.
|
||||
- [../design/fips-mesh-layer.md](../design/fips-mesh-layer.md) —
|
||||
Noise XX link encryption, hop-by-hop forwarding.
|
||||
Noise IK link encryption, hop-by-hop forwarding.
|
||||
- [../design/fips-session-layer.md](../design/fips-session-layer.md)
|
||||
— end-to-end Noise XX, session lifecycle.
|
||||
— end-to-end Noise XK, session lifecycle.
|
||||
- [../design/fips-ipv6-adapter.md](../design/fips-ipv6-adapter.md) —
|
||||
the TUN, the local DNS responder, MTU enforcement.
|
||||
|
||||
@@ -44,7 +44,7 @@ literal sense. Several things derive from it:
|
||||
- Your `fd97:...` mesh address — derived from the public key.
|
||||
- Your `<npub>.fips` DNS name — the npub itself with `.fips`
|
||||
appended.
|
||||
- Every authenticated connection — Noise XX at the mesh layer,
|
||||
- Every authenticated connection — Noise IK at the mesh layer,
|
||||
XK at the session layer, both prove you hold the matching
|
||||
secret key.
|
||||
|
||||
|
||||
@@ -245,9 +245,9 @@ For "what's actually in those packets":
|
||||
- [../design/fips-architecture.md](../design/fips-architecture.md)
|
||||
— the protocol stack and the two-layer encryption model.
|
||||
- [../design/fips-mesh-layer.md](../design/fips-mesh-layer.md) —
|
||||
Noise XX link encryption, hop-by-hop forwarding.
|
||||
Noise IK link encryption, hop-by-hop forwarding.
|
||||
- [../design/fips-session-layer.md](../design/fips-session-layer.md)
|
||||
— end-to-end Noise XX between source and destination.
|
||||
— end-to-end Noise XK between source and destination.
|
||||
|
||||
For the trace-it-yourself version of the path you just
|
||||
exercised, see
|
||||
|
||||
@@ -178,7 +178,7 @@ The resolution itself happens at debug-log level, so you will
|
||||
not see it in the default-level journal. The user-facing way to
|
||||
confirm everything worked is `fipsctl show peers` in the next
|
||||
step. (To watch the resolution in the journal, run the daemon
|
||||
manually with `RUST_LOG=fips::nostr=debug`; not
|
||||
manually with `RUST_LOG=fips::discovery::nostr=debug`; not
|
||||
necessary for this tutorial.)
|
||||
|
||||
## Step 5: Verify the resolved endpoint
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
{
|
||||
"nodes": {
|
||||
"fenix": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
],
|
||||
"rust-analyzer-src": "rust-analyzer-src"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1781527054,
|
||||
"narHash": "sha256-1fX9ev2Fh5QoKQ41G9dYutjo5j/jywu6tZse5Eb1Ck4=",
|
||||
"owner": "nix-community",
|
||||
"repo": "fenix",
|
||||
"rev": "8c2e51dffefc040a21975da7abf6f252c8c9b783",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "fenix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1781074563,
|
||||
"narHash": "sha256-md8WlXOlfnIeHeOScMTTHFyf2d6iaTwPl2apR5EQ3P4=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "9ae611a455b90cf061d8f332b977e387bda8e1ca",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"fenix": "fenix",
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"rust-analyzer-src": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1781453968,
|
||||
"narHash": "sha256-+V3nK4pCngbmgyVGXY6Kkrlevp4ocPkJJLf2aqwkDNA=",
|
||||
"owner": "rust-lang",
|
||||
"repo": "rust-analyzer",
|
||||
"rev": "cc272809a173c2c11d0e479d639c811c1eacf049",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "rust-lang",
|
||||
"ref": "nightly",
|
||||
"repo": "rust-analyzer",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
{
|
||||
description = "FIPS — a distributed, decentralized network routing protocol for mesh nodes connecting over arbitrary transports";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
fenix = {
|
||||
url = "github:nix-community/fenix";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
};
|
||||
|
||||
outputs =
|
||||
{
|
||||
self,
|
||||
nixpkgs,
|
||||
flake-utils,
|
||||
fenix,
|
||||
}:
|
||||
flake-utils.lib.eachDefaultSystem (
|
||||
system:
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
|
||||
# Honor the toolchain the repo pins in rust-toolchain.toml
|
||||
# (channel 1.94.1 + rustfmt, clippy) so Nix builds match CI and the
|
||||
# AUR/Debian packaging exactly, including the edition-2024 frontend.
|
||||
rustToolchain = fenix.packages.${system}.fromToolchainFile {
|
||||
file = ./rust-toolchain.toml;
|
||||
sha256 = "sha256-zC8E38iDVJ1oPIzCqTk/Ujo9+9kx9dXq7wAwPMpkpg0=";
|
||||
};
|
||||
|
||||
rustPlatform = pkgs.makeRustPlatform {
|
||||
cargo = rustToolchain;
|
||||
rustc = rustToolchain;
|
||||
};
|
||||
|
||||
cargoToml = pkgs.lib.importTOML ./Cargo.toml;
|
||||
|
||||
# libdbus-sys (pulled in transitively by `bluer`, Linux/glibc only)
|
||||
# runs `bindgen` against the system D-Bus headers at build time.
|
||||
nativeBuildInputs = [
|
||||
pkgs.pkg-config
|
||||
rustPlatform.bindgenHook # sets LIBCLANG_PATH + clang for bindgen
|
||||
];
|
||||
|
||||
buildInputs = pkgs.lib.optionals pkgs.stdenv.isLinux [
|
||||
pkgs.dbus # libdbus-1.so.3, linked via bluer→libdbus-sys
|
||||
pkgs.stdenv.cc.cc.lib # libgcc_s.so.1, needed by every Rust binary
|
||||
];
|
||||
|
||||
fips = rustPlatform.buildRustPackage {
|
||||
pname = "fips";
|
||||
version = cargoToml.package.version;
|
||||
|
||||
src = pkgs.lib.cleanSourceWith {
|
||||
src = ./.;
|
||||
# Drop the build dir and the usual editor/VCS noise so the source
|
||||
# hash is stable and unrelated edits don't trigger rebuilds.
|
||||
filter =
|
||||
path: type:
|
||||
(pkgs.lib.cleanSourceFilter path type) && (baseNameOf path != "target");
|
||||
};
|
||||
|
||||
cargoLock.lockFile = ./Cargo.lock;
|
||||
|
||||
inherit buildInputs;
|
||||
|
||||
# autoPatchelfHook rewrites the RPATH of the built binaries so the
|
||||
# daemon finds libdbus-1.so.3 (linked via bluer→libdbus-sys) in the
|
||||
# Nix store at runtime — without it the `fips` binary fails to load
|
||||
# on NixOS where there is no global /usr/lib.
|
||||
nativeBuildInputs =
|
||||
nativeBuildInputs ++ pkgs.lib.optionals pkgs.stdenv.isLinux [ pkgs.autoPatchelfHook ];
|
||||
|
||||
# The test suite exercises TUN devices, raw sockets and mDNS, none of
|
||||
# which exist in the build sandbox. The AUR/Debian packaging likewise
|
||||
# ships the release binaries without running the integration tests
|
||||
# here, so keep the package build hermetic and skip them.
|
||||
doCheck = false;
|
||||
|
||||
meta = {
|
||||
description = cargoToml.package.description;
|
||||
homepage = cargoToml.package.homepage;
|
||||
license = pkgs.lib.licenses.mit;
|
||||
mainProgram = "fips";
|
||||
platforms = pkgs.lib.platforms.linux ++ pkgs.lib.platforms.darwin;
|
||||
};
|
||||
};
|
||||
|
||||
mkApp = name: {
|
||||
type = "app";
|
||||
program = "${fips}/bin/${name}";
|
||||
meta.description = "Run the ${name} binary from the FIPS package";
|
||||
};
|
||||
in
|
||||
{
|
||||
packages = {
|
||||
default = fips;
|
||||
fips = fips;
|
||||
};
|
||||
|
||||
apps = {
|
||||
default = mkApp "fips";
|
||||
fips = mkApp "fips";
|
||||
fipsctl = mkApp "fipsctl";
|
||||
fips-gateway = mkApp "fips-gateway";
|
||||
fipstop = mkApp "fipstop";
|
||||
};
|
||||
|
||||
# `nix flake check` builds the package (and thus validates the flake on
|
||||
# the current system).
|
||||
checks.fips = fips;
|
||||
|
||||
devShells.default = pkgs.mkShell {
|
||||
inherit buildInputs;
|
||||
nativeBuildInputs = nativeBuildInputs ++ [
|
||||
rustToolchain
|
||||
pkgs.cargo-edit
|
||||
];
|
||||
# Point rust-analyzer at the matching std sources.
|
||||
RUST_SRC_PATH = "${rustToolchain}/lib/rustlib/src/rust/library";
|
||||
};
|
||||
|
||||
formatter = pkgs.nixfmt;
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -6,8 +6,7 @@
|
||||
# Usage:
|
||||
# make deb Build a Debian/Ubuntu .deb package
|
||||
# make tarball Build a systemd install tarball
|
||||
# make ipk Build an OpenWrt .ipk package (opkg, OpenWrt 24.x and earlier)
|
||||
# make apk Build an OpenWrt .apk package (apk-tools, mandatory on OpenWrt 25+)
|
||||
# make ipk Build an OpenWrt .ipk package
|
||||
# make aur Build fips-git AUR package and validate with namcap
|
||||
# make pkg Build a macOS .pkg installer
|
||||
# make zip Build a Windows .zip package
|
||||
@@ -18,7 +17,7 @@ SHELL := /bin/bash
|
||||
PACKAGING_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
|
||||
PROJECT_ROOT := $(abspath $(PACKAGING_DIR)/..)
|
||||
|
||||
.PHONY: all deb tarball ipk apk aur pkg zip clean
|
||||
.PHONY: all deb tarball ipk aur pkg zip clean
|
||||
|
||||
all: deb tarball
|
||||
|
||||
@@ -31,9 +30,6 @@ tarball:
|
||||
ipk:
|
||||
@bash $(PACKAGING_DIR)/openwrt-ipk/build-ipk.sh
|
||||
|
||||
apk:
|
||||
@bash $(PACKAGING_DIR)/openwrt-apk/build-apk.sh
|
||||
|
||||
aur:
|
||||
@bash $(PACKAGING_DIR)/aur/build-aur.sh
|
||||
|
||||
|
||||
@@ -8,8 +8,7 @@ All build outputs go to `deploy/` at the project root.
|
||||
```sh
|
||||
make deb # Debian/Ubuntu .deb
|
||||
make tarball # systemd install tarball
|
||||
make ipk # OpenWrt .ipk (opkg, OpenWrt 24.x and earlier)
|
||||
make apk # OpenWrt .apk (apk-tools, mandatory on OpenWrt 25+)
|
||||
make ipk # OpenWrt .ipk
|
||||
make aur # Arch Linux AUR package (fips-git, local build + namcap)
|
||||
make pkg # macOS .pkg installer
|
||||
make zip # Windows .zip package
|
||||
@@ -47,8 +46,7 @@ packaging/
|
||||
debian/ Debian/Ubuntu .deb packaging via cargo-deb
|
||||
macos/ macOS .pkg installer via pkgbuild
|
||||
systemd/ Generic Linux systemd tarball packaging
|
||||
openwrt-ipk/ OpenWrt .ipk packaging via cargo-zigbuild (opkg)
|
||||
openwrt-apk/ OpenWrt .apk packaging via cargo-zigbuild + apk mkpkg
|
||||
openwrt/ OpenWrt .ipk packaging via cargo-zigbuild
|
||||
windows/ Windows .zip package with service scripts
|
||||
```
|
||||
|
||||
@@ -102,7 +100,7 @@ sudo ./fips-<version>-linux-<arch>/install.sh
|
||||
See [systemd/README.install.md](systemd/README.install.md) for full
|
||||
installation and configuration instructions.
|
||||
|
||||
### OpenWrt (`.ipk`, opkg — OpenWrt 24.x and earlier)
|
||||
### OpenWrt (`.ipk`)
|
||||
|
||||
Cross-compiled with cargo-zigbuild and assembled as a standard `.ipk`
|
||||
archive. Supports aarch64, mipsel, mips, arm, and x86\_64 targets.
|
||||
@@ -112,33 +110,12 @@ archive. Supports aarch64, mipsel, mips, arm, and x86\_64 targets.
|
||||
make ipk
|
||||
|
||||
# Build for a specific architecture
|
||||
bash packaging/openwrt-ipk/build-ipk.sh --arch mipsel
|
||||
bash packaging/openwrt/build-ipk.sh --arch mipsel
|
||||
```
|
||||
|
||||
See [openwrt-ipk/README.md](openwrt-ipk/README.md) for router-specific
|
||||
See [openwrt/README.md](openwrt/README.md) for router-specific
|
||||
installation instructions.
|
||||
|
||||
### OpenWrt (`.apk`, apk-tools — mandatory on OpenWrt 25+)
|
||||
|
||||
OpenWrt 25 makes apk-tools the mandatory package manager (it is opt-in on
|
||||
24.10). Same SDK-free approach
|
||||
(cargo-zigbuild), but the `.apk` container is assembled by `apk mkpkg`
|
||||
rather than hand-rolled, so the build additionally needs an apk-tools v3
|
||||
`apk` binary built from source. The installed-filesystem payload is shared
|
||||
with the `.ipk` package.
|
||||
|
||||
```sh
|
||||
# Build (default: aarch64; also x86_64)
|
||||
make apk
|
||||
|
||||
# Build for a specific architecture
|
||||
bash packaging/openwrt-apk/build-apk.sh --arch x86_64
|
||||
```
|
||||
|
||||
Packages are unsigned; install with `apk add --allow-untrusted`. See
|
||||
[openwrt-apk/README.md](openwrt-apk/README.md) for building apk-tools and
|
||||
router-specific installation.
|
||||
|
||||
### macOS (`.pkg`)
|
||||
|
||||
Built with `pkgbuild` (included with Xcode command-line tools). Installs
|
||||
@@ -200,27 +177,6 @@ yay -S fips # release build from latest tag
|
||||
See [aur/README.md](aur/README.md) for AUR publication instructions
|
||||
and maintainer guide.
|
||||
|
||||
### Nix / NixOS (flake)
|
||||
|
||||
A [flake](../flake.nix) at the project root builds all four binaries
|
||||
(`fips`, `fipsctl`, `fips-gateway`, `fipstop`) from source. It pins the
|
||||
exact toolchain from `rust-toolchain.toml` via
|
||||
[fenix](https://github.com/nix-community/fenix) and wires up the
|
||||
build-time native dependencies (`libclang` for `bindgen`, plus `dbus`
|
||||
and `pkg-config` for BLE), so it needs no system setup beyond Nix with
|
||||
flakes enabled.
|
||||
|
||||
```sh
|
||||
nix build .#fips # build the package (all four binaries)
|
||||
nix run .#fips -- --help # run a binary directly
|
||||
nix run .#fipsctl -- status
|
||||
nix develop # dev shell with the pinned toolchain + cargo-edit
|
||||
nix flake check # build + validate the flake
|
||||
```
|
||||
|
||||
Add to a NixOS configuration via the flake's `packages.<system>.fips`
|
||||
output, e.g. `environment.systemPackages = [ fips.packages.${system}.default ];`.
|
||||
|
||||
## Shared Assets
|
||||
|
||||
`common/` contains assets used across packaging formats:
|
||||
|
||||
@@ -10,21 +10,12 @@ node:
|
||||
#
|
||||
# Or set an explicit key (overrides persistent):
|
||||
# nsec: "nsec1..."
|
||||
# Mesh-lookup protocol (node.lookup.*): the overlay coordinate-lookup engine
|
||||
# (mesh address -> coordinates). Defaults shown; uncomment to override.
|
||||
# lookup:
|
||||
# ttl: 64
|
||||
# attempt_timeouts_secs: [1, 2, 4, 8]
|
||||
# recent_expiry_secs: 10
|
||||
# backoff_base_secs: 0
|
||||
# backoff_max_secs: 0
|
||||
# forward_min_interval_secs: 2
|
||||
rendezvous:
|
||||
# Optional Nostr-mediated overlay endpoint rendezvous.
|
||||
discovery:
|
||||
# Optional Nostr-mediated overlay endpoint discovery.
|
||||
# nostr:
|
||||
# enabled: true
|
||||
# policy: configured_only # disabled | configured_only | open
|
||||
# open_discovery_max_pending: 64 # caps queued open-rendezvous retries
|
||||
# open_discovery_max_pending: 64 # caps queued open-discovery retries
|
||||
# app: "fips-overlay-v1"
|
||||
# advertise: true
|
||||
# advert_relays:
|
||||
@@ -43,17 +34,17 @@ node:
|
||||
# - "stun:stun.cloudflare.com:3478"
|
||||
# - "stun:global.stun.twilio.com:3478"
|
||||
#
|
||||
# Optional mDNS-based LAN rendezvous for sub-second same-LAN pairing.
|
||||
# Optional mDNS-based LAN discovery for sub-second same-LAN pairing.
|
||||
# Opt-in (default false): default-off avoids a per-LAN identity
|
||||
# broadcast on nodes that have deliberately disabled other rendezvous
|
||||
# broadcast on nodes that have deliberately disabled other discovery
|
||||
# channels, and avoids any multicast surprise on upgrade. Requires an
|
||||
# operational UDP transport (the advertised port is the one peers dial).
|
||||
# lan:
|
||||
# enabled: false
|
||||
# # Optional application/network scope carried in the LAN-only TXT
|
||||
# # record. Browsers that set a scope ignore adverts for other scopes.
|
||||
# # Kept separate from the Nostr rendezvous `app` tag so relay-visible
|
||||
# # adverts can stay generic while LAN rendezvous stays per-private-network.
|
||||
# # Kept separate from the Nostr discovery `app` tag so relay-visible
|
||||
# # adverts can stay generic while LAN discovery stays per-private-network.
|
||||
# # scope: "lab-floor-3"
|
||||
# # Advanced: overrides the mDNS service type. Leave unset in normal
|
||||
# # use — only needed to run multiple isolated services on one
|
||||
@@ -98,7 +89,7 @@ transports:
|
||||
# Ethernet transport — uncomment and set your interface name.
|
||||
# ethernet:
|
||||
# interface: "eth0"
|
||||
# listen: true
|
||||
# discovery: true
|
||||
# announce: true
|
||||
# auto_connect: true
|
||||
# accept_connections: true
|
||||
@@ -156,5 +147,5 @@ peers: []
|
||||
# - transport: udp
|
||||
# addr: "test-us01.fips.network:2121" # IP or hostname (e.g., "peer.example.com:2121")
|
||||
# - transport: udp
|
||||
# addr: "nat" # Use node.rendezvous.nostr for Nostr/STUN hole punching
|
||||
# addr: "nat" # Use node.discovery.nostr for Nostr/STUN hole punching
|
||||
# connect_policy: auto_connect
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
# Build a .deb package for FIPS using cargo-deb.
|
||||
#
|
||||
# Usage: ./build-deb.sh [--target <triple>] [--version <version>] [--no-build]
|
||||
# [--features <list>]
|
||||
#
|
||||
# Prerequisites: cargo-deb (install with: cargo install cargo-deb)
|
||||
# Output: deploy/fips_<version>_<arch>.deb
|
||||
@@ -20,9 +19,6 @@ Options:
|
||||
--target <triple> Rust target triple to build/package
|
||||
--version <version> Override Debian package version
|
||||
--no-build Package existing binaries without running cargo build
|
||||
--features <list> Cargo features to build with (comma-separated). Marks the
|
||||
auto-derived Version so the package is distinguishable
|
||||
from a default build of the same commit.
|
||||
-h, --help Show this help
|
||||
EOF
|
||||
}
|
||||
@@ -30,7 +26,6 @@ EOF
|
||||
TARGET_TRIPLE=""
|
||||
VERSION_OVERRIDE=""
|
||||
NO_BUILD=0
|
||||
FEATURES=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
@@ -46,10 +41,6 @@ while [[ $# -gt 0 ]]; do
|
||||
NO_BUILD=1
|
||||
shift
|
||||
;;
|
||||
--features)
|
||||
FEATURES="${2:?missing value for --features}"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
@@ -62,16 +53,6 @@ while [[ $# -gt 0 ]]; do
|
||||
esac
|
||||
done
|
||||
|
||||
# A feature build that skips the build step would stamp a feature-marked Version
|
||||
# onto whatever binaries already sit in target/, which is the one outcome the
|
||||
# marking exists to prevent. Refuse rather than emit a package that misdescribes
|
||||
# itself.
|
||||
if [[ -n "${FEATURES}" && "${NO_BUILD}" -eq 1 ]]; then
|
||||
echo "--features cannot be combined with --no-build: the features would not" >&2
|
||||
echo "reach the binaries, but the Version would claim they had." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "${PROJECT_ROOT}"
|
||||
|
||||
# Ensure cargo-deb is available
|
||||
@@ -100,33 +81,13 @@ if [[ -z "${VERSION_OVERRIDE}" ]]; then
|
||||
if [[ -n "$(git status --porcelain 2>/dev/null)" ]]; then
|
||||
DIRTY_SUFFIX=".dirty"
|
||||
fi
|
||||
# A feature build of a given commit is a different package from the
|
||||
# default build of that same commit, but nothing else in this version
|
||||
# says so: the crate version, the date and the sha are all identical.
|
||||
# Without a marker the two are byte-identical versions, so installing
|
||||
# one over the other is an apt no-op (the very failure the per-commit
|
||||
# version above exists to prevent) and the node offers no way to tell
|
||||
# which one it is running. Underscores and commas are not legal in a
|
||||
# Debian version, so the feature list is folded to dots.
|
||||
FEATURE_SUFFIX=""
|
||||
if [[ -n "${FEATURES}" ]]; then
|
||||
FEATURE_SUFFIX="+$(printf '%s' "${FEATURES}" | tr -c 'a-zA-Z0-9.' '.')"
|
||||
fi
|
||||
# Debian Version: <upstream>~dev+git<YYYYMMDD>.<sha>[.dirty][+<features>]-1
|
||||
# Debian Version: <upstream>~dev+git<YYYYMMDD>.<sha>[.dirty]-1
|
||||
# The "~" makes every dev build sort BEFORE the eventual tagged
|
||||
# release; the date+sha makes consecutive dev builds compare as
|
||||
# different versions; the trailing "-1" is the Debian revision.
|
||||
# The feature suffix sorts ABOVE the unsuffixed build, so installing a
|
||||
# feature build is an upgrade and reverting to the default build is a
|
||||
# downgrade — which apt refuses without being told to, and `dpkg -i`
|
||||
# performs. Revert with `dpkg -i`, not `apt install`.
|
||||
VERSION_OVERRIDE="${BASE_VERSION}~dev+git${GIT_DATE}.${GIT_SHA}${DIRTY_SUFFIX}${FEATURE_SUFFIX}-1"
|
||||
VERSION_OVERRIDE="${BASE_VERSION}~dev+git${GIT_DATE}.${GIT_SHA}${DIRTY_SUFFIX}-1"
|
||||
echo "Auto-derived dev Version: ${VERSION_OVERRIDE}"
|
||||
fi
|
||||
elif [[ -n "${FEATURES}" ]]; then
|
||||
echo "Warning: --version was given with --features, so the Version carries no" >&2
|
||||
echo "feature marker and this package is indistinguishable from a default" >&2
|
||||
echo "build of the same commit. Mark it yourself if that matters." >&2
|
||||
fi
|
||||
|
||||
# Build the .deb package
|
||||
@@ -144,9 +105,6 @@ fi
|
||||
if [[ "${NO_BUILD}" -eq 1 ]]; then
|
||||
cargo_args+=(--no-build)
|
||||
fi
|
||||
if [[ -n "${FEATURES}" ]]; then
|
||||
cargo_args+=(--features "${FEATURES}")
|
||||
fi
|
||||
cargo "${cargo_args[@]}"
|
||||
|
||||
# Move output to deploy/
|
||||
|
||||
@@ -19,11 +19,6 @@ RestartSec=5
|
||||
RuntimeDirectory=fips
|
||||
RuntimeDirectoryMode=0750
|
||||
|
||||
# Log directory (/var/log/fips/), where the built-in tick-body profiler writes
|
||||
# its capture files. Declared so systemd creates it on start and removes it on
|
||||
# purge; the daemon runs as root and already has access without it.
|
||||
LogsDirectory=fips
|
||||
|
||||
# Security hardening (daemon runs as root for TUN and raw sockets)
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
# FIPS OpenWrt Package (apk)
|
||||
|
||||
Builds a FIPS `.apk` for **OpenWrt 25+**, where apk-tools is the mandatory
|
||||
package manager. apk is also available opt-in on **24.10** (where opkg remains
|
||||
the default). For OpenWrt 24.x and earlier, the `.ipk` package in
|
||||
[`../openwrt-ipk/`](../openwrt-ipk/) still works.
|
||||
|
||||
Like the `.ipk` build, this is **SDK-free**: it cross-compiles with
|
||||
`cargo-zigbuild` and assembles the package directly — no OpenWrt SDK image. The
|
||||
`.ipk` format is a plain tar.gz we can hand-roll, but the `.apk` (apk-tools v3
|
||||
ADB) container is not, so we drive the official `apk mkpkg` applet — the same
|
||||
tool OpenWrt's own [`include/package-pack.mk`](https://github.com/openwrt/openwrt/blob/main/include/package-pack.mk)
|
||||
calls. The only extra requirement over the `.ipk` build is the `apk` binary.
|
||||
|
||||
## Layout
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `build-apk.sh` | Cross-compile + assemble the `.apk` via `apk mkpkg` |
|
||||
| `apk-version.sh` | Map a release tag / commit height to an apk-tools-valid version |
|
||||
| `apk-version.test.sh` | Case-table test for `apk-version.sh` (`sh apk-version.test.sh`) |
|
||||
|
||||
The installed-filesystem payload (init scripts, `fips.yaml`, sysctl drop-ins,
|
||||
hotplug, uci-defaults, …) is **shared** with the `.ipk` package — there is one
|
||||
canonical copy in [`../openwrt-ipk/files/`](../openwrt-ipk/files/). `build-apk.sh`
|
||||
stages from there, so the two packages always ship the same files. Keep the
|
||||
staging block in `build-apk.sh` in sync with `../openwrt-ipk/build-ipk.sh`.
|
||||
|
||||
## Versioning
|
||||
|
||||
apk-tools enforces a strict version grammar
|
||||
(`<digit>(.<digit>)*(_<suffix><digit>*)*(-r<N>)`). `apk-version.sh` builds a
|
||||
valid version from structured inputs rather than rewriting an already-flattened
|
||||
string:
|
||||
|
||||
| Input | apk version |
|
||||
|---|---|
|
||||
| `tag v1.2.3` | `1.2.3-r0` |
|
||||
| `tag v1.2.3-rc1` | `1.2.3_rc1-r0` |
|
||||
| `dev 1234` (commit height) | `0.0.0_git1234-r0` |
|
||||
|
||||
The human-readable version (`v1.2.3`, `master.123.abcdef0`) is still used for the
|
||||
artifact filename; only the metadata embedded in the package is normalized.
|
||||
|
||||
## Building
|
||||
|
||||
### Prerequisites
|
||||
|
||||
| Requirement | Notes |
|
||||
|---|---|
|
||||
| `cargo install cargo-zigbuild` + `zig` | Rust musl cross-compilation (as for `.ipk`) |
|
||||
| apk-tools v3 `apk` binary | Provides `apk mkpkg`; not packaged for most distros — build from source |
|
||||
| `fakeroot` | Optional; makes packaged files root-owned on an unprivileged build host |
|
||||
|
||||
apk-tools is not in Debian/Ubuntu repos, so build the pinned release from source.
|
||||
Pin the same commit the targeted OpenWrt release ships (see
|
||||
`package/system/apk/Makefile` upstream) so the `.apk` is readable by the device's
|
||||
`apk`. CI builds **3.0.5** (`b5a31c0d…`):
|
||||
|
||||
```bash
|
||||
sudo apt-get install -y build-essential meson ninja-build pkg-config \
|
||||
zlib1g-dev libssl-dev libzstd-dev liblzma-dev lua5.4-dev scdoc
|
||||
git clone https://gitlab.alpinelinux.org/alpine/apk-tools.git
|
||||
cd apk-tools && git checkout b5a31c0d865342ad80be10d68f1bb3d3ad9b0866
|
||||
meson setup build && ninja -C build src/apk
|
||||
export APK_BIN="$PWD/build/src/apk"
|
||||
```
|
||||
|
||||
### Build the package
|
||||
|
||||
```bash
|
||||
# from the repo root
|
||||
./packaging/openwrt-apk/build-apk.sh --arch aarch64 # or x86_64, mipsel, mips, arm
|
||||
```
|
||||
|
||||
Output: `dist/fips_<version>_<openwrt-arch>.apk`. Override the version with
|
||||
`PKG_VERSION` (filename) and `APK_VERSION` (embedded metadata); otherwise both are
|
||||
derived from git.
|
||||
|
||||
## Installing on the router
|
||||
|
||||
Packages are **unsigned** (the same posture as our `.ipk`), so install with
|
||||
`--allow-untrusted`:
|
||||
|
||||
```bash
|
||||
scp -O dist/fips_<version>_<arch>.apk root@192.168.1.1:/tmp/
|
||||
ssh root@192.168.1.1 apk add --allow-untrusted /tmp/fips_<version>_<arch>.apk
|
||||
```
|
||||
|
||||
On OpenWrt 25.x, installing from a *signed repository* requires the publisher's
|
||||
key; a single `--allow-untrusted` package install does not. If we ever publish an
|
||||
apk feed, add ECDSA (prime256v1) signing via `apk mkpkg --sign` and distribute the
|
||||
public key to `/etc/apk/keys/`.
|
||||
|
||||
`/etc/fips/fips.yaml` is marked as a config file (via
|
||||
`/lib/apk/packages/fips.conffiles`), so apk preserves local edits across upgrades,
|
||||
and `/lib/upgrade/keep.d/fips` preserves `/etc/fips/` across `sysupgrade` — the
|
||||
same guarantees as the `.ipk` package.
|
||||
@@ -1,74 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Emit an apk-tools-compatible version string for FIPS.
|
||||
#
|
||||
# apk-tools enforces a strict version grammar:
|
||||
# <digit>(.<digit>)*(_<suffix><digit>*)*(-r<N>)
|
||||
# where <suffix> is a recognised pre-release/post-release token
|
||||
# (alpha, beta, pre, rc, cvs, svn, git, hg, p).
|
||||
#
|
||||
# Unlike a regex rewrite of an already-flattened version string, this
|
||||
# helper builds the apk version directly from the *structured* inputs the
|
||||
# caller already has (a release tag, or a commit height). There is no
|
||||
# parsing-back-out of a "branch.height.hash" blob, so there is no fragile
|
||||
# reparse step to get wrong.
|
||||
#
|
||||
# Usage:
|
||||
# apk-version.sh tag <git-tag> # e.g. v1.2.3, v1.2.3-rc1
|
||||
# apk-version.sh dev <height> # e.g. 1234 (git rev-list --count HEAD)
|
||||
# apk-version.sh auto # derive from the current git checkout
|
||||
#
|
||||
# Examples:
|
||||
# apk-version.sh tag v1.2.3 -> 1.2.3-r0
|
||||
# apk-version.sh tag v1.2.3-rc1 -> 1.2.3_rc1-r0
|
||||
# apk-version.sh dev 1234 -> 0.0.0_git1234-r0
|
||||
set -eu
|
||||
|
||||
mode="${1:-auto}"
|
||||
|
||||
case "$mode" in
|
||||
tag) raw_tag="${2:?tag mode requires a tag argument}"; height="" ;;
|
||||
dev) raw_tag=""; height="${2:?dev mode requires a height argument}" ;;
|
||||
auto)
|
||||
if raw_tag="$(git describe --exact-match --tags 2>/dev/null)"; then
|
||||
height=""
|
||||
else
|
||||
raw_tag=""
|
||||
height="$(git rev-list --count HEAD 2>/dev/null || echo 0)"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "usage: $0 [auto | tag <git-tag> | dev <height>]" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -n "$raw_tag" ]; then
|
||||
# Release tag: vX.Y.Z or vX.Y.Z-<pre>. Strip the leading 'v', split the
|
||||
# core (X.Y.Z) from the pre-release token, and map our hyphen separator
|
||||
# to apk's '_' pre-release marker.
|
||||
body="${raw_tag#v}"
|
||||
core="${body%%-*}"
|
||||
case "$body" in
|
||||
*-*) pre="${body#*-}" ;;
|
||||
*) pre="" ;;
|
||||
esac
|
||||
|
||||
case "$pre" in
|
||||
"") suffix="" ;;
|
||||
alpha*|beta*|pre*|rc*) suffix="_${pre}" ;;
|
||||
*)
|
||||
# Unknown pre-release token: apk would reject or misorder it, so
|
||||
# drop it rather than emit an invalid version. The human-readable
|
||||
# PACKAGE_VERSION (the raw tag) is still used for the filename.
|
||||
suffix=""
|
||||
;;
|
||||
esac
|
||||
|
||||
printf '%s%s-r0\n' "$core" "$suffix"
|
||||
else
|
||||
# Untagged build: no meaningful semver, so anchor at 0.0.0 and encode the
|
||||
# monotonic commit height as a _git pre-release component. This keeps apk's
|
||||
# ordering sane across dev builds without smuggling the hash/branch into a
|
||||
# field that cannot represent them.
|
||||
printf '0.0.0_git%s-r0\n' "${height:-0}"
|
||||
fi
|
||||
@@ -1,45 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Case-table test for apk-version.sh. Run: sh apk-version.test.sh
|
||||
set -eu
|
||||
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
SUT="$HERE/apk-version.sh"
|
||||
|
||||
fail=0
|
||||
check() {
|
||||
# check <expected> <args...>
|
||||
expected="$1"; shift
|
||||
actual="$(sh "$SUT" "$@")"
|
||||
if [ "$actual" = "$expected" ]; then
|
||||
printf ' PASS %-22s -> %s\n' "$*" "$actual"
|
||||
else
|
||||
printf ' FAIL %-22s -> %s (expected %s)\n' "$*" "$actual" "$expected"
|
||||
fail=1
|
||||
fi
|
||||
}
|
||||
|
||||
echo "== apk-version.sh =="
|
||||
|
||||
# Plain release tags.
|
||||
check "1.2.3-r0" tag v1.2.3
|
||||
check "0.4.0-r0" tag v0.4.0
|
||||
check "10.20.30-r0" tag v10.20.30
|
||||
|
||||
# Pre-release tags: hyphen separator becomes apk's '_' marker.
|
||||
check "1.2.3_rc1-r0" tag v1.2.3-rc1
|
||||
check "1.2.3_alpha1-r0" tag v1.2.3-alpha1
|
||||
check "1.2.3_beta2-r0" tag v1.2.3-beta2
|
||||
check "1.2.3_pre1-r0" tag v1.2.3-pre1
|
||||
|
||||
# Unknown pre-release token is dropped (apk cannot represent it).
|
||||
check "1.2.3-r0" tag v1.2.3-weird9
|
||||
|
||||
# Dev builds: monotonic commit height as a _git component.
|
||||
check "0.0.0_git1234-r0" dev 1234
|
||||
check "0.0.0_git0-r0" dev 0
|
||||
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
echo "FAILED"
|
||||
exit 1
|
||||
fi
|
||||
echo "OK"
|
||||
@@ -1,298 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Build a FIPS .apk package for OpenWrt without the OpenWrt SDK.
|
||||
#
|
||||
# apk-tools (.apk) is the mandatory package manager from OpenWrt 25 onward; it
|
||||
# is also available opt-in on 24.10, where opkg (.ipk) remains the default. The
|
||||
# .ipk package in ../openwrt-ipk/ still covers OpenWrt 24.x and earlier; this
|
||||
# .apk package is what you need on 25+. Unlike the .ipk format (a plain tar.gz
|
||||
# of tarballs that we
|
||||
# assemble by hand in ../openwrt-ipk/build-ipk.sh), the .apk container is the
|
||||
# apk-tools v3 ADB format, which is impractical to hand-roll. Instead we drive
|
||||
# the official `apk mkpkg` applet — the same tool OpenWrt's build system calls
|
||||
# in include/package-pack.mk — so no SDK is required, only the `apk` binary.
|
||||
#
|
||||
# Usage:
|
||||
# ./packaging/openwrt-apk/build-apk.sh [--arch <name>]
|
||||
#
|
||||
# Architectures (--arch): aarch64 [default], x86_64, mipsel, mips, arm
|
||||
# (the apk CI matrix ships aarch64 + x86_64; the rest are buildable locally).
|
||||
#
|
||||
# Output: dist/fips_<version>_<openwrt-arch>.apk
|
||||
#
|
||||
# Prerequisites:
|
||||
# cargo install cargo-zigbuild (Rust musl cross-compilation)
|
||||
# apk-tools v3 `apk` binary on PATH, or pointed at via APK_BIN=/path/to/apk
|
||||
# (build from source — see README.md; CI builds apk-tools 3.0.5).
|
||||
# fakeroot (optional but recommended; makes packaged files root-owned).
|
||||
#
|
||||
# Install on a router (packages are unsigned, like our .ipk):
|
||||
# scp -O dist/fips_<version>_<arch>.apk root@192.168.1.1:/tmp/
|
||||
# ssh root@192.168.1.1 apk add --allow-untrusted /tmp/fips_<version>_<arch>.apk
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Arguments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ARCH="aarch64"
|
||||
BIN_DIR="" # if set, use prebuilt binaries from here instead of compiling
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--arch) ARCH="$2"; shift 2 ;;
|
||||
--arch=*) ARCH="${1#*=}"; shift ;;
|
||||
--bin-dir) BIN_DIR="$2"; shift 2 ;;
|
||||
--bin-dir=*) BIN_DIR="${1#*=}"; shift ;;
|
||||
*) echo "Unknown argument: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Architecture mapping
|
||||
#
|
||||
# RUST_TARGET — passed to cargo --target
|
||||
# OPENWRT_ARCH — apk "arch:" field and the package filename
|
||||
#
|
||||
# Kept in sync with ../openwrt-ipk/build-ipk.sh (same target table).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
case "$ARCH" in
|
||||
aarch64)
|
||||
RUST_TARGET="aarch64-unknown-linux-musl"
|
||||
OPENWRT_ARCH="aarch64_cortex-a53"
|
||||
;;
|
||||
mipsel)
|
||||
RUST_TARGET="mipsel-unknown-linux-musl"
|
||||
OPENWRT_ARCH="mipsel_24kc"
|
||||
;;
|
||||
mips)
|
||||
RUST_TARGET="mips-unknown-linux-musl"
|
||||
OPENWRT_ARCH="mips_24kc"
|
||||
;;
|
||||
arm)
|
||||
RUST_TARGET="arm-unknown-linux-musleabihf"
|
||||
OPENWRT_ARCH="arm_cortex-a7"
|
||||
;;
|
||||
x86_64)
|
||||
RUST_TARGET="x86_64-unknown-linux-musl"
|
||||
OPENWRT_ARCH="x86_64"
|
||||
;;
|
||||
*)
|
||||
echo "Unknown arch: $ARCH" >&2
|
||||
echo "Valid: aarch64, mipsel, mips, arm, x86_64" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
# The installed-filesystem payload (init scripts, config, sysctl, etc.) is
|
||||
# shared with the .ipk package; there is one canonical copy in openwrt-ipk/.
|
||||
FILES_DIR="$PROJECT_ROOT/packaging/openwrt-ipk/files"
|
||||
DIST_DIR="$PROJECT_ROOT/dist"
|
||||
|
||||
PKG_NAME="fips"
|
||||
# Human-readable version for the filename (e.g. v0.4.0 or master.123.abcdef0),
|
||||
# mirroring the .ipk artifacts and the CI/NIP-94 plumbing.
|
||||
PKG_VERSION="${PKG_VERSION:-$(cd "$PROJECT_ROOT" && git describe --tags --always --dirty 2>/dev/null || echo "0.1.0")}"
|
||||
# apk-tools-compatible version embedded inside the package metadata.
|
||||
APK_VERSION="${APK_VERSION:-$(cd "$PROJECT_ROOT" && sh "$SCRIPT_DIR/apk-version.sh" auto)}"
|
||||
|
||||
APK_BIN="${APK_BIN:-apk}"
|
||||
if ! command -v "$APK_BIN" >/dev/null 2>&1; then
|
||||
echo "Error: apk-tools binary not found (looked for '$APK_BIN')." >&2
|
||||
echo " Build apk-tools v3 from source or set APK_BIN=/path/to/apk." >&2
|
||||
echo " See packaging/openwrt-apk/README.md." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Building $PKG_NAME $PKG_VERSION (apk version $APK_VERSION) for $OPENWRT_ARCH ($RUST_TARGET)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Obtain binaries
|
||||
#
|
||||
# Either use a directory of prebuilt binaries (--bin-dir; CI cross-compiles
|
||||
# once in a shared job and hands them to both the .ipk and .apk packagers), or
|
||||
# compile from source here for a self-contained local build.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if [ -n "$BIN_DIR" ]; then
|
||||
RELEASE_DIR="$BIN_DIR"
|
||||
echo "==> Using prebuilt binaries from $RELEASE_DIR"
|
||||
for bin in fips fipsctl fipstop fips-gateway; do
|
||||
[ -f "$RELEASE_DIR/$bin" ] || {
|
||||
echo "Error: prebuilt binary not found: $RELEASE_DIR/$bin" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
else
|
||||
if ! command -v cargo-zigbuild &>/dev/null; then
|
||||
echo "Error: cargo-zigbuild not found." >&2
|
||||
echo " Install: cargo install cargo-zigbuild" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! rustup target list --installed | grep -q "^$RUST_TARGET$"; then
|
||||
echo "==> Adding Rust target $RUST_TARGET..."
|
||||
rustup target add "$RUST_TARGET"
|
||||
fi
|
||||
|
||||
echo "==> Compiling..."
|
||||
cd "$PROJECT_ROOT"
|
||||
cargo zigbuild \
|
||||
--release \
|
||||
--target "$RUST_TARGET" \
|
||||
--bin fips \
|
||||
--bin fipsctl \
|
||||
--bin fipstop \
|
||||
--bin fips-gateway
|
||||
|
||||
RELEASE_DIR="$PROJECT_ROOT/target/$RUST_TARGET/release"
|
||||
|
||||
echo "==> Stripping binaries..."
|
||||
STRIP="${LLVM_STRIP:-strip}"
|
||||
for bin in fips fipsctl fipstop fips-gateway; do
|
||||
"$STRIP" "$RELEASE_DIR/$bin" 2>/dev/null || true
|
||||
done
|
||||
fi
|
||||
|
||||
SIZE=$(du -sh "$RELEASE_DIR/fips" | cut -f1)
|
||||
echo " fips: $SIZE"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Stage the installed filesystem tree (--files root for apk mkpkg)
|
||||
# ---------------------------------------------------------------------------
|
||||
# This block is the same payload as ../openwrt-ipk/build-ipk.sh; keep the two
|
||||
# in sync. The CI apk structural check asserts every path below is present.
|
||||
|
||||
WORK_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK_DIR"' EXIT
|
||||
|
||||
STAGE_DIR="$WORK_DIR/root" # becomes the package's filesystem
|
||||
SCRIPTS_DIR="$WORK_DIR/scripts" # maintainer scripts (metadata, not payload)
|
||||
mkdir -p "$STAGE_DIR" "$SCRIPTS_DIR"
|
||||
|
||||
install -d "$STAGE_DIR/usr/bin"
|
||||
install -m 0755 "$RELEASE_DIR/fips" "$STAGE_DIR/usr/bin/fips"
|
||||
install -m 0755 "$RELEASE_DIR/fipsctl" "$STAGE_DIR/usr/bin/fipsctl"
|
||||
install -m 0755 "$RELEASE_DIR/fipstop" "$STAGE_DIR/usr/bin/fipstop"
|
||||
install -m 0755 "$RELEASE_DIR/fips-gateway" "$STAGE_DIR/usr/bin/fips-gateway"
|
||||
install -m 0755 "$FILES_DIR/usr/bin/fips-mesh-setup" "$STAGE_DIR/usr/bin/fips-mesh-setup"
|
||||
install -m 0755 "$FILES_DIR/usr/bin/fips-ap-setup" "$STAGE_DIR/usr/bin/fips-ap-setup"
|
||||
|
||||
install -d "$STAGE_DIR/etc/init.d"
|
||||
install -m 0755 "$FILES_DIR/etc/init.d/fips" "$STAGE_DIR/etc/init.d/fips"
|
||||
install -m 0755 "$FILES_DIR/etc/init.d/fips-gateway" "$STAGE_DIR/etc/init.d/fips-gateway"
|
||||
|
||||
install -d "$STAGE_DIR/etc/fips"
|
||||
install -m 0600 "$FILES_DIR/etc/fips/fips.yaml" "$STAGE_DIR/etc/fips/fips.yaml"
|
||||
install -m 0755 "$FILES_DIR/etc/fips/firewall.sh" "$STAGE_DIR/etc/fips/firewall.sh"
|
||||
|
||||
# The shared fips.yaml ships ethernet.wan.interface: "eth0", the OpenWrt 24
|
||||
# default. This .apk package targets OpenWrt 25+ (DSA), where the WAN port is
|
||||
# named "wan", so ship "wan" as the default. Patching the staged copy keeps the
|
||||
# as-installed config correct for the platform without maintaining a second copy
|
||||
# of the file; operators can still edit /etc/fips/fips.yaml for non-standard boards.
|
||||
sed -i 's|interface: "eth0"|interface: "wan"|' "$STAGE_DIR/etc/fips/fips.yaml"
|
||||
|
||||
install -d "$STAGE_DIR/etc/dnsmasq.d"
|
||||
install -m 0644 "$FILES_DIR/etc/dnsmasq.d/fips.conf" "$STAGE_DIR/etc/dnsmasq.d/fips.conf"
|
||||
|
||||
install -d "$STAGE_DIR/etc/sysctl.d"
|
||||
install -m 0644 "$FILES_DIR/etc/sysctl.d/fips-bridge.conf" "$STAGE_DIR/etc/sysctl.d/fips-bridge.conf"
|
||||
install -m 0644 "$FILES_DIR/etc/sysctl.d/fips-gateway.conf" "$STAGE_DIR/etc/sysctl.d/fips-gateway.conf"
|
||||
|
||||
install -d "$STAGE_DIR/etc/hotplug.d/net"
|
||||
install -m 0755 "$FILES_DIR/etc/hotplug.d/net/99-fips" "$STAGE_DIR/etc/hotplug.d/net/99-fips"
|
||||
|
||||
install -d "$STAGE_DIR/etc/uci-defaults"
|
||||
install -m 0755 "$FILES_DIR/etc/uci-defaults/90-fips-setup" "$STAGE_DIR/etc/uci-defaults/90-fips-setup"
|
||||
|
||||
install -d "$STAGE_DIR/lib/upgrade/keep.d"
|
||||
install -m 0644 "$FILES_DIR/lib/upgrade/keep.d/fips" "$STAGE_DIR/lib/upgrade/keep.d/fips"
|
||||
|
||||
# ---- conffiles ----
|
||||
# apk mkpkg discovers config files from /lib/apk/packages/<name>.conffiles
|
||||
# inside the --files tree (same mechanism OpenWrt's package-pack.mk uses).
|
||||
# Listing fips.yaml here makes apk preserve user edits across upgrades, the
|
||||
# apk equivalent of opkg's conffiles handling.
|
||||
install -d "$STAGE_DIR/lib/apk/packages"
|
||||
cat > "$STAGE_DIR/lib/apk/packages/${PKG_NAME}.conffiles" <<'EOF'
|
||||
/etc/fips/fips.yaml
|
||||
EOF
|
||||
|
||||
# ---- maintainer scripts ----
|
||||
# Map our opkg maintainer scripts onto apk's lifecycle phases:
|
||||
# opkg postinst -> apk post-install (enable + start services)
|
||||
# opkg prerm -> apk pre-deinstall (stop + disable services)
|
||||
|
||||
cat > "$SCRIPTS_DIR/post-install" <<'EOF'
|
||||
#!/bin/sh
|
||||
# Run first-boot UCI setup (the script deletes itself when done).
|
||||
if [ -x /etc/uci-defaults/90-fips-setup ]; then
|
||||
/etc/uci-defaults/90-fips-setup && rm -f /etc/uci-defaults/90-fips-setup
|
||||
fi
|
||||
|
||||
/etc/init.d/fips enable
|
||||
/etc/init.d/fips start
|
||||
/etc/init.d/fips-gateway enable
|
||||
/etc/init.d/fips-gateway start
|
||||
exit 0
|
||||
EOF
|
||||
|
||||
cat > "$SCRIPTS_DIR/pre-deinstall" <<'EOF'
|
||||
#!/bin/sh
|
||||
/etc/init.d/fips-gateway stop 2>/dev/null || true
|
||||
/etc/init.d/fips-gateway disable 2>/dev/null || true
|
||||
/etc/init.d/fips stop 2>/dev/null || true
|
||||
/etc/init.d/fips disable 2>/dev/null || true
|
||||
exit 0
|
||||
EOF
|
||||
|
||||
chmod 0755 "$SCRIPTS_DIR/post-install" "$SCRIPTS_DIR/pre-deinstall"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Assemble the .apk via apk mkpkg
|
||||
# ---------------------------------------------------------------------------
|
||||
# fakeroot makes the packaged files root-owned even though CI runs unprivileged.
|
||||
|
||||
DESCRIPTION="FIPS Mesh Network Daemon. Distributed, decentralized mesh networking over UDP, TCP, and raw Ethernet, with a TUN interface (fips0), ULA IPv6 addressing, and a .fips DNS responder."
|
||||
DEPENDS="kmod-tun kmod-br-netfilter kmod-nft-nat kmod-nf-conntrack ip-full"
|
||||
|
||||
PKG_FILENAME="${PKG_NAME}_${PKG_VERSION}_${OPENWRT_ARCH}.apk"
|
||||
mkdir -p "$DIST_DIR"
|
||||
|
||||
FAKEROOT=""
|
||||
if command -v fakeroot >/dev/null 2>&1; then
|
||||
FAKEROOT="fakeroot"
|
||||
else
|
||||
echo "Warning: fakeroot not found — packaged files will be owned by the build user." >&2
|
||||
fi
|
||||
|
||||
$FAKEROOT "$APK_BIN" mkpkg \
|
||||
--info "name:$PKG_NAME" \
|
||||
--info "version:$APK_VERSION" \
|
||||
--info "description:$DESCRIPTION" \
|
||||
--info "arch:$OPENWRT_ARCH" \
|
||||
--info "license:MIT" \
|
||||
--info "origin:$PKG_NAME" \
|
||||
--info "url:https://github.com/jmcorgan/fips" \
|
||||
--info "maintainer:FIPS Network" \
|
||||
--info "depends:$DEPENDS" \
|
||||
--script "post-install:$SCRIPTS_DIR/post-install" \
|
||||
--script "pre-deinstall:$SCRIPTS_DIR/pre-deinstall" \
|
||||
--files "$STAGE_DIR" \
|
||||
--output "$DIST_DIR/$PKG_FILENAME"
|
||||
|
||||
echo ""
|
||||
echo "==> Done: dist/$PKG_FILENAME"
|
||||
echo " $(du -sh "$DIST_DIR/$PKG_FILENAME" | cut -f1)"
|
||||
echo ""
|
||||
echo "Install on router (OpenWrt 25+, or 24.10 with apk enabled):"
|
||||
echo " scp -O dist/$PKG_FILENAME root@192.168.1.1:/tmp/"
|
||||
echo " ssh root@192.168.1.1 apk add --allow-untrusted /tmp/$PKG_FILENAME"
|
||||
@@ -96,12 +96,6 @@ define Package/fips/install
|
||||
$(INSTALL_BIN) $(RUST_RELEASE_DIR)/fipstop $(1)/usr/bin/fipstop
|
||||
$(INSTALL_BIN) $(RUST_RELEASE_DIR)/fips-gateway $(1)/usr/bin/fips-gateway
|
||||
|
||||
# 802.11s mesh backhaul setup helper
|
||||
$(INSTALL_BIN) $(CURDIR)/files/usr/bin/fips-mesh-setup $(1)/usr/bin/fips-mesh-setup
|
||||
|
||||
# Open "FIPS" access SSID setup helper
|
||||
$(INSTALL_BIN) $(CURDIR)/files/usr/bin/fips-ap-setup $(1)/usr/bin/fips-ap-setup
|
||||
|
||||
# procd init script
|
||||
$(INSTALL_DIR) $(1)/etc/init.d
|
||||
$(INSTALL_BIN) $(CURDIR)/files/etc/init.d/fips $(1)/etc/init.d/fips
|
||||
|
||||
@@ -14,7 +14,6 @@ For ad-hoc deployment without the build system, see
|
||||
| `/usr/bin/fipsctl` | CLI control tool (`fipsctl show peers`, `fipsctl show links`, …) |
|
||||
| `/usr/bin/fipstop` | Live TUI dashboard |
|
||||
| `/usr/bin/fips-gateway` | Outbound LAN gateway service (not started by default) |
|
||||
| `/usr/bin/fips-mesh-setup` | Opt-in helper — creates an open 802.11s mesh interface for router↔router backhaul |
|
||||
| `/etc/init.d/fips` | procd service for the daemon (auto-start, crash respawn) |
|
||||
| `/etc/init.d/fips-gateway` | procd service for the gateway (disabled by default) |
|
||||
| `/etc/fips/fips.yaml` | Node configuration (edit before first start) |
|
||||
@@ -133,10 +132,7 @@ The default config enables:
|
||||
|
||||
For Ethernet transport, uncomment the `ethernet:` section and set the correct
|
||||
physical interface names for your router. **Always use physical port names
|
||||
(`eth0`, `eth1`, or DSA port names like `wan`/`lan1`), never bridge names
|
||||
(`br-lan`).** The shipped default WAN port is `eth0` (OpenWrt 24); on OpenWrt
|
||||
25 (DSA) boards the WAN port is named `wan` — the `.apk` package ships that
|
||||
default. Run `ip link show` to confirm the names on your board. See
|
||||
(`eth0`, `eth1`), never bridge names (`br-lan`).** See
|
||||
[`deploy/native/README.md`](../../deploy/native/README.md) for details.
|
||||
|
||||
## Service management
|
||||
|
||||
@@ -27,14 +27,11 @@ set -euo pipefail
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ARCH="aarch64"
|
||||
BIN_DIR="" # if set, use prebuilt binaries from here instead of compiling
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--arch) ARCH="$2"; shift 2 ;;
|
||||
--arch=*) ARCH="${1#*=}"; shift ;;
|
||||
--bin-dir) BIN_DIR="$2"; shift 2 ;;
|
||||
--bin-dir=*) BIN_DIR="${1#*=}"; shift ;;
|
||||
*) echo "Unknown argument: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
@@ -89,55 +86,44 @@ PKG_VERSION="${PKG_VERSION:-$(cd "$PROJECT_ROOT" && git describe --tags --always
|
||||
echo "==> Building $PKG_NAME $PKG_VERSION for $OPENWRT_ARCH ($RUST_TARGET)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Obtain binaries
|
||||
#
|
||||
# Either use a directory of prebuilt binaries (--bin-dir; CI cross-compiles
|
||||
# once in a shared job and hands them to both the .ipk and .apk packagers), or
|
||||
# compile from source here for a self-contained local build.
|
||||
# Prerequisites
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if [ -n "$BIN_DIR" ]; then
|
||||
RELEASE_DIR="$BIN_DIR"
|
||||
echo "==> Using prebuilt binaries from $RELEASE_DIR"
|
||||
for bin in fips fipsctl fipstop fips-gateway; do
|
||||
[ -f "$RELEASE_DIR/$bin" ] || {
|
||||
echo "Error: prebuilt binary not found: $RELEASE_DIR/$bin" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
else
|
||||
if ! command -v cargo-zigbuild &>/dev/null; then
|
||||
echo "Error: cargo-zigbuild not found." >&2
|
||||
echo " Install: cargo install cargo-zigbuild" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! rustup target list --installed | grep -q "^$RUST_TARGET$"; then
|
||||
echo "==> Adding Rust target $RUST_TARGET..."
|
||||
rustup target add "$RUST_TARGET"
|
||||
fi
|
||||
|
||||
echo "==> Compiling..."
|
||||
cd "$PROJECT_ROOT"
|
||||
cargo zigbuild \
|
||||
--release \
|
||||
--target "$RUST_TARGET" \
|
||||
--bin fips \
|
||||
--bin fipsctl \
|
||||
--bin fipstop \
|
||||
--bin fips-gateway
|
||||
|
||||
RELEASE_DIR="$PROJECT_ROOT/target/$RUST_TARGET/release"
|
||||
|
||||
echo "==> Stripping binaries..."
|
||||
STRIP="${LLVM_STRIP:-strip}"
|
||||
for bin in fips fipsctl fipstop fips-gateway; do
|
||||
"$STRIP" "$RELEASE_DIR/$bin" 2>/dev/null || true
|
||||
done
|
||||
if ! command -v cargo-zigbuild &>/dev/null; then
|
||||
echo "Error: cargo-zigbuild not found." >&2
|
||||
echo " Install: cargo install cargo-zigbuild" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! rustup target list --installed | grep -q "^$RUST_TARGET$"; then
|
||||
echo "==> Adding Rust target $RUST_TARGET..."
|
||||
rustup target add "$RUST_TARGET"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Build
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
echo "==> Compiling..."
|
||||
cd "$PROJECT_ROOT"
|
||||
cargo zigbuild \
|
||||
--release \
|
||||
--target "$RUST_TARGET" \
|
||||
--bin fips \
|
||||
--bin fipsctl \
|
||||
--bin fipstop \
|
||||
--bin fips-gateway
|
||||
|
||||
RELEASE_DIR="$PROJECT_ROOT/target/$RUST_TARGET/release"
|
||||
|
||||
echo "==> Stripping binaries..."
|
||||
STRIP="${LLVM_STRIP:-strip}"
|
||||
for bin in fips fipsctl fipstop fips-gateway; do
|
||||
"$STRIP" "$RELEASE_DIR/$bin" 2>/dev/null || true
|
||||
done
|
||||
|
||||
SIZE=$(du -sh "$RELEASE_DIR/fips" | cut -f1)
|
||||
echo " fips: $SIZE"
|
||||
echo " fips: $SIZE after strip"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Assemble .ipk
|
||||
@@ -161,8 +147,6 @@ install -m 0755 "$RELEASE_DIR/fips" "$DATA_DIR/usr/bin/fips"
|
||||
install -m 0755 "$RELEASE_DIR/fipsctl" "$DATA_DIR/usr/bin/fipsctl"
|
||||
install -m 0755 "$RELEASE_DIR/fipstop" "$DATA_DIR/usr/bin/fipstop"
|
||||
install -m 0755 "$RELEASE_DIR/fips-gateway" "$DATA_DIR/usr/bin/fips-gateway"
|
||||
install -m 0755 "$FILES_DIR/usr/bin/fips-mesh-setup" "$DATA_DIR/usr/bin/fips-mesh-setup"
|
||||
install -m 0755 "$FILES_DIR/usr/bin/fips-ap-setup" "$DATA_DIR/usr/bin/fips-ap-setup"
|
||||
|
||||
install -d "$DATA_DIR/etc/init.d"
|
||||
install -m 0755 "$FILES_DIR/etc/init.d/fips" "$DATA_DIR/etc/init.d/fips"
|
||||
|
||||
@@ -10,21 +10,12 @@ node:
|
||||
#
|
||||
# Or set an explicit key (overrides persistent):
|
||||
# nsec: "nsec1..."
|
||||
# Mesh-lookup protocol (node.lookup.*): the overlay coordinate-lookup engine
|
||||
# (mesh address -> coordinates). Defaults shown; uncomment to override.
|
||||
# lookup:
|
||||
# ttl: 64
|
||||
# attempt_timeouts_secs: [1, 2, 4, 8]
|
||||
# recent_expiry_secs: 10
|
||||
# backoff_base_secs: 0
|
||||
# backoff_max_secs: 0
|
||||
# forward_min_interval_secs: 2
|
||||
rendezvous:
|
||||
# Optional Nostr-mediated overlay endpoint rendezvous.
|
||||
discovery:
|
||||
# Optional Nostr-mediated overlay endpoint discovery.
|
||||
# nostr:
|
||||
# enabled: true
|
||||
# policy: configured_only # disabled | configured_only | open
|
||||
# open_discovery_max_pending: 64 # caps queued open-rendezvous retries
|
||||
# open_discovery_max_pending: 64 # caps queued open-discovery retries
|
||||
# app: "fips-overlay-v1"
|
||||
# advertise: true
|
||||
# advert_relays:
|
||||
@@ -43,14 +34,6 @@ node:
|
||||
# - "stun:stun.cloudflare.com:3478"
|
||||
# - "stun:global.stun.twilio.com:3478"
|
||||
|
||||
# mDNS/DNS-SD peer rendezvous on the local link. Ships commented (the
|
||||
# daemon default is off); 'fips-ap-setup' uncomments it when creating
|
||||
# the access SSID — phone FIPS apps cannot see raw-Ethernet beacons,
|
||||
# so mDNS is how they find this router's daemon. Daemon-wide switch,
|
||||
# left enabled on 'fips-ap-setup remove'.
|
||||
# lan:
|
||||
# enabled: true
|
||||
|
||||
tun:
|
||||
enabled: true
|
||||
name: fips0
|
||||
@@ -72,11 +55,7 @@ dns:
|
||||
|
||||
transports:
|
||||
udp:
|
||||
# Dual-stack wildcard, not "0.0.0.0": access-SSID clients (phones) learn
|
||||
# this node's addresses from the mDNS advert and prefer the IPv6
|
||||
# link-local — a v4-only bind silently drops their Noise msg1.
|
||||
# OpenWrt is Linux (bindv6only=0), so "[::]" accepts v4 too.
|
||||
bind_addr: "[::]:2121"
|
||||
bind_addr: "0.0.0.0:2121"
|
||||
# advertise_on_nostr: true
|
||||
# public: false # false => advertise udp:nat; true => advertise bound host:port
|
||||
# accept_connections: true # default; refuse inbound msg1 when false
|
||||
@@ -95,73 +74,23 @@ transports:
|
||||
ethernet:
|
||||
wan:
|
||||
interface: "eth0"
|
||||
listen: true
|
||||
discovery: true
|
||||
announce: true
|
||||
auto_connect: true
|
||||
accept_connections: true
|
||||
wwan:
|
||||
interface: "phy0-sta0"
|
||||
listen: true
|
||||
discovery: true
|
||||
announce: true
|
||||
auto_connect: true
|
||||
accept_connections: true
|
||||
lan:
|
||||
interface: "br-lan"
|
||||
listen: true
|
||||
discovery: true
|
||||
announce: true
|
||||
auto_connect: true
|
||||
accept_connections: true
|
||||
|
||||
# 802.11s mesh backhaul between FIPS routers. These entries ship
|
||||
# commented out so a stock install that never creates fips-mesh*
|
||||
# logs no per-boot "interface missing" bind warning. Running
|
||||
# 'fips-mesh-setup <radio>' creates the interface AND uncomments the
|
||||
# matching block here (once per radio; radio0 -> fips-mesh0, radio1 ->
|
||||
# fips-mesh1); 'fips-mesh-setup remove' re-comments it. Restart fips
|
||||
# after — a transport whose interface is missing at startup is skipped,
|
||||
# not retried. Dual-band routers can mesh on both bands at once —
|
||||
# failover, not multipath: FIPS keeps one active link per peer, the
|
||||
# other band stands by. The mesh runs OPEN (no SAE) with 802.11s
|
||||
# forwarding off: FIPS's Noise handshake is the encryption and
|
||||
# authentication, and FIPS is the routing layer. See
|
||||
# docs/how-to/set-up-80211s-mesh-backhaul.md.
|
||||
# mesh0:
|
||||
# interface: "fips-mesh0"
|
||||
# discovery: true
|
||||
# announce: true
|
||||
# auto_connect: true
|
||||
# accept_connections: true
|
||||
# mesh1:
|
||||
# interface: "fips-mesh1"
|
||||
# discovery: true
|
||||
# announce: true
|
||||
# auto_connect: true
|
||||
# accept_connections: true
|
||||
|
||||
# Open "!FIPS" access SSID for phones and laptops running FIPS. These
|
||||
# entries ship commented out so a stock install that never creates
|
||||
# fips-ap* logs no per-boot "interface missing" bind warning. Running
|
||||
# 'fips-ap-setup <radio>' creates the interface AND uncomments the
|
||||
# matching block here (once per radio; radio0 -> fips-ap0, radio1 ->
|
||||
# fips-ap1); 'fips-ap-setup remove' re-comments it. Restart fips after
|
||||
# — a transport whose interface is missing at startup is skipped, not
|
||||
# retried. The SSID is OPEN and isolated on purpose: FIPS's Noise
|
||||
# handshake is the only security layer, and associated clients reach
|
||||
# nothing but the FIPS handshake surface. See
|
||||
# docs/how-to/set-up-open-access-ssid.md.
|
||||
# ap0:
|
||||
# interface: "fips-ap0"
|
||||
# discovery: true
|
||||
# announce: true
|
||||
# auto_connect: true
|
||||
# accept_connections: true
|
||||
# ap1:
|
||||
# interface: "fips-ap1"
|
||||
# discovery: true
|
||||
# announce: true
|
||||
# auto_connect: true
|
||||
# accept_connections: true
|
||||
|
||||
# Bluetooth Low Energy transport — requires BlueZ and the 'ble' feature.
|
||||
# ble:
|
||||
# adapter: "hci0"
|
||||
@@ -192,5 +121,5 @@ peers: []
|
||||
# - transport: udp
|
||||
# addr: "test-us01.fips.network:2121" # IP or hostname (e.g., "peer.example.com:2121")
|
||||
# - transport: udp
|
||||
# addr: "nat" # Use node.rendezvous.nostr for Nostr/STUN hole punching
|
||||
# addr: "nat" # Use node.discovery.nostr for Nostr/STUN hole punching
|
||||
# connect_policy: auto_connect
|
||||
|
||||
@@ -12,17 +12,6 @@ start_service() {
|
||||
# Ensure TUN module is loaded before starting the daemon.
|
||||
modprobe tun 2>/dev/null || true
|
||||
|
||||
# Pre-create the control-socket runtime directory so the daemon binds the
|
||||
# canonical /run/fips/control.sock instead of falling back to /tmp. This is
|
||||
# the procd equivalent of the systemd unit's RuntimeDirectory=fips (and the
|
||||
# fips.tmpfiles "d /run/fips 0750 root fips" entry); OpenWrt was the only
|
||||
# platform missing it. Without it, fips-gateway — which creates /run/fips
|
||||
# for its own gateway.sock — makes fipsctl/fipstop resolve a control socket
|
||||
# under /run/fips that the daemon actually bound under /tmp.
|
||||
mkdir -p /run/fips
|
||||
chmod 0750 /run/fips
|
||||
chgrp fips /run/fips 2>/dev/null || true
|
||||
|
||||
procd_open_instance
|
||||
procd_set_param command "$PROG" --config "$CONFIG"
|
||||
# Respawn: restart after 5 s, give up after 5 consecutive failures within
|
||||
|
||||
@@ -1,412 +0,0 @@
|
||||
#!/bin/sh
|
||||
# fips-ap-setup — configure the open "FIPS" access SSID for phones/laptops.
|
||||
#
|
||||
# Usage:
|
||||
# fips-ap-setup <radio> [ssid] e.g. fips-ap-setup radio0
|
||||
# fips-ap-setup remove [radio] no radio: remove all instances
|
||||
#
|
||||
# Creates an open AP on the given radio so client devices running FIPS can
|
||||
# reach the router. Every FIPS router broadcasts the SAME SSID ("!FIPS" by
|
||||
# default — the leading '!' sorts it to the top of alphabetically ordered
|
||||
# network pickers): same SSID + unique BSSIDs is one standard ESS, so a
|
||||
# phone saves the network once and roams between all FIPS routers natively.
|
||||
#
|
||||
# - encryption 'none' — the AP is OPEN on purpose. FIPS's Noise IK
|
||||
# handshake authenticates and encrypts everything above the radio, and
|
||||
# the security type must be uniform across ALL routers anyway: clients
|
||||
# key a saved network on SSID + security type, so one router with a PSK
|
||||
# splits the ESS into a different saved network. A stranger can
|
||||
# associate AND form a FIPS peer link — that is the point of open
|
||||
# access. The Noise handshake authenticates each link (no
|
||||
# impersonation of another identity, no MITM); it does NOT gate who
|
||||
# may peer. Admission is open up to the daemon's max-peers cap; the
|
||||
# firewall zone below is what confines every client to the FIPS
|
||||
# overlay (no path to br-lan or the WAN).
|
||||
# - DHCPv4 + RA IPv6 — dnsmasq serves DHCPv4 from a FIXED subnet,
|
||||
# 10.21.<N>.0/24 (echoes FIPS port 2121), identical on every router:
|
||||
# a roaming phone keeps its lease across routers, and dnsmasq's
|
||||
# authoritative mode (the OpenWrt default) ACKs the renew a foreign
|
||||
# router never issued. odhcpd additionally announces a ULA prefix in
|
||||
# router advertisements (stateless SLAAC); DHCPv6 stays off. FIPS
|
||||
# itself only needs link-local + mDNS, but client provisioning checks
|
||||
# (Android disconnects without an RA or a DHCP offer) and plain
|
||||
# laptops both want a real address. The network provides no internet,
|
||||
# so phones mark it unvalidated and keep cellular as the default
|
||||
# route while staying associated.
|
||||
# - ISOLATED — own network and firewall zone: no path to
|
||||
# br-lan, no forwarding to the WAN, and AP client isolation on.
|
||||
# Associated clients reach only the FIPS handshake surface.
|
||||
#
|
||||
# Interfaces are named per radio index (radio0 -> fips-ap0, radio1 ->
|
||||
# fips-ap1). Unlike the 802.11s backhaul there is NO same-channel
|
||||
# constraint — clients scan when they roam, so every router picks its
|
||||
# access channels freely.
|
||||
#
|
||||
# The shipped /etc/fips/fips.yaml carries 'ap0' and 'ap1' entries under
|
||||
# 'transports.ethernet' bound to these names, but commented out — a stock
|
||||
# install that never creates fips-ap* then logs no bind warning. This
|
||||
# helper uncomments the matching entry when it creates an interface and
|
||||
# re-comments it on remove, so the daemon binds the transport without a
|
||||
# manual config edit. It also uncomments the node.rendezvous.lan block
|
||||
# (mDNS/DNS-SD — how phone FIPS apps discover the daemon); that switch is
|
||||
# daemon-wide and stays on at remove. After an interface is up, restart
|
||||
# fips.
|
||||
# See docs/how-to/set-up-open-access-ssid.md for the full guide.
|
||||
|
||||
DEFAULT_SSID="!FIPS"
|
||||
CONFIG="/etc/fips/fips.yaml"
|
||||
|
||||
# Replace $CONFIG with the rewritten $CONFIG.tmp. Force mode 0600 first: the
|
||||
# package installs fips.yaml 0600 (it may hold an inline 'nsec' private key),
|
||||
# and a fresh tmp file would otherwise land world-readable after the move.
|
||||
ap_config_write() {
|
||||
chmod 600 "$CONFIG.tmp" && mv "$CONFIG.tmp" "$CONFIG"
|
||||
}
|
||||
|
||||
# Uncomment the 'ap<idx>' transports.ethernet block in $CONFIG (created by
|
||||
# 'fips-ap-setup'). Reversible with ap_config_disable. Returns:
|
||||
# 0 enabled (or already active) 1 no config file 2 no such block
|
||||
ap_config_enable() {
|
||||
idx="$1"
|
||||
[ -f "$CONFIG" ] || return 1
|
||||
grep -q "^ ap$idx:" "$CONFIG" && return 0
|
||||
grep -q "^ # ap$idx:" "$CONFIG" || return 2
|
||||
awk -v idx="$idx" '
|
||||
$0 ~ ("^ # ap" idx ":[ \t]*$") { blk = 1; sub(/^ # /, " "); print; next }
|
||||
blk && /^ # / { sub(/^ # /, " "); print; next }
|
||||
{ blk = 0; print }
|
||||
' "$CONFIG" > "$CONFIG.tmp" && ap_config_write
|
||||
}
|
||||
|
||||
# Uncomment the 'lan' block under node.rendezvous in $CONFIG — the daemon's
|
||||
# mDNS/DNS-SD responder+browser. Phone FIPS apps cannot open raw-Ethernet
|
||||
# sockets, so mDNS is how they find this router's daemon. The match is
|
||||
# scoped to node.rendezvous: transports.ethernet also has a 'lan' entry at
|
||||
# the same indent. Daemon-wide switch — enabled here, deliberately NOT
|
||||
# re-commented on remove (other transports use it once on). Returns:
|
||||
# 0 enabled (or already active) 1 no config file 2 no such block
|
||||
lan_rendezvous_enable() {
|
||||
[ -f "$CONFIG" ] || return 1
|
||||
state="$(awk '
|
||||
/^[A-Za-z_]/ { top = $1 }
|
||||
top == "node:" && /^ [A-Za-z_]/ { sec = $1 }
|
||||
top == "node:" && sec == "rendezvous:" && /^ lan:[ \t]*$/ { print "active"; exit }
|
||||
top == "node:" && sec == "rendezvous:" && /^ # lan:[ \t]*$/ { print "commented"; exit }
|
||||
' "$CONFIG")"
|
||||
case "$state" in
|
||||
active) return 0 ;;
|
||||
commented) ;;
|
||||
*) return 2 ;;
|
||||
esac
|
||||
awk '
|
||||
/^[A-Za-z_]/ { top = $1 }
|
||||
top == "node:" && /^ [A-Za-z_]/ { sec = $1 }
|
||||
top == "node:" && sec == "rendezvous:" && $0 ~ /^ # lan:[ \t]*$/ { blk = 1; sub(/^ # /, " "); print; next }
|
||||
blk && /^ # / { sub(/^ # /, " "); print; next }
|
||||
{ blk = 0; print }
|
||||
' "$CONFIG" > "$CONFIG.tmp" && ap_config_write
|
||||
}
|
||||
|
||||
# Re-comment the 'ap<idx>' block so the daemon stops binding it (and stops
|
||||
# warning about the now-missing interface). Inverse of ap_config_enable.
|
||||
ap_config_disable() {
|
||||
idx="$1"
|
||||
[ -f "$CONFIG" ] || return 1
|
||||
grep -q "^ ap$idx:" "$CONFIG" || return 0
|
||||
awk -v idx="$idx" '
|
||||
$0 ~ ("^ ap" idx ":[ \t]*$") { blk = 1; sub(/^ /, " # "); print; next }
|
||||
blk && /^ / { sub(/^ /, " # "); print; next }
|
||||
{ blk = 0; print }
|
||||
' "$CONFIG" > "$CONFIG.tmp" && ap_config_write
|
||||
}
|
||||
|
||||
usage() {
|
||||
echo "Usage: fips-ap-setup <radio> [ssid]" >&2
|
||||
echo " fips-ap-setup remove [radio]" >&2
|
||||
echo "Radios on this device:" >&2
|
||||
uci show wireless 2>/dev/null | sed -n "s/^wireless\.\([^.]*\)=wifi-device$/ \1/p" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# List the UCI section names of fips-managed access-point wifi-ifaces.
|
||||
ap_sections() {
|
||||
uci show wireless 2>/dev/null | sed -n "s/^wireless\.\(fips_ap[^.=]*\)=wifi-iface$/\1/p"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# remove [radio] — delete the wireless, network, dhcp, and firewall sections
|
||||
# created below
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if [ "$1" = "remove" ]; then
|
||||
if [ -n "$2" ]; then
|
||||
SECTIONS="fips_ap_$(printf '%s' "$2" | tr -c 'a-zA-Z0-9_' '_')"
|
||||
else
|
||||
SECTIONS="$(ap_sections)"
|
||||
fi
|
||||
[ -n "$SECTIONS" ] || {
|
||||
echo "No fips access-point instances configured."
|
||||
exit 0
|
||||
}
|
||||
for section in $SECTIONS; do
|
||||
ifname="$(uci -q get "wireless.$section.ifname")"
|
||||
uci -q delete "wireless.$section"
|
||||
uci -q delete "network.$section"
|
||||
uci -q delete "dhcp.$section"
|
||||
uci -q del_list "firewall.fips_ap.network=$section"
|
||||
# Re-comment the matching ap<N> transport in fips.yaml so the
|
||||
# daemon stops warning about the interface we just removed.
|
||||
idx="$(printf '%s' "$ifname" | sed -n 's/.*[^0-9]\([0-9]\{1,\}\)$/\1/p')"
|
||||
[ -n "$idx" ] && ap_config_disable "$idx"
|
||||
echo "Removed ${ifname:-$section}."
|
||||
done
|
||||
# Drop the shared zone and its rules once the last instance is gone.
|
||||
if [ -z "$(uci -q get firewall.fips_ap.network)" ]; then
|
||||
uci -q delete firewall.fips_ap
|
||||
uci -q delete firewall.fips_ap_icmpv6
|
||||
uci -q delete firewall.fips_ap_dhcpv4
|
||||
uci -q delete firewall.fips_ap_mdns
|
||||
uci -q delete firewall.fips_ap_fips_udp
|
||||
uci -q delete firewall.fips_ap_fips_tcp
|
||||
fi
|
||||
uci commit wireless
|
||||
uci commit network
|
||||
uci commit dhcp
|
||||
uci commit firewall
|
||||
wifi reload
|
||||
/etc/init.d/dnsmasq reload
|
||||
/etc/init.d/odhcpd reload
|
||||
/etc/init.d/firewall reload
|
||||
echo "Restart fips: /etc/init.d/fips restart"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
RADIO="$1"
|
||||
SSID="${2:-$DEFAULT_SSID}"
|
||||
|
||||
[ -n "$RADIO" ] || usage
|
||||
|
||||
if [ "$(uci -q get "wireless.$RADIO")" != "wifi-device" ]; then
|
||||
echo "Error: '$RADIO' is not a wifi-device in /etc/config/wireless." >&2
|
||||
usage
|
||||
fi
|
||||
|
||||
# One instance per radio: section fips_ap_<radio>, netdev fips-ap<N>
|
||||
# where N is the radio's trailing index (radio0 -> fips-ap0). For radios
|
||||
# named without a trailing number, fall back to the first free index.
|
||||
SECTION="fips_ap_$(printf '%s' "$RADIO" | tr -c 'a-zA-Z0-9_' '_')"
|
||||
IDX="$(printf '%s' "$RADIO" | sed -n 's/.*[^0-9]\([0-9]\{1,\}\)$/\1/p')"
|
||||
[ -n "$IDX" ] || IDX="$(printf '%s' "$RADIO" | sed -n 's/^\([0-9]\{1,\}\)$/\1/p')"
|
||||
if [ -z "$IDX" ]; then
|
||||
IDX=0
|
||||
while uci show wireless 2>/dev/null | grep -q "\.ifname='fips-ap$IDX'"; do
|
||||
IDX=$((IDX + 1))
|
||||
done
|
||||
fi
|
||||
AP_IFNAME="fips-ap$IDX"
|
||||
|
||||
# Refuse a name collision from another radio's instance (e.g. two radios
|
||||
# whose names end in the same digit) rather than silently hijacking it.
|
||||
OWNER="$(uci show wireless 2>/dev/null \
|
||||
| sed -n "s/^wireless\.\(fips_ap[^.=]*\)\.ifname='$AP_IFNAME'$/\1/p")"
|
||||
if [ -n "$OWNER" ] && [ "$OWNER" != "$SECTION" ]; then
|
||||
echo "Error: $AP_IFNAME is already used by section '$OWNER'." >&2
|
||||
echo "Remove it first: fips-ap-setup remove" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wireless: open AP with client isolation. Clients of the same AP cannot
|
||||
# exchange L2 frames directly — two FIPS phones on one router still reach
|
||||
# each other through the router at the overlay layer. Isolation is an L2
|
||||
# control only: a stranger who peers is an overlay peer like any other, so
|
||||
# the FIPS overlay (not L2) is the trust boundary between clients.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
uci -q delete "wireless.$SECTION"
|
||||
uci set "wireless.$SECTION=wifi-iface"
|
||||
uci set "wireless.$SECTION.device=$RADIO"
|
||||
uci set "wireless.$SECTION.mode=ap"
|
||||
uci set "wireless.$SECTION.ssid=$SSID"
|
||||
uci set "wireless.$SECTION.encryption=none"
|
||||
uci set "wireless.$SECTION.isolate=1"
|
||||
uci set "wireless.$SECTION.ifname=$AP_IFNAME"
|
||||
uci set "wireless.$SECTION.network=$SECTION"
|
||||
|
||||
# Radios ship disabled on fresh OpenWrt installs; a disabled radio would
|
||||
# leave the AP down with no error anywhere visible.
|
||||
if [ "$(uci -q get "wireless.$RADIO.disabled")" = "1" ]; then
|
||||
echo "Note: enabling $RADIO (was disabled)."
|
||||
uci -q delete "wireless.$RADIO.disabled"
|
||||
fi
|
||||
|
||||
CHANNEL="$(uci -q get "wireless.$RADIO.channel")"
|
||||
BAND="$(uci -q get "wireless.$RADIO.band")"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Network: IPv4 from the fixed convention 10.21.<IDX>.1/24 — deterministic,
|
||||
# so every router serving the same radio index lands on the same subnet and
|
||||
# a roaming client's lease stays valid. IPv6 is a static ULA /64 so odhcpd
|
||||
# has a prefix to announce; that space is per-router and disposable — a
|
||||
# roaming phone SLAACs a fresh address on each router, and the FIPS overlay
|
||||
# identity (not the IP) is the mobility anchor. The ULA is derived from the
|
||||
# router's global ULA prefix; a re-run keeps the address already configured.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
AP_ADDR="$(uci -q get "network.$SECTION.ip6addr")"
|
||||
case "$AP_ADDR" in
|
||||
fd*) ;; # keep the existing address on re-run
|
||||
*)
|
||||
ULA_BASE=""
|
||||
ULA_PREFIX="$(uci -q get network.globals.ula_prefix)"
|
||||
case "$ULA_PREFIX" in
|
||||
fd*::/48) ULA_BASE="${ULA_PREFIX%::/48}" ;;
|
||||
esac
|
||||
if [ -z "$ULA_BASE" ]; then
|
||||
HEX="$(head -c 5 /dev/urandom | hexdump -e '5/1 "%02x"')"
|
||||
ULA_BASE="fd$(printf '%s' "$HEX" | cut -c1-2):$(printf '%s' "$HEX" | cut -c3-6):$(printf '%s' "$HEX" | cut -c7-10)"
|
||||
echo "Note: no usable ULA prefix in network.globals — generated $ULA_BASE::/48 for this AP."
|
||||
fi
|
||||
# 64000 = 0xfa00 — high subnet IDs keep clear of br-lan's low
|
||||
# ip6assign allocations from the same ULA prefix.
|
||||
AP_ADDR="$ULA_BASE:$(printf '%04x' $((64000 + IDX)))::1/64"
|
||||
;;
|
||||
esac
|
||||
|
||||
AP_ADDR4="10.21.$IDX.1"
|
||||
|
||||
uci -q delete "network.$SECTION"
|
||||
uci set "network.$SECTION=interface"
|
||||
uci set "network.$SECTION.proto=static"
|
||||
uci set "network.$SECTION.ipaddr=$AP_ADDR4"
|
||||
uci set "network.$SECTION.netmask=255.255.255.0"
|
||||
uci set "network.$SECTION.ip6addr=$AP_ADDR"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DHCP/RA: dnsmasq DHCPv4 leases out of 10.21.<IDX>.0/24, plus router
|
||||
# advertisements for the ULA (stateless SLAAC, no DHCPv6). ra_default '2'
|
||||
# announces a default router even without an upstream default route:
|
||||
# Android's provisioning wants address + route + DNS, and its validation
|
||||
# probe then fails by design (no internet), so the phone keeps cellular as
|
||||
# the default route. 'dhcpv4 server' is read by BOTH dnsmasq (the default
|
||||
# DHCPv4 server) and odhcpd (serves v4 only when odhcpd.maindhcp is set),
|
||||
# so either arrangement hands out leases.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
uci -q delete "dhcp.$SECTION"
|
||||
uci set "dhcp.$SECTION=dhcp"
|
||||
uci set "dhcp.$SECTION.interface=$SECTION"
|
||||
uci set "dhcp.$SECTION.ra=server"
|
||||
uci set "dhcp.$SECTION.ra_default=2"
|
||||
uci set "dhcp.$SECTION.dhcpv6=disabled"
|
||||
uci set "dhcp.$SECTION.dhcpv4=server"
|
||||
uci set "dhcp.$SECTION.start=10"
|
||||
uci set "dhcp.$SECTION.limit=200"
|
||||
|
||||
# Authoritative is the OpenWrt default, but roaming correctness depends on
|
||||
# it (a foreign router must ACK a lease it never issued), so pin it.
|
||||
[ -n "$(uci -q get dhcp.@dnsmasq[0])" ] && uci set dhcp.@dnsmasq[0].authoritative=1
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Firewall: one shared 'fips_ap' zone for all instances. Everything is
|
||||
# rejected except what a FIPS client needs — DHCPv4 (addressing), ICMPv6
|
||||
# (SLAAC itself), mDNS (discovery), and the FIPS UDP/TCP transports (the
|
||||
# handshake surface).
|
||||
# The raw-Ethernet transport (EtherType 0x2121) is not IP and never
|
||||
# traverses the firewall. No forwardings exist, so there is no path to
|
||||
# br-lan or the WAN.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if [ "$(uci -q get firewall.fips_ap)" != "zone" ]; then
|
||||
uci set firewall.fips_ap=zone
|
||||
fi
|
||||
uci set firewall.fips_ap.name=fips_ap
|
||||
uci set firewall.fips_ap.input=REJECT
|
||||
uci set firewall.fips_ap.output=ACCEPT
|
||||
uci set firewall.fips_ap.forward=REJECT
|
||||
uci -q del_list "firewall.fips_ap.network=$SECTION"
|
||||
uci add_list "firewall.fips_ap.network=$SECTION"
|
||||
|
||||
# ap_rule <section-suffix> <name> <proto> [dest_port]
|
||||
ap_rule() {
|
||||
rule="firewall.fips_ap_$1"
|
||||
uci -q delete "$rule"
|
||||
uci set "$rule=rule"
|
||||
uci set "$rule.name=$2"
|
||||
uci set "$rule.src=fips_ap"
|
||||
uci set "$rule.proto=$3"
|
||||
uci set "$rule.target=ACCEPT"
|
||||
[ -z "${4:-}" ] || uci set "$rule.dest_port=$4"
|
||||
}
|
||||
|
||||
ap_rule icmpv6 "FIPS-AP-ICMPv6" icmp
|
||||
uci set firewall.fips_ap_icmpv6.family=ipv6
|
||||
ap_rule dhcpv4 "FIPS-AP-DHCPv4" udp 67
|
||||
uci set firewall.fips_ap_dhcpv4.family=ipv4
|
||||
ap_rule mdns "FIPS-AP-mDNS" udp 5353
|
||||
ap_rule fips_udp "FIPS-AP-FIPS-UDP" udp 2121
|
||||
ap_rule fips_tcp "FIPS-AP-FIPS-TCP" tcp 8443
|
||||
|
||||
uci commit wireless
|
||||
uci commit network
|
||||
uci commit dhcp
|
||||
uci commit firewall
|
||||
wifi reload
|
||||
/etc/init.d/dnsmasq reload
|
||||
/etc/init.d/odhcpd reload
|
||||
/etc/init.d/firewall reload
|
||||
|
||||
# Enable the matching ap<N> transport in the shipped fips.yaml (it ships
|
||||
# commented out). Tailor the restart hint to what we could do.
|
||||
ap_config_enable "$IDX"
|
||||
case $? in
|
||||
0) TRANSPORT_NOTE="The ap$IDX transport in $CONFIG that binds '$AP_IFNAME' is
|
||||
now uncommented and enabled." ;;
|
||||
1) TRANSPORT_NOTE="No $CONFIG found — add a transports.ethernet entry binding
|
||||
interface '$AP_IFNAME' by hand." ;;
|
||||
*) TRANSPORT_NOTE="No 'ap$IDX' entry in $CONFIG — add a transports.ethernet
|
||||
entry binding interface '$AP_IFNAME' by hand (copy the ap0 block)." ;;
|
||||
esac
|
||||
|
||||
# Phones discover the daemon via mDNS, not raw-Ethernet beacons — make sure
|
||||
# the daemon-wide mDNS rendezvous is on.
|
||||
if lan_rendezvous_enable; then
|
||||
MDNS_NOTE="node.rendezvous.lan (mDNS) is enabled — phone FIPS apps
|
||||
discover this router via DNS-SD."
|
||||
else
|
||||
MDNS_NOTE="Could not enable mDNS in $CONFIG — set
|
||||
'node.rendezvous.lan.enabled: true' by hand; phone FIPS apps rely
|
||||
on it to discover this router."
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
Created open access SSID '$SSID' as $AP_IFNAME on $RADIO \
|
||||
(band ${BAND:-?}, channel ${CHANNEL:-auto}).
|
||||
DHCPv4 on $AP_ADDR4/24 and RA IPv6 on $AP_ADDR — no internet,
|
||||
isolated from br-lan and the WAN.
|
||||
|
||||
ALL FIPS routers must broadcast this SSID with the same security type
|
||||
(open) — phones then save it once and roam between routers as one
|
||||
network. The 10.21.$IDX.0/24 subnet is the same on every router on
|
||||
purpose: leases survive roaming. Unlike the mesh backhaul, channels
|
||||
are free per router. On a dual-band router, run fips-ap-setup for the
|
||||
other radio too so clients can pick either band.
|
||||
|
||||
On first connect a phone warns that the network has no internet —
|
||||
choose "stay connected" and "don't ask again". That choice is stored
|
||||
per SSID, so it covers every FIPS router.
|
||||
|
||||
Next steps:
|
||||
1. $TRANSPORT_NOTE
|
||||
2. $MDNS_NOTE
|
||||
Restart the daemon AFTER the interface is up — a transport whose
|
||||
interface is missing at startup is skipped, not retried:
|
||||
/etc/init.d/fips restart
|
||||
3. Associate a phone or laptop running FIPS and verify:
|
||||
iw dev $AP_IFNAME station dump
|
||||
and the FIPS link on top of it:
|
||||
fipsctl show peers
|
||||
|
||||
Run 'fips-ap-setup remove' to undo all instances, or
|
||||
'fips-ap-setup remove $RADIO' for just this one.
|
||||
EOF
|
||||
@@ -1,261 +0,0 @@
|
||||
#!/bin/sh
|
||||
# fips-mesh-setup — configure open 802.11s mesh interfaces for FIPS backhaul.
|
||||
#
|
||||
# Usage:
|
||||
# fips-mesh-setup <radio> [mesh-id] e.g. fips-mesh-setup radio1
|
||||
# fips-mesh-setup remove [radio] no radio: remove all instances
|
||||
#
|
||||
# Creates a mesh-point interface on the given radio and leaves everything
|
||||
# above L2 to FIPS. Run once per radio: dual-band routers can mesh on both
|
||||
# bands at once (2.4 GHz reaches further, 5 GHz carries more). Note this is
|
||||
# failover, not multipath — FIPS keeps one active link per peer; the other
|
||||
# band stands by and reconnects the peer if the active link dies.
|
||||
#
|
||||
# - encryption 'none' — the mesh is OPEN on purpose. FIPS's Noise
|
||||
# handshake authenticates and encrypts every peer link, so SAE would
|
||||
# only duplicate that (and on ath10k it forces the slower raw Tx/Rx
|
||||
# firmware mode). A stranger can form an 802.11s peering AND a FIPS
|
||||
# peer link — the Noise handshake authenticates each link (no
|
||||
# impersonation of another identity, no MITM), it does not gate who
|
||||
# may peer. Admission is open up to the daemon's max-peers cap.
|
||||
# - mesh_fwding '0' — disables 802.11s HWMP forwarding so each mesh
|
||||
# link is a plain L2 neighbor link. FIPS is the routing layer; two
|
||||
# routing layers would fight.
|
||||
#
|
||||
# Interfaces are named per radio index (radio0 -> fips-mesh0, radio1 ->
|
||||
# fips-mesh1) and are intentionally NOT bridged into br-lan: the FIPS
|
||||
# Ethernet transport binds each directly and runs discovery beacons over it.
|
||||
#
|
||||
# The shipped /etc/fips/fips.yaml carries 'mesh0' and 'mesh1' entries under
|
||||
# 'transports.ethernet' bound to these names, but commented out — a stock
|
||||
# install that never creates fips-mesh* then logs no bind warning. This
|
||||
# helper uncomments the matching entry when it creates an interface and
|
||||
# re-comments it on remove, so the daemon binds the transport without a
|
||||
# manual config edit. After an interface is up, restart fips.
|
||||
# See docs/how-to/set-up-80211s-mesh-backhaul.md for the full guide.
|
||||
|
||||
DEFAULT_MESH_ID="fips-mesh"
|
||||
CONFIG="/etc/fips/fips.yaml"
|
||||
|
||||
# Replace $CONFIG with the rewritten $CONFIG.tmp. Force mode 0600 first: the
|
||||
# package installs fips.yaml 0600 (it may hold an inline 'nsec' private key),
|
||||
# and a fresh tmp file would otherwise land world-readable after the move.
|
||||
mesh_config_write() {
|
||||
chmod 600 "$CONFIG.tmp" && mv "$CONFIG.tmp" "$CONFIG"
|
||||
}
|
||||
|
||||
# Uncomment the 'mesh<idx>' transports.ethernet block in $CONFIG (created by
|
||||
# 'fips-mesh-setup'). Reversible with mesh_config_disable. Returns:
|
||||
# 0 enabled (or already active) 1 no config file 2 no such block
|
||||
mesh_config_enable() {
|
||||
idx="$1"
|
||||
[ -f "$CONFIG" ] || return 1
|
||||
grep -q "^ mesh$idx:" "$CONFIG" && return 0
|
||||
grep -q "^ # mesh$idx:" "$CONFIG" || return 2
|
||||
awk -v idx="$idx" '
|
||||
$0 ~ ("^ # mesh" idx ":[ \t]*$") { blk = 1; sub(/^ # /, " "); print; next }
|
||||
blk && /^ # / { sub(/^ # /, " "); print; next }
|
||||
{ blk = 0; print }
|
||||
' "$CONFIG" > "$CONFIG.tmp" && mesh_config_write
|
||||
}
|
||||
|
||||
# Re-comment the 'mesh<idx>' block so the daemon stops binding it (and stops
|
||||
# warning about the now-missing interface). Inverse of mesh_config_enable.
|
||||
mesh_config_disable() {
|
||||
idx="$1"
|
||||
[ -f "$CONFIG" ] || return 1
|
||||
grep -q "^ mesh$idx:" "$CONFIG" || return 0
|
||||
awk -v idx="$idx" '
|
||||
$0 ~ ("^ mesh" idx ":[ \t]*$") { blk = 1; sub(/^ /, " # "); print; next }
|
||||
blk && /^ / { sub(/^ /, " # "); print; next }
|
||||
{ blk = 0; print }
|
||||
' "$CONFIG" > "$CONFIG.tmp" && mesh_config_write
|
||||
}
|
||||
|
||||
usage() {
|
||||
echo "Usage: fips-mesh-setup <radio> [mesh-id]" >&2
|
||||
echo " fips-mesh-setup remove [radio]" >&2
|
||||
echo "Radios on this device:" >&2
|
||||
uci show wireless 2>/dev/null | sed -n "s/^wireless\.\([^.]*\)=wifi-device$/ \1/p" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# List the UCI section names of fips-managed mesh wifi-ifaces.
|
||||
mesh_sections() {
|
||||
uci show wireless 2>/dev/null | sed -n "s/^wireless\.\(fips_mesh[^.=]*\)=wifi-iface$/\1/p"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# remove [radio] — delete the wireless and network sections created below
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if [ "$1" = "remove" ]; then
|
||||
if [ -n "$2" ]; then
|
||||
SECTIONS="fips_mesh_$(printf '%s' "$2" | tr -c 'a-zA-Z0-9_' '_')"
|
||||
else
|
||||
SECTIONS="$(mesh_sections)"
|
||||
fi
|
||||
[ -n "$SECTIONS" ] || {
|
||||
echo "No fips mesh instances configured."
|
||||
exit 0
|
||||
}
|
||||
for section in $SECTIONS; do
|
||||
ifname="$(uci -q get "wireless.$section.ifname")"
|
||||
uci -q delete "wireless.$section"
|
||||
uci -q delete "network.$section"
|
||||
# Re-comment the matching mesh<N> transport in fips.yaml so the
|
||||
# daemon stops warning about the interface we just removed.
|
||||
idx="$(printf '%s' "$ifname" | sed -n 's/.*[^0-9]\([0-9]\{1,\}\)$/\1/p')"
|
||||
[ -n "$idx" ] && mesh_config_disable "$idx"
|
||||
echo "Removed ${ifname:-$section}."
|
||||
done
|
||||
uci commit wireless
|
||||
uci commit network
|
||||
# 'wifi reload' re-applies the whole wireless config, so it briefly drops
|
||||
# every client AP on all radios (a few seconds) — expected on remove.
|
||||
wifi reload
|
||||
echo "Restart fips: /etc/init.d/fips restart"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
RADIO="$1"
|
||||
MESH_ID="${2:-$DEFAULT_MESH_ID}"
|
||||
|
||||
[ -n "$RADIO" ] || usage
|
||||
|
||||
if [ "$(uci -q get "wireless.$RADIO")" != "wifi-device" ]; then
|
||||
echo "Error: '$RADIO' is not a wifi-device in /etc/config/wireless." >&2
|
||||
usage
|
||||
fi
|
||||
|
||||
# One instance per radio: section fips_mesh_<radio>, netdev fips-mesh<N>
|
||||
# where N is the radio's trailing index (radio0 -> fips-mesh0). For radios
|
||||
# named without a trailing number, fall back to the first free index.
|
||||
SECTION="fips_mesh_$(printf '%s' "$RADIO" | tr -c 'a-zA-Z0-9_' '_')"
|
||||
IDX="$(printf '%s' "$RADIO" | sed -n 's/.*[^0-9]\([0-9]\{1,\}\)$/\1/p')"
|
||||
[ -n "$IDX" ] || IDX="$(printf '%s' "$RADIO" | sed -n 's/^\([0-9]\{1,\}\)$/\1/p')"
|
||||
if [ -z "$IDX" ]; then
|
||||
IDX=0
|
||||
while uci show wireless 2>/dev/null | grep -q "\.ifname='fips-mesh$IDX'"; do
|
||||
IDX=$((IDX + 1))
|
||||
done
|
||||
fi
|
||||
MESH_IFNAME="fips-mesh$IDX"
|
||||
|
||||
# Refuse a name collision from another radio's instance (e.g. two radios
|
||||
# whose names end in the same digit) rather than silently hijacking it.
|
||||
OWNER="$(uci show wireless 2>/dev/null \
|
||||
| sed -n "s/^wireless\.\(fips_mesh[^.=]*\)\.ifname='$MESH_IFNAME'$/\1/p")"
|
||||
if [ -n "$OWNER" ] && [ "$OWNER" != "$SECTION" ]; then
|
||||
echo "Error: $MESH_IFNAME is already used by section '$OWNER'." >&2
|
||||
echo "Remove it first: fips-mesh-setup remove" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Driver capability check (advisory — config below is harmless either way)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if command -v iw >/dev/null 2>&1; then
|
||||
if ! iw list 2>/dev/null | grep -q "\* mesh point"; then
|
||||
echo "Warning: no radio on this device advertises 'mesh point' support" >&2
|
||||
echo "(iw list | grep 'mesh point'). The interface may fail to come up." >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wireless: open 802.11s mesh point, HWMP forwarding off
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
uci -q delete "wireless.$SECTION"
|
||||
uci set "wireless.$SECTION=wifi-iface"
|
||||
uci set "wireless.$SECTION.device=$RADIO"
|
||||
uci set "wireless.$SECTION.mode=mesh"
|
||||
uci set "wireless.$SECTION.mesh_id=$MESH_ID"
|
||||
uci set "wireless.$SECTION.encryption=none"
|
||||
uci set "wireless.$SECTION.mesh_fwding=0"
|
||||
uci set "wireless.$SECTION.ifname=$MESH_IFNAME"
|
||||
uci set "wireless.$SECTION.network=$SECTION"
|
||||
|
||||
# Radios ship disabled on fresh OpenWrt installs; a disabled radio would
|
||||
# leave the mesh interface down with no error anywhere visible.
|
||||
if [ "$(uci -q get "wireless.$RADIO.disabled")" = "1" ]; then
|
||||
echo "Note: enabling $RADIO (was disabled)."
|
||||
uci -q delete "wireless.$RADIO.disabled"
|
||||
fi
|
||||
|
||||
# The mesh inherits the radio's channel, and mesh points only peer on the
|
||||
# same channel. 'auto' lets each router pick its own — the classic silent
|
||||
# non-peering cause — so surface the setting loudly.
|
||||
CHANNEL="$(uci -q get "wireless.$RADIO.channel")"
|
||||
BAND="$(uci -q get "wireless.$RADIO.band")"
|
||||
if [ -z "$CHANNEL" ] || [ "$CHANNEL" = "auto" ]; then
|
||||
echo "Warning: $RADIO channel is '${CHANNEL:-unset}' — each router may" >&2
|
||||
echo "auto-select a different channel and mesh points only peer on the" >&2
|
||||
echo "same one. Pin the same channel on every backhaul router, e.g.:" >&2
|
||||
echo " uci set wireless.$RADIO.channel='36' && uci commit wireless && wifi reload" >&2
|
||||
fi
|
||||
|
||||
# A client (sta) interface on the same radio follows its upstream AP's
|
||||
# channel and drags every other interface with it — a mesh pinned to a
|
||||
# different channel silently never joins, and does not recover when the
|
||||
# STA disconnects.
|
||||
for s in $(uci show wireless 2>/dev/null | sed -n "s/^wireless\.\([^.]*\)\.mode='sta'$/\1/p"); do
|
||||
if [ "$(uci -q get "wireless.$s.device")" = "$RADIO" ]; then
|
||||
echo "Warning: $RADIO also carries client interface '$s' (mode 'sta')." >&2
|
||||
echo "The whole radio follows that STA's upstream channel — a mesh" >&2
|
||||
echo "pinned to a different channel stays down silently. Align the" >&2
|
||||
echo "mesh channel with the upstream AP, or put the mesh on a radio" >&2
|
||||
echo "without a STA (a roaming uplink is incompatible with a" >&2
|
||||
echo "fixed-channel mesh on the same radio)." >&2
|
||||
fi
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Network: unmanaged interface so netifd brings the netdev up. No IP config —
|
||||
# the FIPS Ethernet transport speaks raw frames on it.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
uci -q delete "network.$SECTION"
|
||||
uci set "network.$SECTION=interface"
|
||||
uci set "network.$SECTION.proto=none"
|
||||
|
||||
uci commit wireless
|
||||
uci commit network
|
||||
# 'wifi reload' re-applies the whole wireless config, so it briefly drops
|
||||
# every client AP on all radios (a few seconds) — expected when adding a mesh.
|
||||
wifi reload
|
||||
|
||||
# Enable the matching mesh<N> transport in the shipped fips.yaml (it ships
|
||||
# commented out). Tailor the restart hint to what we could do.
|
||||
mesh_config_enable "$IDX"
|
||||
case $? in
|
||||
0) TRANSPORT_NOTE="The mesh$IDX transport in $CONFIG that binds '$MESH_IFNAME' is
|
||||
now uncommented and enabled." ;;
|
||||
1) TRANSPORT_NOTE="No $CONFIG found — add a transports.ethernet entry binding
|
||||
interface '$MESH_IFNAME' by hand." ;;
|
||||
*) TRANSPORT_NOTE="No 'mesh$IDX' entry in $CONFIG — add a transports.ethernet
|
||||
entry binding interface '$MESH_IFNAME' by hand (copy the mesh0 block)." ;;
|
||||
esac
|
||||
|
||||
cat <<EOF
|
||||
Created open 802.11s mesh '$MESH_ID' as $MESH_IFNAME on $RADIO \
|
||||
(band ${BAND:-?}, channel ${CHANNEL:-auto}).
|
||||
|
||||
ALL routers in this backhaul must share this mesh ID AND channel
|
||||
(per band). On a dual-band router, run fips-mesh-setup for the other
|
||||
radio too — second band is a standby path (failover, not multipath).
|
||||
|
||||
Next steps:
|
||||
1. $TRANSPORT_NOTE
|
||||
Restart the daemon AFTER the interface is up — a transport whose
|
||||
interface is missing at startup is skipped, not retried:
|
||||
/etc/init.d/fips restart
|
||||
2. Verify L2 peering with a second FIPS router in range:
|
||||
iw dev $MESH_IFNAME station dump
|
||||
and the FIPS link on top of it:
|
||||
fipsctl show peers
|
||||
|
||||
Run 'fips-mesh-setup remove' to undo all instances, or
|
||||
'fips-mesh-setup remove $RADIO' for just this one.
|
||||
EOF
|
||||
@@ -68,7 +68,7 @@ and set the interface name:
|
||||
transports:
|
||||
ethernet:
|
||||
interface: "eth0"
|
||||
listen: true
|
||||
discovery: true
|
||||
announce: true
|
||||
auto_connect: true
|
||||
accept_connections: true
|
||||
|
||||
@@ -14,11 +14,6 @@ RestartSec=5
|
||||
RuntimeDirectory=fips
|
||||
RuntimeDirectoryMode=0750
|
||||
|
||||
# Log directory (/var/log/fips/), where the built-in tick-body profiler writes
|
||||
# its capture files. Declared so systemd creates it on start and removes it on
|
||||
# purge; the daemon runs as root and already has access without it.
|
||||
LogsDirectory=fips
|
||||
|
||||
# Security hardening (daemon runs as root for TUN and raw sockets)
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
|
||||
@@ -8,7 +8,7 @@ use fips::config::{IdentitySource, resolve_identity};
|
||||
use fips::version;
|
||||
use fips::{Config, Node};
|
||||
use std::path::PathBuf;
|
||||
use tracing::{debug, error, info};
|
||||
use tracing::{debug, error, info, warn};
|
||||
use tracing_subscriber::{EnvFilter, fmt};
|
||||
|
||||
/// FIPS mesh network daemon
|
||||
@@ -157,23 +157,26 @@ async fn run_daemon(
|
||||
|
||||
info!("FIPS running");
|
||||
|
||||
// Serve until the shutdown signal, then drain in place before returning.
|
||||
// The rx loop observes the signal directly, so its channels are never
|
||||
// destructively cancelled — they live in the loop's locals across serve and
|
||||
// drain, and are dropped only on clean exit (after which teardown does not
|
||||
// need them). On the signal the loop broadcasts a shutdown Disconnect and
|
||||
// waits (bounded by node.drain_timeout_secs) for peers to clear.
|
||||
match node.run_rx_loop_with_shutdown(shutdown_signal).await {
|
||||
Ok(()) => info!("RX loop exited"),
|
||||
Err(e) => error!("RX loop error: {}", e),
|
||||
// Run the RX event loop until shutdown signal.
|
||||
// stop() drops the packet channel, causing run_rx_loop to exit.
|
||||
tokio::select! {
|
||||
result = node.run_rx_loop() => {
|
||||
match result {
|
||||
Ok(()) => info!("RX loop exited"),
|
||||
Err(e) => error!("RX loop error: {}", e),
|
||||
}
|
||||
}
|
||||
_ = shutdown_signal => {
|
||||
info!("Shutdown signal received");
|
||||
}
|
||||
}
|
||||
|
||||
info!("FIPS shutting down");
|
||||
|
||||
// Close the drain window (if the loop drained) and tear down. A drained
|
||||
// loop tears down without re-broadcasting; a loop that exited some other
|
||||
// way falls back to the immediate stop().
|
||||
node.finish_shutdown().await;
|
||||
// Stop the node (shuts down transports, TUN, I/O threads)
|
||||
if let Err(e) = node.stop().await {
|
||||
warn!("Error during shutdown: {}", e);
|
||||
}
|
||||
|
||||
info!("FIPS shutdown complete");
|
||||
}
|
||||
|
||||
@@ -76,37 +76,6 @@ enum Commands {
|
||||
#[command(subcommand)]
|
||||
what: StatsCommands,
|
||||
},
|
||||
/// Control the built-in profiler (requires a `--features profiling` build)
|
||||
#[cfg(feature = "profiling")]
|
||||
Profile {
|
||||
#[command(subcommand)]
|
||||
what: ProfileCommands,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(feature = "profiling")]
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum ProfileCommands {
|
||||
/// Profile the rx-loop tick body
|
||||
Tick {
|
||||
#[command(subcommand)]
|
||||
action: ProfileTickAction,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(feature = "profiling")]
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum ProfileTickAction {
|
||||
/// Start a capture
|
||||
On {
|
||||
/// Directory for the capture file (default /var/log/fips)
|
||||
#[arg(long)]
|
||||
dir: Option<PathBuf>,
|
||||
},
|
||||
/// Stop the running capture
|
||||
Off,
|
||||
/// Report capture state
|
||||
Status,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
@@ -502,20 +471,6 @@ fn main() {
|
||||
build_command("show_stats_history", params)
|
||||
}
|
||||
},
|
||||
#[cfg(feature = "profiling")]
|
||||
Commands::Profile { what } => match what {
|
||||
ProfileCommands::Tick { action } => match action {
|
||||
ProfileTickAction::On { dir } => match dir {
|
||||
Some(dir) => build_command(
|
||||
"profile_tick_on",
|
||||
serde_json::json!({"dir": dir.display().to_string()}),
|
||||
),
|
||||
None => build_query("profile_tick_on"),
|
||||
},
|
||||
ProfileTickAction::Off => build_query("profile_tick_off"),
|
||||
ProfileTickAction::Status => build_query("profile_tick_status"),
|
||||
},
|
||||
},
|
||||
Commands::Keygen { .. } => unreachable!(),
|
||||
};
|
||||
|
||||
|
||||
@@ -121,6 +121,7 @@ fn draw_stats(frame: &mut Frame, data: &serde_json::Value, scroll: u16, focused:
|
||||
&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"),
|
||||
@@ -129,26 +130,6 @@ fn draw_stats(frame: &mut Frame, data: &serde_json::Value, scroll: u16, focused:
|
||||
Line::from(""),
|
||||
helpers::section_header("Outbound"),
|
||||
helpers::kv_line("Sent", &helpers::nested_u64(data, "stats", "sent")),
|
||||
helpers::kv_line(
|
||||
"Full Sends",
|
||||
&helpers::nested_u64(data, "stats", "full_sends"),
|
||||
),
|
||||
helpers::kv_line(
|
||||
"Deltas Sent",
|
||||
&helpers::nested_u64(data, "stats", "deltas_sent"),
|
||||
),
|
||||
helpers::kv_line(
|
||||
"NACKs Sent",
|
||||
&helpers::nested_u64(data, "stats", "nacks_sent"),
|
||||
),
|
||||
helpers::kv_line(
|
||||
"NACKs Received",
|
||||
&helpers::nested_u64(data, "stats", "nacks_received"),
|
||||
),
|
||||
helpers::kv_line(
|
||||
"Size Changes",
|
||||
&helpers::nested_u64(data, "stats", "size_changes"),
|
||||
),
|
||||
helpers::kv_line(
|
||||
"Debounce Suppressed",
|
||||
&helpers::nested_u64(data, "stats", "debounce_suppressed"),
|
||||
|
||||
@@ -83,35 +83,6 @@ fn fwd_value(data: &serde_json::Value, pkt_key: &str, byte_key: &str) -> String
|
||||
format!("{} pkts ({})", pkts, helpers::format_bytes(bytes))
|
||||
}
|
||||
|
||||
/// Read a raw forwarding counter as a u64 (0 if missing), for arithmetic
|
||||
/// (percentages, derived totals) that the string-returning helpers can't do.
|
||||
fn fwd_count(data: &serde_json::Value, key: &str) -> u64 {
|
||||
data.get("forwarding")
|
||||
.and_then(|f| f.get(key))
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Total mesh egress = locally-originated + transit-forwarded, formatted as
|
||||
/// "N pkts (B)". There is no single daemon counter for everything this node
|
||||
/// transmits to peers, so it is derived from its two contributors.
|
||||
fn mesh_tx_value(data: &serde_json::Value) -> String {
|
||||
let pkts = fwd_count(data, "originated_packets") + fwd_count(data, "forwarded_packets");
|
||||
let bytes = fwd_count(data, "originated_bytes") + fwd_count(data, "forwarded_bytes");
|
||||
format!("{} pkts ({})", pkts, helpers::format_bytes(bytes))
|
||||
}
|
||||
|
||||
/// Format a route-class count as "N (xx.x%)" where the percentage is the class's
|
||||
/// share of total forwarded (transit) packets. Zero forwarded yields "0.0%".
|
||||
fn route_class_value(count: u64, total_forwarded: u64) -> String {
|
||||
let pct = if total_forwarded > 0 {
|
||||
count as f64 / total_forwarded as f64 * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
format!("{count} ({pct:.1}%)")
|
||||
}
|
||||
|
||||
/// Build a section: a styled header line followed by the kv pairs rendered
|
||||
/// through the group helper so the section's values share a left edge.
|
||||
fn section(title: &str, pairs: &[(&str, String)]) -> Vec<Line<'static>> {
|
||||
@@ -134,138 +105,43 @@ fn draw_routing_stats(
|
||||
let cols =
|
||||
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).split(inner);
|
||||
|
||||
// Shorthand for a nested counter value (e.g. lookup.req_received).
|
||||
let lookup = |key: &str| helpers::nested_u64(data, "lookup", key);
|
||||
// Shorthand for a nested counter value (e.g. discovery.req_received).
|
||||
let disc = |key: &str| helpers::nested_u64(data, "discovery", key);
|
||||
let err = |key: &str| helpers::nested_u64(data, "error_signals", key);
|
||||
let cong = |key: &str| helpers::nested_u64(data, "congestion", key);
|
||||
|
||||
// The node is an interface adapter between the local host stack and the
|
||||
// mesh; the left column reads each side as a Transmitted/Received pair.
|
||||
//
|
||||
// Local Stack — traffic crossing the TUN / local-origination boundary:
|
||||
// Transmitted is what the host injects into the mesh (originated), Received
|
||||
// is what the mesh hands up to the host (delivered).
|
||||
// Left column: Forwarding + Discovery. Each section's values share a left
|
||||
// edge via the kv_lines group helper.
|
||||
let mut left = section(
|
||||
"Local Stack",
|
||||
"Forwarding",
|
||||
&[
|
||||
(
|
||||
"Transmitted",
|
||||
fwd_value(data, "originated_packets", "originated_bytes"),
|
||||
),
|
||||
(
|
||||
"Received",
|
||||
fwd_value(data, "delivered_packets", "delivered_bytes"),
|
||||
),
|
||||
],
|
||||
);
|
||||
left.push(Line::from(""));
|
||||
// Mesh — traffic crossing the peer-link boundary: Transmitted is everything
|
||||
// this node puts on the wire (originated + forwarded, derived), Received is
|
||||
// the ingress aggregate from peers (own-delivered + transit + drops).
|
||||
left.extend(section(
|
||||
"Mesh",
|
||||
&[
|
||||
("Transmitted", mesh_tx_value(data)),
|
||||
(
|
||||
"Received",
|
||||
fwd_value(data, "received_packets", "received_bytes"),
|
||||
),
|
||||
],
|
||||
));
|
||||
left.push(Line::from(""));
|
||||
left.extend(section(
|
||||
"Lookup Requests",
|
||||
&[
|
||||
("Received", lookup("req_received")),
|
||||
("Forwarded", lookup("req_forwarded")),
|
||||
("Initiated", lookup("req_initiated")),
|
||||
("Deduplicated", lookup("req_deduplicated")),
|
||||
("Target Is Us", lookup("req_target_is_us")),
|
||||
("Duplicate", lookup("req_duplicate")),
|
||||
("Bloom Miss", lookup("req_bloom_miss")),
|
||||
("Backoff Suppressed", lookup("req_backoff_suppressed")),
|
||||
("Fwd Rate Limited", lookup("req_forward_rate_limited")),
|
||||
("TTL Exhausted", lookup("req_ttl_exhausted")),
|
||||
("Decode Error", lookup("req_decode_error")),
|
||||
],
|
||||
));
|
||||
left.push(Line::from(""));
|
||||
left.extend(section(
|
||||
"Lookup Responses",
|
||||
&[
|
||||
("Received", lookup("resp_received")),
|
||||
("Accepted", lookup("resp_accepted")),
|
||||
("Forwarded", lookup("resp_forwarded")),
|
||||
("Timed Out", lookup("resp_timed_out")),
|
||||
("Identity Miss", lookup("resp_identity_miss")),
|
||||
("Proof Failed", lookup("resp_proof_failed")),
|
||||
("Decode Error", lookup("resp_decode_error")),
|
||||
],
|
||||
));
|
||||
|
||||
// Right column — "Forwarded" (transit / routed through this node).
|
||||
// Forwarded total, then the route-class breakdown (a percentage partition
|
||||
// of the total), then the transit-path drop reasons.
|
||||
let fwd_total = fwd_count(data, "forwarded_packets");
|
||||
let mut right = section(
|
||||
"Forwarded",
|
||||
&[(
|
||||
"Forwarded",
|
||||
fwd_value(data, "forwarded_packets", "forwarded_bytes"),
|
||||
)],
|
||||
);
|
||||
// Blank separator after the Forwarded total, matching the spacing between
|
||||
// every other section pair; the total and its route-class breakdown read
|
||||
// as two distinct groups.
|
||||
right.push(Line::from(""));
|
||||
// Route-class breakdown: a partition of Forwarded, each line annotated with
|
||||
// its share of the total. Tree-down cross — the dive-to-tree-child
|
||||
// cut-through — is the last class; Tree-down + Tree-down cross sum to the
|
||||
// pre-split tree-down total.
|
||||
right.extend(section(
|
||||
"Route Class",
|
||||
&[
|
||||
(
|
||||
"Direct Peer",
|
||||
route_class_value(fwd_count(data, "route_direct_peer"), fwd_total),
|
||||
"Delivered",
|
||||
fwd_value(data, "delivered_packets", "delivered_bytes"),
|
||||
),
|
||||
(
|
||||
"Tree-down",
|
||||
route_class_value(fwd_count(data, "route_tree_down"), fwd_total),
|
||||
"Forwarded",
|
||||
fwd_value(data, "forwarded_packets", "forwarded_bytes"),
|
||||
),
|
||||
(
|
||||
"Tree-up",
|
||||
route_class_value(fwd_count(data, "route_tree_up"), fwd_total),
|
||||
"Originated",
|
||||
fwd_value(data, "originated_packets", "originated_bytes"),
|
||||
),
|
||||
(
|
||||
"Cross-link descend",
|
||||
route_class_value(fwd_count(data, "route_crosslink_descend"), fwd_total),
|
||||
),
|
||||
(
|
||||
"Cross-link ascend",
|
||||
route_class_value(fwd_count(data, "route_crosslink_ascend"), fwd_total),
|
||||
),
|
||||
(
|
||||
"Tree-down cross",
|
||||
route_class_value(fwd_count(data, "route_tree_down_cross"), fwd_total),
|
||||
),
|
||||
],
|
||||
));
|
||||
right.push(Line::from(""));
|
||||
right.extend(section(
|
||||
"Dropped",
|
||||
&[
|
||||
(
|
||||
"No Route",
|
||||
fwd_value(data, "drop_no_route_packets", "drop_no_route_bytes"),
|
||||
"Decode Error",
|
||||
fwd_value(data, "decode_error_packets", "decode_error_bytes"),
|
||||
),
|
||||
(
|
||||
"TTL Exhausted",
|
||||
fwd_value(data, "ttl_exhausted_packets", "ttl_exhausted_bytes"),
|
||||
),
|
||||
(
|
||||
"Decode Error",
|
||||
fwd_value(data, "decode_error_packets", "decode_error_bytes"),
|
||||
"No Route",
|
||||
fwd_value(data, "drop_no_route_packets", "drop_no_route_bytes"),
|
||||
),
|
||||
(
|
||||
"MTU Exceeded",
|
||||
@@ -276,16 +152,47 @@ fn draw_routing_stats(
|
||||
fwd_value(data, "drop_send_error_packets", "drop_send_error_bytes"),
|
||||
),
|
||||
],
|
||||
);
|
||||
left.push(Line::from(""));
|
||||
left.extend(section(
|
||||
"Discovery Requests",
|
||||
&[
|
||||
("Received", disc("req_received")),
|
||||
("Forwarded", disc("req_forwarded")),
|
||||
("Initiated", disc("req_initiated")),
|
||||
("Deduplicated", disc("req_deduplicated")),
|
||||
("Target Is Us", disc("req_target_is_us")),
|
||||
("Duplicate", disc("req_duplicate")),
|
||||
("Bloom Miss", disc("req_bloom_miss")),
|
||||
("Backoff Suppressed", disc("req_backoff_suppressed")),
|
||||
("Fwd Rate Limited", disc("req_forward_rate_limited")),
|
||||
("TTL Exhausted", disc("req_ttl_exhausted")),
|
||||
("Decode Error", disc("req_decode_error")),
|
||||
],
|
||||
));
|
||||
right.push(Line::from(""));
|
||||
right.extend(section(
|
||||
left.push(Line::from(""));
|
||||
left.extend(section(
|
||||
"Discovery Responses",
|
||||
&[
|
||||
("Received", disc("resp_received")),
|
||||
("Accepted", disc("resp_accepted")),
|
||||
("Forwarded", disc("resp_forwarded")),
|
||||
("Timed Out", disc("resp_timed_out")),
|
||||
("Identity Miss", disc("resp_identity_miss")),
|
||||
("Proof Failed", disc("resp_proof_failed")),
|
||||
("Decode Error", disc("resp_decode_error")),
|
||||
],
|
||||
));
|
||||
|
||||
// Right column: Error Signals + Congestion
|
||||
let mut right = section(
|
||||
"Error Signals",
|
||||
&[
|
||||
("Coords Required", err("coords_required")),
|
||||
("Path Broken", err("path_broken")),
|
||||
("MTU Exceeded", err("mtu_exceeded")),
|
||||
],
|
||||
));
|
||||
);
|
||||
right.push(Line::from(""));
|
||||
right.extend(section(
|
||||
"Congestion",
|
||||
|
||||
@@ -1134,12 +1134,7 @@ fn routing_focused_pane_scrolls() {
|
||||
let mut app1 = app_with(Tab::Routing, data);
|
||||
app1.data.insert(Tab::Cache, json!({}));
|
||||
app1.focused_pane.insert(Tab::Routing, 2);
|
||||
// Congestion is the last section of the right ("Forwarded") column, below
|
||||
// the route-class breakdown, Dropped, and Error Signals groups. The left
|
||||
// column is the taller of the two, so scrolling fully to the bottom would
|
||||
// over-scroll the right column past Congestion; this offset lands the
|
||||
// Congestion region inside the short window instead.
|
||||
app1.scroll_offsets.insert((Tab::Routing, 2), 24);
|
||||
app1.scroll_offsets.insert((Tab::Routing, 2), 6);
|
||||
let buf1 = testkit::render(100, 20, |frame, area| {
|
||||
super::routing::draw(frame, &app1, area);
|
||||
});
|
||||
@@ -1189,15 +1184,6 @@ fn mmp_focused_pane_indicator() {
|
||||
});
|
||||
// The focused Session MMP title is cyan; the unfocused Link MMP title is not.
|
||||
assert_eq!(testkit::fg_at(&buf, "Session MMP"), Some(Color::Cyan));
|
||||
// Presence before colour. `fg_at` is `find(..)?` mapped to the cell's fg, so
|
||||
// it returns None for a title that was never drawn, and None != Some(Cyan) --
|
||||
// meaning the assertion below passed when the Link MMP pane was missing
|
||||
// entirely. Asserting it is on screen first is what makes the next line read
|
||||
// "not highlighted" rather than "not there".
|
||||
assert!(
|
||||
testkit::find(&buf, "Link MMP").is_some(),
|
||||
"the unfocused Link MMP title should still be rendered"
|
||||
);
|
||||
assert_ne!(testkit::fg_at(&buf, "Link MMP"), Some(Color::Cyan));
|
||||
}
|
||||
|
||||
|
||||
@@ -174,6 +174,10 @@ fn draw_stats(frame: &mut Frame, data: &serde_json::Value, scroll: u16, focused:
|
||||
&helpers::nested_u64(data, "stats", "sig_failed"),
|
||||
),
|
||||
helpers::kv_line("Stale", &helpers::nested_u64(data, "stats", "stale")),
|
||||
helpers::kv_line(
|
||||
"Parent Switched",
|
||||
&helpers::nested_u64(data, "stats", "parent_switched"),
|
||||
),
|
||||
helpers::kv_line(
|
||||
"Loop Detected",
|
||||
&helpers::nested_u64(data, "stats", "loop_detected"),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Generic Bloom filter data structure.
|
||||
|
||||
use core::fmt;
|
||||
use std::fmt;
|
||||
|
||||
use tracing::trace;
|
||||
|
||||
use super::{BloomError, DEFAULT_FILTER_SIZE_BITS, DEFAULT_HASH_COUNT};
|
||||
use crate::NodeAddr;
|
||||
@@ -67,18 +69,16 @@ impl BloomFilter {
|
||||
|
||||
/// Insert a NodeAddr into the filter.
|
||||
pub fn insert(&mut self, node_addr: &NodeAddr) {
|
||||
let (h1, h2) = Self::base_hashes(node_addr.as_bytes());
|
||||
for i in 0..self.hash_count {
|
||||
let bit_index = self.bit_index(h1, h2, i);
|
||||
let bit_index = self.hash(node_addr.as_bytes(), i);
|
||||
self.set_bit(bit_index);
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert raw bytes into the filter.
|
||||
pub fn insert_bytes(&mut self, data: &[u8]) {
|
||||
let (h1, h2) = Self::base_hashes(data);
|
||||
for i in 0..self.hash_count {
|
||||
let bit_index = self.bit_index(h1, h2, i);
|
||||
let bit_index = self.hash(data, i);
|
||||
self.set_bit(bit_index);
|
||||
}
|
||||
}
|
||||
@@ -93,9 +93,8 @@ impl BloomFilter {
|
||||
|
||||
/// Check if the filter might contain raw bytes.
|
||||
pub fn contains_bytes(&self, data: &[u8]) -> bool {
|
||||
let (h1, h2) = Self::base_hashes(data);
|
||||
for i in 0..self.hash_count {
|
||||
let bit_index = self.bit_index(h1, h2, i);
|
||||
let bit_index = self.hash(data, i);
|
||||
if !self.get_bit(bit_index) {
|
||||
return false;
|
||||
}
|
||||
@@ -142,11 +141,6 @@ impl BloomFilter {
|
||||
self.count_ones() as f64 / self.num_bits as f64
|
||||
}
|
||||
|
||||
/// Current false-positive rate: fill ratio raised to the hash count.
|
||||
pub fn fpr(&self) -> f64 {
|
||||
crate::proto::math::powi(self.fill_ratio(), self.hash_count as u32)
|
||||
}
|
||||
|
||||
/// Estimate the number of elements in the filter.
|
||||
///
|
||||
/// Uses the formula: n = -(m/k) * ln(1 - X/m)
|
||||
@@ -168,12 +162,13 @@ impl BloomFilter {
|
||||
}
|
||||
|
||||
let fill = x / m;
|
||||
let fpr = self.fpr();
|
||||
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) * libm::log(1.0 - fill))
|
||||
Some(-(m / k) * (1.0 - fill).ln())
|
||||
}
|
||||
|
||||
/// Check if the filter is empty.
|
||||
@@ -201,28 +196,21 @@ impl BloomFilter {
|
||||
self.hash_count
|
||||
}
|
||||
|
||||
/// Compute the two base hashes for `data` with a single SHA-256 digest.
|
||||
/// Compute a hash index for the given data and hash function number.
|
||||
///
|
||||
/// Double hashing derives the k hash functions from two base hashes:
|
||||
/// h(x,i) = (h1(x) + i*h2(x)) mod m. Computing the digest once here and
|
||||
/// reusing `(h1, h2)` across all k functions avoids re-hashing per k.
|
||||
fn base_hashes(data: &[u8]) -> (u64, u64) {
|
||||
// Use first 16 bytes of SHA-256 for h1 and h2.
|
||||
/// Uses double hashing: h(x,i) = (h1(x) + i*h2(x)) mod m
|
||||
fn hash(&self, data: &[u8], k: u8) -> usize {
|
||||
// Use first 16 bytes of SHA-256 for h1 and h2
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(data);
|
||||
let hash = hasher.finalize();
|
||||
|
||||
// h1 from first 8 bytes, h2 from next 8 bytes (little-endian).
|
||||
// h1 from first 8 bytes
|
||||
let h1 = u64::from_le_bytes(hash[0..8].try_into().unwrap());
|
||||
// h2 from next 8 bytes
|
||||
let h2 = u64::from_le_bytes(hash[8..16].try_into().unwrap());
|
||||
(h1, h2)
|
||||
}
|
||||
|
||||
/// Derive the bit index for hash function `k` from the base hashes.
|
||||
///
|
||||
/// Uses double hashing: h(x,k) = (h1(x) + k*h2(x)) mod m.
|
||||
fn bit_index(&self, h1: u64, h2: u64, k: u8) -> usize {
|
||||
let combined = h1.wrapping_add((k as u64).wrapping_mul(h2));
|
||||
(combined as usize) % self.num_bits
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Bloom Filter Implementation
|
||||
//!
|
||||
//! 1KB Bloom filters for reachability in FIPS routing. Each node
|
||||
//! maintains filters that summarize which destinations are reachable
|
||||
//! through each peer, enabling efficient routing decisions without
|
||||
//! global network knowledge.
|
||||
//!
|
||||
//! ## v1 Parameters
|
||||
//!
|
||||
//! - Size: 1 KB (8,192 bits) - sized for actual ~400-800 entry occupancy
|
||||
//! - Hash functions: k=5 - optimal at ~1,200 entries, good for 800-1,600
|
||||
//! - Bandwidth: 1 KB/announce (75% reduction from original 4KB design)
|
||||
//!
|
||||
//! These parameters are right-sized for typical network occupancy of
|
||||
//! ~250-800 entries per node.
|
||||
|
||||
mod filter;
|
||||
mod state;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
pub use filter::BloomFilter;
|
||||
pub use state::BloomState;
|
||||
|
||||
/// Default filter size in bits (1KB = 8,192 bits).
|
||||
///
|
||||
/// Sized for ~800-1,600 entries. FPR ~0.05% at 400 entries, ~0.9% at 800.
|
||||
/// This is v1 protocol default (size_class=1).
|
||||
pub const DEFAULT_FILTER_SIZE_BITS: usize = 8192;
|
||||
|
||||
/// Default filter size in bytes (1KB).
|
||||
pub const DEFAULT_FILTER_SIZE_BYTES: usize = DEFAULT_FILTER_SIZE_BITS / 8;
|
||||
|
||||
/// Default number of hash functions.
|
||||
///
|
||||
/// k=5 is optimal at ~1,200 entries and a good compromise for 800-1,600.
|
||||
/// At 400 entries: FPR ~0.05%. At 800 entries: FPR ~0.9%.
|
||||
pub const DEFAULT_HASH_COUNT: u8 = 5;
|
||||
|
||||
/// Size class for v1 protocol (1 KB filters).
|
||||
pub const V1_SIZE_CLASS: u8 = 1;
|
||||
|
||||
/// Filter sizes by size_class: bytes = 512 << size_class
|
||||
pub const SIZE_CLASS_BYTES: [usize; 4] = [512, 1024, 2048, 4096];
|
||||
|
||||
/// Errors related to Bloom filter operations.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum BloomError {
|
||||
#[error("invalid filter size: expected {expected} bits, got {got}")]
|
||||
InvalidSize { expected: usize, got: usize },
|
||||
|
||||
#[error("filter size must be a multiple of 8, got {0}")]
|
||||
SizeNotByteAligned(usize),
|
||||
|
||||
#[error("hash count must be positive")]
|
||||
ZeroHashCount,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,6 +1,6 @@
|
||||
//! FIPS-specific Bloom filter announcement state management.
|
||||
|
||||
use alloc::collections::{BTreeMap, BTreeSet};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use super::BloomFilter;
|
||||
use crate::NodeAddr;
|
||||
@@ -13,19 +13,19 @@ pub struct BloomState {
|
||||
/// This node's NodeAddr (always included in outgoing filters).
|
||||
own_node_addr: NodeAddr,
|
||||
/// Leaf-only nodes we speak for (included in our filter).
|
||||
leaf_dependents: BTreeSet<NodeAddr>,
|
||||
leaf_dependents: HashSet<NodeAddr>,
|
||||
/// Whether this node operates in leaf-only mode.
|
||||
is_leaf_only: bool,
|
||||
/// Rate limiting: minimum interval between outgoing updates (milliseconds).
|
||||
update_debounce_ms: u64,
|
||||
/// Timestamp of last update sent (per peer, in milliseconds).
|
||||
last_update_sent: BTreeMap<NodeAddr, u64>,
|
||||
last_update_sent: HashMap<NodeAddr, u64>,
|
||||
/// Peers that need a filter update.
|
||||
pending_updates: BTreeSet<NodeAddr>,
|
||||
pending_updates: HashSet<NodeAddr>,
|
||||
/// Current sequence number for outgoing filters.
|
||||
sequence: u64,
|
||||
/// Last outgoing filter sent to each peer (for change detection).
|
||||
last_sent_filters: BTreeMap<NodeAddr, BloomFilter>,
|
||||
last_sent_filters: HashMap<NodeAddr, BloomFilter>,
|
||||
}
|
||||
|
||||
impl BloomState {
|
||||
@@ -33,13 +33,13 @@ impl BloomState {
|
||||
pub fn new(own_node_addr: NodeAddr) -> Self {
|
||||
Self {
|
||||
own_node_addr,
|
||||
leaf_dependents: BTreeSet::new(),
|
||||
leaf_dependents: HashSet::new(),
|
||||
is_leaf_only: false,
|
||||
update_debounce_ms: 500,
|
||||
last_update_sent: BTreeMap::new(),
|
||||
pending_updates: BTreeSet::new(),
|
||||
last_update_sent: HashMap::new(),
|
||||
pending_updates: HashSet::new(),
|
||||
sequence: 0,
|
||||
last_sent_filters: BTreeMap::new(),
|
||||
last_sent_filters: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ impl BloomState {
|
||||
}
|
||||
|
||||
/// Get the set of leaf dependents.
|
||||
pub fn leaf_dependents(&self) -> &BTreeSet<NodeAddr> {
|
||||
pub fn leaf_dependents(&self) -> &HashSet<NodeAddr> {
|
||||
&self.leaf_dependents
|
||||
}
|
||||
|
||||
@@ -170,81 +170,23 @@ impl BloomState {
|
||||
&mut self,
|
||||
exclude_from: &NodeAddr,
|
||||
peer_addrs: &[NodeAddr],
|
||||
peer_filters: &BTreeMap<NodeAddr, BloomFilter>,
|
||||
peer_filters: &HashMap<NodeAddr, BloomFilter>,
|
||||
) {
|
||||
let targets: Vec<NodeAddr> = peer_addrs
|
||||
.iter()
|
||||
.filter(|addr| *addr != exclude_from)
|
||||
.copied()
|
||||
.collect();
|
||||
|
||||
for (peer_addr, new_filter) in self.compute_outgoing_filters(&targets, peer_filters) {
|
||||
let changed = match self.last_sent_filters.get(&peer_addr) {
|
||||
for peer_addr in peer_addrs {
|
||||
if peer_addr == exclude_from {
|
||||
continue;
|
||||
}
|
||||
let new_filter = self.compute_outgoing_filter(peer_addr, peer_filters);
|
||||
let changed = match self.last_sent_filters.get(peer_addr) {
|
||||
Some(last) => *last != new_filter,
|
||||
None => true, // never sent → must send
|
||||
};
|
||||
if changed {
|
||||
self.pending_updates.insert(peer_addr);
|
||||
self.pending_updates.insert(*peer_addr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the outgoing filter for many peers in one pass.
|
||||
///
|
||||
/// Equivalent to calling [`compute_outgoing_filter`](Self::compute_outgoing_filter)
|
||||
/// once per target, but linear in the number of contributing peer
|
||||
/// filters instead of quadratic. The per-peer call rebuilds the whole
|
||||
/// union from scratch, so computing it for every peer costs
|
||||
/// O(targets × filters) 1 KB merges; announce fan-out on a
|
||||
/// large node does exactly that, once per tick and again on every
|
||||
/// inbound announce.
|
||||
///
|
||||
/// The split-horizon exclusion is the only thing that differs between
|
||||
/// targets, so the union of "everything except peer i" is assembled
|
||||
/// from a running prefix union and a precomputed suffix union. Merging
|
||||
/// is a bytewise OR, which is commutative and associative, so the
|
||||
/// result is bit-identical to the per-peer computation.
|
||||
pub fn compute_outgoing_filters(
|
||||
&self,
|
||||
targets: &[NodeAddr],
|
||||
peer_filters: &BTreeMap<NodeAddr, BloomFilter>,
|
||||
) -> BTreeMap<NodeAddr, BloomFilter> {
|
||||
let base = self.base_filter();
|
||||
let keys: Vec<NodeAddr> = peer_filters.keys().copied().collect();
|
||||
let n = keys.len();
|
||||
|
||||
// suffix[i] = union of peer_filters[keys[i..]]; suffix[n] is empty.
|
||||
let mut suffix = vec![BloomFilter::new(); n + 1];
|
||||
for i in (0..n).rev() {
|
||||
let mut acc = suffix[i + 1].clone();
|
||||
// Size mismatches are skipped, exactly as in the per-peer path.
|
||||
let _ = acc.merge(&peer_filters[&keys[i]]);
|
||||
suffix[i] = acc;
|
||||
}
|
||||
|
||||
// Filter for a target that contributes nothing: everything merged.
|
||||
let mut all = base.clone();
|
||||
let _ = all.merge(&suffix[0]);
|
||||
|
||||
let mut per_key: BTreeMap<NodeAddr, BloomFilter> = BTreeMap::new();
|
||||
let mut prefix = BloomFilter::new();
|
||||
for i in 0..n {
|
||||
let mut outgoing = base.clone();
|
||||
let _ = outgoing.merge(&prefix);
|
||||
let _ = outgoing.merge(&suffix[i + 1]);
|
||||
per_key.insert(keys[i], outgoing);
|
||||
let _ = prefix.merge(&peer_filters[&keys[i]]);
|
||||
}
|
||||
|
||||
targets
|
||||
.iter()
|
||||
.map(|target| {
|
||||
let filter = per_key.get(target).cloned().unwrap_or_else(|| all.clone());
|
||||
(*target, filter)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Compute the outgoing filter for a specific peer.
|
||||
///
|
||||
/// The filter includes:
|
||||
@@ -257,7 +199,7 @@ impl BloomState {
|
||||
pub fn compute_outgoing_filter(
|
||||
&self,
|
||||
exclude_peer: &NodeAddr,
|
||||
peer_filters: &BTreeMap<NodeAddr, BloomFilter>,
|
||||
peer_filters: &HashMap<NodeAddr, BloomFilter>,
|
||||
) -> BloomFilter {
|
||||
let mut filter = BloomFilter::new();
|
||||
|
||||
@@ -1,9 +1,302 @@
|
||||
//! Tests for `BloomState` (announcement state management).
|
||||
use super::*;
|
||||
use crate::NodeAddr;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use alloc::collections::BTreeMap;
|
||||
fn make_node_addr(val: u8) -> NodeAddr {
|
||||
let mut bytes = [0u8; 16];
|
||||
bytes[0] = val;
|
||||
NodeAddr::from_bytes(bytes)
|
||||
}
|
||||
|
||||
use crate::proto::bloom::{BloomFilter, BloomState};
|
||||
use crate::testutil::make_node_addr;
|
||||
// ===== BloomFilter Tests =====
|
||||
|
||||
#[test]
|
||||
fn test_bloom_filter_new() {
|
||||
let filter = BloomFilter::new();
|
||||
assert_eq!(filter.num_bits(), DEFAULT_FILTER_SIZE_BITS);
|
||||
assert_eq!(filter.hash_count(), DEFAULT_HASH_COUNT);
|
||||
assert_eq!(filter.count_ones(), 0);
|
||||
assert!(filter.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_filter_insert_contains() {
|
||||
let mut filter = BloomFilter::new();
|
||||
let node1 = make_node_addr(1);
|
||||
let node2 = make_node_addr(2);
|
||||
|
||||
assert!(!filter.contains(&node1));
|
||||
assert!(!filter.contains(&node2));
|
||||
|
||||
filter.insert(&node1);
|
||||
|
||||
assert!(filter.contains(&node1));
|
||||
// node2 might have false positive, but very unlikely with single insert
|
||||
assert!(!filter.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_filter_multiple_inserts() {
|
||||
let mut filter = BloomFilter::new();
|
||||
|
||||
for i in 0..100 {
|
||||
let node = make_node_addr(i);
|
||||
filter.insert(&node);
|
||||
}
|
||||
|
||||
// All inserted items should be found
|
||||
for i in 0..100 {
|
||||
let node = make_node_addr(i);
|
||||
assert!(filter.contains(&node), "Node {} not found", i);
|
||||
}
|
||||
|
||||
// Fill ratio should be reasonable
|
||||
let fill = filter.fill_ratio();
|
||||
assert!(fill > 0.0 && fill < 0.5, "Unexpected fill ratio: {}", fill);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_filter_merge() {
|
||||
let mut filter1 = BloomFilter::new();
|
||||
let mut filter2 = BloomFilter::new();
|
||||
|
||||
let node1 = make_node_addr(1);
|
||||
let node2 = make_node_addr(2);
|
||||
|
||||
filter1.insert(&node1);
|
||||
filter2.insert(&node2);
|
||||
|
||||
filter1.merge(&filter2).unwrap();
|
||||
|
||||
assert!(filter1.contains(&node1));
|
||||
assert!(filter1.contains(&node2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_filter_union() {
|
||||
let mut filter1 = BloomFilter::new();
|
||||
let mut filter2 = BloomFilter::new();
|
||||
|
||||
let node1 = make_node_addr(1);
|
||||
let node2 = make_node_addr(2);
|
||||
|
||||
filter1.insert(&node1);
|
||||
filter2.insert(&node2);
|
||||
|
||||
let union = filter1.union(&filter2).unwrap();
|
||||
|
||||
assert!(union.contains(&node1));
|
||||
assert!(union.contains(&node2));
|
||||
// Original filters unchanged
|
||||
assert!(!filter1.contains(&node2));
|
||||
assert!(!filter2.contains(&node1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_filter_clear() {
|
||||
let mut filter = BloomFilter::new();
|
||||
let node = make_node_addr(1);
|
||||
|
||||
filter.insert(&node);
|
||||
assert!(!filter.is_empty());
|
||||
|
||||
filter.clear();
|
||||
assert!(filter.is_empty());
|
||||
assert_eq!(filter.count_ones(), 0);
|
||||
assert!(!filter.contains(&node));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_filter_merge_size_mismatch() {
|
||||
let mut filter1 = BloomFilter::with_params(1024, 7).unwrap();
|
||||
let filter2 = BloomFilter::with_params(2048, 7).unwrap();
|
||||
|
||||
let result = filter1.merge(&filter2);
|
||||
assert!(matches!(result, Err(BloomError::InvalidSize { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_filter_custom_params() {
|
||||
let filter = BloomFilter::with_params(1024, 5).unwrap();
|
||||
assert_eq!(filter.num_bits(), 1024);
|
||||
assert_eq!(filter.num_bytes(), 128);
|
||||
assert_eq!(filter.hash_count(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_filter_invalid_params() {
|
||||
// Not byte-aligned (1001 is not divisible by 8)
|
||||
assert!(matches!(
|
||||
BloomFilter::with_params(1001, 7),
|
||||
Err(BloomError::SizeNotByteAligned(1001))
|
||||
));
|
||||
|
||||
// Zero size
|
||||
assert!(matches!(
|
||||
BloomFilter::with_params(0, 7),
|
||||
Err(BloomError::SizeNotByteAligned(0))
|
||||
));
|
||||
|
||||
// Zero hash count
|
||||
assert!(matches!(
|
||||
BloomFilter::with_params(1024, 0),
|
||||
Err(BloomError::ZeroHashCount)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
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();
|
||||
|
||||
assert_eq!(original, restored);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_filter_estimated_count() {
|
||||
let mut filter = BloomFilter::new();
|
||||
|
||||
// Empty filter
|
||||
assert_eq!(filter.estimated_count(f64::INFINITY), Some(0.0));
|
||||
|
||||
// Insert some items
|
||||
for i in 0..50 {
|
||||
filter.insert(&make_node_addr(i));
|
||||
}
|
||||
|
||||
// Estimate should be reasonably close to 50
|
||||
let estimate = filter.estimated_count(f64::INFINITY).unwrap();
|
||||
assert!(
|
||||
estimate > 30.0 && estimate < 100.0,
|
||||
"Unexpected estimate: {}",
|
||||
estimate
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_filter_equality() {
|
||||
let mut filter1 = BloomFilter::new();
|
||||
let mut filter2 = BloomFilter::new();
|
||||
|
||||
assert_eq!(filter1, filter2);
|
||||
|
||||
filter1.insert(&make_node_addr(1));
|
||||
assert_ne!(filter1, filter2);
|
||||
|
||||
filter2.insert(&make_node_addr(1));
|
||||
assert_eq!(filter1, filter2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_filter_from_bytes_empty() {
|
||||
let result = BloomFilter::from_bytes(vec![], 5);
|
||||
assert!(matches!(result, Err(BloomError::SizeNotByteAligned(0))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_filter_from_bytes_zero_hash_count() {
|
||||
let result = BloomFilter::from_bytes(vec![0u8; 128], 0);
|
||||
assert!(matches!(result, Err(BloomError::ZeroHashCount)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_filter_from_slice() {
|
||||
let mut original = BloomFilter::new();
|
||||
original.insert(&make_node_addr(42));
|
||||
let bytes = original.as_bytes();
|
||||
|
||||
let restored = BloomFilter::from_slice(bytes, original.hash_count()).unwrap();
|
||||
assert_eq!(original, restored);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_filter_insert_bytes_contains_bytes() {
|
||||
let mut filter = BloomFilter::new();
|
||||
let data1 = b"hello world";
|
||||
let data2 = b"goodbye";
|
||||
|
||||
assert!(!filter.contains_bytes(data1));
|
||||
|
||||
filter.insert_bytes(data1);
|
||||
assert!(filter.contains_bytes(data1));
|
||||
assert!(!filter.contains_bytes(data2));
|
||||
|
||||
filter.insert_bytes(data2);
|
||||
assert!(filter.contains_bytes(data1));
|
||||
assert!(filter.contains_bytes(data2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_filter_estimated_count_saturated() {
|
||||
// Create a small filter with all bits set
|
||||
let bytes = vec![0xFF; 8]; // all bits set
|
||||
let filter = BloomFilter::from_bytes(bytes, 3).unwrap();
|
||||
|
||||
// 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]
|
||||
fn test_bloom_filter_default() {
|
||||
let default: BloomFilter = Default::default();
|
||||
let explicit = BloomFilter::new();
|
||||
assert_eq!(default, explicit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bloom_filter_debug_format() {
|
||||
let mut filter = BloomFilter::new();
|
||||
let debug = format!("{:?}", filter);
|
||||
assert!(debug.contains("BloomFilter"));
|
||||
assert!(debug.contains("8192"));
|
||||
assert!(debug.contains("hash_count"));
|
||||
|
||||
// With some entries
|
||||
for i in 0..10 {
|
||||
filter.insert(&make_node_addr(i));
|
||||
}
|
||||
let debug = format!("{:?}", filter);
|
||||
assert!(debug.contains("fill_ratio"));
|
||||
assert!(debug.contains("est_count"));
|
||||
}
|
||||
|
||||
// ===== BloomState Tests =====
|
||||
|
||||
#[test]
|
||||
fn test_bloom_state_new() {
|
||||
@@ -133,7 +426,7 @@ fn test_bloom_state_compute_outgoing_filter() {
|
||||
let mut filter2 = BloomFilter::new();
|
||||
filter2.insert(&make_node_addr(200));
|
||||
|
||||
let mut peer_filters = BTreeMap::new();
|
||||
let mut peer_filters = HashMap::new();
|
||||
peer_filters.insert(peer1, filter1);
|
||||
peer_filters.insert(peer2, filter2);
|
||||
|
||||
@@ -184,7 +477,7 @@ fn test_bloom_state_record_sent_filter() {
|
||||
state.record_sent_filter(peer, filter);
|
||||
|
||||
// Compute what would be sent to peer (just our own node, no peer filters)
|
||||
let peer_filters = BTreeMap::new();
|
||||
let peer_filters = HashMap::new();
|
||||
let peer_addrs = vec![peer];
|
||||
state.mark_changed_peers(&make_node_addr(99), &peer_addrs, &peer_filters);
|
||||
|
||||
@@ -221,7 +514,7 @@ fn test_bloom_state_remove_peer_state() {
|
||||
|
||||
// Sent filter cleared — mark_changed_peers should treat as "never sent"
|
||||
state.clear_pending_updates();
|
||||
let peer_filters = BTreeMap::new();
|
||||
let peer_filters = HashMap::new();
|
||||
let peer_addrs = vec![peer];
|
||||
state.mark_changed_peers(&make_node_addr(99), &peer_addrs, &peer_filters);
|
||||
assert!(state.needs_update(&peer)); // never sent → must send
|
||||
@@ -235,7 +528,7 @@ fn test_bloom_state_mark_changed_peers_never_sent() {
|
||||
let peer1 = make_node_addr(1);
|
||||
let peer2 = make_node_addr(2);
|
||||
|
||||
let peer_filters = BTreeMap::new();
|
||||
let peer_filters = HashMap::new();
|
||||
let peer_addrs = vec![peer1, peer2];
|
||||
|
||||
// No filters ever sent — all peers should be marked
|
||||
@@ -252,7 +545,7 @@ fn test_bloom_state_mark_changed_peers_unchanged() {
|
||||
|
||||
let peer1 = make_node_addr(1);
|
||||
let peer2 = make_node_addr(2);
|
||||
let peer_filters = BTreeMap::new();
|
||||
let peer_filters = HashMap::new();
|
||||
let peer_addrs = vec![peer1, peer2];
|
||||
|
||||
// Compute and record what would be sent to each peer
|
||||
@@ -275,7 +568,7 @@ fn test_bloom_state_mark_changed_peers_one_changed() {
|
||||
|
||||
let peer1 = make_node_addr(1);
|
||||
let peer2 = make_node_addr(2);
|
||||
let peer_filters = BTreeMap::new();
|
||||
let peer_filters = HashMap::new();
|
||||
let peer_addrs = vec![peer1, peer2];
|
||||
|
||||
// Record current outgoing filters for both peers
|
||||
@@ -287,7 +580,7 @@ fn test_bloom_state_mark_changed_peers_one_changed() {
|
||||
// Now peer1 sends us a filter with new entries
|
||||
let mut inbound_from_peer1 = BloomFilter::new();
|
||||
inbound_from_peer1.insert(&make_node_addr(100));
|
||||
let mut updated_peer_filters = BTreeMap::new();
|
||||
let mut updated_peer_filters = HashMap::new();
|
||||
updated_peer_filters.insert(peer1, inbound_from_peer1);
|
||||
|
||||
// mark_changed_peers triggered by receiving from peer1
|
||||
@@ -305,7 +598,7 @@ fn test_bloom_state_mark_changed_peers_excludes_source() {
|
||||
let mut state = BloomState::new(node);
|
||||
|
||||
let peer1 = make_node_addr(1);
|
||||
let peer_filters = BTreeMap::new();
|
||||
let peer_filters = HashMap::new();
|
||||
let peer_addrs = vec![peer1];
|
||||
|
||||
// peer1 is both the source and the only peer — should be skipped
|
||||
@@ -9,7 +9,7 @@ use std::collections::HashMap;
|
||||
use super::CacheStats;
|
||||
use super::entry::CacheEntry;
|
||||
use crate::NodeAddr;
|
||||
use crate::proto::stp::TreeCoordinate;
|
||||
use crate::tree::TreeCoordinate;
|
||||
|
||||
/// Default maximum entries in coordinate cache.
|
||||
pub const DEFAULT_COORD_CACHE_SIZE: usize = 50_000;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Cache entry with TTL and LRU tracking.
|
||||
|
||||
use crate::proto::stp::TreeCoordinate;
|
||||
use crate::tree::TreeCoordinate;
|
||||
|
||||
/// A cached coordinate entry.
|
||||
#[derive(Clone, Debug)]
|
||||
|
||||
@@ -24,7 +24,6 @@ mod node;
|
||||
mod peer;
|
||||
mod transport;
|
||||
|
||||
use crate::node::REKEY_JITTER_SECS;
|
||||
use crate::upper::config::{DnsConfig, TunConfig};
|
||||
use crate::{Identity, IdentityError};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -34,9 +33,9 @@ use thiserror::Error;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use gateway::{ConntrackConfig, GatewayConfig, GatewayDnsConfig, PortForward, Proto};
|
||||
pub use node::{
|
||||
BloomConfig, BuffersConfig, CacheConfig, ControlConfig, LimitsConfig, LookupConfig, MmpConfig,
|
||||
NodeConfig, NostrRendezvousConfig, NostrRendezvousPolicy, RateLimitConfig, RekeyConfig,
|
||||
RendezvousConfig, RetryConfig, SessionConfig, SessionMmpConfig, TreeConfig,
|
||||
BloomConfig, BuffersConfig, CacheConfig, ControlConfig, DiscoveryConfig, LimitsConfig,
|
||||
NodeConfig, NostrDiscoveryConfig, NostrDiscoveryPolicy, RateLimitConfig, RekeyConfig,
|
||||
RetryConfig, SessionConfig, SessionMmpConfig, TreeConfig,
|
||||
};
|
||||
pub use peer::{ConnectPolicy, PeerAddress, PeerConfig};
|
||||
pub use transport::{
|
||||
@@ -490,59 +489,10 @@ impl Config {
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
let mut config: Config =
|
||||
serde_yaml::from_str(&contents).map_err(|e| ConfigError::ParseYaml {
|
||||
path: path.to_path_buf(),
|
||||
source: e,
|
||||
})?;
|
||||
config.normalize_deprecated_keys();
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// COMPAT (drop at the v2 cutover): fold a deprecated `node.discovery:`
|
||||
/// block into the `node.lookup.*` (mesh-lookup scalars) and
|
||||
/// `node.rendezvous.*` (nostr/LAN peer rendezvous) tables that replaced it.
|
||||
///
|
||||
/// Runs at every deserialize boundary (see `load_file`). A present legacy
|
||||
/// field fills the corresponding new-table field, so a config that predates
|
||||
/// the split keeps behaving identically. When a legacy block is seen, a
|
||||
/// one-time deprecation warning names the old→new key moves. Exposed to the
|
||||
/// crate so config tests that deserialize directly can invoke it.
|
||||
pub(crate) fn normalize_deprecated_keys(&mut self) {
|
||||
let Some(compat) = self.node.discovery.take() else {
|
||||
return;
|
||||
};
|
||||
tracing::warn!(
|
||||
target: "fips::config",
|
||||
"`node.discovery.*` is deprecated and will be removed: mesh-lookup \
|
||||
scalars moved to `node.lookup.*`, and peer-rendezvous keys moved to \
|
||||
`node.rendezvous.nostr.*` / `node.rendezvous.lan.*`. Please migrate; \
|
||||
a legacy `node.discovery` block still applies for now."
|
||||
);
|
||||
if let Some(v) = compat.ttl {
|
||||
self.node.lookup.ttl = v;
|
||||
}
|
||||
if let Some(v) = compat.attempt_timeouts_secs {
|
||||
self.node.lookup.attempt_timeouts_secs = v;
|
||||
}
|
||||
if let Some(v) = compat.recent_expiry_secs {
|
||||
self.node.lookup.recent_expiry_secs = v;
|
||||
}
|
||||
if let Some(v) = compat.backoff_base_secs {
|
||||
self.node.lookup.backoff_base_secs = v;
|
||||
}
|
||||
if let Some(v) = compat.backoff_max_secs {
|
||||
self.node.lookup.backoff_max_secs = v;
|
||||
}
|
||||
if let Some(v) = compat.forward_min_interval_secs {
|
||||
self.node.lookup.forward_min_interval_secs = v;
|
||||
}
|
||||
if let Some(v) = compat.nostr {
|
||||
self.node.rendezvous.nostr = v;
|
||||
}
|
||||
if let Some(v) = compat.lan {
|
||||
self.node.rendezvous.lan = v;
|
||||
}
|
||||
serde_yaml::from_str(&contents).map_err(|e| ConfigError::ParseYaml {
|
||||
path: path.to_path_buf(),
|
||||
source: e,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the standard search paths in priority order (lowest to highest).
|
||||
@@ -638,21 +588,6 @@ impl Config {
|
||||
self.node.leaf_only
|
||||
}
|
||||
|
||||
/// Derive the node profile from config.
|
||||
///
|
||||
/// leaf_only → Leaf (implies non-routing),
|
||||
/// disable_routing → NonRouting,
|
||||
/// otherwise → Full.
|
||||
pub fn node_profile(&self) -> crate::proto::fmp::NodeProfile {
|
||||
if self.node.leaf_only {
|
||||
crate::proto::fmp::NodeProfile::Leaf
|
||||
} else if self.node.disable_routing {
|
||||
crate::proto::fmp::NodeProfile::NonRouting
|
||||
} else {
|
||||
crate::proto::fmp::NodeProfile::Full
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the configured peers.
|
||||
pub fn peers(&self) -> &[PeerConfig] {
|
||||
&self.peers
|
||||
@@ -665,7 +600,7 @@ impl Config {
|
||||
|
||||
/// Validate cross-field configuration invariants.
|
||||
pub fn validate(&self) -> Result<(), ConfigError> {
|
||||
let nostr = &self.node.rendezvous.nostr;
|
||||
let nostr = &self.node.discovery.nostr;
|
||||
|
||||
let any_transport_advertises_on_nostr = self
|
||||
.transports
|
||||
@@ -685,13 +620,13 @@ impl Config {
|
||||
|
||||
if any_transport_advertises_on_nostr && !nostr.enabled {
|
||||
return Err(ConfigError::Validation(
|
||||
"at least one transport has `advertise_on_nostr = true`, but `node.rendezvous.nostr.enabled` is false".to_string(),
|
||||
"at least one transport has `advertise_on_nostr = true`, but `node.discovery.nostr.enabled` is false".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if self.peers.iter().any(|peer| peer.via_nostr) && !nostr.enabled {
|
||||
return Err(ConfigError::Validation(
|
||||
"at least one peer has `via_nostr = true`, but `node.rendezvous.nostr.enabled` is false".to_string(),
|
||||
"at least one peer has `via_nostr = true`, but `node.discovery.nostr.enabled` is false".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -713,12 +648,12 @@ impl Config {
|
||||
if nostr.enabled && has_nat_udp_advert {
|
||||
if nostr.dm_relays.is_empty() {
|
||||
return Err(ConfigError::Validation(
|
||||
"NAT UDP advert publishing requires `node.rendezvous.nostr.dm_relays` to be non-empty".to_string(),
|
||||
"NAT UDP advert publishing requires `node.discovery.nostr.dm_relays` to be non-empty".to_string(),
|
||||
));
|
||||
}
|
||||
if nostr.stun_servers.is_empty() {
|
||||
return Err(ConfigError::Validation(
|
||||
"NAT UDP advert publishing requires `node.rendezvous.nostr.stun_servers` to be non-empty".to_string(),
|
||||
"NAT UDP advert publishing requires `node.discovery.nostr.stun_servers` to be non-empty".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -751,32 +686,6 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
// Reject rekey triggers that fire immediately and forever. Both
|
||||
// arms are checked regardless of `node.rekey.enabled` so that
|
||||
// turning rekey on later cannot surface a config error at a
|
||||
// surprising moment. There is deliberately no upper bound:
|
||||
// u64::MAX is the idiom for disabling one arm of the trigger.
|
||||
let rekey = &self.node.rekey;
|
||||
|
||||
if rekey.after_messages == 0 {
|
||||
return Err(ConfigError::Validation(
|
||||
"`node.rekey.after_messages` must be at least 1; 0 fires the message-count trigger on every poll instead of disabling it. \
|
||||
Use a very large value to effectively disable the message-count trigger."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let jitter_secs = REKEY_JITTER_SECS.unsigned_abs();
|
||||
if rekey.after_secs <= jitter_secs {
|
||||
return Err(ConfigError::Validation(format!(
|
||||
"`node.rekey.after_secs` is {}, but must be greater than the per-session rekey jitter of {jitter_secs}s; \
|
||||
each session offsets the interval by a random value in [-{jitter_secs}, +{jitter_secs}] seconds, so a smaller interval saturates to zero \
|
||||
and rekeys on sight for roughly half of sessions. \
|
||||
Use a very large value to effectively disable the timer trigger.",
|
||||
rekey.after_secs
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -812,84 +721,6 @@ node:
|
||||
assert!(config.has_identity());
|
||||
}
|
||||
|
||||
/// The fips.yaml shipped in the OpenWrt package must keep parsing as the
|
||||
/// config schema evolves. Both the 802.11s mesh backhaul entries
|
||||
/// (docs/how-to/set-up-80211s-mesh-backhaul.md) and the open-access SSID
|
||||
/// entries (docs/how-to/set-up-open-access-ssid.md) ship commented out —
|
||||
/// one per radio, so dual-band routers can run either on both bands — so
|
||||
/// a stock install that never creates fips-mesh*/fips-ap* logs no
|
||||
/// per-boot bind warning; `fips-mesh-setup`/`fips-ap-setup` uncomment the
|
||||
/// matching block when they create the interface. Verify both states
|
||||
/// parse: as shipped (both inactive), and after the uncomment the helpers
|
||||
/// perform.
|
||||
#[test]
|
||||
fn shipped_openwrt_config_parses() {
|
||||
let yaml = include_str!("../../packaging/openwrt-ipk/files/etc/fips/fips.yaml");
|
||||
|
||||
// As shipped: parses, and the mesh/ap entries are commented out (a
|
||||
// running daemon binds no fips-mesh*/fips-ap* transport, no warning).
|
||||
let config: Config = serde_yaml::from_str(yaml).expect("shipped OpenWrt fips.yaml");
|
||||
for name in ["mesh0", "mesh1", "ap0", "ap1"] {
|
||||
assert!(
|
||||
!config
|
||||
.transports
|
||||
.ethernet
|
||||
.iter()
|
||||
.any(|(n, _)| n == Some(name)),
|
||||
"{name} must ship commented out, not active, in fips.yaml"
|
||||
);
|
||||
}
|
||||
|
||||
// What `fips-mesh-setup`/`fips-ap-setup` produce: uncomment each
|
||||
// block, which must still parse into a transport bound to the right
|
||||
// netdev.
|
||||
let uncommented =
|
||||
uncomment_transport_blocks(&uncomment_transport_blocks(yaml, "mesh"), "ap");
|
||||
let config: Config = serde_yaml::from_str(&uncommented)
|
||||
.expect("fips.yaml with mesh and ap transports uncommented");
|
||||
for (name, interface) in [
|
||||
("mesh0", "fips-mesh0"),
|
||||
("mesh1", "fips-mesh1"),
|
||||
("ap0", "fips-ap0"),
|
||||
("ap1", "fips-ap1"),
|
||||
] {
|
||||
assert!(
|
||||
config
|
||||
.transports
|
||||
.ethernet
|
||||
.iter()
|
||||
.any(|(n, eth)| n == Some(name) && eth.interface == interface),
|
||||
"{name} entry missing after uncommenting shipped fips.yaml"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirror the setup helpers' block uncomment: strip the ` # ` prefix
|
||||
/// from each `# <prefix><N>:` header and its ` # ` continuation
|
||||
/// lines, leaving every other comment untouched.
|
||||
fn uncomment_transport_blocks(yaml: &str, prefix: &str) -> String {
|
||||
let header = format!(" # {prefix}");
|
||||
let mut out = String::new();
|
||||
let mut in_block = false;
|
||||
for line in yaml.lines() {
|
||||
let is_header = line
|
||||
.strip_prefix(&header)
|
||||
.and_then(|r| r.strip_suffix(':'))
|
||||
.is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()));
|
||||
if is_header {
|
||||
in_block = true;
|
||||
out.push_str(&line.replacen(" # ", " ", 1));
|
||||
} else if in_block && line.starts_with(" # ") {
|
||||
out.push_str(&line.replacen(" # ", " ", 1));
|
||||
} else {
|
||||
in_block = false;
|
||||
out.push_str(line);
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_yaml_with_hex() {
|
||||
let yaml = r#"
|
||||
@@ -1430,9 +1261,7 @@ peers:
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_legacy_discovery_nostr_config_compat() {
|
||||
// COMPAT (drop at the v2 cutover): a deprecated `node.discovery.nostr`
|
||||
// block must fold into `node.rendezvous.nostr` via normalize.
|
||||
fn test_parse_nostr_discovery_config() {
|
||||
let yaml = r#"
|
||||
node:
|
||||
discovery:
|
||||
@@ -1456,27 +1285,26 @@ peers:
|
||||
- transport: udp
|
||||
addr: "nat"
|
||||
"#;
|
||||
let mut config: Config = serde_yaml::from_str(yaml).unwrap();
|
||||
config.normalize_deprecated_keys();
|
||||
assert!(config.node.rendezvous.nostr.enabled);
|
||||
assert!(!config.node.rendezvous.nostr.advertise);
|
||||
assert_eq!(config.node.rendezvous.nostr.app, "fips.nat.test.v1");
|
||||
assert_eq!(config.node.rendezvous.nostr.signal_ttl_secs, 45);
|
||||
let config: Config = serde_yaml::from_str(yaml).unwrap();
|
||||
assert!(config.node.discovery.nostr.enabled);
|
||||
assert!(!config.node.discovery.nostr.advertise);
|
||||
assert_eq!(config.node.discovery.nostr.app, "fips.nat.test.v1");
|
||||
assert_eq!(config.node.discovery.nostr.signal_ttl_secs, 45);
|
||||
assert_eq!(
|
||||
config.node.rendezvous.nostr.policy,
|
||||
NostrRendezvousPolicy::ConfiguredOnly
|
||||
config.node.discovery.nostr.policy,
|
||||
NostrDiscoveryPolicy::ConfiguredOnly
|
||||
);
|
||||
assert_eq!(config.node.rendezvous.nostr.open_discovery_max_pending, 12);
|
||||
assert_eq!(config.node.discovery.nostr.open_discovery_max_pending, 12);
|
||||
assert_eq!(
|
||||
config.node.rendezvous.nostr.advert_relays,
|
||||
config.node.discovery.nostr.advert_relays,
|
||||
vec!["wss://relay-a.example".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
config.node.rendezvous.nostr.dm_relays,
|
||||
config.node.discovery.nostr.dm_relays,
|
||||
vec!["wss://relay-b.example".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
config.node.rendezvous.nostr.stun_servers,
|
||||
config.node.discovery.nostr.stun_servers,
|
||||
vec!["stun:stun.example.org:3478".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -1486,55 +1314,6 @@ peers:
|
||||
assert!(config.peers[0].via_nostr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_lookup_and_rendezvous_new_keys() {
|
||||
// The post-split keys parse directly, with no deprecated block and no
|
||||
// normalize warning.
|
||||
let yaml = r#"
|
||||
node:
|
||||
lookup:
|
||||
ttl: 7
|
||||
attempt_timeouts_secs: [3, 6]
|
||||
forward_min_interval_secs: 9
|
||||
rendezvous:
|
||||
nostr:
|
||||
enabled: true
|
||||
app: "fips.new.keys.v1"
|
||||
"#;
|
||||
let mut config: Config = serde_yaml::from_str(yaml).unwrap();
|
||||
config.normalize_deprecated_keys();
|
||||
assert_eq!(config.node.lookup.ttl, 7);
|
||||
assert_eq!(config.node.lookup.attempt_timeouts_secs, vec![3, 6]);
|
||||
assert_eq!(config.node.lookup.forward_min_interval_secs, 9);
|
||||
// Unset scalar keeps its default.
|
||||
assert_eq!(config.node.lookup.recent_expiry_secs, 10);
|
||||
assert!(config.node.rendezvous.nostr.enabled);
|
||||
assert_eq!(config.node.rendezvous.nostr.app, "fips.new.keys.v1");
|
||||
assert!(config.node.discovery.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_legacy_discovery_lookup_scalars_compat() {
|
||||
// COMPAT (drop at the v2 cutover): legacy `node.discovery` mesh-lookup
|
||||
// scalars must fold into `node.lookup`; unset keys keep their defaults.
|
||||
let yaml = r#"
|
||||
node:
|
||||
discovery:
|
||||
ttl: 5
|
||||
backoff_base_secs: 4
|
||||
backoff_max_secs: 30
|
||||
"#;
|
||||
let mut config: Config = serde_yaml::from_str(yaml).unwrap();
|
||||
config.normalize_deprecated_keys();
|
||||
assert_eq!(config.node.lookup.ttl, 5);
|
||||
assert_eq!(config.node.lookup.backoff_base_secs, 4);
|
||||
assert_eq!(config.node.lookup.backoff_max_secs, 30);
|
||||
// Unset legacy scalar leaves the new-table default intact.
|
||||
assert_eq!(config.node.lookup.attempt_timeouts_secs, vec![1, 2, 4, 8]);
|
||||
// The compat block is consumed by normalize.
|
||||
assert!(config.node.discovery.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_transport_advert_requires_nostr_enabled() {
|
||||
let mut config = Config::default();
|
||||
@@ -1542,14 +1321,13 @@ node:
|
||||
advertise_on_nostr: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
config.node.rendezvous.nostr.enabled = false;
|
||||
config.node.discovery.nostr.enabled = false;
|
||||
|
||||
let err = config.validate().expect_err("validation should fail");
|
||||
assert!(err.to_string().contains("advertise_on_nostr"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::field_reassign_with_default)]
|
||||
fn test_validate_peer_via_nostr_requires_nostr_enabled() {
|
||||
let mut config = Config {
|
||||
peers: vec![PeerConfig {
|
||||
@@ -1559,14 +1337,13 @@ node:
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
config.node.rendezvous.nostr.enabled = false;
|
||||
config.node.discovery.nostr.enabled = false;
|
||||
|
||||
let err = config.validate().expect_err("validation should fail");
|
||||
assert!(err.to_string().contains("via_nostr"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::field_reassign_with_default)]
|
||||
fn test_validate_peer_addresses_required_unless_via_nostr() {
|
||||
// Empty addresses + via_nostr=false → error.
|
||||
let mut config = Config {
|
||||
@@ -1581,7 +1358,7 @@ node:
|
||||
|
||||
// Empty addresses + via_nostr=true + nostr.enabled=true → ok.
|
||||
config.peers[0].via_nostr = true;
|
||||
config.node.rendezvous.nostr.enabled = true;
|
||||
config.node.discovery.nostr.enabled = true;
|
||||
config
|
||||
.validate()
|
||||
.expect("via_nostr should allow empty addresses");
|
||||
@@ -1590,8 +1367,8 @@ node:
|
||||
#[test]
|
||||
fn test_validate_nat_udp_advert_requires_relays_and_stun() {
|
||||
let mut config = Config::default();
|
||||
config.node.rendezvous.nostr.enabled = true;
|
||||
config.node.rendezvous.nostr.dm_relays.clear();
|
||||
config.node.discovery.nostr.enabled = true;
|
||||
config.node.discovery.nostr.dm_relays.clear();
|
||||
config.transports.udp = TransportInstances::Single(UdpConfig {
|
||||
advertise_on_nostr: Some(true),
|
||||
public: Some(false),
|
||||
@@ -1601,8 +1378,8 @@ node:
|
||||
let err = config.validate().expect_err("validation should fail");
|
||||
assert!(err.to_string().contains("dm_relays"));
|
||||
|
||||
config.node.rendezvous.nostr.dm_relays = vec!["wss://relay.example".to_string()];
|
||||
config.node.rendezvous.nostr.stun_servers.clear();
|
||||
config.node.discovery.nostr.dm_relays = vec!["wss://relay.example".to_string()];
|
||||
config.node.discovery.nostr.stun_servers.clear();
|
||||
let err = config.validate().expect_err("validation should fail");
|
||||
assert!(err.to_string().contains("stun_servers"));
|
||||
}
|
||||
@@ -1682,86 +1459,6 @@ node:
|
||||
.expect("outbound_only should be exempt from the loopback check");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_default_rekey_settings_ok() {
|
||||
Config::default()
|
||||
.validate()
|
||||
.expect("shipped default rekey settings must validate");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_rekey_after_messages_zero_rejected() {
|
||||
let mut config = Config::default();
|
||||
config.node.rekey.after_messages = 0;
|
||||
|
||||
let err = config.validate().expect_err("validation should fail");
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("after_messages"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_rekey_after_messages_one_accepted() {
|
||||
let mut config = Config::default();
|
||||
config.node.rekey.after_messages = 1;
|
||||
|
||||
config
|
||||
.validate()
|
||||
.expect("after_messages = 1 rekeys every message, which is wasteful but well defined");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_rekey_after_secs_at_or_below_jitter_rejected() {
|
||||
let jitter = REKEY_JITTER_SECS.unsigned_abs();
|
||||
|
||||
for after_secs in [0, 1, jitter - 1, jitter] {
|
||||
let mut config = Config::default();
|
||||
config.node.rekey.after_secs = after_secs;
|
||||
|
||||
match config.validate() {
|
||||
Err(e) => assert!(e.to_string().contains("after_secs"), "got: {e}"),
|
||||
Ok(()) => panic!("after_secs = {after_secs} should be rejected"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_rekey_after_secs_just_above_jitter_accepted() {
|
||||
let mut config = Config::default();
|
||||
config.node.rekey.after_secs = REKEY_JITTER_SECS.unsigned_abs() + 1;
|
||||
|
||||
config
|
||||
.validate()
|
||||
.expect("one second above the jitter bound leaves a non-zero effective interval");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_rekey_unbounded_values_accepted() {
|
||||
let mut config = Config::default();
|
||||
config.node.rekey.after_secs = u64::MAX;
|
||||
config.node.rekey.after_messages = u64::MAX;
|
||||
|
||||
config
|
||||
.validate()
|
||||
.expect("u64::MAX disables an arm of the trigger and must stay legal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_rekey_checked_even_when_disabled() {
|
||||
let mut config = Config::default();
|
||||
config.node.rekey.enabled = false;
|
||||
config.node.rekey.after_messages = 0;
|
||||
|
||||
let err = config.validate().expect_err("validation should fail");
|
||||
assert!(err.to_string().contains("after_messages"));
|
||||
|
||||
let mut config = Config::default();
|
||||
config.node.rekey.enabled = false;
|
||||
config.node.rekey.after_secs = REKEY_JITTER_SECS.unsigned_abs();
|
||||
|
||||
let err = config.validate().expect_err("validation should fail");
|
||||
assert!(err.to_string().contains("after_secs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_outbound_only_forces_ephemeral_bind() {
|
||||
let cfg = UdpConfig {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::IdentityConfig;
|
||||
use crate::proto::mmp::{DEFAULT_LOG_INTERVAL_SECS, DEFAULT_OWD_WINDOW_SIZE, MmpMode};
|
||||
use crate::mmp::{DEFAULT_LOG_INTERVAL_SECS, DEFAULT_OWD_WINDOW_SIZE, MmpConfig, MmpMode};
|
||||
|
||||
// ============================================================================
|
||||
// Node Configuration Subsections
|
||||
@@ -186,42 +186,48 @@ impl CacheConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Mesh-lookup protocol (`node.lookup.*`): the overlay coordinate-lookup
|
||||
/// engine (address → coordinates). The peer-rendezvous keys that used to
|
||||
/// share this table (`nostr`/`lan`) now live under [`RendezvousConfig`]
|
||||
/// (`node.rendezvous.*`).
|
||||
/// Discovery protocol (`node.discovery.*`).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LookupConfig {
|
||||
/// Hop limit for LookupRequest flood (`node.lookup.ttl`).
|
||||
#[serde(default = "LookupConfig::default_ttl")]
|
||||
pub struct DiscoveryConfig {
|
||||
/// Hop limit for LookupRequest flood (`node.discovery.ttl`).
|
||||
#[serde(default = "DiscoveryConfig::default_ttl")]
|
||||
pub ttl: u8,
|
||||
/// Per-attempt timeouts in seconds (`node.lookup.attempt_timeouts_secs`).
|
||||
/// Per-attempt timeouts in seconds (`node.discovery.attempt_timeouts_secs`).
|
||||
/// Each entry is the time to wait for a response before sending the next
|
||||
/// LookupRequest (with a fresh request_id). Sequence length determines the
|
||||
/// total number of attempts before declaring the destination unreachable.
|
||||
/// Default `[1, 2, 4, 8]` gives 4 attempts and a 15s total budget.
|
||||
#[serde(default = "LookupConfig::default_attempt_timeouts_secs")]
|
||||
#[serde(default = "DiscoveryConfig::default_attempt_timeouts_secs")]
|
||||
pub attempt_timeouts_secs: Vec<u64>,
|
||||
/// Dedup cache expiry in seconds (`node.lookup.recent_expiry_secs`).
|
||||
#[serde(default = "LookupConfig::default_recent_expiry_secs")]
|
||||
/// Dedup cache expiry in seconds (`node.discovery.recent_expiry_secs`).
|
||||
#[serde(default = "DiscoveryConfig::default_recent_expiry_secs")]
|
||||
pub recent_expiry_secs: u64,
|
||||
/// Base backoff after lookup failure in seconds (`node.lookup.backoff_base_secs`).
|
||||
/// Base backoff after lookup failure in seconds (`node.discovery.backoff_base_secs`).
|
||||
/// Doubles per consecutive failure up to `backoff_max_secs`. Defaults to 0
|
||||
/// (no post-failure suppression); the per-attempt sequence in
|
||||
/// `attempt_timeouts_secs` provides the only retry pacing.
|
||||
#[serde(default = "LookupConfig::default_backoff_base_secs")]
|
||||
#[serde(default = "DiscoveryConfig::default_backoff_base_secs")]
|
||||
pub backoff_base_secs: u64,
|
||||
/// Maximum backoff cap in seconds (`node.lookup.backoff_max_secs`).
|
||||
#[serde(default = "LookupConfig::default_backoff_max_secs")]
|
||||
/// Maximum backoff cap in seconds (`node.discovery.backoff_max_secs`).
|
||||
#[serde(default = "DiscoveryConfig::default_backoff_max_secs")]
|
||||
pub backoff_max_secs: u64,
|
||||
/// Minimum interval between forwarded lookups for the same target in seconds
|
||||
/// (`node.lookup.forward_min_interval_secs`).
|
||||
/// (`node.discovery.forward_min_interval_secs`).
|
||||
/// Defense-in-depth against misbehaving nodes.
|
||||
#[serde(default = "LookupConfig::default_forward_min_interval_secs")]
|
||||
#[serde(default = "DiscoveryConfig::default_forward_min_interval_secs")]
|
||||
pub forward_min_interval_secs: u64,
|
||||
/// Nostr-mediated overlay endpoint discovery.
|
||||
#[serde(default = "DiscoveryConfig::default_nostr")]
|
||||
pub nostr: NostrDiscoveryConfig,
|
||||
/// mDNS / DNS-SD peer discovery on the local link. Identity surface
|
||||
/// is a strict subset of what `nostr.advertise` already publishes
|
||||
/// publicly, so there's no marginal privacy cost; the latency win
|
||||
/// for same-LAN peers is large (sub-second pairing, no relay).
|
||||
#[serde(default = "DiscoveryConfig::default_lan")]
|
||||
pub lan: crate::discovery::lan::LanDiscoveryConfig,
|
||||
}
|
||||
|
||||
impl Default for LookupConfig {
|
||||
impl Default for DiscoveryConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
ttl: 64,
|
||||
@@ -230,11 +236,13 @@ impl Default for LookupConfig {
|
||||
backoff_base_secs: 0,
|
||||
backoff_max_secs: 0,
|
||||
forward_min_interval_secs: 2,
|
||||
nostr: NostrDiscoveryConfig::default(),
|
||||
lan: crate::discovery::lan::LanDiscoveryConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LookupConfig {
|
||||
impl DiscoveryConfig {
|
||||
fn default_ttl() -> u8 {
|
||||
64
|
||||
}
|
||||
@@ -253,45 +261,12 @@ impl LookupConfig {
|
||||
fn default_forward_min_interval_secs() -> u64 {
|
||||
2
|
||||
}
|
||||
}
|
||||
|
||||
/// Peer rendezvous (`node.rendezvous.*`): how the node finds peers to connect
|
||||
/// to at all — Nostr-mediated overlay endpoints and mDNS/DNS-SD on the local
|
||||
/// link. Distinct from mesh lookup ([`LookupConfig`]), which finds coordinates
|
||||
/// for an already-known mesh address.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct RendezvousConfig {
|
||||
/// Nostr-mediated overlay endpoint rendezvous (`node.rendezvous.nostr.*`).
|
||||
#[serde(default)]
|
||||
pub nostr: NostrRendezvousConfig,
|
||||
/// mDNS / DNS-SD peer rendezvous on the local link (`node.rendezvous.lan.*`).
|
||||
/// Identity surface is a strict subset of what `nostr.advertise` already
|
||||
/// publishes publicly, so there's no marginal privacy cost; the latency
|
||||
/// win for same-LAN peers is large (sub-second pairing, no relay).
|
||||
#[serde(default)]
|
||||
pub lan: crate::mdns::LanRendezvousConfig,
|
||||
}
|
||||
|
||||
/// COMPAT (drop at the v2 cutover): a deprecated legacy `node.discovery:` block.
|
||||
///
|
||||
/// The `node.discovery.*` table was split into `node.lookup.*` (mesh-lookup
|
||||
/// scalars) and `node.rendezvous.*` (nostr/LAN peer rendezvous). Because
|
||||
/// `NodeConfig` does not deny unknown fields, a still-deployed `node.discovery:`
|
||||
/// block would otherwise deserialize into nothing and silently revert every
|
||||
/// lookup/rendezvous setting to its default. This all-`Option` mirror captures
|
||||
/// it so [`Config::normalize_deprecated_keys`] can fold it into the new tables
|
||||
/// with a one-time deprecation warning; unset legacy keys stay `None` and leave
|
||||
/// the new-table defaults intact.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct DiscoveryConfigCompat {
|
||||
pub ttl: Option<u8>,
|
||||
pub attempt_timeouts_secs: Option<Vec<u64>>,
|
||||
pub recent_expiry_secs: Option<u64>,
|
||||
pub backoff_base_secs: Option<u64>,
|
||||
pub backoff_max_secs: Option<u64>,
|
||||
pub forward_min_interval_secs: Option<u64>,
|
||||
pub nostr: Option<NostrRendezvousConfig>,
|
||||
pub lan: Option<crate::mdns::LanRendezvousConfig>,
|
||||
fn default_nostr() -> NostrDiscoveryConfig {
|
||||
NostrDiscoveryConfig::default()
|
||||
}
|
||||
fn default_lan() -> crate::discovery::lan::LanDiscoveryConfig {
|
||||
crate::discovery::lan::LanDiscoveryConfig::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Nostr advert discovery policy.
|
||||
@@ -303,33 +278,33 @@ pub(crate) struct DiscoveryConfigCompat {
|
||||
/// - `open`: also consider adverts for non-configured peers
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum NostrRendezvousPolicy {
|
||||
pub enum NostrDiscoveryPolicy {
|
||||
Disabled,
|
||||
#[default]
|
||||
ConfiguredOnly,
|
||||
Open,
|
||||
}
|
||||
|
||||
/// Nostr-mediated overlay endpoint discovery (`node.rendezvous.nostr.*`).
|
||||
/// Nostr-mediated overlay endpoint discovery (`node.discovery.nostr.*`).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct NostrRendezvousConfig {
|
||||
pub struct NostrDiscoveryConfig {
|
||||
/// Enable Nostr-signaled traversal bootstrap.
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
/// Publish service advertisements so remote peers can bootstrap inbound.
|
||||
#[serde(default = "NostrRendezvousConfig::default_advertise")]
|
||||
#[serde(default = "NostrDiscoveryConfig::default_advertise")]
|
||||
pub advertise: bool,
|
||||
/// Relay URLs used for service advertisements.
|
||||
#[serde(default = "NostrRendezvousConfig::default_advert_relays")]
|
||||
#[serde(default = "NostrDiscoveryConfig::default_advert_relays")]
|
||||
pub advert_relays: Vec<String>,
|
||||
/// Relay URLs used for encrypted signaling events.
|
||||
#[serde(default = "NostrRendezvousConfig::default_dm_relays")]
|
||||
#[serde(default = "NostrDiscoveryConfig::default_dm_relays")]
|
||||
pub dm_relays: Vec<String>,
|
||||
/// STUN servers used for local reflexive address discovery.
|
||||
/// Outbound observation uses only this local list; peer-advertised STUN
|
||||
/// values are informational and are not treated as egress targets.
|
||||
#[serde(default = "NostrRendezvousConfig::default_stun_servers")]
|
||||
#[serde(default = "NostrDiscoveryConfig::default_stun_servers")]
|
||||
pub stun_servers: Vec<String>,
|
||||
/// Whether to advertise local (RFC 1918 / ULA) interface addresses as
|
||||
/// host candidates in the traversal offer.
|
||||
@@ -343,85 +318,85 @@ pub struct NostrRendezvousConfig {
|
||||
#[serde(default)]
|
||||
pub share_local_candidates: bool,
|
||||
/// Traversal application namespace and advert identifier suffix.
|
||||
#[serde(default = "NostrRendezvousConfig::default_app")]
|
||||
#[serde(default = "NostrDiscoveryConfig::default_app")]
|
||||
pub app: String,
|
||||
/// Signaling TTL in seconds.
|
||||
#[serde(default = "NostrRendezvousConfig::default_signal_ttl_secs")]
|
||||
#[serde(default = "NostrDiscoveryConfig::default_signal_ttl_secs")]
|
||||
pub signal_ttl_secs: u64,
|
||||
/// Policy for advert-derived endpoint discovery.
|
||||
#[serde(default)]
|
||||
pub policy: NostrRendezvousPolicy,
|
||||
pub policy: NostrDiscoveryPolicy,
|
||||
/// Max number of open-discovery peers queued for outbound retry/connection
|
||||
/// at once. Prevents unbounded queue growth from ambient advert traffic.
|
||||
#[serde(default = "NostrRendezvousConfig::default_open_discovery_max_pending")]
|
||||
#[serde(default = "NostrDiscoveryConfig::default_open_discovery_max_pending")]
|
||||
pub open_discovery_max_pending: usize,
|
||||
/// Max concurrent inbound traversal offers processed at once.
|
||||
/// Acts as a rate limit against offer spam from relays.
|
||||
#[serde(default = "NostrRendezvousConfig::default_max_concurrent_incoming_offers")]
|
||||
#[serde(default = "NostrDiscoveryConfig::default_max_concurrent_incoming_offers")]
|
||||
pub max_concurrent_incoming_offers: usize,
|
||||
/// Max cached overlay adverts retained from relay traffic.
|
||||
/// Bounds memory under ambient advert volume.
|
||||
#[serde(default = "NostrRendezvousConfig::default_advert_cache_max_entries")]
|
||||
#[serde(default = "NostrDiscoveryConfig::default_advert_cache_max_entries")]
|
||||
pub advert_cache_max_entries: usize,
|
||||
/// Max seen-session IDs retained for replay detection.
|
||||
/// Oldest entries are evicted when the cap is exceeded.
|
||||
#[serde(default = "NostrRendezvousConfig::default_seen_sessions_max_entries")]
|
||||
#[serde(default = "NostrDiscoveryConfig::default_seen_sessions_max_entries")]
|
||||
pub seen_sessions_max_entries: usize,
|
||||
/// Overall punch attempt timeout in seconds.
|
||||
#[serde(default = "NostrRendezvousConfig::default_attempt_timeout_secs")]
|
||||
#[serde(default = "NostrDiscoveryConfig::default_attempt_timeout_secs")]
|
||||
pub attempt_timeout_secs: u64,
|
||||
/// Replay tracking retention window in seconds.
|
||||
#[serde(default = "NostrRendezvousConfig::default_replay_window_secs")]
|
||||
#[serde(default = "NostrDiscoveryConfig::default_replay_window_secs")]
|
||||
pub replay_window_secs: u64,
|
||||
/// Delay before punch traffic starts.
|
||||
#[serde(default = "NostrRendezvousConfig::default_punch_start_delay_ms")]
|
||||
#[serde(default = "NostrDiscoveryConfig::default_punch_start_delay_ms")]
|
||||
pub punch_start_delay_ms: u64,
|
||||
/// Interval between punch packets.
|
||||
#[serde(default = "NostrRendezvousConfig::default_punch_interval_ms")]
|
||||
#[serde(default = "NostrDiscoveryConfig::default_punch_interval_ms")]
|
||||
pub punch_interval_ms: u64,
|
||||
/// How long to keep punching before failure.
|
||||
#[serde(default = "NostrRendezvousConfig::default_punch_duration_ms")]
|
||||
#[serde(default = "NostrDiscoveryConfig::default_punch_duration_ms")]
|
||||
pub punch_duration_ms: u64,
|
||||
/// Advert TTL in seconds.
|
||||
#[serde(default = "NostrRendezvousConfig::default_advert_ttl_secs")]
|
||||
#[serde(default = "NostrDiscoveryConfig::default_advert_ttl_secs")]
|
||||
pub advert_ttl_secs: u64,
|
||||
/// How often adverts are refreshed in seconds.
|
||||
#[serde(default = "NostrRendezvousConfig::default_advert_refresh_secs")]
|
||||
#[serde(default = "NostrDiscoveryConfig::default_advert_refresh_secs")]
|
||||
pub advert_refresh_secs: u64,
|
||||
/// Settle delay in seconds after Nostr discovery starts before the
|
||||
/// one-shot startup sweep of cached adverts runs. Allows the relay
|
||||
/// subscription backlog to populate the in-memory advert cache.
|
||||
/// Only used under `policy: open`. Default: 5.
|
||||
#[serde(default = "NostrRendezvousConfig::default_startup_sweep_delay_secs")]
|
||||
#[serde(default = "NostrDiscoveryConfig::default_startup_sweep_delay_secs")]
|
||||
pub startup_sweep_delay_secs: u64,
|
||||
/// Maximum age in seconds for cached adverts considered by the
|
||||
/// one-shot startup sweep. Adverts whose `created_at` is older than
|
||||
/// `now - startup_sweep_max_age_secs` are skipped. Only used under
|
||||
/// `policy: open`. Default: 3600 (1 hour).
|
||||
#[serde(default = "NostrRendezvousConfig::default_startup_sweep_max_age_secs")]
|
||||
#[serde(default = "NostrDiscoveryConfig::default_startup_sweep_max_age_secs")]
|
||||
pub startup_sweep_max_age_secs: u64,
|
||||
/// Number of consecutive NAT-traversal failures against a peer before
|
||||
/// an extended cooldown is applied to throttle further offer publishes.
|
||||
/// At this threshold the daemon also actively re-fetches the peer's
|
||||
/// advert from `advert_relays` to evict cache entries for peers that
|
||||
/// have gone away. Default: 5.
|
||||
#[serde(default = "NostrRendezvousConfig::default_failure_streak_threshold")]
|
||||
#[serde(default = "NostrDiscoveryConfig::default_failure_streak_threshold")]
|
||||
pub failure_streak_threshold: u32,
|
||||
/// Cooldown applied to a peer once `failure_streak_threshold` is hit.
|
||||
/// Suppresses both open-discovery sweep enqueues and per-attempt
|
||||
/// retry firings until elapsed. Default: 1800 (30 minutes).
|
||||
#[serde(default = "NostrRendezvousConfig::default_extended_cooldown_secs")]
|
||||
#[serde(default = "NostrDiscoveryConfig::default_extended_cooldown_secs")]
|
||||
pub extended_cooldown_secs: u64,
|
||||
/// Minimum interval between `NAT traversal failed` WARN log lines for
|
||||
/// the same peer. Subsequent failures inside the window log at DEBUG.
|
||||
/// Reduces log spam on public-test nodes with many cache-learned
|
||||
/// peers. Default: 300 (5 minutes).
|
||||
#[serde(default = "NostrRendezvousConfig::default_warn_log_interval_secs")]
|
||||
#[serde(default = "NostrDiscoveryConfig::default_warn_log_interval_secs")]
|
||||
pub warn_log_interval_secs: u64,
|
||||
/// Maximum entries retained in the per-npub failure-state map.
|
||||
/// Bounds memory under high cache turnover. Oldest entries (by last
|
||||
/// failure time) evicted when the cap is exceeded. Default: 4096.
|
||||
#[serde(default = "NostrRendezvousConfig::default_failure_state_max_entries")]
|
||||
#[serde(default = "NostrDiscoveryConfig::default_failure_state_max_entries")]
|
||||
pub failure_state_max_entries: usize,
|
||||
/// Cooldown applied after observing a fatal protocol mismatch on a
|
||||
/// Nostr-adopted bootstrap transport (e.g. `Unknown FMP version`
|
||||
@@ -429,11 +404,11 @@ pub struct NostrRendezvousConfig {
|
||||
/// of `extended_cooldown_secs` and much longer because the mismatch
|
||||
/// is structural — re-traversing the peer is wasted effort until one
|
||||
/// side upgrades. Default: 86400 (24 hours).
|
||||
#[serde(default = "NostrRendezvousConfig::default_protocol_mismatch_cooldown_secs")]
|
||||
#[serde(default = "NostrDiscoveryConfig::default_protocol_mismatch_cooldown_secs")]
|
||||
pub protocol_mismatch_cooldown_secs: u64,
|
||||
}
|
||||
|
||||
impl Default for NostrRendezvousConfig {
|
||||
impl Default for NostrDiscoveryConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
@@ -444,7 +419,7 @@ impl Default for NostrRendezvousConfig {
|
||||
share_local_candidates: false,
|
||||
app: Self::default_app(),
|
||||
signal_ttl_secs: Self::default_signal_ttl_secs(),
|
||||
policy: NostrRendezvousPolicy::default(),
|
||||
policy: NostrDiscoveryPolicy::default(),
|
||||
open_discovery_max_pending: Self::default_open_discovery_max_pending(),
|
||||
max_concurrent_incoming_offers: Self::default_max_concurrent_incoming_offers(),
|
||||
advert_cache_max_entries: Self::default_advert_cache_max_entries(),
|
||||
@@ -467,7 +442,7 @@ impl Default for NostrRendezvousConfig {
|
||||
}
|
||||
}
|
||||
|
||||
impl NostrRendezvousConfig {
|
||||
impl NostrDiscoveryConfig {
|
||||
fn default_advertise() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -497,11 +472,7 @@ impl NostrRendezvousConfig {
|
||||
}
|
||||
|
||||
fn default_app() -> String {
|
||||
// Branch-specific default. `next` runs FMP-v1 which is wire-
|
||||
// incompatible with `master`'s FMP-v0, so the two namespaces
|
||||
// separate the discovery overlays by default — operators who
|
||||
// want cross-branch discovery can override here.
|
||||
"fips-overlay-v1-next".to_string()
|
||||
"fips-overlay-v1".to_string()
|
||||
}
|
||||
|
||||
fn default_signal_ttl_secs() -> u64 {
|
||||
@@ -664,8 +635,8 @@ pub struct BloomConfig {
|
||||
pub update_debounce_ms: u64,
|
||||
/// Antipoison cap: reject inbound FilterAnnounce whose FPR exceeds
|
||||
/// this value (`node.bloom.max_inbound_fpr`). Valid range `(0.0, 1.0)`.
|
||||
/// Default `0.20` ≈ fill 0.7248 at k=5 ≈ ~2,114 entries on the 1 KB
|
||||
/// filter (Swamidass–Baldi). Raised from 0.10 so aggregates that are
|
||||
/// Default `0.10` ≈ fill 0.631 at k=5 ≈ ~1,630 entries on the 1 KB
|
||||
/// filter (Swamidass–Baldi). Raised from 0.05 so aggregates that are
|
||||
/// legitimately near their operating ceiling are not rejected before
|
||||
/// the network reaches the fixed-filter capacity limit; conceptually
|
||||
/// distinct from future autoscaling hysteresis setpoints — same unit,
|
||||
@@ -677,8 +648,8 @@ pub struct BloomConfig {
|
||||
impl Default for BloomConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
update_debounce_ms: Self::default_update_debounce_ms(),
|
||||
max_inbound_fpr: Self::default_max_inbound_fpr(),
|
||||
update_debounce_ms: 500,
|
||||
max_inbound_fpr: 0.10,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -688,7 +659,7 @@ impl BloomConfig {
|
||||
500
|
||||
}
|
||||
fn default_max_inbound_fpr() -> f64 {
|
||||
0.20
|
||||
0.10
|
||||
}
|
||||
}
|
||||
|
||||
@@ -755,41 +726,6 @@ impl SessionConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// MMP configuration (`node.mmp.*`).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MmpConfig {
|
||||
/// Operating mode (`node.mmp.mode`).
|
||||
#[serde(default)]
|
||||
pub mode: MmpMode,
|
||||
|
||||
/// Periodic operator log interval in seconds (`node.mmp.log_interval_secs`).
|
||||
#[serde(default = "MmpConfig::default_log_interval_secs")]
|
||||
pub log_interval_secs: u64,
|
||||
|
||||
/// OWD trend ring buffer size (`node.mmp.owd_window_size`).
|
||||
#[serde(default = "MmpConfig::default_owd_window_size")]
|
||||
pub owd_window_size: usize,
|
||||
}
|
||||
|
||||
impl Default for MmpConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: MmpMode::default(),
|
||||
log_interval_secs: DEFAULT_LOG_INTERVAL_SECS,
|
||||
owd_window_size: DEFAULT_OWD_WINDOW_SIZE,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MmpConfig {
|
||||
fn default_log_interval_secs() -> u64 {
|
||||
DEFAULT_LOG_INTERVAL_SECS
|
||||
}
|
||||
fn default_owd_window_size() -> usize {
|
||||
DEFAULT_OWD_WINDOW_SIZE
|
||||
}
|
||||
}
|
||||
|
||||
/// Session-layer Metrics Measurement Protocol (`node.session_mmp.*`).
|
||||
///
|
||||
/// Separate from link-layer `node.mmp.*` to allow independent mode/interval
|
||||
@@ -1013,18 +949,7 @@ pub struct NodeConfig {
|
||||
#[serde(default)]
|
||||
pub identity: IdentityConfig,
|
||||
|
||||
/// Non-routing mode (`node.disable_routing`).
|
||||
///
|
||||
/// Tree participation and one-way bloom receipt, but no transit
|
||||
/// forwarding or bloom combination/propagation. Overridden by
|
||||
/// `leaf_only` (leaf implies non-routing).
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub disable_routing: bool,
|
||||
|
||||
/// Leaf-only mode (`node.leaf_only`).
|
||||
///
|
||||
/// Single upstream peer, no tree/bloom/transit. Implies
|
||||
/// `disable_routing`.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub leaf_only: bool,
|
||||
|
||||
@@ -1045,19 +970,6 @@ pub struct NodeConfig {
|
||||
#[serde(default = "NodeConfig::default_link_dead_timeout_secs")]
|
||||
pub link_dead_timeout_secs: u64,
|
||||
|
||||
/// Graceful-shutdown drain deadline in seconds (`node.drain_timeout_secs`).
|
||||
/// The bounded `Draining` phase broadcasts a shutdown `Disconnect` and then
|
||||
/// waits up to this long for peers to clear before tearing down, early-
|
||||
/// exiting as soon as all peers are gone. `None` selects the 2-second
|
||||
/// default (see [`NodeConfig::drain_timeout`]).
|
||||
///
|
||||
/// Kept `Option` deliberately: `NodeConfig` has no `deny_unknown_fields`, so
|
||||
/// a naive non-`Option` add with a `default` fn would silently rewrite the
|
||||
/// value into deployed configs on the next serialize. The `Option` +
|
||||
/// `skip_serializing_if` keeps absent configs absent.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub drain_timeout_secs: Option<u64>,
|
||||
|
||||
/// Resource limits (`node.limits.*`).
|
||||
#[serde(default)]
|
||||
pub limits: LimitsConfig,
|
||||
@@ -1074,19 +986,9 @@ pub struct NodeConfig {
|
||||
#[serde(default)]
|
||||
pub cache: CacheConfig,
|
||||
|
||||
/// Mesh-lookup protocol (`node.lookup.*`).
|
||||
/// Discovery protocol (`node.discovery.*`).
|
||||
#[serde(default)]
|
||||
pub lookup: LookupConfig,
|
||||
|
||||
/// Peer rendezvous (`node.rendezvous.*`).
|
||||
#[serde(default)]
|
||||
pub rendezvous: RendezvousConfig,
|
||||
|
||||
/// COMPAT (drop at the v2 cutover): a deprecated legacy `node.discovery:`
|
||||
/// block, folded into `lookup`/`rendezvous` by
|
||||
/// [`Config::normalize_deprecated_keys`]. Never re-serialized.
|
||||
#[serde(default, skip_serializing)]
|
||||
pub(crate) discovery: Option<DiscoveryConfigCompat>,
|
||||
pub discovery: DiscoveryConfig,
|
||||
|
||||
/// Spanning tree (`node.tree.*`).
|
||||
#[serde(default)]
|
||||
@@ -1134,20 +1036,16 @@ impl Default for NodeConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
identity: IdentityConfig::default(),
|
||||
disable_routing: false,
|
||||
leaf_only: false,
|
||||
tick_interval_secs: 1,
|
||||
base_rtt_ms: 100,
|
||||
heartbeat_interval_secs: 10,
|
||||
link_dead_timeout_secs: 30,
|
||||
drain_timeout_secs: None,
|
||||
limits: LimitsConfig::default(),
|
||||
rate_limit: RateLimitConfig::default(),
|
||||
retry: RetryConfig::default(),
|
||||
cache: CacheConfig::default(),
|
||||
lookup: LookupConfig::default(),
|
||||
rendezvous: RendezvousConfig::default(),
|
||||
discovery: None,
|
||||
discovery: DiscoveryConfig::default(),
|
||||
tree: TreeConfig::default(),
|
||||
bloom: BloomConfig::default(),
|
||||
session: SessionConfig::default(),
|
||||
@@ -1191,72 +1089,12 @@ impl NodeConfig {
|
||||
fn default_link_dead_timeout_secs() -> u64 {
|
||||
30
|
||||
}
|
||||
|
||||
/// Graceful-shutdown drain deadline as a `Duration`.
|
||||
///
|
||||
/// Returns the configured `drain_timeout_secs`, or the 2-second default
|
||||
/// when unset. Used by the daemon's bounded `Draining` phase.
|
||||
pub fn drain_timeout(&self) -> std::time::Duration {
|
||||
std::time::Duration::from_secs(self.drain_timeout_secs.unwrap_or(2))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_config_default() {
|
||||
let config = MmpConfig::default();
|
||||
assert_eq!(config.mode, MmpMode::Full);
|
||||
assert_eq!(config.log_interval_secs, 30);
|
||||
assert_eq!(config.owd_window_size, 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_yaml_parse() {
|
||||
let yaml = r#"
|
||||
mode: lightweight
|
||||
log_interval_secs: 60
|
||||
owd_window_size: 48
|
||||
"#;
|
||||
let config: MmpConfig = serde_yaml::from_str(yaml).unwrap();
|
||||
assert_eq!(config.mode, MmpMode::Lightweight);
|
||||
assert_eq!(config.log_interval_secs, 60);
|
||||
assert_eq!(config.owd_window_size, 48);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_yaml_partial() {
|
||||
let yaml = "mode: minimal";
|
||||
let config: MmpConfig = serde_yaml::from_str(yaml).unwrap();
|
||||
assert_eq!(config.mode, MmpMode::Minimal);
|
||||
assert_eq!(config.log_interval_secs, DEFAULT_LOG_INTERVAL_SECS);
|
||||
assert_eq!(config.owd_window_size, DEFAULT_OWD_WINDOW_SIZE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_drain_timeout_default_and_override() {
|
||||
// Unset → the 2-second default.
|
||||
let c = NodeConfig::default();
|
||||
assert_eq!(c.drain_timeout_secs, None);
|
||||
assert_eq!(c.drain_timeout(), std::time::Duration::from_secs(2));
|
||||
|
||||
// Explicit override is honored.
|
||||
let c2 = NodeConfig {
|
||||
drain_timeout_secs: Some(10),
|
||||
..NodeConfig::default()
|
||||
};
|
||||
assert_eq!(c2.drain_timeout(), std::time::Duration::from_secs(10));
|
||||
|
||||
// A zero override is a valid (immediate) drain, not the default.
|
||||
let c3 = NodeConfig {
|
||||
drain_timeout_secs: Some(0),
|
||||
..NodeConfig::default()
|
||||
};
|
||||
assert_eq!(c3.drain_timeout(), std::time::Duration::from_secs(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ecn_config_defaults() {
|
||||
let c = EcnConfig::default();
|
||||
@@ -1285,27 +1123,27 @@ owd_window_size: 48
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nostr_rendezvous_startup_sweep_defaults() {
|
||||
let c = NostrRendezvousConfig::default();
|
||||
fn test_nostr_discovery_startup_sweep_defaults() {
|
||||
let c = NostrDiscoveryConfig::default();
|
||||
assert_eq!(c.startup_sweep_delay_secs, 5);
|
||||
assert_eq!(c.startup_sweep_max_age_secs, 3_600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nostr_rendezvous_startup_sweep_yaml_override() {
|
||||
fn test_nostr_discovery_startup_sweep_yaml_override() {
|
||||
let yaml = "enabled: true\npolicy: open\nstartup_sweep_delay_secs: 10\nstartup_sweep_max_age_secs: 1800\n";
|
||||
let c: NostrRendezvousConfig = serde_yaml::from_str(yaml).unwrap();
|
||||
let c: NostrDiscoveryConfig = serde_yaml::from_str(yaml).unwrap();
|
||||
assert!(c.enabled);
|
||||
assert_eq!(c.policy, NostrRendezvousPolicy::Open);
|
||||
assert_eq!(c.policy, NostrDiscoveryPolicy::Open);
|
||||
assert_eq!(c.startup_sweep_delay_secs, 10);
|
||||
assert_eq!(c.startup_sweep_max_age_secs, 1_800);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nostr_rendezvous_startup_sweep_partial_yaml_uses_defaults() {
|
||||
fn test_nostr_discovery_startup_sweep_partial_yaml_uses_defaults() {
|
||||
// Only override delay; max_age should fall back to default.
|
||||
let yaml = "enabled: true\nstartup_sweep_delay_secs: 30\n";
|
||||
let c: NostrRendezvousConfig = serde_yaml::from_str(yaml).unwrap();
|
||||
let c: NostrDiscoveryConfig = serde_yaml::from_str(yaml).unwrap();
|
||||
assert_eq!(c.startup_sweep_delay_secs, 30);
|
||||
assert_eq!(c.startup_sweep_max_age_secs, 3_600);
|
||||
}
|
||||
|
||||
@@ -282,10 +282,9 @@ pub struct EthernetConfig {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub send_buf_size: Option<usize>,
|
||||
|
||||
/// Listen for neighbor beacons from other nodes. Default: true.
|
||||
/// (Renamed from `discovery`; the old key is still accepted.)
|
||||
#[serde(default, alias = "discovery", skip_serializing_if = "Option::is_none")]
|
||||
pub listen: Option<bool>,
|
||||
/// Listen for discovery beacons from other nodes. Default: true.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub discovery: Option<bool>,
|
||||
|
||||
/// Broadcast announcement beacons on the LAN. Default: false.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -320,9 +319,9 @@ impl EthernetConfig {
|
||||
self.send_buf_size.unwrap_or(DEFAULT_ETHERNET_SEND_BUF)
|
||||
}
|
||||
|
||||
/// Whether to listen for neighbor beacons. Default: true.
|
||||
pub fn listen(&self) -> bool {
|
||||
self.listen.unwrap_or(true)
|
||||
/// Whether to listen for discovery beacons. Default: true.
|
||||
pub fn discovery(&self) -> bool {
|
||||
self.discovery.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// Whether to broadcast announcement beacons. Default: false.
|
||||
@@ -1047,22 +1046,4 @@ mod tests {
|
||||
assert_eq!(parse_bind_port("[::]:443"), Some(443));
|
||||
assert_eq!(parse_bind_port("not-a-socket-addr"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ethernet_listen_accepts_legacy_discovery_alias_and_rejects_unknown() {
|
||||
// (a) The legacy `discovery:` key is still accepted via serde alias.
|
||||
let legacy: EthernetConfig =
|
||||
serde_yaml::from_str("interface: eth0\ndiscovery: true\n").unwrap();
|
||||
assert_eq!(legacy.listen, Some(true));
|
||||
|
||||
// (b) The new canonical `listen:` key parses into the renamed field.
|
||||
let renamed: EthernetConfig =
|
||||
serde_yaml::from_str("interface: eth0\nlisten: true\n").unwrap();
|
||||
assert_eq!(renamed.listen, Some(true));
|
||||
|
||||
// (c) `deny_unknown_fields` still rejects an unknown ethernet key.
|
||||
let bogus: Result<EthernetConfig, _> =
|
||||
serde_yaml::from_str("interface: eth0\nbogus: true\n");
|
||||
assert!(bogus.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,7 +236,7 @@ pub fn show_peers(node: &Node) -> Value {
|
||||
// Per-npub Nostr-traversal failure-state snapshot, indexed by npub
|
||||
// for O(1) per-peer lookup. Empty if Nostr discovery is disabled.
|
||||
let nostr_state: std::collections::HashMap<String, _> = node
|
||||
.nostr_rendezvous_handle()
|
||||
.nostr_discovery_handle()
|
||||
.map(|d| {
|
||||
d.failure_state_snapshot()
|
||||
.into_iter()
|
||||
@@ -988,70 +988,56 @@ pub(crate) fn show_bloom_from_handle(handle: &super::read_handle::ControlReadHan
|
||||
/// `show_mmp` — MMP metrics summary.
|
||||
pub fn show_mmp(node: &Node) -> Value {
|
||||
// Link-layer MMP per peer
|
||||
let peers: Vec<Value> = node
|
||||
.peers()
|
||||
.filter_map(|peer| {
|
||||
let mmp = peer.mmp()?;
|
||||
let addr = *peer.node_addr();
|
||||
let metrics = &mmp.metrics;
|
||||
let peers: Vec<Value> = node.peers().filter_map(|peer| {
|
||||
let mmp = peer.mmp()?;
|
||||
let addr = *peer.node_addr();
|
||||
let metrics = &mmp.metrics;
|
||||
|
||||
let mut link_layer = json!({
|
||||
"loss_rate": metrics.loss_rate(),
|
||||
"etx": metrics.etx,
|
||||
"goodput_bps": metrics.goodput_bps,
|
||||
});
|
||||
let mut link_layer = json!({
|
||||
"loss_rate": metrics.loss_rate(),
|
||||
"etx": metrics.etx,
|
||||
"goodput_bps": metrics.goodput_bps,
|
||||
"spin_bit_role": if mmp.spin_bit.is_initiator() { "initiator" } else { "responder" },
|
||||
});
|
||||
|
||||
if let Some(smoothed_loss) = metrics.smoothed_loss() {
|
||||
link_layer["smoothed_loss"] = json!(smoothed_loss);
|
||||
}
|
||||
if let Some(smoothed_etx) = metrics.smoothed_etx() {
|
||||
link_layer["smoothed_etx"] = json!(smoothed_etx);
|
||||
}
|
||||
if let Some(srtt) = metrics.srtt_ms() {
|
||||
link_layer["srtt_ms"] = json!(srtt);
|
||||
if let Some(setx) = metrics.smoothed_etx() {
|
||||
link_layer["lqi"] = json!(setx * (1.0 + srtt / 100.0));
|
||||
}
|
||||
if let Some(smoothed_loss) = metrics.smoothed_loss() {
|
||||
link_layer["smoothed_loss"] = json!(smoothed_loss);
|
||||
}
|
||||
if let Some(smoothed_etx) = metrics.smoothed_etx() {
|
||||
link_layer["smoothed_etx"] = json!(smoothed_etx);
|
||||
}
|
||||
if let Some(srtt) = metrics.srtt_ms() {
|
||||
link_layer["srtt_ms"] = json!(srtt);
|
||||
if let Some(setx) = metrics.smoothed_etx() {
|
||||
link_layer["lqi"] = json!(setx * (1.0 + srtt / 100.0));
|
||||
}
|
||||
}
|
||||
|
||||
// Trend indicators
|
||||
if metrics.rtt_trend.initialized() {
|
||||
link_layer["rtt_trend"] = json!(trend_label(
|
||||
metrics.rtt_trend.short(),
|
||||
metrics.rtt_trend.long()
|
||||
));
|
||||
}
|
||||
if metrics.loss_trend.initialized() {
|
||||
link_layer["loss_trend"] = json!(trend_label(
|
||||
metrics.loss_trend.short(),
|
||||
metrics.loss_trend.long()
|
||||
));
|
||||
}
|
||||
if metrics.goodput_trend.initialized() {
|
||||
link_layer["goodput_trend"] = json!(trend_label(
|
||||
metrics.goodput_trend.short(),
|
||||
metrics.goodput_trend.long()
|
||||
));
|
||||
}
|
||||
if metrics.jitter_trend.initialized() {
|
||||
link_layer["jitter_trend"] = json!(trend_label(
|
||||
metrics.jitter_trend.short(),
|
||||
metrics.jitter_trend.long()
|
||||
));
|
||||
}
|
||||
// Trend indicators
|
||||
if metrics.rtt_trend.initialized() {
|
||||
link_layer["rtt_trend"] = json!(trend_label(metrics.rtt_trend.short(), metrics.rtt_trend.long()));
|
||||
}
|
||||
if metrics.loss_trend.initialized() {
|
||||
link_layer["loss_trend"] = json!(trend_label(metrics.loss_trend.short(), metrics.loss_trend.long()));
|
||||
}
|
||||
if metrics.goodput_trend.initialized() {
|
||||
link_layer["goodput_trend"] = json!(trend_label(metrics.goodput_trend.short(), metrics.goodput_trend.long()));
|
||||
}
|
||||
if metrics.jitter_trend.initialized() {
|
||||
link_layer["jitter_trend"] = json!(trend_label(metrics.jitter_trend.short(), metrics.jitter_trend.long()));
|
||||
}
|
||||
|
||||
link_layer["delivery_ratio_forward"] = json!(metrics.delivery_ratio_forward);
|
||||
link_layer["delivery_ratio_reverse"] = json!(metrics.delivery_ratio_reverse);
|
||||
link_layer["ecn_ce_count"] = json!(metrics.last_ecn_ce_count());
|
||||
link_layer["delivery_ratio_forward"] = json!(metrics.delivery_ratio_forward);
|
||||
link_layer["delivery_ratio_reverse"] = json!(metrics.delivery_ratio_reverse);
|
||||
link_layer["ecn_ce_count"] = json!(metrics.last_ecn_ce_count());
|
||||
|
||||
Some(json!({
|
||||
"peer": hex::encode(addr.as_bytes()),
|
||||
"display_name": node.peer_display_name(&addr),
|
||||
"mode": format!("{}", mmp.mode()),
|
||||
"link_layer": link_layer,
|
||||
}))
|
||||
})
|
||||
.collect();
|
||||
Some(json!({
|
||||
"peer": hex::encode(addr.as_bytes()),
|
||||
"display_name": node.peer_display_name(&addr),
|
||||
"mode": format!("{}", mmp.mode()),
|
||||
"link_layer": link_layer,
|
||||
}))
|
||||
}).collect();
|
||||
|
||||
// Session-layer MMP
|
||||
let sessions: Vec<Value> = node
|
||||
@@ -1130,6 +1116,7 @@ pub(crate) fn show_mmp_from_handle(handle: &super::read_handle::ControlReadHandl
|
||||
"loss_rate": peer.loss_rate,
|
||||
"etx": peer.etx,
|
||||
"goodput_bps": peer.goodput_bps,
|
||||
"spin_bit_role": if peer.spin_bit_initiator { "initiator" } else { "responder" },
|
||||
});
|
||||
|
||||
if let Some(smoothed_loss) = peer.smoothed_loss {
|
||||
@@ -1316,18 +1303,17 @@ pub fn show_connections(node: &Node) -> Value {
|
||||
let now = now_ms();
|
||||
let connections: Vec<Value> = node
|
||||
.connections()
|
||||
.map(|(_, machine)| {
|
||||
let link_id = machine.link_id();
|
||||
.map(|conn| {
|
||||
let mut conn_json = json!({
|
||||
"link_id": link_id.as_u64(),
|
||||
"direction": format!("{}", machine.conn_direction()),
|
||||
"handshake_state": node.connection_handshake_state(link_id),
|
||||
"started_at_ms": node.connection_started_at(link_id),
|
||||
"idle_ms": now.saturating_sub(node.connection_last_activity(link_id)),
|
||||
"resend_count": node.connection_resend_count(link_id),
|
||||
"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) = node.connection_expected_identity(link_id) {
|
||||
if let Some(identity) = conn.expected_identity() {
|
||||
conn_json["expected_peer"] = json!(identity.npub());
|
||||
}
|
||||
|
||||
@@ -1501,9 +1487,7 @@ pub fn show_routing(node: &Node) -> Value {
|
||||
"recent_requests": node.recent_request_count(),
|
||||
"retries": retries,
|
||||
"forwarding": serde_json::to_value(metrics.forwarding.snapshot()).unwrap_or_default(),
|
||||
// COMPAT: `discovery` is the deprecated alias for `lookup`; drop at the v2 cutover.
|
||||
"discovery": serde_json::to_value(metrics.lookup.snapshot()).unwrap_or_default(),
|
||||
"lookup": serde_json::to_value(metrics.lookup.snapshot()).unwrap_or_default(),
|
||||
"discovery": serde_json::to_value(metrics.discovery.snapshot()).unwrap_or_default(),
|
||||
"error_signals": serde_json::to_value(metrics.errors.snapshot()).unwrap_or_default(),
|
||||
"congestion": serde_json::to_value(metrics.congestion.snapshot()).unwrap_or_default(),
|
||||
})
|
||||
@@ -1559,9 +1543,7 @@ pub(crate) fn show_routing_from_handle(handle: &super::read_handle::ControlReadH
|
||||
"recent_requests": view.recent_requests,
|
||||
"retries": retries,
|
||||
"forwarding": serde_json::to_value(metrics.forwarding.snapshot()).unwrap_or_default(),
|
||||
// COMPAT: `discovery` is the deprecated alias for `lookup`; drop at the v2 cutover.
|
||||
"discovery": serde_json::to_value(metrics.lookup.snapshot()).unwrap_or_default(),
|
||||
"lookup": serde_json::to_value(metrics.lookup.snapshot()).unwrap_or_default(),
|
||||
"discovery": serde_json::to_value(metrics.discovery.snapshot()).unwrap_or_default(),
|
||||
"error_signals": serde_json::to_value(metrics.errors.snapshot()).unwrap_or_default(),
|
||||
"congestion": serde_json::to_value(metrics.congestion.snapshot()).unwrap_or_default(),
|
||||
})
|
||||
@@ -2346,9 +2328,7 @@ pub(crate) fn show_metrics_from_handle(handle: &super::read_handle::ControlReadH
|
||||
let m = handle.metrics();
|
||||
json!({
|
||||
"forwarding": m.forwarding.snapshot(),
|
||||
// COMPAT: `discovery` is the deprecated alias for `lookup`; drop at the v2 cutover.
|
||||
"discovery": m.lookup.snapshot(),
|
||||
"lookup": m.lookup.snapshot(),
|
||||
"discovery": m.discovery.snapshot(),
|
||||
"tree": m.tree.snapshot(),
|
||||
"bloom": m.bloom.snapshot(),
|
||||
"congestion": m.congestion.snapshot(),
|
||||
@@ -2781,12 +2761,12 @@ mod tests {
|
||||
/// Structural confirmation that the rx_loop no longer dispatches `show_*`:
|
||||
/// the rx_loop source carries no `queries::dispatch` call and no
|
||||
/// `starts_with("show_")` routing branch. Reads the committed source of
|
||||
/// `src/node/dataplane/rx_loop.rs` and asserts both markers are absent. This
|
||||
/// `src/node/handlers/rx_loop.rs` and asserts both markers are absent. This
|
||||
/// is the milestone's "remove `show_*` from the data-plane dispatch path"
|
||||
/// invariant, guarded against regression.
|
||||
#[test]
|
||||
fn rx_loop_has_no_show_dispatch() {
|
||||
let src = include_str!("../node/dataplane/rx_loop.rs");
|
||||
let src = include_str!("../node/handlers/rx_loop.rs");
|
||||
assert!(
|
||||
!src.contains("queries::dispatch"),
|
||||
"rx_loop must not call queries::dispatch (show_* served off-loop)"
|
||||
@@ -2814,9 +2794,7 @@ mod tests {
|
||||
|
||||
let expected_families = [
|
||||
("forwarding", "received_packets"),
|
||||
// `discovery` is the deprecated dual-emit alias for `lookup`; drop at the v2 cutover.
|
||||
("discovery", "req_received"),
|
||||
("lookup", "req_received"),
|
||||
("tree", "accepted"),
|
||||
("bloom", "accepted"),
|
||||
("congestion", "ce_forwarded"),
|
||||
|
||||
@@ -118,41 +118,10 @@ impl ControlReadHandle {
|
||||
/// Cutover queries (R1) read only `NodeContext` / `MetricsRegistry` (the state
|
||||
/// the read handle already bundles) plus host-OS facts (`/proc`, nftables), so
|
||||
/// they render entirely in the control task without touching `Node`.
|
||||
///
|
||||
/// **It now also carries mutating commands**, namely the `profile_tick_*`
|
||||
/// family under the `profiling` feature. They are served here rather than on
|
||||
/// the rx_loop deliberately: all of their state is process statics, they need
|
||||
/// no `&mut Node`, and routing them through the loop would make the toggle
|
||||
/// queue behind the very behavior it exists to measure.
|
||||
pub(crate) fn snapshot_dispatch(request: &Request, handle: &ControlReadHandle) -> Option<Response> {
|
||||
use crate::control::queries;
|
||||
|
||||
match request.command.as_str() {
|
||||
// Tick-body profiler toggle. Present only in a `--features profiling`
|
||||
// build; otherwise these fall through to the rx_loop dispatch, which
|
||||
// reports them as unknown commands.
|
||||
#[cfg(feature = "profiling")]
|
||||
"profile_tick_on" => {
|
||||
let dir = request
|
||||
.params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("dir"))
|
||||
.and_then(|v| v.as_str());
|
||||
let context = handle.context();
|
||||
let npub = context.identity.npub();
|
||||
let period = context.config.node.tick_interval_secs;
|
||||
Some(match crate::instr::capture::start(dir, &npub, period) {
|
||||
Ok(value) => Response::ok(value),
|
||||
Err(e) => Response::error(e),
|
||||
})
|
||||
}
|
||||
#[cfg(feature = "profiling")]
|
||||
"profile_tick_off" => Some(match crate::instr::capture::stop() {
|
||||
Ok(value) => Response::ok(value),
|
||||
Err(e) => Response::error(e),
|
||||
}),
|
||||
#[cfg(feature = "profiling")]
|
||||
"profile_tick_status" => Some(Response::ok(crate::instr::capture::status())),
|
||||
"show_listening_sockets" => Some(Response::ok(
|
||||
queries::show_listening_sockets_from_handle(handle),
|
||||
)),
|
||||
|
||||
@@ -692,6 +692,7 @@ pub(crate) struct MmpPeerRow {
|
||||
pub loss_rate: f64,
|
||||
pub etx: f64,
|
||||
pub goodput_bps: f64,
|
||||
pub spin_bit_initiator: bool,
|
||||
pub smoothed_loss: Option<f64>,
|
||||
pub smoothed_etx: Option<f64>,
|
||||
pub srtt_ms: Option<f64>,
|
||||
|
||||
@@ -10,20 +10,13 @@
|
||||
"accepted": 0,
|
||||
"debounce_suppressed": 0,
|
||||
"decode_error": 0,
|
||||
"deltas_sent": 0,
|
||||
"fill_exceeded": 0,
|
||||
"full_sends": 0,
|
||||
"invalid": 0,
|
||||
"nacks_received": 0,
|
||||
"nacks_sent": 0,
|
||||
"non_v1": 0,
|
||||
"received": 0,
|
||||
"send_failed": 0,
|
||||
"sent": 0,
|
||||
"size_changes": 0,
|
||||
"stale": 0,
|
||||
"total_compressed_bytes": 0,
|
||||
"total_raw_bytes": 0,
|
||||
"unknown_peer": 0
|
||||
},
|
||||
"uptree_estimated_count": null,
|
||||
|
||||