Author SHA1 Message Date
Johnathan Corgan 15417f9623 chore(release): v0.4.0-rc.1 (throwaway RC version bump) 2026-06-14 20:19:53 +00:00
82 changed files with 680 additions and 5388 deletions
+1 -1
View File
@@ -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: |
+2 -2
View File
@@ -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 }}
+16 -16
View File
@@ -56,7 +56,7 @@ jobs:
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
@@ -68,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
@@ -77,7 +77,7 @@ jobs:
cache: false
rustflags: ''
- name: Cache Cargo registry + build
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
@@ -102,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'
@@ -130,7 +130,7 @@ jobs:
rustflags: ''
- name: Cache Cargo registry + build
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
@@ -159,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: |
@@ -180,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"
@@ -195,7 +195,7 @@ jobs:
rustflags: ''
- name: Cache Cargo registry + build
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
@@ -236,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"
@@ -248,7 +248,7 @@ jobs:
rustflags: ''
- name: Cache Cargo registry + build
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
@@ -271,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
@@ -280,7 +280,7 @@ jobs:
rustflags: ''
- name: Cache Cargo registry + build
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
@@ -309,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
@@ -478,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
@@ -668,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/
+5 -5
View File
@@ -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
+6 -6
View File
@@ -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
+30 -431
View File
@@ -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
@@ -446,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 }}
@@ -460,7 +403,6 @@ jobs:
- name: Upload to Blossom
id: blossom_upload
continue-on-error: true
shell: bash
env:
BLOSSOM_SERVER: "https://blossom.primal.net"
@@ -468,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
@@ -502,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"
@@ -524,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
@@ -582,351 +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 \
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
+5 -5
View File
@@ -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
+295 -405
View File
@@ -9,16 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
### Changed
### Fixed
## [0.4.0] - 2026-06-27
### Added
#### Transports (Nym, mDNS LAN discovery)
- Nym mixnet transport (`transports.nym`) for outbound peer links
tunneled through a local `nym-socks5-client` SOCKS5 proxy into the
Nym mixnet, as a privacy transport alongside Tor. Outbound-only and
@@ -26,26 +16,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
crate dependencies. A single-container example
(`examples/sidecar-nostr-mixnet-relay/`) demonstrates FIPS peering
across the mixnet end to end.
- Opt-in mDNS / DNS-SD LAN discovery for sub-second pairing of peers on
the same local link, without a relay or NAT-traversal roundtrip.
Disabled by default; operators enable it with
`node.discovery.lan.enabled: true`. Configurable service type and an
optional `node.discovery.lan.scope` that isolates discovery to peers
sharing the same private-network scope. The advertised UDP port is
chosen from a non-bootstrap operational UDP transport using a stable
selector, so it is deterministic across restarts.
#### Admission / peer-list management
- `Node::update_peers` for runtime peer-list refresh, returning an
`UpdatePeersOutcome` summarizing added, removed, and retained peers.
Re-derives active peer connections from a new peer configuration
without dropping links to peers that remain in the set.
`PeerAddress` gains a `seen_at_ms` recency field (with
`with_seen_at_ms`) used to prefer more recently observed addresses.
#### Data-plane / metrics / observability
- Typed `RejectReason` classification for receive-path silent-rejection
sites across the node. Each rejection-and-return path now passes a
typed reason to `NodeStats::record_reject`, which routes it to a
@@ -58,73 +28,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
site that was previously silent. Several handshake, MMP, tree, and
discovery rejection paths that had no counter at all are now counted,
including the `send_lookup_response` no-route drop
(`DiscoveryStats::resp_no_route`).
(`DiscoveryStats::resp_no_route`). Existing
direct counters at the bloom / discovery / forwarding sites are
retained alongside the new dispatch while the rollout is in progress;
a later change collapses the duplicate increment.
- Internal atomic metric registry (`Arc<MetricsRegistry>`) that shadows
the plain-`u64` `NodeStats` counters, written alongside them and
validated by a whole-struct debug-build parity check. Covers the
forwarding receive counters, the full discovery counter family, and the
tree, bloom, congestion, and error-signal counter families, with
tree, bloom, congestion, and error-signal counter families so far, with
the hottest counters cache-line padded. Behavior-neutral:
`NodeStats` remains the serving path. Groundwork for sampling metrics
without contending the receive loop.
- `fipsctl stats metrics`, backed by a new counter-only `show_metrics`
control query that dumps the atomic metric registry as flat counter
name/value pairs. Serves a Prometheus-style scraper that samples node
counters without contending the receive loop.
- `Node::update_peers` for runtime peer-list refresh, returning an
`UpdatePeersOutcome` summarizing added, removed, and retained peers.
Re-derives active peer connections from a new peer configuration
without dropping links to peers that remain in the set.
`PeerAddress` gains a `seen_at_ms` recency field (with
`with_seen_at_ms`) used to prefer more recently observed addresses.
- Opt-in mDNS / DNS-SD LAN discovery for sub-second pairing of peers on
the same local link, without a relay or NAT-traversal roundtrip.
Disabled by default; operators enable it with
`node.discovery.lan.enabled: true`. Configurable service type and an
optional `node.discovery.lan.scope` that isolates discovery to peers
sharing the same private-network scope. The advertised UDP port is
chosen from a non-bootstrap operational UDP transport using a stable
selector, so it is deterministic across restarts.
- `pool_inbound` and `pool_outbound` counters on the TCP and Tor
transport stats (`TcpStats`, `TorStats`). Per-direction accounting
is updated at every pool-insert and receive-loop-exit site, plus on
transport stop and on send-failure-driven removal. Surfaces through
`TcpStatsSnapshot` and `TorStatsSnapshot` for `show_transports`.
#### Spanning-tree / mesh-size / routing
- Six route-class transit counters that partition transit-forwarded
packets by their tree relationship to the chosen next hop: tree-up
(peer is our ancestor), tree-down (peer is our descendant and the
destination is within its subtree), tree-down-cross (peer is our
descendant but the destination is outside its subtree), cross-link
descend (lateral peer, destination within its subtree), cross-link
ascend (lateral peer, destination outside its subtree), and
direct-peer. The six classes sum to `forwarded_packets` (asserted by a
unit test) and are computed from tree coordinates at the transit
chokepoint, so error-signal routing callers are excluded. They surface
through the forwarding stats snapshot via `show_routing` and
`show_status`.
- `fipsctl stats metrics`, backed by a new counter-only `show_metrics`
control query that dumps the atomic metric registry as flat counter
name/value pairs. Serves a Prometheus-style scraper that samples node
counters without contending the receive loop.
- Discovery now counts `LookupRequest`s dropped when the dedup cache is
full. A saturated `recent_requests` cache
(`MAX_RECENT_DISCOVERY_REQUESTS`) previously dropped requests
silently; a new `DiscoveryStats::req_dedup_cache_full` counter (typed
reject reason `DiscoveryReject::ReqDedupCacheFull`) makes the drop
visible through `show_routing`.
#### Packaging & deployment
- OpenWrt `.apk` packaging (`packaging/openwrt-apk/`, `make apk`) for
OpenWrt 25+, where apk-tools is the mandatory package manager (the
existing `.ipk` continues to cover OpenWrt 24.x and earlier). Built
SDK-free: it reuses the `.ipk` cross-compile (`cargo-zigbuild`) and the
shared installed-filesystem payload, and assembles the package with
`apk mkpkg` from apk-tools 3.0.5 built from source — no OpenWrt SDK
image. A `build-apk` CI job (aarch64, x86_64) builds and structurally
verifies the package; releases now publish `.apk` artifacts and
checksums alongside `.ipk`. Packages are unsigned, installed with
`apk add --allow-untrusted`, matching the `.ipk` posture.
- Nix flake (`flake.nix` at the project root) for reproducible
from-source builds on Nix/NixOS. Builds all four binaries (`fips`,
`fipsctl`, `fips-gateway`, `fipstop`), pins the exact toolchain from
`rust-toolchain.toml` via fenix, and wires the build-time native
dependencies (`libclang` for `bindgen`, plus `dbus` and `pkg-config`),
so it needs no host setup beyond Nix with flakes enabled. Flake inputs
are lock-pinned (`flake.lock` committed) for reproducibility, and the
flake exposes `nix build`, `nix run`, a `nix develop` dev shell with the
pinned toolchain, and `nix flake check`. The flake produces binaries
(and a NixOS `packages.<system>.fips` output); the systemd/service
integration that the `.deb`/tarball installers provide is handled
through the NixOS configuration instead.
#### Docs & contributor tooling
- [`PR-REVIEW.md`](PR-REVIEW.md) — the 13-criteria PR review checklist
the maintainer runs against every incoming PR, published at the
repo root so contributors can run the same pass on their own change
@@ -146,25 +90,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
#### FMP/FSP rekey reliability
- `complete_rekey_msg2` now returns the remote peer's startup epoch
alongside the new Noise session, so the rekey path can detect a peer
restart and clear stale session state.
#### NAT traversal / Nostr discovery
- Nostr discovery startup is now non-blocking. `Node::start` no
longer waits for relay connect, subscribe, or initial advert
publish before returning. A slow or unreachable relay no longer
holds node startup hostage; local transports come up immediately
and the relay path catches up asynchronously in background tasks.
Subscribe retries with exponential backoff (2 s base, 60 s cap),
publish attempts time out at 10 s, and the new tasks are aborted
cleanly on `Node::stop`.
#### Spanning-tree / mesh-size / routing
- Active-peer path selection now sorts address candidates by recency
(`seen_at_ms`), preferring the most recently observed address when
racing concurrent path probes.
@@ -173,9 +101,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`MAX_RETRY_CONNECTIONS_PER_TICK`, and
`MAX_PARALLEL_PATH_CANDIDATES_PER_PEER`. Work beyond a tick's budget
is deferred to the next tick rather than discarded.
#### Admission / peer caps
- Nostr discovery startup is now non-blocking. `Node::start` no
longer waits for relay connect, subscribe, or initial advert
publish before returning. A slow or unreachable relay no longer
holds node startup hostage; local transports come up immediately
and the relay path catches up asynchronously in background tasks.
Subscribe retries with exponential backoff (2 s base, 60 s cap),
publish attempts time out at 10 s, and the new tasks are aborted
cleanly on `Node::stop`.
- `node.bloom.max_inbound_fpr` default raised from `0.05` to `0.10`. The
cap rejects inbound `FilterAnnounce` whose FPR (`fill^k`) exceeds it. On
the fixed 1 KB / k=5 filter, `0.05` corresponds to fill 0.549 (~1,300
@@ -191,9 +124,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
per-transport `max_inbound_connections`, then node-wide
`max_connections`, then the built-in default of 256. Established peers
remain bounded node-wide by `add_connection`.
#### Data-plane / worker-pool / metrics / observability
- The control-socket read surface is now served off the `rx_loop`.
Every pure-read `show_*` query — `show_status`, the `show_stats_*`
family, `show_listening_sockets`, the new `show_metrics`,
@@ -218,6 +148,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
snapshot fields above. Built on a ratatui `TestBackend`
render-snapshot harness that asserts the text grid and per-cell
style of every `ui::draw_*` against canned `show_*` JSON.
- Static host aliases in `/etc/fips/hosts` now hot-reload on mtime
change instead of only at daemon startup, so `fipsctl`/`fipstop`
display names reflect edits without a restart. The peer ACL and host
map both reload once per node tick through a new lock-free
`Reloadable` snapshot.
- Steady-state log noise reduced on saturated public-mesh nodes.
Routine per-peer connection-lifecycle and capacity-cap events are
demoted from info/warn to debug — FMP K-bit cutover promotion,
@@ -228,6 +163,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
are no longer drowned out. An exhausted FMP-msg1 / FSP-msg3 rekey
retransmission-budget abort (an expected, self-limiting outcome on
lossy or high-latency links) is likewise demoted from warn to debug.
- Sidecar example (`examples/sidecar-nostr-relay`): `udp.mtu` is now
overridable via the `FIPS_UDP_MTU` environment variable, defaulting to
1472 (preserving prior behavior). Plumbed through `docker-compose.yml`
and documented in the README env-var table. Annotated the static-CI
node template `mtu: 1472` literal with the same Docker-bridge
rationale and a pointer at the daemon's 1280 default.
- Overhauled `CONTRIBUTING.md`: replaced generic Rust-template framing
with a FIPS-specific entry point covering the four-layer
architecture, branch model and PR-target selection, structured bug
reporting, scope discipline and local-CI requirements, an AI coding
assistant policy, and project communication channels. Added
`docs/branching.md` as the long-form companion covering the release
workflow, version conventions, and merge-direction rationale.
- CI and release-publish workflows hardened:
- `ci.yml` declares a top-level `concurrency` block keyed on
`(workflow, ref)` with `cancel-in-progress: true`. Force-pushes
and rapid successive pushes to the same ref now retire any
in-flight run rather than letting superseded and current-tip runs
both burn runner minutes.
- `aur-publish.yml` rewritten to fetch the upstream source tarball
and compute its `b2sum` in CI, then patch `pkgver` and the
`b2sums` SKIP placeholder in `PKGBUILD` in-place. Previously
`updpkgsums: true` downloaded the tarball into the AUR working
tree, where it was rejected by AUR's 488 KiB max-blob hook —
silently no-op'ing the v0.3.0 stable AUR push. `fips.sysusers` /
`fips.tmpfiles` asset b2sums are recomputed in the same step to
stay in sync with the local files. `workflow_dispatch` gains a
tag input so historical release tags can be re-published
manually, and `continue-on-error: true` is dropped so future
regressions surface in CI.
- New `aur-publish-git.yml` workflow for the `fips-git` VCS
PKGBUILD, triggered on master pushes touching `PKGBUILD-git` or
companion files plus `workflow_dispatch`. `pkgver` is computed at
build time by the PKGBUILD's `pkgver()` function, so this workflow
is not tied to release tags.
- Tag-triggered `package-*` release-build workflows remain
untouched.
- macOS UDP receive path now batches up to 32 datagrams per kernel
wakeup via `recvmsg_x(2)`, matching the Linux `recvmmsg(2)`
amortization shape introduced in v0.3.0. Previously macOS fell
@@ -300,22 +272,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
and the connected-UDP drain no longer busy-spins on a poll error
(#106).
#### Transports & config
- Static host aliases in `/etc/fips/hosts` now hot-reload on mtime
change instead of only at daemon startup, so `fipsctl`/`fipstop`
display names reflect edits without a restart. The peer ACL and host
map both reload once per node tick through a new lock-free
`Reloadable` snapshot.
- Sidecar example (`examples/sidecar-nostr-relay`): `udp.mtu` is now
overridable via the `FIPS_UDP_MTU` environment variable, defaulting to
1472 (preserving prior behavior). Plumbed through `docker-compose.yml`
and documented in the README env-var table. Annotated the static-CI
node template `mtu: 1472` literal with the same Docker-bridge
rationale and a pointer at the daemon's 1280 default.
#### Packaging & deployment
- The Debian package no longer ships `/etc/fips/fips.yaml` as a dpkg
conf-file. The default configuration is installed as an example at
`/usr/share/fips/fips.yaml.example`, and `postinst` seeds
@@ -327,36 +283,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
example is placed under `/usr/share/fips`, deliberately outside
`/usr/share/doc`, which minimal and container installs path-exclude
(so the install-time seed source is never dropped).
- openwrt: the `.apk` package now defaults `ethernet.wan` to the
OpenWrt 25 DSA port name `wan`; the `.ipk` package keeps `eth0` for
OpenWrt 24 and earlier.
#### CI & test-harness reliability
- CI and release-publish workflows hardened:
- `ci.yml` declares a top-level `concurrency` block keyed on
`(workflow, ref)` with `cancel-in-progress: true`. Force-pushes
and rapid successive pushes to the same ref now retire any
in-flight run rather than letting superseded and current-tip runs
both burn runner minutes.
- `aur-publish.yml` rewritten to fetch the upstream source tarball
and compute its `b2sum` in CI, then patch `pkgver` and the
`b2sums` SKIP placeholder in `PKGBUILD` in-place. Previously
`updpkgsums: true` downloaded the tarball into the AUR working
tree, where it was rejected by AUR's 488 KiB max-blob hook —
silently no-op'ing the v0.3.0 stable AUR push. `fips.sysusers` /
`fips.tmpfiles` asset b2sums are recomputed in the same step to
stay in sync with the local files. `workflow_dispatch` gains a
tag input so historical release tags can be re-published
manually, and `continue-on-error: true` is dropped so future
regressions surface in CI.
- New `aur-publish-git.yml` workflow for the `fips-git` VCS
PKGBUILD, triggered on master pushes touching `PKGBUILD-git` or
companion files plus `workflow_dispatch`. `pkgver` is computed at
build time by the PKGBUILD's `pkgver()` function, so this workflow
is not tied to release tags.
- Tag-triggered `package-*` release-build workflows remain
untouched.
- Local and GitHub CI integration coverage brought into parity, and
the Rust toolchain selection given a single source of truth:
- The `admission-cap` integration suite, previously run only by
@@ -379,70 +305,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`-D warnings` is newly imposed. The OpenWrt nightly Tier-3 leg
keeps `@nightly`.
#### Docs & contributor tooling
- Overhauled `CONTRIBUTING.md`: replaced generic Rust-template framing
with a FIPS-specific entry point covering the four-layer
architecture, branch model and PR-target selection, structured bug
reporting, scope discipline and local-CI requirements, an AI coding
assistant policy, and project communication channels. Added
`docs/branching.md` as the long-form companion covering the release
workflow, version conventions, and merge-direction rationale.
### Fixed
#### FMP/FSP rekey reliability
- FMP link-layer rekey is now reliable under packet loss, bringing it up
to the FSP session layer's rekey discipline. The rekey msg1
retransmission driver was previously uncapped and never abandoned, so a
rekey that never completed resent msg1 forever; it now uses a bounded
retransmission budget (`handshake_max_resends` with exponential
backoff) and abandons the rekey cycle cleanly once the budget is
exhausted, mirroring the FSP rekey msg3 driver. With the cap in place
the link-dead heartbeat is rekey-aware: `check_link_heartbeats` no
longer reaps a link that is still actively carrying rekey-handshake
traffic, while a genuinely dead link is still reaped once the budget
abandons. At the K-bit cutover the receiver now authenticates an
inbound frame against the pending session before promoting it, instead
of promoting on the bare header K-bit; under jitter a node could
otherwise promote a stale pending session, leaving the two endpoints on
different keys and silently dropping traffic until the link died — the
same failure class already closed on FSP, now closed on FMP.
- FSP session rekey is now hitless under packet loss and reordering.
Previously, a rekey could leave the two endpoints holding different
key sets for a brief window — if a handshake message was lost in
transit one side rotated keys while the other did not, and traffic
sealed in one key epoch reached a peer still on the other epoch and
failed to decrypt, producing bursts of AEAD decryption failures and
dropped connectivity until a later rekey reconverged the pair. The
receive path now trial-decrypts each frame against every live key
epoch (current, pending, and the draining previous session) for the
duration of the rekey transition, so no rotation ordering and no
packet reordering can cause a decryption failure. The previous-epoch
slot is retained as long as the peer keeps using it, with its drain
deadline anchored on the last frame the peer authenticates against
it rather than a fixed wall-clock timer, so a peer that did not
receive the new keys is not stranded by a silent permanent decrypt
failure. The lost-handshake case is closed by retransmitting the
third rekey handshake message until the peer is confirmed on the
new keys, with a bounded retry budget after which the rekey cycle
is cleanly abandoned and retried. There are no FSP decryption
failures across a rekey under lossy, jittery links.
- ±15s symmetric jitter is applied per session to the FMP and FSP rekey
timer trigger, eliminating the steady-state dual-initiation race in
symmetric-start meshes (previously the smaller-NodeAddr tie-breaker
resolved correctness only after every cycle's collision).
`node.rekey.after_secs` becomes the nominal interval rather than a
floor; the mean is preserved.
- The Tor transport now increments its `connect_refused` statistic (the
"Refused" line in fipstop) when a SOCKS5 connection is actively
refused, instead of recording every connect failure as a generic
SOCKS5 error. The counter previously stayed at zero.
- MMP sender metrics now ignore duplicate or regressed receiver reports
before updating RTT, loss, goodput, or ETX. Receiver reports also
suppress timestamp echo when dwell time overflows, so stale reports
cannot inflate SRTT.
- Six discovery counters (`req_decode_error`, `req_duplicate`,
`req_ttl_exhausted`, `resp_decode_error`, `resp_identity_miss`,
`resp_proof_failed`) no longer double-count. Each was incremented both
by a direct bump and again through the typed reject dispatch; the
redundant direct increment is removed, so each counts once per event.
- Six bloom counters (`decode_error`, `invalid`, `non_v1`,
`unknown_peer`, `stale`, `fill_exceeded`) no longer double-count. Each
was incremented both by a direct bump and again through the typed
reject dispatch; the redundant direct increment is removed, so each
counts once per event.
- Five forwarding reject packet counters (`decode_error_packets`,
`ttl_exhausted_packets`, `drop_no_route_packets`,
`drop_mtu_exceeded_packets`, `drop_send_error_packets`) no longer
double-count. Each was incremented both by the byte-aware outcome
recorder and again through the typed reject dispatch; the two calls are
collapsed into a single byte-aware reject entry point, so packets and
bytes each count once per event.
- A stale FSP (session-layer) session is now cleared when a peer
restart is detected during FMP rekey or cross-connection promotion.
Previously the old session could linger after the peer came back
with a new startup epoch, leaving the session-layer map out of sync
with the freshly promoted peer.
#### NAT traversal / Nostr discovery
- Two nodes that each `auto_connect` to the other no longer stall their
Nostr-mediated NAT-traversal handshake. Each side ran both an
initiator and a responder traversal session, binding a separate UDP
@@ -455,55 +350,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
its own NodeAddr is smaller — so one matching socket pair survives on
both ends and the peer's redundant initiator times out harmlessly.
One-sided (asymmetric) `auto_connect` has no co-active initiator and is
never suppressed, so connectivity is preserved.
- NAT-traversal cross-init adoption is now deterministic under
simultaneous dual-initiation. Previously, when two peers'
Nostr-mediated UDP punches completed within the same scheduling
window, each side's bootstrap-completion event arrived with an
in-flight handshake already recorded against the other peer (each
side had received an inbound msg1 from the other's pre-punch
outbound attempt). The deduplication skip then fired on both
sides, neither installed the fresh traversal socket as canonical,
and the 45-second peer-adoption budget expired with both nodes
stuck waiting for an adoption that never happened. The handler now
applies the same deterministic NodeAddr tie-breaker the codebase
already uses for rekey dual-initiation and cross-connection
resolution: the smaller NodeAddr wins as adopter, tears down its
in-flight handshake state, and proceeds with adoption; the larger
NodeAddr keeps the skip semantics, and its in-flight outbound is
reconciled by the cross-connection logic when the winner's fresh
msg1 arrives over the adopted socket. The dual cross-init stall is
eliminated; cross-init NAT-traversal completes in well under a
second even under host CPU contention.
- Nostr-discovered NAT-traversal events (`BootstrapEvent::Established`
and `BootstrapEvent::Failed`) for peers that are already connected
or actively handshaking are now short-circuited at the
`poll_nostr_discovery` dispatch sites before any cooldown
bookkeeping or fallback retry scheduling runs. Stale `Failed` events
previously poisoned the per-peer failure-state cooldown of healthy
peers and could trigger redundant retraversal attempts via
`schedule_retry` / `try_peer_addresses`; stale `Established`
handoffs could attempt to adopt a second socket against a live
connection. A defense-in-depth guard was added to
`adopt_established_traversal` so the same invariant holds if a
future caller bypasses the outer dispatch check. As a side benefit,
narrows a cooldown-poisoning vector previously available to an
attacker injecting stale failure events for an active peer.
- Nostr discovery now filters unroutable direct UDP/TCP advert
endpoints. Publisher and validator retain only endpoints that parse as
concrete socket addresses with routable IPs and nonzero ports;
`udp:nat` rendezvous endpoints and Tor endpoints pass through
unchanged. Adverts that collapse to zero usable endpoints after
filtering are rejected with a clear "missing publicly routable
endpoints" error. Before this change, misconfigured nodes could
publish RFC1918, loopback, link-local, CGNAT 100.64/10, IPv6 ULA,
or IPv6 link-local endpoints into Nostr discovery, and consumers
would cache and dial them; in mixed LAN/VPN/NAT environments, that
could prefer a misleading one-way private path over the intended
`udp:nat` bootstrap.
#### Admission / peer caps
never suppressed, so connectivity is preserved. (Distinct from the
cross-init adoption tie-breaker below, which dedups two simultaneous
punches but does not stop each node from running redundant
initiator + responder sessions.)
- FMP link-layer rekey is now reliable under packet loss, bringing it up
to the FSP session layer's rekey discipline:
- The rekey msg1 retransmission driver was uncapped and never
abandoned, so a rekey that never completed resent msg1 forever. It
now uses a bounded retransmission budget (`handshake_max_resends`
with exponential backoff) and abandons the rekey cycle cleanly once
the budget is exhausted, mirroring the FSP rekey msg3 driver. With
the cap in place the link-dead heartbeat is rekey-aware:
`check_link_heartbeats` no longer reaps a link that is still
actively carrying rekey-handshake traffic, while a genuinely dead
link is still reaped once the budget abandons.
- At the K-bit cutover the receiver now authenticates an inbound frame
against the pending session before promoting it, instead of
promoting on the bare header K-bit. Under jitter a node could
otherwise promote a stale pending session, leaving the two endpoints
on different keys and silently dropping traffic until the link died
— the same failure class already closed on FSP, now closed on FMP.
- Node-level multi-node tests no longer flake under parallel CPU load.
They previously delivered handshake packets over real localhost UDP,
whose kernel receive buffer could overflow and drop a packet when many
tests ran concurrently, panicking the large-network convergence tests.
A `cfg(test)`-only loopback `TransportHandle` variant now delivers
packets directly between nodes over an unbounded in-process channel, so
there is no socket buffer to overflow, and the previously-quarantined
large-network tests run in the default suite again. The shipping daemon
build is unaffected (the variant is test-gated).
- Integration suites that wait for the mesh to converge no longer
false-fail under concurrent CI load. The rekey, static-mesh, and
sidecar suites replace a fixed wall-clock baseline timeout (and a blind
sleep) with a progress-aware wait that polls the suite's own pairwise
pings, returns as soon as every pair is reachable, extends its deadline
while the reachable-pair count is still climbing, and gives up only
when progress stalls.
- TCP and Tor `max_inbound_connections` admission cap is now compared
against the per-direction inbound count (`pool_inbound`) rather than
the combined pool size. Outbound connect-on-send connections share
@@ -532,13 +415,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
reconnect / cross-connection bypass for known peers still fires) and
before index allocation + Msg2 wire send. The late cap check inside
`promote_connection` is intentionally retained as
defense-in-depth. Wire savings observed in a 45 s tcpdump at
defense-in-depth. Wire savings observed in a 45 s ops tcpdump at
saturation: ~3.6 cap-denials/s × Msg2 (~104 B + AEAD compute) each.
Bigger win is cleaner peer-side semantics — no fake-completed
handshake whose subsequent data frames fail decryption on this side.
#### Spanning-tree / mesh-size / routing
- The mesh-size estimator (`compute_mesh_size`) no longer over-counts
under filter overlap. It previously summed the per-filter cardinality
of the parent and each child filter, which assumes the filters are
@@ -549,11 +429,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
nearly-but-not-exactly doubling during rebalancing). The estimator now
computes the cardinality of the OR-union over self plus every
connected peer's inbound filter, dropping the parent/child tree gating
entirely. OR is idempotent, so any overlap is deduplicated — the
result equals the old sum in the disjoint case, stays correct under
overlap, damps the parent-switch flap, and removes the estimate's
dependence on tree-declaration cache freshness. The per-peer 500 ms
rate-limiter and overall recompute cadence are unchanged.
entirely.
OR is idempotent, so any overlap is deduplicated — the result equals
the old sum in the disjoint case, stays correct under overlap, damps
the parent-switch flap, and removes the estimate's dependence on
tree-declaration cache freshness. The per-peer 500 ms rate-limiter and
overall recompute cadence are unchanged.
- Spanning-tree state distribution is now eventually-consistent.
Previously every `send_tree_announce_to_all` call site fired only
on a local state-change event (parent switch, self-root promotion,
@@ -591,20 +472,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
that peer, provoking the better-rooted peer to re-push its real
position immediately. The echo fires only in that one direction and is
bounded by the existing per-peer rate limiter.
- Coord cache invalidation made surgical at parent-position-change
and root-change sites. Replaces the previous unconditional
`CoordCache::clear()` calls with two targeted methods:
`invalidate_via_node(node_addr)` (drops entries whose cached
ancestry contains the changed node, used at parent-switch /
become-root / loop-detection sites) and `invalidate_other_roots`
(drops entries from a different tree, used at root-change sites).
The previous global flush left `find_next_hop` returning `None`
for every non-direct-peer destination after every parent switch
until the cache passively re-warmed; surgical invalidation
preserves entries that remain correct across the topology change.
Peer-removal retains the original "no invalidation" behavior
(`find_next_hop` already recomputes against the current peer set
every call, and Discovery handles "no route" on demand).
- `rx_loop` tick-arm stall under convergence-phase mesh pressure
is eliminated. Previously, the tick body's per-peer `check_*`
loops (heartbeats, bloom announces, MMP reports, tree announces)
@@ -636,133 +504,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
synchronous connect (e.g., explicit operator-driven
`fipsctl connect`); the tick path just no longer trips it.
#### Data-plane / metrics / observability
- The Tor transport now increments its `connect_refused` statistic (the
"Refused" line in fipstop) when a SOCKS5 connection is actively
refused, instead of recording every connect failure as a generic
SOCKS5 error. The counter previously stayed at zero.
- MMP sender metrics now ignore duplicate or regressed receiver reports
before updating RTT, loss, goodput, or ETX. Receiver reports also
suppress timestamp echo when dwell time overflows, so stale reports
cannot inflate SRTT.
- Reject-reason counters no longer double-count now that the rollout's
interim direct increments are removed. Six discovery counters
(`req_decode_error`, `req_duplicate`, `req_ttl_exhausted`,
`resp_decode_error`, `resp_identity_miss`, `resp_proof_failed`), six
bloom counters (`decode_error`, `invalid`, `non_v1`, `unknown_peer`,
`stale`, `fill_exceeded`), and five forwarding reject packet counters
(`decode_error_packets`, `ttl_exhausted_packets`,
`drop_no_route_packets`, `drop_mtu_exceeded_packets`,
`drop_send_error_packets`) were each incremented both by a direct bump
and again through the typed reject dispatch. The redundant direct
increments are removed — for the forwarding family the two calls are
collapsed into a single byte-aware reject entry point — so each counter
(and, for forwarding, its byte tally) counts once per event.
- Transport-layer mutex poisoning no longer cascades. Ten
`Mutex::lock().unwrap()` sites across the UDP, BLE, and Ethernet
transports would turn a single panic (poisoning the mutex) into a
cascade of panics on every subsequent lock. Each is replaced with
`lock().unwrap_or_else(|e| e.into_inner())`, recovering the guarded
data with no new dependency and no call-graph change; four
`local_addr.unwrap()` calls on the UDP start/adopt paths get a
provably-safe sentinel fallback. The critical sections are short,
locally-scoped, and not reachable from peer input, so this is
robustness hardening, not a remotely-triggerable fix.
#### Peer lifecycle / gateway
- A manual `fipsctl disconnect` now notifies the peer so teardown is
symmetric. Previously a manual disconnect tore down only the local
side and sent the peer nothing, so the peer kept its session and never
re-emitted its tree and filter announcements; on reconnect it was
never re-adopted as a child and its bloom filter was never recorded.
The local side now sends the disconnected peer a scoped `Disconnect`
(the same message graceful shutdown sends), so both ends tear down and
re-handshake cleanly on the next connection.
- `fips-gateway` no longer drops long-lived or DNS-cached client
mappings while traffic is still flowing. The virtual-IP pool's TTL
clock advanced only on DNS re-query, never on traffic, and the mapping
TTL is wired equal to the DNS TTL, so an in-use mapping was forced to
drain at TTL and reclaimed at the first zero-conntrack tick — breaking
long-lived, bursty, or DNS-cached clients. The tick now refreshes the
mapping's last-referenced time whenever conntrack reports active
sessions, and recovers a draining mapping to active (with a fresh
grace window) when traffic resumes; only genuinely idle mappings
drain.
#### macOS self-traffic / resolver
- Self-addressed mesh traffic is now delivered locally on macOS instead
of being dropped, for both `ping6` and full TCP/UDP. The point-to-point
`utun` interface egresses self-addressed traffic into the daemon, which
previously pushed it onto the mesh outbound path where it was dropped
for lack of a route to self; such packets are now hairpinned back to
the TUN for inbound delivery. macOS first routes self-addressed packets
as loopback (a `LOCAL` route via `lo0`), which leaves their transport
TX checksum offloaded and unfinished, so re-injecting them verbatim
made the local stack drop every segment whose checksum MSS clamping did
not happen to rewrite (the SYN and SYN-ACK got through, but the bare
ACK, data, and FIN were dropped, so connections to a node's own
`<npub>.fips` service half-opened and hung). The hairpin path now
recomputes the TCP/UDP checksum before re-injection, so full
self-connections — not just `ping6` — to a node's own `<npub>.fips`
address work. Linux was unaffected (the kernel already loops
self-traffic via `lo`). (#117)
- macOS `.fips` name resolution now works on a fresh install: the
shipped resolver shim points at `::1`, matching the daemon's default
IPv6 DNS listener, instead of `127.0.0.1`. The mismatched shim
(`nameserver 127.0.0.1` while the daemon listens on `::1`) broke
`getaddrinfo` for `.fips` on every macOS install since the resolver
was introduced.
#### CI & test-harness reliability
- Node-level multi-node tests no longer flake under parallel CPU load.
They previously delivered handshake packets over real localhost UDP,
whose kernel receive buffer could overflow and drop a packet when many
tests ran concurrently, panicking the large-network convergence tests.
A `cfg(test)`-only loopback `TransportHandle` variant now delivers
packets directly between nodes over an unbounded in-process channel, so
there is no socket buffer to overflow, and the previously-quarantined
large-network tests run in the default suite again. The shipping daemon
build is unaffected (the variant is test-gated).
- Integration suites that wait for the mesh to converge no longer
false-fail under concurrent CI load. The rekey, static-mesh, and
sidecar suites replace a fixed wall-clock baseline timeout (and a blind
sleep) with a progress-aware wait that polls the suite's own pairwise
pings, returns as soon as every pair is reachable, extends its deadline
while the reachable-pair count is still climbing, and gives up only
when progress stalls.
- Rekey integration test (`testing/static/scripts/rekey-test.sh`) no
longer false-fails on GitHub runners under packet loss and CPU
contention. Phase 1, Phase 3, and Phase 5 strict per-pair pings retry
up to 4 attempts (configurable via `MAX_PING_ATTEMPTS` /
`PING_RETRY_DELAY`) — under 1% per-direction loss, single-shot 20-pair
ping_all misses ~33% per phase from ICMP noise alone, and the
4-attempt retry brings that floor to ~3.2e-6 per phase; the
`wait_for_full_baseline` convergence loop stays single-shot so retries
there cannot conflate transient ping loss with still-converging routing
state. Phase 1 baseline-convergence headroom is bumped from 36s to 60s
to eliminate the intermittent Phase 1 timeout that previously required
a `gh run rerun --failed`, and a post-second-rekey settle window is
added in Phase 5 (mirroring Phase 3's 12-second pattern) to close the
post-rekey per-pair-ping flake from convergence exceeding the per-ping
5-second timeout. Test scaffold only; no daemon code changes, and the
success path is unchanged because the wait loops return as soon as all
20 pairs converge.
- ACL-allowlist integration test (`testing/acl-allowlist/test.sh`):
converted `assert_log_contains` from a one-shot `docker logs | grep`
snapshot into a bounded poll with the same wait-with-timeout shape
as `wait_for_peers_exact`. Absorbs the millisecond-to-second
variance in the XX-handshake cross-connection tie-breaker: the
inbound-handshake-context rejection can land tens of milliseconds
after the test's previous one-shot grep gave up, producing a
pre-existing flake on CI. Success-path cost is unchanged — the helper
returns as soon as the pattern appears.
#### Packaging & deployment
- NAT-traversal cross-init adoption is now deterministic under
simultaneous dual-initiation. Previously, when two peers'
Nostr-mediated UDP punches completed within the same scheduling
window, each side's bootstrap-completion event arrived with an
in-flight handshake already recorded against the other peer (each
side had received an inbound msg1 from the other's pre-punch
outbound attempt). The deduplication skip then fired on both
sides, neither installed the fresh traversal socket as canonical,
and the 45-second peer-adoption budget expired with both nodes
stuck waiting for an adoption that never happened. The handler now
applies the same deterministic NodeAddr tie-breaker the codebase
already uses for rekey dual-initiation and cross-connection
resolution: the smaller NodeAddr wins as adopter, tears down its
in-flight handshake state, and proceeds with adoption; the larger
NodeAddr keeps the skip semantics, and its in-flight outbound is
reconciled by the cross-connection logic when the winner's fresh
msg1 arrives over the adopted socket. The dual cross-init stall is
eliminated; cross-init NAT-traversal completes in well under a
second even under host CPU contention.
- FSP session rekey is now hitless under packet loss and reordering.
Previously, a rekey could leave the two endpoints holding different
key sets for a brief window — if a handshake message was lost in
transit one side rotated keys while the other did not, and traffic
sealed in one key epoch reached a peer still on the other epoch and
failed to decrypt, producing bursts of AEAD decryption failures and
dropped connectivity until a later rekey reconverged the pair. The
receive path now trial-decrypts each frame against every live key
epoch (current, pending, and the draining previous session) for the
duration of the rekey transition, so no rotation ordering and no
packet reordering can cause a decryption failure. The previous-epoch
slot is retained as long as the peer keeps using it, with its drain
deadline anchored on the last frame the peer authenticates against
it rather than a fixed wall-clock timer, so a peer that did not
receive the new keys is not stranded by a silent permanent decrypt
failure. The lost-handshake case is closed by retransmitting the
third rekey handshake message until the peer is confirmed on the
new keys, with a bounded retry budget after which the rekey cycle
is cleanly abandoned and retried. There are no FSP decryption
failures across a rekey under lossy, jittery links.
- AUR packaging: the `fips` and `fips-git` PKGBUILDs now install the
`fips-dns-setup` and `fips-dns-teardown` helpers into
`/usr/lib/fips/`, matching the Debian package. The AUR `package()`
@@ -784,9 +564,113 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
the arch-named file is present and carries a SHA-256 integrity
chain from the build runner through to `gh release upload`, so a
recurrence fails CI instead of publishing.
#### fipstop
- Nostr discovery: filter unroutable direct UDP/TCP advert endpoints.
Publisher and validator now retain only endpoints that parse as
concrete socket addresses with routable IPs and nonzero ports.
`udp:nat` rendezvous endpoints and Tor endpoints pass through
unchanged. Adverts that collapse to zero usable endpoints after
filtering are rejected with a clear "missing publicly routable
endpoints" error. Before this change, misconfigured nodes could
publish RFC1918, loopback, link-local, CGNAT 100.64/10, IPv6 ULA,
or IPv6 link-local endpoints into Nostr discovery, and consumers
would cache and dial them; in mixed LAN/VPN/NAT environments, that
could prefer a misleading one-way private path over the intended
`udp:nat` bootstrap.
- Coord cache invalidation made surgical at parent-position-change
and root-change sites. Replaces the previous unconditional
`CoordCache::clear()` calls with two targeted methods:
`invalidate_via_node(node_addr)` (drops entries whose cached
ancestry contains the changed node, used at parent-switch /
become-root / loop-detection sites) and `invalidate_other_roots`
(drops entries from a different tree, used at root-change sites).
The previous global flush left `find_next_hop` returning `None`
for every non-direct-peer destination after every parent switch
until the cache passively re-warmed; surgical invalidation
preserves entries that remain correct across the topology change.
Peer-removal retains the original "no invalidation" behavior
(`find_next_hop` already recomputes against the current peer set
every call, and Discovery handles "no route" on demand).
- Rekey integration test (`testing/static/scripts/rekey-test.sh`):
Phase 1, Phase 3, and Phase 5 strict per-pair pings now retry up
to 4 attempts (configurable via `MAX_PING_ATTEMPTS` /
`PING_RETRY_DELAY`). Under low-level packet loss (1% per
direction), single-shot 20-pair ping_all misses with probability
~33% per phase from ICMP noise alone, masking the routing-state
signal the asserts target. The 4-attempt retry brings that floor
to ~3.2e-6 per phase. The `wait_for_full_baseline` convergence
loop itself stays single-shot — retries there would conflate
transient ping loss with still-converging routing state. Test
scaffold only; no daemon code changes.
- Apply ±15s symmetric jitter per session to the FMP and FSP rekey
timer trigger. Eliminates the steady-state dual-initiation race
in symmetric-start meshes; previously the smaller-NodeAddr
tie-breaker resolved correctness after every cycle's collision.
`node.rekey.after_secs` becomes the nominal interval rather than
a floor; mean is preserved.
- Rekey integration test (`testing/static/scripts/rekey-test.sh`):
bumped Phase 1 baseline-convergence headroom from 36s to 60s.
Eliminates the intermittent GitHub-runner Phase 1 timeout that
previously required `gh run rerun --failed`. Cost on the success
path is unchanged because the wait loop returns as soon as all 20
pairs converge.
- Rekey integration test (`testing/static/scripts/rekey-test.sh`):
added a post-second-rekey settle window in Phase 5, mirroring
Phase 3's existing 12-second pattern. Closes the intermittent
GitHub-runner Phase 5 per-pair-ping flake caused by post-rekey
routing convergence exceeding the per-ping 5-second timeout under
runner CPU contention. Cost on the success path is a fixed 12s per
suite run.
- ACL-allowlist integration test (`testing/acl-allowlist/test.sh`):
converted `assert_log_contains` from a one-shot `docker logs | grep`
snapshot into a bounded poll with the same wait-with-timeout shape
as `wait_for_peers_exact`. Absorbs the millisecond-to-second
variance in the XX-handshake cross-connection tie-breaker: the
inbound-handshake-context rejection can land tens of milliseconds
after the test's previous one-shot grep gave up, producing a
pre-existing flake on next-branch CI. Success-path cost is
unchanged — the helper returns as soon as the pattern appears.
- Nostr-discovered NAT-traversal events (`BootstrapEvent::Established`
and `BootstrapEvent::Failed`) for peers that are already connected
or actively handshaking are now short-circuited at the
`poll_nostr_discovery` dispatch sites before any cooldown
bookkeeping or fallback retry scheduling runs. Stale `Failed` events
previously poisoned the per-peer failure-state cooldown of healthy
peers and could trigger redundant retraversal attempts via
`schedule_retry` / `try_peer_addresses`; stale `Established`
handoffs could attempt to adopt a second socket against a live
connection. A defense-in-depth guard was added to
`adopt_established_traversal` so the same invariant holds if a
future caller bypasses the outer dispatch check. As a side benefit,
narrows a cooldown-poisoning vector previously available to an
attacker injecting stale failure events for an active peer.
- A manual `fipsctl disconnect` now notifies the peer so teardown is
symmetric. Previously a manual disconnect tore down only the local
side and sent the peer nothing, so the peer kept its session and never
re-emitted its tree and filter announcements; on reconnect it was
never re-adopted as a child and its bloom filter was never recorded.
The local side now sends the disconnected peer a scoped `Disconnect`
(the same message graceful shutdown sends), so both ends tear down and
re-handshake cleanly on the next connection.
- `fips-gateway` no longer drops long-lived or DNS-cached client
mappings while traffic is still flowing. The virtual-IP pool's TTL
clock advanced only on DNS re-query, never on traffic, and the mapping
TTL is wired equal to the DNS TTL, so an in-use mapping was forced to
drain at TTL and reclaimed at the first zero-conntrack tick — breaking
long-lived, bursty, or DNS-cached clients. The tick now refreshes the
mapping's last-referenced time whenever conntrack reports active
sessions, and recovers a draining mapping to active (with a fresh
grace window) when traffic resumes; only genuinely idle mappings
drain.
- Transport-layer mutex poisoning no longer cascades. Ten
`Mutex::lock().unwrap()` sites across the UDP, BLE, and Ethernet
transports would turn a single panic (poisoning the mutex) into a
cascade of panics on every subsequent lock. Each is replaced with
`lock().unwrap_or_else(|e| e.into_inner())`, recovering the guarded
data with no new dependency and no call-graph change; four
`local_addr.unwrap()` calls on the UDP start/adopt paths get a
provably-safe sentinel fallback. The critical sections are short,
locally-scoped, and not reachable from peer input, so this is
robustness hardening, not a remotely-triggerable fix.
- `fipstop` no longer renders a garbled screen on startup or leaves
stray bytes on quit, most visible over SSH and inside tmux. Startup
forces a full repaint (`terminal.clear()`) before the first draw so
@@ -794,6 +678,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
stdin-poll thread a stop flag and joins it before restoring the
terminal, so post-raw-mode keystrokes or terminal query responses no
longer echo onto the restored screen.
- macOS `.fips` name resolution now works on a fresh install: the
shipped resolver shim points at `::1`, matching the daemon's default
IPv6 DNS listener, instead of `127.0.0.1`. The mismatched shim
(`nameserver 127.0.0.1` while the daemon listens on `::1`) broke
`getaddrinfo` for `.fips` on every macOS install since the resolver
was introduced.
## [0.3.0] - 2026-05-11
-4
View File
@@ -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)
Generated
+1 -1
View File
@@ -1074,7 +1074,7 @@ checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5"
[[package]]
name = "fips"
version = "0.5.0-dev"
version = "0.4.0-rc.1"
dependencies = [
"arc-swap",
"bech32",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "fips"
version = "0.5.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"
+19 -29
View File
@@ -3,7 +3,7 @@
![banner](docs/logos/fips_banner.png)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![Rust](https://img.shields.io/badge/rust-1.85%2B-orange.svg)](https://www.rust-lang.org/)
[![Status](https://img.shields.io/badge/status-v0.5.0--dev-green.svg)](#status--roadmap)
[![Status](https://img.shields.io/badge/status-v0.4.0--dev-green.svg)](#status--roadmap)
A self-organizing encrypted mesh network built on Nostr identities,
capable of operating over arbitrary transports without central
@@ -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,14 +204,12 @@ testing/ Docker-based integration test harnesses + chaos simulation
## Status & roadmap
FIPS is at **v0.5.0-dev** on the `master` branch.
[v0.4.0](https://github.com/jmcorgan/fips/releases/tag/v0.4.0) has
shipped; this development line continues the testing-and-polishing
track toward v0.5.0. 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. v0.4.0 added the Nym mixnet transport and
mDNS LAN discovery alongside the existing Nostr-mediated peer discovery,
UDP NAT traversal, peer ACL, and packaging hardening. New wire-format work
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.
+2 -44
View File
@@ -1,6 +1,6 @@
# FIPS v0.4.0
**Released**: 2026-06-21 (provisional)
**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
-9
View File
@@ -41,18 +41,9 @@ fn main() {
// satisfy libdbus-sys's pkg-config cross-compile requirement, and musl
// router targets don't run BlueZ by default anyway.
println!("cargo:rustc-check-cfg=cfg(bluer_available)");
// `ble_available` gates the platform-agnostic BLE transport module (pool,
// discovery, per-peer PSM, the generic `BleTransport`). The module compiles
// on every platform that has — or will have — a concrete `BleIo` backend;
// the backend is selected per-platform (BluerIo on linux-glibc, BluestIo on
// macOS, AndroidIo on Android, else the in-memory MockBleIo fallback).
println!("cargo:rustc-check-cfg=cfg(ble_available)");
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
let target_env = std::env::var("CARGO_CFG_TARGET_ENV").unwrap_or_default();
if target_os == "linux" && target_env != "musl" {
println!("cargo:rustc-cfg=bluer_available");
}
if matches!(target_os.as_str(), "linux" | "macos" | "android") {
println!("cargo:rustc-cfg=ble_available");
}
}
-28
View File
@@ -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 |
+1 -1
View File
@@ -899,7 +899,7 @@ transitions through `Starting` to `Up` (operational). `stop()` moves to
| 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 |
| BLE | **Implemented** (Linux/glibc and Android; experimental) | L2CAP CoC, ATT_MTU negotiation, per-link MTU; Linux via BlueZ, Android via the embedder radio bridge; macOS/Windows/musl skip |
| BLE | **Implemented** (Linux/glibc only; experimental) | L2CAP CoC, ATT_MTU negotiation, per-link MTU; musl/macOS/Windows skip |
| Radio | Future direction | Constrained MTU (51222 bytes) |
| Serial | Future direction | SLIP/COBS framing, point-to-point |
-17
View File
@@ -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
-1
View File
@@ -44,7 +44,6 @@ crate accordingly).
| -------- | -------------- |
| Linux (glibc) | Supported. |
| Linux (musl, OpenWrt) | Disabled at build time. |
| Android | Supported (native Android BLE, via the embedder's radio bridge). |
| macOS | Not supported. |
| Windows | Not supported. |
+2 -44
View File
@@ -1,6 +1,6 @@
# FIPS v0.4.0
**Released**: 2026-06-21 (provisional)
**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
Generated
-100
View File
@@ -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
}
-128
View File
@@ -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;
}
);
}
+2 -6
View File
@@ -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
+5 -49
View File
@@ -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:
-98
View File
@@ -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.
-74
View File
@@ -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
-45
View File
@@ -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"
-296
View File
@@ -1,296 +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 -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"
+1 -4
View File
@@ -132,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
+33 -47
View File
@@ -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
@@ -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
+38 -131
View File
@@ -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>> {
@@ -139,39 +110,49 @@ fn draw_routing_stats(
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"),
),
(
"Delivered",
fwd_value(data, "delivered_packets", "delivered_bytes"),
),
(
"Forwarded",
fwd_value(data, "forwarded_packets", "forwarded_bytes"),
),
(
"Originated",
fwd_value(data, "originated_packets", "originated_bytes"),
),
(
"Decode Error",
fwd_value(data, "decode_error_packets", "decode_error_bytes"),
),
(
"TTL Exhausted",
fwd_value(data, "ttl_exhausted_packets", "ttl_exhausted_bytes"),
),
(
"No Route",
fwd_value(data, "drop_no_route_packets", "drop_no_route_bytes"),
),
(
"MTU Exceeded",
fwd_value(data, "drop_mtu_exceeded_packets", "drop_mtu_exceeded_bytes"),
),
(
"Send Error",
fwd_value(data, "drop_send_error_packets", "drop_send_error_bytes"),
),
],
));
);
left.push(Line::from(""));
left.extend(section(
"Discovery Requests",
@@ -203,89 +184,15 @@ fn draw_routing_stats(
],
));
// 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");
// Right column: Error Signals + Congestion
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),
),
(
"Tree-down",
route_class_value(fwd_count(data, "route_tree_down"), fwd_total),
),
(
"Tree-up",
route_class_value(fwd_count(data, "route_tree_up"), fwd_total),
),
(
"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"),
),
(
"TTL Exhausted",
fwd_value(data, "ttl_exhausted_packets", "ttl_exhausted_bytes"),
),
(
"Decode Error",
fwd_value(data, "decode_error_packets", "decode_error_bytes"),
),
(
"MTU Exceeded",
fwd_value(data, "drop_mtu_exceeded_packets", "drop_mtu_exceeded_bytes"),
),
(
"Send Error",
fwd_value(data, "drop_send_error_packets", "drop_send_error_bytes"),
),
],
));
right.push(Line::from(""));
right.extend(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",
+1 -6
View File
@@ -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);
});
+1 -35
View File
@@ -41,7 +41,7 @@ use super::snapshot::{EntitySnapshot, RoutingSnapshot, StatsSnapshot};
/// starting R1 as `show_*` queries cut over to off-loop rendering; until then
/// they are wired but unread.
#[derive(Clone)]
pub struct ControlReadHandle {
pub(crate) struct ControlReadHandle {
/// Effectively-immutable node context (config, identity, limits).
context: Arc<NodeContext>,
/// Metrics registry (counters / gauges) for `show_stats_*`.
@@ -108,40 +108,6 @@ impl ControlReadHandle {
}
}
/// A minimal, public view of one peer for embedders (e.g. an app UI), read
/// lock-free from the tick-published snapshot. See [`ControlReadHandle::peer_views`].
#[derive(Debug, Clone)]
pub struct PeerView {
/// The peer's `node_addr`, hex-encoded.
pub node_addr_hex: String,
/// Resolved npub (or the `node_addr` hex when not yet resolved to a peer).
pub npub: String,
/// Whether the peer is currently in the live authenticated-peer table.
pub connected: bool,
}
impl ControlReadHandle {
/// A lock-free snapshot of known peers (node_addr / npub / connected),
/// read from the tick-published stats snapshot.
///
/// Intended for embedders that run [`crate::Node::run_rx_loop`] on a
/// background task (so the node is exclusively borrowed there) and poll peer
/// state from a clone of this handle — the read touches only an `ArcSwap`
/// load, never the `Node`. See the Myco app for the reference embedding.
pub fn peer_views(&self) -> Vec<PeerView> {
self.stats
.load()
.peer_meta
.iter()
.map(|(addr, meta)| PeerView {
node_addr_hex: addr.to_string(),
npub: meta.npub.clone(),
connected: meta.is_active,
})
.collect()
}
}
/// Attempt to serve a request entirely from the read handle, off the rx_loop.
///
/// Returns `Some(response)` when the command is a pure-snapshot query that has
-6
View File
@@ -53,12 +53,6 @@
"originated_packets": 0,
"received_bytes": 0,
"received_packets": 0,
"route_crosslink_ascend": 0,
"route_crosslink_descend": 0,
"route_direct_peer": 0,
"route_tree_down": 0,
"route_tree_down_cross": 0,
"route_tree_up": 0,
"ttl_exhausted_bytes": 0,
"ttl_exhausted_packets": 0
},
-6
View File
@@ -22,12 +22,6 @@
"originated_packets": 0,
"received_bytes": 0,
"received_packets": 0,
"route_crosslink_ascend": 0,
"route_crosslink_descend": 0,
"route_direct_peer": 0,
"route_tree_down": 0,
"route_tree_down_cross": 0,
"route_tree_up": 0,
"ttl_exhausted_bytes": 0,
"ttl_exhausted_packets": 0
},
-1
View File
@@ -8,7 +8,6 @@
pub mod lan;
pub mod nostr;
pub mod platform;
use crate::config::UdpConfig;
use crate::{NodeAddr, TransportId};
-141
View File
@@ -1,141 +0,0 @@
//! Platform-pushed peer discovery.
//!
//! A generic seam for an embedding platform (e.g. an Android app layer that
//! runs its own radio discovery, such as Wi-Fi Aware) to push "peer `npub` is
//! reachable at `addr` over transport type `T`" events into a running node —
//! the transport-agnostic generalization of the LAN mDNS drain
//! (`poll_lan_discovery`), which delivers the same shape but is hardwired to
//! UDP transports.
//!
//! The queue is a process-global, like the Android BLE bridge injection seam
//! (`set_android_ble_bridge`): the embedder pushes without holding a `Node`
//! handle, and the node drains once per tick in `poll_platform_discovery`.
//! Events pushed while no node is running are retained up to [`QUEUE_CAP`]
//! (oldest dropped first) so a push racing a node rebuild is not lost.
//! With more than one node in a process, whichever drains first consumes
//! the events (same caveat as the BLE bridge) — intended for the
//! single-node embedding case.
//!
//! The pushed npub is only a routing hint: the Noise IK handshake is the
//! authentication, exactly as with mDNS adverts — a spoofed push fails the
//! IK exchange and is dropped.
use std::collections::VecDeque;
use std::sync::Mutex;
/// Maximum retained events while undrained. Beyond this the oldest event is
/// dropped: platform pushes are periodic (radio discovery re-fires), so a
/// dropped event is re-learned, while an unbounded queue would grow forever
/// if the node is stopped.
const QUEUE_CAP: usize = 256;
/// A peer reachability event pushed by the embedding platform.
///
/// Addresses and identities are strings at this seam (it is crossed from
/// JNI); they are parsed and validated at drain time, where a bad value is
/// logged and skipped rather than surfaced to the pusher.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PlatformPeerEvent {
/// The platform established reachability: dial `addr` on an operational
/// transport whose type name matches `transport_type`. For `udp` the
/// selection is family-aware — an IPv6 target picks an IPv6-capable
/// socket, never a wildcard IPv4 one. For IPv6 link-local addresses the
/// scope must be a numeric ifindex (`"[fe80::x%3]:4870"`) —
/// interface-name scopes do not parse.
Available {
npub: String,
addr: String,
transport_type: String,
},
/// The platform observed the link go away (e.g. the Wi-Fi Aware data
/// path was lost). The node closes any pooled connection it holds for
/// the peer's current address on that transport so a dead socket is
/// not re-used; reconnection is left to the ordinary machinery.
Lost {
npub: String,
transport_type: String,
},
}
static QUEUE: Mutex<VecDeque<PlatformPeerEvent>> = Mutex::new(VecDeque::new());
fn push(event: PlatformPeerEvent) {
let mut queue = QUEUE.lock().unwrap_or_else(|e| e.into_inner());
if queue.len() >= QUEUE_CAP {
queue.pop_front();
}
queue.push_back(event);
}
/// Push "peer is reachable at `addr` over `transport_type`".
pub fn platform_peer_available(npub: &str, addr: &str, transport_type: &str) {
push(PlatformPeerEvent::Available {
npub: npub.to_string(),
addr: addr.to_string(),
transport_type: transport_type.to_string(),
});
}
/// Push "the platform-managed link to peer went away".
pub fn platform_peer_lost(npub: &str, transport_type: &str) {
push(PlatformPeerEvent::Lost {
npub: npub.to_string(),
transport_type: transport_type.to_string(),
});
}
/// Drain all queued events. Called by the node once per tick.
pub fn drain_platform_peer_events() -> Vec<PlatformPeerEvent> {
let mut queue = QUEUE.lock().unwrap_or_else(|e| e.into_inner());
queue.drain(..).collect()
}
#[cfg(test)]
mod tests {
use super::*;
/// The queue is a process-global, so tests touching it must not
/// interleave across test threads.
static TEST_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn push_drain_roundtrip() {
let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
drain_platform_peer_events();
platform_peer_available("npub1abc", "[fe80::1%3]:4870", "tcp");
platform_peer_lost("npub1abc", "tcp");
let events = drain_platform_peer_events();
assert_eq!(events.len(), 2);
assert_eq!(
events[0],
PlatformPeerEvent::Available {
npub: "npub1abc".into(),
addr: "[fe80::1%3]:4870".into(),
transport_type: "tcp".into(),
}
);
assert_eq!(
events[1],
PlatformPeerEvent::Lost {
npub: "npub1abc".into(),
transport_type: "tcp".into(),
}
);
assert!(drain_platform_peer_events().is_empty());
}
#[test]
fn queue_caps_by_dropping_oldest() {
let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
drain_platform_peer_events();
for i in 0..(QUEUE_CAP + 10) {
platform_peer_available(&format!("npub{i}"), "addr", "tcp");
}
let events = drain_platform_peer_events();
assert_eq!(events.len(), QUEUE_CAP);
match &events[0] {
PlatformPeerEvent::Available { npub, .. } => assert_eq!(npub, "npub10"),
other => panic!("unexpected event: {other:?}"),
}
}
}
+5 -29
View File
@@ -439,30 +439,14 @@ impl Node {
let origin_coords = self.tree_state().my_coords().clone();
let request = LookupRequest::generate(*target, origin, origin_coords, ttl, 0);
// Prefer tree peers whose bloom filter contains the target.
let mut peer_addrs: Vec<NodeAddr> = self
// Send only to tree peers whose bloom filter contains the target
let peer_addrs: Vec<NodeAddr> = self
.peers
.iter()
.filter(|(addr, peer)| self.is_tree_peer(addr) && peer.may_reach(target))
.map(|(addr, _)| *addr)
.collect();
// Edge fallback: if no tree peer advertises the target — always the case
// for a *directly connected* target, since a neighbour is never in another
// peer's bloom — flood the request to EVERY peer we can send to, not just
// tree peers. Crucially this includes the target itself when it's a direct
// (non-tree) neighbour: a node answers a lookup for its own address, so the
// querier learns its coordinates and can route to it. Without this a
// mesh-edge node can't reach its own neighbours.
if peer_addrs.is_empty() {
peer_addrs = self
.peers
.iter()
.filter(|(_, peer)| peer.can_send())
.map(|(addr, _)| *addr)
.collect();
}
let peer_count = peer_addrs.len();
debug!(
@@ -525,22 +509,14 @@ impl Node {
return;
}
// Bloom filter pre-check: normally, if no peer's filter contains the
// target we skip. But a *directly connected* peer is never in any other
// peer's bloom (you reach it directly, not through them), so a mesh-edge
// node — one whose only peers are direct links (BLE / Wi-Fi Aware / LAN),
// with sparse blooms — could never discover coordinates for its own
// neighbours and would fail to route to them. Only suppress on a bloom
// miss when we have several peers whose blooms we can actually trust;
// otherwise fall through and flood the lookup (bounded by TTL + the
// `sent == 0` guard below).
// Bloom filter pre-check: if no peer's filter contains the target,
// it's not in the mesh — skip the lookup and record as failure.
let reachable = self.peers.values().any(|peer| peer.may_reach(dest));
if !reachable && self.peers.len() > 2 {
if !reachable {
self.metrics().discovery.req_bloom_miss.inc();
self.discovery_backoff.record_failure(dest);
debug!(
target_node = %self.peer_display_name(dest),
peers = self.peers.len(),
"Discovery skipped, target not in any peer bloom filter"
);
return;
+2 -21
View File
@@ -263,11 +263,6 @@ impl Node {
let ce_flag = header.flags & FLAG_CE != 0;
let sp_flag = header.flags & FLAG_SP != 0;
// Transport-preference cutover: gate roaming so a faster transport
// (Wi-Fi Aware / UDP) isn't dragged back to a slower one (BLE) by a
// stray packet. Computed before the peer borrow (needs `self`).
let roam_pref = self.transport_preference(packet.transport_id);
let roam_hysteresis = self.roam_hysteresis_ms();
if let Some(peer) = self.peers.get_mut(&node_addr) {
if let Some(mmp) = peer.mmp_mut() {
mmp.receiver.record_recv(
@@ -279,13 +274,7 @@ impl Node {
);
let _spin_rtt = mmp.spin_bit.rx_observe(sp_flag, header.counter, now);
}
peer.roam_current_addr(
packet.transport_id,
packet.remote_addr.clone(),
roam_pref,
packet.timestamp_ms,
roam_hysteresis,
);
peer.set_current_addr(packet.transport_id, packet.remote_addr.clone());
peer.link_stats_mut()
.record_recv(packet.data.len(), packet.timestamp_ms);
peer.touch(packet.timestamp_ms);
@@ -367,18 +356,10 @@ impl Node {
return;
};
let now = Instant::now();
let roam_pref = self.transport_preference(transport_id);
let roam_hysteresis = self.roam_hysteresis_ms();
let mut address_changed = false;
if let Some(peer) = self.peers.get_mut(node_addr) {
peer.reset_decrypt_failures();
address_changed = peer.roam_current_addr(
transport_id,
remote_addr.clone(),
roam_pref,
packet_timestamp_ms,
roam_hysteresis,
);
address_changed = peer.set_current_addr(transport_id, remote_addr.clone());
peer.link_stats_mut()
.record_recv(packet_len, packet_timestamp_ms);
peer.touch(packet_timestamp_ms);
-5
View File
@@ -151,11 +151,6 @@ impl Node {
}
} else {
self.metrics().forwarding.record_forwarded(encoded.len());
// Classify this transit forward by route class (partition of
// forwarded_packets). Done here, at the data-plane chokepoint, so
// the error-signal routing callers of find_next_hop are excluded.
let class = self.classify_forward(&datagram.dest_addr, &next_hop_addr);
self.metrics().forwarding.record_route_class(class);
if outgoing_ce {
self.metrics().congestion.ce_forwarded.inc();
}
-1
View File
@@ -264,7 +264,6 @@ impl Node {
self.poll_pending_connects().await;
self.poll_nostr_discovery().await;
self.poll_lan_discovery().await;
self.poll_platform_discovery().await;
self.resend_pending_handshakes(now_ms).await;
self.resend_pending_rekeys(now_ms).await;
self.resend_pending_session_handshakes(now_ms).await;
-9
View File
@@ -2140,15 +2140,6 @@ impl Node {
}
if let Err(e) = self.send_ipv6_packet(&dest_addr, &ipv6_packet).await {
debug!(dest = %self.peer_display_name(&dest_addr), error = %e, "Failed to send TUN packet via session");
// An established session can still have no route when its
// coordinates were never learned or went stale — e.g. a
// platform-pushed peer (Wi-Fi Aware / LAN) whose Noise session
// came up before tree discovery. Unlike session initiation
// (below), this path didn't trigger a lookup, so the route
// never warmed and every packet was silently dropped. Kick
// discovery (rate-limited internally); the app's retransmit
// then succeeds once coordinates arrive.
self.maybe_initiate_lookup(&dest_addr).await;
}
return;
}
+29 -186
View File
@@ -367,27 +367,6 @@ impl Node {
.min_by_key(|(id, _)| id.as_u32())
}
/// Resolve a discovered `(transport_type, addr)` pair to an operational
/// transport. For `udp` addresses the socket family matters — a wildcard
/// IPv4 socket cannot send to an IPv6 link-local target (e.g. a Wi-Fi
/// Aware data path) — so when the address parses as a socket address the
/// selection is family-aware and skips bootstrap-adopted sockets. All
/// other transport types match by name alone.
fn find_transport_for_discovered_addr(
&self,
transport_type: &str,
addr: &str,
) -> Option<TransportId> {
if transport_type == "udp"
&& let Ok(remote_addr) = addr.parse::<SocketAddr>()
{
return self
.find_udp_transport_for_remote_addr(remote_addr)
.map(|(id, _)| id);
}
self.find_transport_for_type(transport_type)
}
/// Initiate a connection to a peer on a specific transport and address.
///
/// For connectionless transports (UDP, Ethernet): allocates a link, starts
@@ -620,18 +599,6 @@ impl Node {
let remote_addr = peer.addr;
if self.peers.contains_key(&node_addr) {
// Don't re-probe an alternate path that is strictly worse
// than the one the peer is already on: a peer settled on a
// faster transport (Wi-Fi Aware / UDP) must not be pulled
// back to BLE by BLE rediscovery. Equal-or-better candidates
// still refresh, so BLE → Aware upgrades proceed.
if let Some(cur) = self.peers.get(&node_addr).and_then(|p| p.transport_id()) {
if self.transport_preference(cur)
> self.transport_preference(candidate_transport_id)
{
continue;
}
}
let transport_name = transport.transport_type().name;
let candidate = PeerAddress::new(transport_name, remote_addr.to_string());
if self.active_peer_candidate_is_fresh_enough_to_skip(
@@ -989,139 +956,6 @@ impl Node {
}
}
/// Drain platform-pushed peers and initiate Noise IK handshakes.
///
/// The transport-agnostic sibling of `poll_lan_discovery`: an embedding
/// platform (e.g. the Android Wi-Fi Aware radio) pushes
/// `(npub, addr, transport type)` events into the process-global queue
/// (`crate::discovery::platform`) and this drains them once per tick.
/// As with mDNS, the pushed npub is only a hint — the IK handshake is
/// the authentication.
///
/// Unlike the LAN drain, an event for an already-active peer starts an
/// alternate-path handshake (gated by the same freshness/in-flight
/// checks as `poll_transport_discovery`), so a platform push can move a
/// peer onto a faster transport — the BLE→Wi-Fi-Aware cutover.
pub(super) async fn poll_platform_discovery(&mut self) {
let events = crate::discovery::platform::drain_platform_peer_events();
if events.is_empty() {
return;
}
let mut connect_budget = self.discovery_connect_budget();
for event in events {
match event {
crate::discovery::platform::PlatformPeerEvent::Available {
npub,
addr,
transport_type,
} => {
let Some(transport_id) =
self.find_transport_for_discovered_addr(&transport_type, &addr)
else {
debug!(
npub = %npub,
transport_type = %transport_type,
addr = %addr,
"platform: skip pushed peer with no compatible operational transport"
);
continue;
};
let identity = match crate::PeerIdentity::from_npub(&npub) {
Ok(id) => id,
Err(err) => {
debug!(npub = %npub, error = %err, "platform: skip bad npub");
continue;
}
};
let peer_node_addr = *identity.node_addr();
if peer_node_addr == *self.identity().node_addr() {
continue;
}
let remote_addr = crate::transport::TransportAddr::from_string(&addr);
if self.peers.contains_key(&peer_node_addr) {
// Active peer: this is a path upgrade, not a first
// contact — apply the alternate-path gates.
let candidate = PeerAddress::new(&transport_type, addr.clone());
if self.active_peer_candidate_is_fresh_enough_to_skip(
&peer_node_addr,
std::slice::from_ref(&candidate),
) {
continue;
}
}
if self.is_connecting_to_peer_on_path(
&peer_node_addr,
transport_id,
&remote_addr,
) {
continue;
}
if connect_budget == 0 {
debug!(npub = %npub, "platform: connect budget exhausted");
continue;
}
connect_budget = connect_budget.saturating_sub(1);
info!(
peer = %self.peer_display_name(&peer_node_addr),
transport_id = %transport_id,
remote_addr = %remote_addr,
"platform: initiating handshake to pushed peer"
);
if let Err(err) = self
.initiate_connection(transport_id, remote_addr, identity)
.await
{
debug!(
npub = %npub,
error = %err,
"platform: failed to initiate connection to pushed peer"
);
}
}
crate::discovery::platform::PlatformPeerEvent::Lost {
npub,
transport_type,
} => {
let Ok(identity) = crate::PeerIdentity::from_npub(&npub) else {
continue;
};
let peer_node_addr = *identity.node_addr();
let Some(peer) = self.peers.get(&peer_node_addr) else {
continue;
};
// Only act if the peer currently sits on the named
// transport type: close the pooled connection so the
// dead socket is not re-used. Reconnection (including
// falling back to another transport) is the ordinary
// machinery's job.
let (Some(transport_id), Some(current_addr)) =
(peer.transport_id(), peer.current_addr().cloned())
else {
continue;
};
let on_named_transport = self
.transports
.get(&transport_id)
.map(|t| t.transport_type().name == transport_type)
.unwrap_or(false);
if !on_named_transport {
continue;
}
info!(
peer = %self.peer_display_name(&peer_node_addr),
transport_id = %transport_id,
remote_addr = %current_addr,
"platform: closing connection for lost pushed peer"
);
if let Some(transport) = self.transports.get(&transport_id) {
transport.close_connection(&current_addr).await;
}
}
}
}
}
/// Poll pending transport connects and initiate handshakes for ready ones.
///
/// Called from the tick handler. For each pending connect, queries the
@@ -1242,12 +1076,6 @@ impl Node {
match handle.start().await {
Ok(()) => {
#[cfg(unix)]
if transport_type == "udp"
&& let (Some(tx), Some(fd)) = (&self.udp_bind_tx, handle.raw_fd())
{
let _ = tx.send(fd);
}
self.transports.insert(transport_id, handle);
}
Err(e) => {
@@ -1373,10 +1201,8 @@ impl Node {
// This allows handshake messages to be sent before we start accepting packets
self.initiate_peer_connections().await;
// Initialize TUN interface last, after transports and peers are ready.
// Skip when the TUN is app-owned (the embedder pre-set `tun_tx` via
// `enable_app_owned_tun`) — then FIPS does no system-TUN ops.
if self.config().tun.enabled && self.tun_tx.is_none() {
// Initialize TUN interface last, after transports and peers are ready
if self.config().tun.enabled {
let address = *self.identity().address();
match TunDevice::create(&self.config().tun, address).await {
Ok(device) => {
@@ -1941,17 +1767,34 @@ impl Node {
continue;
}
} else {
match self.find_transport_for_discovered_addr(&addr.transport, &addr.addr) {
Some(tid) => (tid, TransportAddr::from_string(&addr.addr)),
None => {
debug!(
transport = %addr.transport,
addr = %addr.addr,
"No compatible operational transport for address"
);
continue;
let tid = if addr.transport == "udp"
&& let Ok(remote_socket_addr) = addr.addr.parse::<SocketAddr>()
{
match self.find_udp_transport_for_remote_addr(remote_socket_addr) {
Some((id, _)) => id,
None => {
debug!(
transport = %addr.transport,
addr = %addr.addr,
"No compatible operational UDP transport for address"
);
continue;
}
}
}
} else {
match self.find_transport_for_type(&addr.transport) {
Some(id) => id,
None => {
debug!(
transport = %addr.transport,
addr = %addr.addr,
"No operational transport for address type"
);
continue;
}
}
};
(tid, TransportAddr::from_string(&addr.addr))
};
if self.is_connecting_to_peer_on_path(&peer_node_addr, transport_id, &remote_addr) {
-66
View File
@@ -81,51 +81,6 @@ pub struct ForwardingMetrics {
pub drop_send_error_bytes: Counter,
pub originated_packets: Counter,
pub originated_bytes: Counter,
pub route_tree_up: Counter,
pub route_tree_down: Counter,
pub route_tree_down_cross: Counter,
pub route_crosslink_descend: Counter,
pub route_crosslink_ascend: Counter,
pub route_direct_peer: Counter,
}
/// Route class of a transit-forwarded packet, classified from tree
/// coordinates at the forwarding decision point. The six variants
/// partition `forwarded_packets` exactly.
///
/// Two variants are up-and-over forwards (destination not in the chosen
/// peer's subtree); they differ in whether they depend on a child
/// advertising cross-link reach *upward* to its parent:
/// - `TreeDownCross`: the chosen peer is our tree descendant, but the
/// destination is *not* in that child's subtree. The forward only fired
/// because the child advertised cross-link reach upward to us, beyond its
/// own subtree. If children advertised only their subtree upward, this
/// forward would route up instead, so its count measures how much
/// forwarding depends on the upward cross-link advertisement — the
/// dive-to-tree-child cut-through.
/// - `CrosslinkAscend`: the chosen peer is lateral (neither ancestor nor
/// descendant) and the destination is not in its subtree. This is a node
/// using its *own* cross-link, learned via the peer's split-horizon
/// advertisement to its neighbors, so it does not depend on any upward
/// advertisement. Tracked alongside `TreeDownCross` as the lateral
/// up-and-over contrast.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RouteClass {
/// Chosen peer is our ancestor (tree-up).
TreeUp,
/// Chosen peer is our descendant and dest is in its subtree (canonical
/// tree-down).
TreeDown,
/// Chosen peer is our descendant but dest is *not* in its subtree: the
/// dive-to-tree-child cut-through enabled by upward cross-link
/// advertisement.
TreeDownCross,
/// Chosen peer is lateral and dest is in its subtree (subtree entry).
CrosslinkDescend,
/// Chosen peer is lateral and dest is not in its subtree (up-and-over).
CrosslinkAscend,
/// Chosen peer is the destination itself (degenerate direct hop).
DirectPeer,
}
impl ForwardingMetrics {
@@ -157,21 +112,6 @@ impl ForwardingMetrics {
self.originated_bytes.add(bytes as u64);
}
/// Record the route class of a transit-forwarded packet. The five
/// classes partition `forwarded_packets`, so this is called exactly
/// once per `record_forwarded` (transit chokepoint only).
#[inline]
pub fn record_route_class(&self, class: RouteClass) {
match class {
RouteClass::TreeUp => self.route_tree_up.inc(),
RouteClass::TreeDown => self.route_tree_down.inc(),
RouteClass::TreeDownCross => self.route_tree_down_cross.inc(),
RouteClass::CrosslinkDescend => self.route_crosslink_descend.inc(),
RouteClass::CrosslinkAscend => self.route_crosslink_ascend.inc(),
RouteClass::DirectPeer => self.route_direct_peer.inc(),
}
}
/// Mirror of `ForwardingStats::record_reject_bytes`: route a typed
/// forwarding rejection of `bytes` payload to its packet and byte
/// counters.
@@ -223,12 +163,6 @@ impl ForwardingMetrics {
drop_send_error_bytes: self.drop_send_error_bytes.get(),
originated_packets: self.originated_packets.get(),
originated_bytes: self.originated_bytes.get(),
route_tree_up: self.route_tree_up.get(),
route_tree_down: self.route_tree_down.get(),
route_tree_down_cross: self.route_tree_down_cross.get(),
route_crosslink_descend: self.route_crosslink_descend.get(),
route_crosslink_ascend: self.route_crosslink_ascend.get(),
route_direct_peer: self.route_direct_peer.get(),
}
}
}
+9 -249
View File
@@ -41,18 +41,14 @@ use self::routing_error_rate_limit::RoutingErrorRateLimiter;
/// `node.rekey.after_secs` remains the nominal interval (mean preserved).
pub(crate) const REKEY_JITTER_SECS: i64 = 15;
use self::wire::{
FLAG_CE, FLAG_KEY_EPOCH, FLAG_SP, build_encrypted, build_established_header,
prepend_inner_header,
ESTABLISHED_HEADER_SIZE, FLAG_CE, FLAG_KEY_EPOCH, FLAG_SP, build_encrypted,
build_established_header, prepend_inner_header,
};
// Only referenced by the unix UDP fast-path block below; on Windows the wire
// buffer is sized through build_encrypted, leaving this import otherwise unused.
#[cfg(unix)]
use self::wire::ESTABLISHED_HEADER_SIZE;
use crate::bloom::{BloomFilter, BloomState};
use crate::cache::CoordCache;
use crate::node::session::SessionEntry;
use crate::peer::{ActivePeer, PeerConnection};
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[cfg(unix)]
use crate::transport::ethernet::EthernetTransport;
use crate::transport::nym::NymTransport;
use crate::transport::tcp::TcpTransport;
@@ -65,7 +61,7 @@ use crate::transport::{
use crate::tree::TreeState;
use crate::upper::hosts::HostMap;
use crate::upper::icmp_rate_limit::IcmpRateLimiter;
use crate::upper::tun::{TunError, TunOutboundRx, TunOutboundTx, TunState, TunTx};
use crate::upper::tun::{TunError, TunOutboundRx, TunState, TunTx};
use crate::utils::index::IndexAllocator;
use crate::{Config, ConfigError, Identity, IdentityError, NodeAddr, PeerIdentity};
use rand::Rng;
@@ -440,13 +436,6 @@ pub struct Node {
/// DNS responder task handle.
dns_task: Option<tokio::task::JoinHandle<()>>,
// === App-owned UDP socket binding ===
/// Fires once with the UDP transport's raw fd right after it starts, so an
/// embedder can apply host-specific socket options (e.g. pinning it to one
/// of several OS networks). See [`Self::enable_app_owned_udp_fd`].
#[cfg(unix)]
udp_bind_tx: Option<std::sync::mpsc::Sender<std::os::unix::io::RawFd>>,
// === Index-Based Session Dispatch ===
/// Allocator for session indices.
index_allocator: IndexAllocator,
@@ -706,8 +695,6 @@ impl Node {
tun_shutdown_fd: None,
dns_identity_rx: None,
dns_task: None,
#[cfg(unix)]
udp_bind_tx: None,
index_allocator: IndexAllocator::new(),
peers_by_index: HashMap::new(),
pending_outbound: HashMap::new(),
@@ -869,8 +856,6 @@ impl Node {
tun_shutdown_fd: None,
dns_identity_rx: None,
dns_task: None,
#[cfg(unix)]
udp_bind_tx: None,
index_allocator: IndexAllocator::new(),
peers_by_index: HashMap::new(),
pending_outbound: HashMap::new(),
@@ -943,7 +928,7 @@ impl Node {
}
// Create Ethernet transport instances (Unix only — requires raw sockets)
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[cfg(unix)]
{
let eth_instances: Vec<_> = self
.config()
@@ -1054,47 +1039,6 @@ impl Node {
}
}
// Android BLE: the radio lives in Kotlin; build AndroidIo over the bridge
// injected by the embedder (see ble::android_io::set_android_ble_bridge).
#[cfg(all(target_os = "android", not(test)))]
{
let ble_instances: Vec<_> = self
.config()
.transports
.ble
.iter()
.map(|(name, config)| (name.map(|s| s.to_string()), config.clone()))
.collect();
// Built whether or not a radio bridge exists yet, and without
// capturing the one that does: `AndroidIo` resolves the process-wide
// bridge per operation. The radio is owned by an Android foreground
// service whose lifetime is independent of the node's — it can start
// after the node, and it mints a fresh bridge every time it starts.
// Binding either fact into the transport at construction meant the
// only way to pick up a radio was to stop and rebuild the node,
// which drops every peer and session. Operations attempted while no
// radio is present fail as transport errors and recover on their own
// once one appears.
if !ble_instances.is_empty()
&& crate::transport::ble::android_io::android_ble_bridge().is_none()
{
tracing::info!("BLE configured; waiting for the Android radio bridge");
}
for (name, ble_config) in ble_instances {
let transport_id = self.allocate_transport_id();
let io = crate::transport::ble::android_io::AndroidIo::from_global();
let mut ble = crate::transport::ble::BleTransport::new(
transport_id,
name,
ble_config,
io,
packet_tx.clone(),
);
ble.set_local_pubkey(self.identity().pubkey().serialize());
transports.push(TransportHandle::Ble(ble));
}
}
transports
}
@@ -1108,29 +1052,6 @@ impl Node {
.map(|(id, _)| *id)
}
/// Link preference of a transport instance by id (higher = preferred),
/// from its transport type. See [`crate::transport::transport_link_preference`].
/// Unknown ids get 0 so any real transport outranks a stale/removed one.
pub(crate) fn transport_preference(&self, id: TransportId) -> u8 {
self.transports
.get(&id)
.map(|h| crate::transport::transport_link_preference(h.transport_type().name))
.unwrap_or(0)
}
/// How long a preferred transport must be silent before a lower-preference
/// transport may take over the peer's link (the roaming cutover hysteresis).
/// ~2 heartbeat intervals so a single missed heartbeat can't cause a
/// spurious downgrade; floored so a tiny configured interval can't flap.
pub(crate) fn roam_hysteresis_ms(&self) -> u64 {
(self
.config()
.node
.heartbeat_interval_secs
.saturating_mul(2_000))
.max(15_000)
}
/// Resolve an Ethernet peer address ("interface/mac") to a transport ID
/// and binary TransportAddr.
///
@@ -1141,7 +1062,7 @@ impl Node {
&self,
addr_str: &str,
) -> Result<(TransportId, TransportAddr), NodeError> {
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[cfg(unix)]
{
let (iface, mac_str) = addr_str.split_once('/').ok_or_else(|| {
NodeError::NoTransportForType(format!(
@@ -1173,7 +1094,7 @@ impl Node {
Ok((transport_id, TransportAddr::from_bytes(&mac)))
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
#[cfg(not(unix))]
{
Err(NodeError::NoTransportForType(
"Ethernet transport is not supported on this platform".to_string(),
@@ -1514,10 +1435,8 @@ impl Node {
/// over this node's already-shared `NodeContext` and `MetricsRegistry`.
///
/// Used at control-socket spawn time so pure-snapshot `show_*` queries
/// render off the rx_loop. Cloneable; cheap (all `Arc` clones). Also the
/// public seam for embedders that run [`Self::run_rx_loop`] on a background
/// task and poll peer state via [`crate::control::read_handle::ControlReadHandle::peer_views`].
pub fn control_read_handle(&self) -> crate::control::read_handle::ControlReadHandle {
/// render off the rx_loop. Cloneable; cheap (all `Arc` clones).
pub(crate) fn control_read_handle(&self) -> crate::control::read_handle::ControlReadHandle {
crate::control::read_handle::ControlReadHandle::new(
self.context.clone(),
self.metrics.clone(),
@@ -2747,86 +2666,6 @@ impl Node {
self.peers.get(&next_hop_id).filter(|p| p.can_send())
}
/// Classify a transit forward by route class from tree coordinates.
///
/// Called at the transit chokepoint after `find_next_hop` returns a peer,
/// so the six classes partition `forwarded_packets` exactly. The branch
/// that `find_next_hop` took (bloom vs greedy-tree) is *not* the route
/// class: a peer can be selected by either, so the cut-through splits
/// (`TreeDownCross`, `CrosslinkAscend`) are decided here from coordinates,
/// not from which branch fired.
///
/// Inputs: our coords (`tree_state.my_coords`), the chosen peer's coords
/// (`tree_state.peer_coords`), and the destination coords (re-read from the
/// coord cache, which `find_next_hop` just touched). Both the tree-down and
/// cross-link branches split on whether the destination is in the chosen
/// peer's subtree; when the dest coords are unavailable that test defaults
/// to "not in subtree", i.e. the up-and-over variant (`TreeDownCross` for a
/// descendant peer, `CrosslinkAscend` for a lateral one).
pub(crate) fn classify_forward(
&self,
dest: &NodeAddr,
chosen_peer: &NodeAddr,
) -> metrics::RouteClass {
// Degenerate: the next hop is the destination itself (Branch 2).
if chosen_peer == dest {
return metrics::RouteClass::DirectPeer;
}
let my_addr = self.node_addr();
let my_coords = self.tree_state.my_coords();
// Tree-up: the chosen peer is our ancestor.
if my_coords.has_ancestor(chosen_peer) {
return metrics::RouteClass::TreeUp;
}
// Whether the destination is in the chosen peer's subtree. Both the
// tree-down and cross-link splits below turn on this same test, so it
// is computed once. On the live transit path the dest coords are
// always present here: `find_next_hop` looks them up with an early
// return, so a coord-cache miss yields no next hop to classify (the
// caller signals `CoordsRequired` instead of forwarding). The miss
// branch below is therefore defensive — reachable only by direct
// unit-test calls — and defaults the test to "not in subtree", i.e.
// the up-and-over variant of whichever branch fires (TreeDownCross for
// a descendant peer, CrosslinkAscend for a lateral one), matching the
// original cross-link default-to-ascend.
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let dest_in_peer_subtree = self
.coord_cache
.get(dest, now_ms)
.is_some_and(|dest_coords| dest_coords.has_ancestor(chosen_peer));
// Tree-down: the chosen peer is our descendant (we are its ancestor).
// Split by subtree membership: a dest genuinely below the child is the
// canonical tree-down; a dest *not* below it means we only forwarded
// down because the child advertised cross-link reach upward, beyond its
// own subtree — the dive-to-tree-child cut-through (TreeDownCross).
if let Some(peer_coords) = self.tree_state.peer_coords(chosen_peer)
&& peer_coords.has_ancestor(my_addr)
{
return if dest_in_peer_subtree {
metrics::RouteClass::TreeDown
} else {
metrics::RouteClass::TreeDownCross
};
}
// Cross-link (lateral): split by whether the destination is in the
// chosen peer's subtree. Descend = subtree entry; ascend = up-and-over
// via the node's own cross-link (learned from the peer's split-horizon
// advertisement, independent of any upward advertisement).
if dest_in_peer_subtree {
return metrics::RouteClass::CrosslinkDescend;
}
metrics::RouteClass::CrosslinkAscend
}
/// Select the best peer from a set of bloom filter candidates.
///
/// Uses distance from each candidate's tree coordinates to the destination
@@ -2894,85 +2733,6 @@ impl Node {
self.tun_tx.as_ref()
}
/// Set up an **app-owned TUN**: rather than FIPS creating a system TUN
/// device, the embedder (e.g. an Android `VpnService`) owns the fd and
/// exchanges IPv6 packet bytes with FIPS over the returned channels. Call
/// this after [`Node::new`] and **before** [`Self::start`] — and before
/// moving the node into a background task — exactly like
/// [`Self::control_read_handle`].
///
/// Returns `(app_outbound_tx, app_inbound_rx)`:
/// - push IPv6 packets read from the app's TUN fd into `app_outbound_tx`
/// (app → mesh); FIPS routes them to the destination node.
/// - pull IPv6 packets destined for the app's TUN fd from `app_inbound_rx`
/// (mesh → app) and write them to the fd (`recv_timeout` for clean stop).
///
/// With this set, [`Self::start`] skips system-TUN creation (it gates on
/// `tun_tx` being unset). Packets pushed into `app_outbound_tx` bypass the
/// system-TUN reader's `handle_tun_packet`, so the embedder must do what that
/// path otherwise would: push only `fd::/8`-destined IPv6 packets — FIPS no
/// longer filters the destination or emits ICMPv6 unreachable for off-mesh
/// dests — and clamp TCP MSS on outbound SYNs.
pub fn enable_app_owned_tun(&mut self) -> (TunOutboundTx, std::sync::mpsc::Receiver<Vec<u8>>) {
let tun_channel_size = self.config().node.buffers.tun_channel;
// app → mesh: the app pushes; `run_rx_loop` drains `tun_outbound_rx`.
let (outbound_tx, outbound_rx) = tokio::sync::mpsc::channel(tun_channel_size);
// mesh → app: the node writes inbound packets to `tun_tx`; the app pulls.
let (tun_tx, tun_rx) = std::sync::mpsc::channel();
self.tun_tx = Some(tun_tx);
self.tun_outbound_rx = Some(outbound_rx);
self.tun_state = TunState::Active;
(outbound_tx, tun_rx)
}
/// Set up an **app-owned DNS resolver**: an embedder that answers `.fips`
/// queries itself (e.g. the Android VpnService packet pump, which has no
/// system DNS socket) returns each resolved identity through the sender this
/// returns. The `run_rx_loop` consumes them and calls
/// [`Self::register_identity`] — the same identity-cache population (and hence
/// route warming) the built-in [`crate::upper::dns::run_dns_responder`] does.
///
/// Without this the embedder's answers resolve the AAAA but leave the node's
/// identity cache empty, so the first outbound packet to a freshly-resolved
/// `<npub>.fips` has no cached pubkey to open a session with and is dropped.
///
/// Call after [`Node::new`] and **before** [`Self::start`], like
/// [`Self::enable_app_owned_tun`].
pub fn enable_app_owned_dns(&mut self) -> crate::upper::dns::DnsIdentityTx {
let size = self.config().node.buffers.tun_channel.max(1);
let (identity_tx, identity_rx) = tokio::sync::mpsc::channel(size);
self.dns_identity_rx = Some(identity_rx);
identity_tx
}
/// Set up an **app-owned UDP socket binding**: the returned receiver gets
/// the UDP transport's raw socket fd once, right after the transport opens
/// inside [`Self::start`], so an embedder can apply a socket option FIPS
/// itself has no way to choose — in particular pinning the socket to one
/// of several networks the host OS offers.
///
/// FIPS opens a single wildcard UDP socket and picks the egress path per
/// destination address, which assumes the OS routes by destination alone.
/// Some hosts don't: where the OS binds each socket to one "network" and
/// steers replies by that association, a peer reachable only over a
/// secondary network (a link-local address on an interface that is not
/// the default route) can receive our handshake and have its reply
/// discarded before it reaches the socket. The fd is the only handle that
/// lets the embedder correct this, and it is otherwise private to the
/// transport.
///
/// Call after [`Node::new`] and before [`Self::start`], like
/// [`Self::enable_app_owned_tun`]. Nothing is ever sent if no UDP
/// transport is configured or it fails to start.
#[cfg(unix)]
pub fn enable_app_owned_udp_fd(
&mut self,
) -> std::sync::mpsc::Receiver<std::os::unix::io::RawFd> {
let (tx, rx) = std::sync::mpsc::channel();
self.udp_bind_tx = Some(tx);
rx
}
// === Sending ===
/// Encrypt and send a link-layer message to an authenticated peer.
-6
View File
@@ -229,12 +229,6 @@ pub struct ForwardingStatsSnapshot {
pub drop_send_error_bytes: u64,
pub originated_packets: u64,
pub originated_bytes: u64,
pub route_tree_up: u64,
pub route_tree_down: u64,
pub route_tree_down_cross: u64,
pub route_crosslink_descend: u64,
pub route_crosslink_ascend: u64,
pub route_direct_peer: u64,
}
#[derive(Clone, Debug, Default, Serialize)]
-345
View File
@@ -1110,348 +1110,3 @@ async fn test_routing_source_only_coords_100_nodes() {
cleanup_nodes(&mut nodes).await;
}
// === Route-class classification (transit-forward partition) ===
use crate::node::metrics::{ForwardingMetrics, RouteClass};
/// Current epoch millis, matching the cache-insert idiom used above.
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
#[test]
fn test_classify_forward_tree_up() {
// my_coords = [me, parent, root]; the chosen peer is our parent (an
// ancestor in our path) → tree-up.
let mut node = make_node();
let me = *node.node_addr();
let parent = make_node_addr(10);
let root = make_node_addr(1);
node.tree_state_mut()
.set_my_coords_for_test(TreeCoordinate::from_addrs(vec![me, parent, root]).unwrap());
// Destination somewhere above us; routed via the parent.
let dest = make_node_addr(50);
node.coord_cache_mut().insert(
dest,
TreeCoordinate::from_addrs(vec![dest, root]).unwrap(),
now_ms(),
);
assert_eq!(
node.classify_forward(&dest, &parent),
RouteClass::TreeUp,
"chosen peer is our ancestor"
);
}
#[test]
fn test_classify_forward_tree_down() {
// Chosen peer is our descendant: its coords name us as an ancestor.
let mut node = make_node();
let me = *node.node_addr();
let root = make_node_addr(1);
node.tree_state_mut()
.set_my_coords_for_test(TreeCoordinate::from_addrs(vec![me, root]).unwrap());
let child = make_node_addr(20);
node.tree_state_mut().update_peer(
ParentDeclaration::new(child, me, 1, 1000),
TreeCoordinate::from_addrs(vec![child, me, root]).unwrap(),
);
// Destination below the child; routed down to it.
let dest = make_node_addr(60);
node.coord_cache_mut().insert(
dest,
TreeCoordinate::from_addrs(vec![dest, child, me, root]).unwrap(),
now_ms(),
);
assert_eq!(
node.classify_forward(&dest, &child),
RouteClass::TreeDown,
"chosen peer is our descendant, dest in its subtree"
);
}
#[test]
fn test_classify_forward_tree_down_cross() {
// Chosen peer is our descendant (a tree child), but the destination is NOT
// in that child's subtree: we are diving down to the child only because it
// advertised cross-link reach upward, beyond its own subtree. This is the
// dive-to-tree-child cut-through.
let mut node = make_node();
let me = *node.node_addr();
let root = make_node_addr(1);
node.tree_state_mut()
.set_my_coords_for_test(TreeCoordinate::from_addrs(vec![me, root]).unwrap());
let child = make_node_addr(20);
node.tree_state_mut().update_peer(
ParentDeclaration::new(child, me, 1, 1000),
TreeCoordinate::from_addrs(vec![child, me, root]).unwrap(),
);
// Destination lives elsewhere (directly under root), NOT under the child;
// reachable from the child only via a cross-link.
let dest = make_node_addr(60);
node.coord_cache_mut().insert(
dest,
TreeCoordinate::from_addrs(vec![dest, root]).unwrap(),
now_ms(),
);
assert_eq!(
node.classify_forward(&dest, &child),
RouteClass::TreeDownCross,
"descendant peer, dest not in its subtree (dive-to-tree-child cut-through)"
);
}
#[test]
fn test_classify_forward_crosslink_descend() {
// Chosen peer is lateral (not in our path, we are not in its path) and the
// destination is inside the peer's subtree → cross-link descend.
let mut node = make_node();
let me = *node.node_addr();
let root = make_node_addr(1);
let sibling_parent = make_node_addr(2);
node.tree_state_mut()
.set_my_coords_for_test(TreeCoordinate::from_addrs(vec![me, root]).unwrap());
let peer = make_node_addr(30);
node.tree_state_mut().update_peer(
ParentDeclaration::new(peer, sibling_parent, 1, 1000),
TreeCoordinate::from_addrs(vec![peer, sibling_parent, root]).unwrap(),
);
// Destination is under the cross-link peer.
let dest = make_node_addr(70);
node.coord_cache_mut().insert(
dest,
TreeCoordinate::from_addrs(vec![dest, peer, sibling_parent, root]).unwrap(),
now_ms(),
);
assert_eq!(
node.classify_forward(&dest, &peer),
RouteClass::CrosslinkDescend,
"lateral peer, dest in its subtree"
);
}
#[test]
fn test_classify_forward_crosslink_ascend() {
// Chosen peer is lateral and the destination is NOT in its subtree → the
// up-and-over case (the Bloom v2 behavior delta).
let mut node = make_node();
let me = *node.node_addr();
let root = make_node_addr(1);
let sibling_parent = make_node_addr(2);
node.tree_state_mut()
.set_my_coords_for_test(TreeCoordinate::from_addrs(vec![me, root]).unwrap());
let peer = make_node_addr(40);
node.tree_state_mut().update_peer(
ParentDeclaration::new(peer, sibling_parent, 1, 1000),
TreeCoordinate::from_addrs(vec![peer, sibling_parent, root]).unwrap(),
);
// Destination lives elsewhere (under root directly), NOT under the peer.
let dest = make_node_addr(80);
node.coord_cache_mut().insert(
dest,
TreeCoordinate::from_addrs(vec![dest, root]).unwrap(),
now_ms(),
);
assert_eq!(
node.classify_forward(&dest, &peer),
RouteClass::CrosslinkAscend,
"lateral peer, dest not in its subtree"
);
}
#[test]
fn test_classify_forward_direct_peer() {
// Degenerate case: the next hop is the destination itself.
let mut node = make_node();
let me = *node.node_addr();
let root = make_node_addr(1);
node.tree_state_mut()
.set_my_coords_for_test(TreeCoordinate::from_addrs(vec![me, root]).unwrap());
let dest = make_node_addr(90);
assert_eq!(
node.classify_forward(&dest, &dest),
RouteClass::DirectPeer,
"next hop is the destination"
);
}
#[test]
fn test_route_class_partition_sums_to_forwarded() {
// The six route classes partition forwarded_packets: bumping
// record_forwarded once per record_route_class keeps the sum of the class
// counters equal to forwarded_packets.
let m = ForwardingMetrics::default();
let classes = [
RouteClass::TreeUp,
RouteClass::TreeUp,
RouteClass::TreeDown,
RouteClass::TreeDownCross,
RouteClass::TreeDownCross,
RouteClass::CrosslinkDescend,
RouteClass::CrosslinkAscend,
RouteClass::CrosslinkAscend,
RouteClass::CrosslinkAscend,
RouteClass::DirectPeer,
];
for &c in &classes {
m.record_forwarded(100);
m.record_route_class(c);
}
let snap = m.snapshot();
let class_sum = snap.route_tree_up
+ snap.route_tree_down
+ snap.route_tree_down_cross
+ snap.route_crosslink_descend
+ snap.route_crosslink_ascend
+ snap.route_direct_peer;
assert_eq!(
class_sum, snap.forwarded_packets,
"route classes must partition forwarded_packets"
);
assert_eq!(snap.route_tree_up, 2);
assert_eq!(snap.route_tree_down_cross, 2);
assert_eq!(snap.route_crosslink_ascend, 3);
assert_eq!(snap.route_direct_peer, 1);
}
// === Coord-cache invalidation on parent loss ===
//
// Parent-lost-via-peer-removal is a genuine position change and must
// surgically invalidate the coordinate cache like every other such path
// (reparent → invalidate_via_node; self-root → invalidate_other_roots).
// `make_node_addr(0)` is the network minimum, so the node's random identity
// addr is always greater than it — the reparent/child geometry is deterministic.
#[test]
fn test_parent_loss_reparent_invalidates_coord_cache() {
let mut node = make_node();
let my_addr = *node.node_addr();
let root = make_node_addr(0);
let parent = make_node_addr(1);
let alt = make_node_addr(2);
// Current parent and an alternative, both rooted at `root`.
node.tree_state_mut().update_peer(
ParentDeclaration::new(parent, root, 1, 1000),
TreeCoordinate::from_addrs(vec![parent, root]).unwrap(),
);
node.tree_state_mut().update_peer(
ParentDeclaration::new(alt, root, 1, 1000),
TreeCoordinate::from_addrs(vec![alt, root]).unwrap(),
);
// Adopt `parent`; our coords become [my_addr, parent, root], root = `root`.
node.tree_state_mut().set_parent(parent, 1, 1000);
node.tree_state_mut().recompute_coords();
assert!(!node.tree_state().is_root());
assert_eq!(node.tree_state().root(), &root);
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
// via-node class: a downstream destination that routes through us.
let downstream = make_node_addr(10);
node.coord_cache_mut().insert(
downstream,
TreeCoordinate::from_addrs(vec![downstream, my_addr, root]).unwrap(),
now_ms,
);
// survivor: same root, does not route through us.
let sibling_dest = make_node_addr(11);
node.coord_cache_mut().insert(
sibling_dest,
TreeCoordinate::from_addrs(vec![sibling_dest, alt, root]).unwrap(),
now_ms,
);
// Parent link drops; node reparents onto `alt` (still rooted at `root`).
let changed = node.handle_peer_removal_tree_cleanup(&parent);
assert!(changed);
assert_eq!(node.tree_state().my_declaration().parent_id(), &alt);
assert_eq!(node.tree_state().root(), &root);
assert!(
!node.coord_cache().contains(&downstream, now_ms),
"entry routing through us must be invalidated after reparent"
);
assert!(
node.coord_cache().contains(&sibling_dest, now_ms),
"same-root entry not routing through us must survive (surgical, not a flush)"
);
}
#[test]
fn test_parent_loss_selfroot_invalidates_coord_cache() {
let mut node = make_node();
let my_addr = *node.node_addr();
let old_root = make_node_addr(0);
let parent = make_node_addr(1);
// Adopt `parent` (rooted at `old_root`); no alternative peers exist, so a
// parent loss self-roots the node.
node.tree_state_mut().update_peer(
ParentDeclaration::new(parent, old_root, 1, 1000),
TreeCoordinate::from_addrs(vec![parent, old_root]).unwrap(),
);
node.tree_state_mut().set_parent(parent, 1, 1000);
node.tree_state_mut().recompute_coords();
assert!(!node.tree_state().is_root());
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
// via-node class: routes through us.
let downstream = make_node_addr(10);
node.coord_cache_mut().insert(
downstream,
TreeCoordinate::from_addrs(vec![downstream, my_addr, old_root]).unwrap(),
now_ms,
);
// other-roots class: on the old root, does not route through us.
let foreign = make_node_addr(11);
node.coord_cache_mut().insert(
foreign,
TreeCoordinate::from_addrs(vec![foreign, parent, old_root]).unwrap(),
now_ms,
);
// Parent link drops; no alternative parent → node self-roots.
let changed = node.handle_peer_removal_tree_cleanup(&parent);
assert!(changed);
assert!(node.tree_state().is_root());
assert_eq!(node.tree_state().root(), &my_addr);
assert!(
!node.coord_cache().contains(&downstream, now_ms),
"via-node entry must be invalidated after self-root"
);
assert!(
!node.coord_cache().contains(&foreign, now_ms),
"stale old-root entry must be invalidated after self-root"
);
}
-55
View File
@@ -1995,58 +1995,3 @@ async fn handle_msg1_admits_existing_peer_at_cap() {
"rate limiter must rebalance after the (bypass-admitted) handler returns"
);
}
/// App-owned TUN seam: `enable_app_owned_tun` wires the embedder's packet
/// channels (an Android `VpnService` owns the fd) and marks the TUN active so
/// `start()` skips system-TUN creation.
#[test]
fn app_owned_tun_seam_wires_channels() {
let mut config = crate::Config::new();
config.tun.enabled = true;
let mut node = make_node_with(config);
let (outbound_tx, tun_rx) = node.enable_app_owned_tun();
// TUN is active and the inbound (mesh→app) sender is installed, so `start()`
// will skip `TunDevice::create` (it gates on `tun_tx.is_none()`).
assert_eq!(node.tun_state(), crate::upper::tun::TunState::Active);
assert!(node.tun_tx().is_some(), "inbound sender installed");
// mesh → app: a packet the node delivers to its `tun_tx` reaches the app's rx.
let pkt = vec![0x60u8, 0, 0, 0, 0, 0];
node.tun_tx().unwrap().send(pkt.clone()).unwrap();
assert_eq!(
tun_rx
.recv_timeout(std::time::Duration::from_millis(200))
.unwrap(),
pkt,
"the app pulls the same bytes the node wrote",
);
// app → mesh: the returned sender is live (its matching rx is held by the node
// and drained by `run_rx_loop` → `handle_tun_outbound`).
assert!(outbound_tx.try_send(vec![0x60]).is_ok());
}
/// With an app-owned TUN configured, `start()` must NOT create a system TUN
/// device: it leaves `tun_name` unset (a real device records its interface name)
/// and keeps the TUN `Active` with the app-owned channels.
#[tokio::test]
async fn start_skips_system_tun_when_app_owned() {
let mut config = crate::Config::new();
config.tun.enabled = true;
let mut node = make_node_with(config);
let (_outbound_tx, _tun_rx) = node.enable_app_owned_tun();
node.start().await.unwrap();
// No system device was created (that path records the interface name); the
// app-owned TUN stayed active.
assert!(
node.tun_name().is_none(),
"app-owned TUN must not create a named system device",
);
assert_eq!(node.tun_state(), crate::upper::tun::TunState::Active);
node.stop().await.unwrap();
}
-10
View File
@@ -620,16 +620,6 @@ impl Node {
.tree
.record_reject(TreeReject::OutboundSignFailed);
}
// handle_parent_lost may promote to root OR find new parent;
// cover both invalidation classes (same as the loop-detection
// branch above). Without this, cached downstream entries keep
// our now-stale coordinate prefix until TTL — and get_and_touch
// refreshes the TTL on every routing access, so an actively
// routed stale entry never self-expires.
self.coord_cache
.invalidate_via_node(our_identity.node_addr());
self.coord_cache
.invalidate_other_roots(self.tree_state.root());
info!(
new_root = %self.tree_state.root(),
is_root = self.tree_state.is_root(),
-88
View File
@@ -99,14 +99,6 @@ pub struct ActivePeer {
transport_id: Option<TransportId>,
/// Current transport address (for roaming support).
current_addr: Option<TransportAddr>,
/// Link preference of the current transport (higher = preferred). Gates
/// roaming so a fast transport (e.g. Wi-Fi Aware / UDP) is not dragged back
/// onto a slow one (BLE) by a stray packet — see `roam_current_addr`.
current_transport_preference: u8,
/// When the current transport last delivered an authenticated packet (Unix
/// ms). Lets the roam gate detect that the preferred transport has gone
/// silent, so a lower-preference transport may take over.
current_transport_last_recv_ms: u64,
// === Spanning Tree ===
/// Their latest parent declaration.
@@ -240,8 +232,6 @@ impl ActivePeer {
their_index: None,
transport_id: None,
current_addr: None,
current_transport_preference: 0,
current_transport_last_recv_ms: 0,
declaration: None,
ancestry: None,
tree_announce_min_interval_ms: 500,
@@ -328,11 +318,6 @@ impl ActivePeer {
their_index: Some(their_index),
transport_id: Some(transport_id),
current_addr: Some(current_addr),
// Preference 0 until the first authenticated data packet sets the
// real value (via roam_current_addr); last-recv starts fresh at
// authentication so the preferred link isn't seen as stale.
current_transport_preference: 0,
current_transport_last_recv_ms: authenticated_at,
declaration: None,
ancestry: None,
tree_announce_min_interval_ms: 500,
@@ -550,45 +535,6 @@ impl ActivePeer {
changed
}
/// Roam to `(transport_id, addr)` under the transport-preference cutover
/// policy, returning whether `(transport_id, addr)` actually changed.
///
/// The new path is adopted when it is the current transport (ordinary
/// address roaming, e.g. BLE MAC rotation), when its `preference` is `>=`
/// the current transport's (eager upgrade to a faster link — BLE →
/// Wi-Fi Aware), or when the current, higher-preference transport has gone
/// silent for at least `hysteresis_ms` (the preferred link died — fall
/// back). A lower-preference packet arriving on a still-live preferred
/// transport is ignored for roaming, so e.g. a BLE keepalive cannot drag an
/// active Wi-Fi Aware session back onto BLE. Equal preferences reduce to
/// plain last-authenticated-packet-wins roaming.
pub fn roam_current_addr(
&mut self,
transport_id: TransportId,
addr: TransportAddr,
preference: u8,
now_ms: u64,
hysteresis_ms: u64,
) -> bool {
let same_transport = self.transport_id == Some(transport_id);
let adopt = self.transport_id.is_none()
|| same_transport
|| preference >= self.current_transport_preference
|| now_ms.saturating_sub(self.current_transport_last_recv_ms) >= hysteresis_ms;
if !adopt {
// Stay on the preferred transport; do not refresh its last-recv,
// so it keeps aging toward the hysteresis fallback if it is dead.
return false;
}
let changed =
self.transport_id != Some(transport_id) || self.current_addr.as_ref() != Some(&addr);
self.transport_id = Some(transport_id);
self.current_addr = Some(addr);
self.current_transport_preference = preference;
self.current_transport_last_recv_ms = now_ms;
changed
}
// === Handshake Resend ===
/// Store wire-format msg2 for resend on duplicate msg1.
@@ -1299,40 +1245,6 @@ mod tests {
assert!(!peer.can_send());
}
#[test]
fn test_roam_transport_preference_cutover() {
let mut peer = ActivePeer::new(make_peer_identity(), LinkId::new(1), 1000);
let ble = TransportId::new(1);
let udp = TransportId::new(2);
let ble_addr = TransportAddr::from_string("ble0/AA:BB");
let udp_addr = TransportAddr::from_string("[fe80::1%3]:4870");
const BLE_PREF: u8 = 50;
const UDP_PREF: u8 = 100;
const HYST: u64 = 20_000;
// First packet (no current transport): adopted.
assert!(peer.roam_current_addr(ble, ble_addr.clone(), BLE_PREF, 1000, HYST));
assert_eq!(peer.transport_id(), Some(ble));
// Higher-preference transport: eager upgrade BLE -> Aware/UDP.
assert!(peer.roam_current_addr(udp, udp_addr.clone(), UDP_PREF, 1100, HYST));
assert_eq!(peer.transport_id(), Some(udp));
// Lower-preference keepalive while UDP is fresh: ignored, stays on UDP.
assert!(!peer.roam_current_addr(ble, ble_addr.clone(), BLE_PREF, 1200, HYST));
assert_eq!(peer.transport_id(), Some(udp));
// UDP silent past the hysteresis window: a BLE packet now wins (fall
// back). Last UDP recv was 1100; this arrives at 1100 + HYST + 1.
assert!(peer.roam_current_addr(ble, ble_addr.clone(), BLE_PREF, 1100 + HYST + 1, HYST));
assert_eq!(peer.transport_id(), Some(ble));
// Same-transport address roaming is always allowed.
let ble_addr2 = TransportAddr::from_string("ble0/CC:DD");
assert!(peer.roam_current_addr(ble, ble_addr2.clone(), BLE_PREF, 1100 + HYST + 2, HYST));
assert_eq!(peer.current_addr(), Some(&ble_addr2));
}
#[test]
fn test_tree_position() {
let identity = make_peer_identity();
-766
View File
@@ -1,766 +0,0 @@
//! Android BLE backend: a [`BleIo`] whose radio lives in Kotlin.
//!
//! Android's BLE APIs are Java-only, so Kotlin owns the radio (scan, advertise,
//! L2CAP listen/connect, socket read/write) and exchanges **raw bytes** with this
//! Rust backend over a byte-bridge — symmetric to how nostr-vpn's `MobileTunnel`
//! exchanges TUN packet bytes across the FFI. FIPS keeps everything above the
//! `BleIo` trait (the pool, the cross-probe tiebreaker, the pubkey exchange,
//! Noise); this backend only moves bytes and surfaces adverts.
//!
//! ## Layering
//!
//! FIPS cannot depend on the app crate (`myco-core`), so the split is:
//!
//! - [`AndroidRadio`] — an object-safe trait for the few **commands** the radio
//! must run (listen/connect/advertise/scan/close). `myco-core` implements it
//! via JNI calls into the Kotlin radio object.
//! - [`AndroidBleBridge`] — the channel machinery shared by this backend and the
//! JNI layer. `myco-core` constructs it, injects it via
//! [`set_android_ble_bridge`], and drives its `deliver_*` / `next_send`
//! methods from its `Java_..._NativeCore_*` exports.
//! - [`AndroidIo`] / [`AndroidStream`] / [`AndroidAcceptor`] / [`AndroidScanner`]
//! — the `BleIo` impl, delegating to the bridge.
//!
//! ## Direction of blocking (matches nostr-vpn's MobileTunnel)
//!
//! - **Inbound** bytes/events (Kotlin → Rust) are **pushed** non-blocking into
//! tokio channels (`deliver_recv`, `deliver_inbound`, `deliver_scan`,
//! `deliver_connect_result`); the awaiting FIPS task wakes.
//! - **Outbound** bytes (Rust → Kotlin) are **pulled, blocking with timeout**, by
//! a per-channel Kotlin writer thread via [`AndroidBleBridge::next_send`].
//! `BleStream::send` only pushes into a std channel — it never calls JNI — so
//! the byte hot path never blocks a tokio worker on a JNI upcall.
//!
//! This module is platform-agnostic Rust (no JNI here — that lives in
//! `myco-core`), so it compiles and unit-tests on the host with a mock radio.
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU16, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::sync::Mutex as AsyncMutex;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
use crate::transport::TransportError;
use super::DEFAULT_PSM;
use super::addr::BleAddr;
use super::io::{BleAcceptor, BleIo, BleScanner, BleStream};
use super::psm::PsmMap;
/// Synthetic adapter label (Android does not expose a BlueZ-style adapter name;
/// identity is the pubkey, never the MAC — see ble-interop.md).
const ANDROID_ADAPTER: &str = "ble0";
/// Bound on a per-channel inbound queue and the accept/scan fan-in. Generous so
/// control events (accept/scan) are not dropped under burst; L2CAP data drops are
/// tolerable since FMP/Noise above retransmits.
const CHANNEL_CAP: usize = 256;
/// Bound on the **outbound byte** queue (Rust → Kotlin writer), fixed at channel
/// creation. A bounded queue makes `BleStream::send` backpressure (the `SyncSender`
/// blocks), propagating flow control up through FSP/MMP to the TUN and TCP rather
/// than letting an unbounded queue bufferbloat the link.
///
/// 32 is empirical, swept against the peer speedtest: 8 starved the radio's
/// connection events (~half throughput), 64 bufferbloated TCP (regressed), and 32
/// was best (~200/500 kbps up/down). The sweep was noisy and non-monotonic across
/// single runs, though — run-to-run BLE variance (RF, and whether the OS grants 2M
/// PHY / high connection priority that session) rivals the effect of this knob — so
/// 32 is the best-observed working value, to be re-validated with repeated runs +
/// PHY/interval instrumentation, not a proven optimum.
const SEND_QUEUE_CAP: usize = 32;
/// Transport default MTU, used when the OS reports an unknown (0) channel MTU.
/// Matches `DEFAULT_BLE_MTU` in `config/transport.rs`.
const DEFAULT_BLE_MTU: u16 = 2048;
// ============================================================================
// AndroidRadio — the Kotlin-implemented command surface
// ============================================================================
/// The radio commands the bridge issues to the platform. `myco-core` implements
/// this via JNI `call_method` on the Kotlin `BleRadio` object. Object-safe so the
/// bridge can hold `Arc<dyn AndroidRadio>`.
///
/// These are the **control** plane only — never the byte hot path. Outbound bytes
/// are pulled by Kotlin via [`AndroidBleBridge::next_send`]; inbound bytes are
/// pushed by Kotlin via [`AndroidBleBridge::deliver_recv`].
pub trait AndroidRadio: Send + Sync {
/// Open an insecure L2CAP listener and return the OS-assigned PSM (0 = failure).
fn listen(&self) -> u16;
/// Begin dialing `addr` at `psm`. The outcome is delivered asynchronously via
/// [`AndroidBleBridge::deliver_connect_result`] keyed by `connect_id`.
fn connect(&self, connect_id: i64, addr: &BleAddr, psm: u16);
/// Advertise the FIPS service UUID plus our listener `psm` (16-bit LE
/// service-data — see [`super::psm`]).
fn start_advertising(&self, psm: u16);
fn stop_advertising(&self);
/// Scan for the FIPS UUID; deliver hits via [`AndroidBleBridge::deliver_scan`].
fn start_scanning(&self);
fn stop_scanning(&self);
/// Close the L2CAP socket for `ch_id` (called when FIPS drops the stream).
fn close_channel(&self, ch_id: i64);
}
/// One discovered scan advert (address / learned PSM / RSSI), for the developer
/// UI's "discovered devices" list.
#[derive(Debug, Clone)]
pub struct AdvertView {
/// `BleAddr` string (`adapter/AA:BB:..`); the MAC rotates with privacy.
pub addr: String,
/// The peer's advertised listener PSM (0 if not present in the advert).
pub psm: u16,
/// Signal strength in dBm (negative; closer ≈ less negative).
pub rssi: i32,
}
// ============================================================================
// AndroidBleBridge — the shared channel machinery
// ============================================================================
/// The half of a channel kept by the bridge (the JNI-facing ends).
struct ChannelState {
/// Kotlin-pushed inbound bytes land here; the stream's `recv` awaits them.
recv_tx: mpsc::Sender<Vec<u8>>,
/// `BleStream::send` pushes here; the Kotlin writer thread pulls via `next_send`.
/// `Arc` so `next_send` can clone it out and release the channels lock before
/// blocking on `recv_timeout`.
send_rx: Arc<Mutex<std::sync::mpsc::Receiver<Vec<u8>>>>,
closed: Arc<AtomicBool>,
}
/// The half of a channel handed to the `BleStream` (the FIPS-facing ends).
struct StreamEndpoints {
ch_id: i64,
remote: BleAddr,
send_mtu: u16,
recv_mtu: u16,
recv_rx: mpsc::Receiver<Vec<u8>>,
send_tx: std::sync::mpsc::SyncSender<Vec<u8>>,
closed: Arc<AtomicBool>,
}
/// Channel machinery shared between [`AndroidIo`] and the JNI layer in `myco-core`.
///
/// Constructed by `myco-core` with a concrete [`AndroidRadio`], injected via
/// [`set_android_ble_bridge`], and driven by its `deliver_*` / `next_send`
/// methods from the JNI exports.
pub struct AndroidBleBridge {
radio: Arc<dyn AndroidRadio>,
next_id: AtomicI64,
/// Our own OS-assigned listener PSM, learned from `radio.listen()`.
local_psm: AtomicU16,
/// Learned peer PSMs (advert service-data), consulted on `connect`.
psm_map: PsmMap,
/// Latest scan advert per address (PSM + RSSI), for the developer UI's
/// "discovered" list. Re-learned each scan cycle (addresses rotate).
adverts: Mutex<HashMap<BleAddr, (u16, i32)>>,
channels: Mutex<HashMap<i64, ChannelState>>,
/// connect_id → result slot for an in-flight outbound dial.
connects: Mutex<HashMap<i64, oneshot::Sender<StreamEndpoints>>>,
/// Inbound-accept fan-in; the acceptor takes the receiver once.
accept_tx: mpsc::Sender<StreamEndpoints>,
accept_rx: Mutex<Option<mpsc::Receiver<StreamEndpoints>>>,
/// Scan fan-in; the scanner takes the receiver once.
scan_tx: mpsc::Sender<BleAddr>,
scan_rx: Mutex<Option<mpsc::Receiver<BleAddr>>>,
}
impl AndroidBleBridge {
/// Build a bridge over a concrete radio.
pub fn new(radio: Arc<dyn AndroidRadio>) -> Arc<Self> {
let (accept_tx, accept_rx) = mpsc::channel(CHANNEL_CAP);
let (scan_tx, scan_rx) = mpsc::channel(CHANNEL_CAP);
Arc::new(Self {
radio,
next_id: AtomicI64::new(1),
local_psm: AtomicU16::new(DEFAULT_PSM),
psm_map: PsmMap::new(),
adverts: Mutex::new(HashMap::new()),
channels: Mutex::new(HashMap::new()),
connects: Mutex::new(HashMap::new()),
accept_tx,
accept_rx: Mutex::new(Some(accept_rx)),
scan_tx,
scan_rx: Mutex::new(Some(scan_rx)),
})
}
fn lock_channels(&self) -> std::sync::MutexGuard<'_, HashMap<i64, ChannelState>> {
self.channels.lock().unwrap_or_else(|e| e.into_inner())
}
/// Allocate a channel id and wire its two halves, registering the
/// bridge-facing half and returning the FIPS-facing half.
fn make_channel(&self, remote: BleAddr, send_mtu: u16, recv_mtu: u16) -> StreamEndpoints {
let ch_id = self.next_id.fetch_add(1, Ordering::Relaxed);
let (recv_tx, recv_rx) = mpsc::channel(CHANNEL_CAP);
let (send_tx, send_rx) = std::sync::mpsc::sync_channel(SEND_QUEUE_CAP);
let closed = Arc::new(AtomicBool::new(false));
self.lock_channels().insert(
ch_id,
ChannelState {
recv_tx,
send_rx: Arc::new(Mutex::new(send_rx)),
closed: Arc::clone(&closed),
},
);
StreamEndpoints {
ch_id,
remote,
send_mtu: if send_mtu == 0 {
DEFAULT_BLE_MTU
} else {
send_mtu
},
recv_mtu: if recv_mtu == 0 {
DEFAULT_BLE_MTU
} else {
recv_mtu
},
recv_rx,
send_tx,
closed,
}
}
// --- JNI-facing push/pull surface (called by myco-core's exports) ---
/// Kotlin accepted a new inbound L2CAP channel. Returns the allocated `ch_id`.
pub fn deliver_inbound(&self, remote: BleAddr, send_mtu: u16, recv_mtu: u16) -> i64 {
let ep = self.make_channel(remote, send_mtu, recv_mtu);
let ch_id = ep.ch_id;
if self.accept_tx.try_send(ep).is_err() {
// Acceptor gone or saturated: reclaim the half-registered channel.
self.lock_channels().remove(&ch_id);
return 0;
}
ch_id
}
/// Kotlin finished (or failed) an outbound dial started by `radio.connect`.
/// Returns the allocated `ch_id` on success, else 0.
pub fn deliver_connect_result(
&self,
connect_id: i64,
ok: bool,
remote: BleAddr,
send_mtu: u16,
recv_mtu: u16,
) -> i64 {
let waiter = self
.connects
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(&connect_id);
let Some(tx) = waiter else { return 0 };
if !ok {
drop(tx); // dropping the sender wakes the awaiting connect() as an error
return 0;
}
let ep = self.make_channel(remote, send_mtu, recv_mtu);
let ch_id = ep.ch_id;
if tx.send(ep).is_err() {
self.lock_channels().remove(&ch_id);
return 0;
}
ch_id
}
/// Kotlin discovered a FIPS peer advertising `psm` (its OS-assigned listener
/// PSM) at signal strength `rssi` (dBm). Learns the per-peer PSM, records the
/// advert for the developer UI, and surfaces the address to the scanner.
pub fn deliver_scan(&self, addr: BleAddr, psm: u16, rssi: i32) {
if psm != 0 {
self.psm_map.learn(&addr, psm);
}
self.adverts
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(addr.clone(), (psm, rssi));
let _ = self.scan_tx.try_send(addr);
}
/// Snapshot of the current scan adverts (address / PSM / RSSI) for the
/// developer UI.
pub fn advert_views(&self) -> Vec<AdvertView> {
self.adverts
.lock()
.unwrap_or_else(|e| e.into_inner())
.iter()
.map(|(addr, (psm, rssi))| AdvertView {
addr: addr.to_string_repr(),
psm: *psm,
rssi: *rssi,
})
.collect()
}
/// Drop recorded adverts (called at the start of a scan cycle).
pub fn clear_adverts(&self) {
self.adverts
.lock()
.unwrap_or_else(|e| e.into_inner())
.clear();
}
/// Kotlin read one L2CAP packet for `ch_id`. Returns false if the channel is
/// unknown/closed (Kotlin should then stop its reader).
pub fn deliver_recv(&self, ch_id: i64, data: &[u8]) -> bool {
let tx = self.lock_channels().get(&ch_id).map(|c| c.recv_tx.clone());
match tx {
Some(tx) => tx.try_send(data.to_vec()).is_ok(),
None => false,
}
}
/// Kotlin's per-channel writer thread pulls the next outbound packet, blocking
/// up to `timeout`. `None` = timed out (Kotlin loops) or the channel is gone.
pub fn next_send(&self, ch_id: i64, timeout: Duration) -> Option<Vec<u8>> {
// Clone the per-channel receiver Arc + closed flag, then release the
// channels lock before blocking on recv_timeout (so close/create on other
// channels aren't stalled for up to `timeout`).
let (send_rx, closed) = {
let guard = self.lock_channels();
let state = guard.get(&ch_id)?;
(Arc::clone(&state.send_rx), Arc::clone(&state.closed))
};
let rx = send_rx.lock().unwrap_or_else(|e| e.into_inner());
match rx.recv_timeout(timeout) {
Ok(bytes) => Some(bytes),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => None,
// The send half (the BleStream) was dropped — the stream is gone
// (e.g. the node stopped). Mark closed so the next channel_open()
// returns false and the Kotlin writer thread exits.
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
closed.store(true, Ordering::Relaxed);
None
}
}
}
/// Kotlin reports `ch_id` closed (EOF / socket gone). Wakes the stream's
/// `recv` with a zero-length read (FIPS treats that as peer-closed).
pub fn channel_closed(&self, ch_id: i64) {
if let Some(state) = self.lock_channels().remove(&ch_id) {
state.closed.store(true, Ordering::Relaxed);
// Dropping recv_tx closes the stream's recv_rx → recv() returns Ok(0).
drop(state);
}
}
/// Whether `ch_id` is still open (registered and not marked closed). The JNI
/// `next_send` export uses this to tell a timeout (loop again) from a closed
/// channel (stop the writer thread).
pub fn channel_open(&self, ch_id: i64) -> bool {
self.lock_channels()
.get(&ch_id)
.map(|s| !s.closed.load(Ordering::Relaxed))
.unwrap_or(false)
}
}
// ============================================================================
// Global injection seam
// ============================================================================
static BRIDGE: Mutex<Option<Arc<AndroidBleBridge>>> = Mutex::new(None);
/// Inject the process-wide bridge before `Node::new` / start (one radio per
/// process). Replaceable so a stop-then-start cycle (BLE toggled off then on)
/// can inject a fresh bridge for the rebuilt node.
pub fn set_android_ble_bridge(bridge: Arc<AndroidBleBridge>) {
*BRIDGE.lock().unwrap_or_else(|e| e.into_inner()) = Some(bridge);
}
/// The injected bridge, if any. The node's BLE construction arm reads this.
pub fn android_ble_bridge() -> Option<Arc<AndroidBleBridge>> {
BRIDGE.lock().unwrap_or_else(|e| e.into_inner()).clone()
}
// ============================================================================
// BleIo implementation
// ============================================================================
/// macOS/Android-style external-radio backend over [`AndroidBleBridge`].
pub struct AndroidIo {
/// Bridge to use when one was supplied explicitly (tests). In the app this
/// is `None` and every operation resolves [`android_ble_bridge`] instead.
///
/// Resolving per call is what lets the radio be replaced without rebuilding
/// the node. The Android service mints a fresh bridge each time it starts
/// (a new `BleRadio`, a new JNI handle); if this type captured that `Arc` at
/// construction, a running node would keep driving the dead one, and the
/// only way to adopt the new radio would be to stop and restart the node —
/// dropping every peer and every session with it.
bridge: Option<Arc<AndroidBleBridge>>,
}
impl AndroidIo {
/// Resolve the bridge from the process-wide slot on each operation.
pub fn from_global() -> Self {
Self { bridge: None }
}
/// Pin a specific bridge, for tests that drive one directly.
pub fn new(bridge: Arc<AndroidBleBridge>) -> Self {
Self {
bridge: Some(bridge),
}
}
/// The bridge for this operation. `None` once the radio has gone away —
/// callers surface that as a transport error rather than panicking, since
/// it is reachable simply by toggling Bluetooth off mid-operation.
fn bridge(&self) -> Option<Arc<AndroidBleBridge>> {
match &self.bridge {
Some(b) => Some(Arc::clone(b)),
None => android_ble_bridge(),
}
}
fn require_bridge(&self) -> Result<Arc<AndroidBleBridge>, TransportError> {
self.bridge()
.ok_or_else(|| TransportError::Io(std::io::Error::other("BLE radio not available")))
}
}
/// One live L2CAP channel.
pub struct AndroidStream {
ch_id: i64,
remote: BleAddr,
send_mtu: u16,
recv_mtu: u16,
recv_rx: AsyncMutex<mpsc::Receiver<Vec<u8>>>,
send_tx: std::sync::mpsc::SyncSender<Vec<u8>>,
closed: Arc<AtomicBool>,
radio: Arc<dyn AndroidRadio>,
}
impl AndroidStream {
fn from_endpoints(ep: StreamEndpoints, radio: Arc<dyn AndroidRadio>) -> Self {
Self {
ch_id: ep.ch_id,
remote: ep.remote,
send_mtu: ep.send_mtu,
recv_mtu: ep.recv_mtu,
recv_rx: AsyncMutex::new(ep.recv_rx),
send_tx: ep.send_tx,
closed: ep.closed,
radio,
}
}
}
impl Drop for AndroidStream {
fn drop(&mut self) {
self.closed.store(true, Ordering::Relaxed);
self.radio.close_channel(self.ch_id);
}
}
impl BleStream for AndroidStream {
async fn send(&self, data: &[u8]) -> Result<(), TransportError> {
if self.closed.load(Ordering::Relaxed) {
return Err(TransportError::Io(std::io::Error::other(
"BLE channel closed",
)));
}
// Pure channel push — no JNI on the hot path. The Kotlin writer thread
// pulls this via the bridge's next_send and writes the socket.
//
// Backpressure rather than drop on a full queue. The queue is deliberately
// shallow (SEND_QUEUE_CAP) so it can't bufferbloat the BLE link; awaiting a
// free slot here propagates flow control up through FSP/MMP to the TUN and
// TCP — keeping RTT low *without* the loss-driven throughput collapse that a
// shallow tail-drop queue causes.
let mut payload = data.to_vec();
loop {
if self.closed.load(Ordering::Relaxed) {
return Err(TransportError::Io(std::io::Error::other(
"BLE channel closed",
)));
}
match self.send_tx.try_send(payload) {
Ok(()) => return Ok(()),
Err(std::sync::mpsc::TrySendError::Full(p)) => {
payload = p;
tokio::time::sleep(std::time::Duration::from_millis(2)).await;
}
Err(std::sync::mpsc::TrySendError::Disconnected(_)) => {
return Err(TransportError::Io(std::io::Error::other(
"BLE send: peer closed",
)));
}
}
}
}
async fn recv(&self, buf: &mut [u8]) -> Result<usize, TransportError> {
match self.recv_rx.lock().await.recv().await {
Some(packet) => {
let n = packet.len().min(buf.len());
buf[..n].copy_from_slice(&packet[..n]);
Ok(n)
}
// Sender dropped (channel closed) → peer-closed, per the BleStream
// contract (a zero-length recv means the peer closed).
None => Ok(0),
}
}
fn send_mtu(&self) -> u16 {
self.send_mtu
}
fn recv_mtu(&self) -> u16 {
self.recv_mtu
}
fn remote_addr(&self) -> &BleAddr {
&self.remote
}
}
/// Yields inbound channels Kotlin accepted.
pub struct AndroidAcceptor {
rx: Option<mpsc::Receiver<StreamEndpoints>>,
radio: Arc<dyn AndroidRadio>,
}
impl BleAcceptor for AndroidAcceptor {
type Stream = AndroidStream;
async fn accept(&mut self) -> Result<AndroidStream, TransportError> {
match self.rx.as_mut() {
Some(rx) => match rx.recv().await {
Some(ep) => Ok(AndroidStream::from_endpoints(ep, Arc::clone(&self.radio))),
None => std::future::pending().await, // fan-in closed; idle
},
None => std::future::pending().await, // acceptor already consumed
}
}
}
/// Yields discovered FIPS peers (the learned PSM is captured into the bridge map).
pub struct AndroidScanner {
rx: Option<mpsc::Receiver<BleAddr>>,
}
impl BleScanner for AndroidScanner {
async fn next(&mut self) -> Option<BleAddr> {
match self.rx.as_mut() {
Some(rx) => rx.recv().await,
None => None,
}
}
}
impl BleIo for AndroidIo {
type Stream = AndroidStream;
type Acceptor = AndroidAcceptor;
type Scanner = AndroidScanner;
async fn listen(&self, _psm: u16) -> Result<AndroidAcceptor, TransportError> {
let bridge = self.require_bridge()?;
// Android assigns the listener PSM; the `psm` arg from FIPS is ignored.
let os_psm = bridge.radio.listen();
if os_psm != 0 {
bridge.local_psm.store(os_psm, Ordering::Relaxed);
}
let rx = bridge
.accept_rx
.lock()
.unwrap_or_else(|e| e.into_inner())
.take();
Ok(AndroidAcceptor {
rx,
radio: Arc::clone(&bridge.radio),
})
}
async fn connect(&self, addr: &BleAddr, psm: u16) -> Result<AndroidStream, TransportError> {
let bridge = self.require_bridge()?;
// Substitute the learned per-peer PSM for this address, if known.
let dial_psm = bridge.psm_map.resolve(addr, psm);
let connect_id = bridge.next_id.fetch_add(1, Ordering::Relaxed);
let (tx, rx) = oneshot::channel();
bridge
.connects
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(connect_id, tx);
bridge.radio.connect(connect_id, addr, dial_psm);
// FIPS already wraps connect() in a timeout, so we just await the result.
match rx.await {
Ok(ep) => Ok(AndroidStream::from_endpoints(ep, Arc::clone(&bridge.radio))),
Err(_) => {
bridge
.connects
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(&connect_id);
Err(TransportError::Io(std::io::Error::other(format!(
"BLE connect to {addr} failed"
))))
}
}
}
async fn start_advertising(&self) -> Result<(), TransportError> {
let bridge = self.require_bridge()?;
bridge
.radio
.start_advertising(bridge.local_psm.load(Ordering::Relaxed));
Ok(())
}
async fn stop_advertising(&self) -> Result<(), TransportError> {
let bridge = self.require_bridge()?;
bridge.radio.stop_advertising();
Ok(())
}
async fn start_scanning(&self) -> Result<AndroidScanner, TransportError> {
let bridge = self.require_bridge()?;
// Re-learn PSMs / adverts each scan cycle (addresses rotate with MAC).
bridge.psm_map.clear();
bridge.clear_adverts();
bridge.radio.start_scanning();
let rx = bridge
.scan_rx
.lock()
.unwrap_or_else(|e| e.into_inner())
.take();
Ok(AndroidScanner { rx })
}
fn local_addr(&self) -> Result<BleAddr, TransportError> {
Ok(BleAddr {
adapter: ANDROID_ADAPTER.to_string(),
device: [0, 0, 0, 0, 0, 0],
})
}
fn adapter_name(&self) -> &str {
ANDROID_ADAPTER
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::AtomicU16 as TestAtomicU16;
/// A mock radio that records commands and lets the test drive the bridge.
#[derive(Default)]
struct MockRadio {
listen_psm: TestAtomicU16,
scanning: AtomicBool,
advertising_psm: TestAtomicU16,
}
impl AndroidRadio for MockRadio {
fn listen(&self) -> u16 {
self.listen_psm.load(Ordering::Relaxed)
}
fn connect(&self, _connect_id: i64, _addr: &BleAddr, _psm: u16) {}
fn start_advertising(&self, psm: u16) {
self.advertising_psm.store(psm, Ordering::Relaxed);
}
fn stop_advertising(&self) {}
fn start_scanning(&self) {
self.scanning.store(true, Ordering::Relaxed);
}
fn stop_scanning(&self) {}
fn close_channel(&self, _ch_id: i64) {}
}
fn addr(n: u8) -> BleAddr {
BleAddr {
adapter: ANDROID_ADAPTER.to_string(),
device: [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, n],
}
}
#[tokio::test]
async fn inbound_channel_recv_and_close() {
let radio = Arc::new(MockRadio::default());
let bridge = AndroidBleBridge::new(radio.clone());
let io = AndroidIo::new(Arc::clone(&bridge));
let mut acceptor = io.listen(0).await.unwrap();
// Kotlin accepts an inbound channel, then pushes a packet.
let ch_id = bridge.deliver_inbound(addr(1), 512, 512);
assert!(ch_id > 0);
assert!(bridge.deliver_recv(ch_id, b"hello"));
let stream = acceptor.accept().await.unwrap();
assert_eq!(stream.remote_addr(), &addr(1));
assert_eq!(stream.send_mtu(), 512);
let mut buf = [0u8; 64];
let n = stream.recv(&mut buf).await.unwrap();
assert_eq!(&buf[..n], b"hello");
// Closing the channel makes the next recv return 0 (peer closed).
bridge.channel_closed(ch_id);
let n = stream.recv(&mut buf).await.unwrap();
assert_eq!(n, 0);
}
#[tokio::test]
async fn outbound_send_is_pulled_by_next_send() {
let radio = Arc::new(MockRadio::default());
let bridge = AndroidBleBridge::new(radio.clone());
// Simulate an accepted channel and grab its stream.
let mut acceptor = AndroidIo::new(Arc::clone(&bridge)).listen(0).await.unwrap();
let ch_id = bridge.deliver_inbound(addr(2), 0, 0);
let stream = acceptor.accept().await.unwrap();
// 0 MTU falls back to the transport default.
assert!(stream.send_mtu() > 0);
stream.send(b"out").await.unwrap();
let pulled = bridge.next_send(ch_id, Duration::from_millis(100)).unwrap();
assert_eq!(pulled, b"out");
// Nothing more queued → times out (None).
assert!(bridge.next_send(ch_id, Duration::from_millis(10)).is_none());
}
#[tokio::test]
async fn scan_learns_psm_and_connect_substitutes_it() {
let radio = Arc::new(MockRadio::default());
let bridge = AndroidBleBridge::new(radio.clone());
let io = AndroidIo::new(Arc::clone(&bridge));
let mut scanner = io.start_scanning().await.unwrap();
assert!(radio.scanning.load(Ordering::Relaxed));
bridge.deliver_scan(addr(3), 0x00C1, -50);
assert_eq!(scanner.next().await, Some(addr(3)));
// The advert is also recorded for the developer UI.
let adverts = bridge.advert_views();
assert_eq!(adverts.len(), 1);
assert_eq!(adverts[0].psm, 0x00C1);
assert_eq!(adverts[0].rssi, -50);
// The learned PSM is what a later dial would use (over FIPS's default).
assert_eq!(bridge.psm_map.resolve(&addr(3), DEFAULT_PSM), 0x00C1);
}
#[tokio::test]
async fn advertise_uses_os_assigned_listen_psm() {
let radio = Arc::new(MockRadio::default());
radio.listen_psm.store(0x0099, Ordering::Relaxed);
let bridge = AndroidBleBridge::new(radio.clone());
let io = AndroidIo::new(Arc::clone(&bridge));
let _ = io.listen(0).await.unwrap(); // learns the OS PSM
io.start_advertising().await.unwrap();
assert_eq!(radio.advertising_psm.load(Ordering::Relaxed), 0x0099);
}
}
+50 -94
View File
@@ -1,11 +1,9 @@
//! BLE L2CAP Transport Implementation
//!
//! Provides BLE-based transport for FIPS peer communication using L2CAP
//! Connection-Oriented Channels (CoC). BlueZ (SeqPacket) preserves message
//! boundaries, but stream-oriented backends (Android `BluetoothSocket`,
//! CoreBluetooth) do not, so the receive path recovers FIPS packet boundaries
//! with the shared FMP framer (`tcp::stream::read_fmp_packet`) over a
//! [`stream_read::BleStreamRead`] adapter — reliable on every platform.
//! Connection-Oriented Channels (CoC) in SeqPacket mode. L2CAP CoC
//! preserves message boundaries (unlike TCP byte streams), so no FMP
//! framing is needed — each send/recv is one FIPS packet.
//!
//! ## Architecture
//!
@@ -24,15 +22,7 @@ pub mod addr;
pub mod discovery;
pub mod io;
pub mod pool;
pub mod psm;
pub mod stats;
pub mod stream_read;
// The Android backend (radio in Kotlin, bytes over a bridge). Compiled on
// Android, and under `cfg(test)` on any host so its channel logic is unit-tested
// without a device. Not built into non-test desktop builds.
#[cfg(any(target_os = "android", test))]
pub mod android_io;
use super::{
ConnectionState, DiscoveredPeer, PacketTx, ReceivedPacket, Transport, TransportAddr,
@@ -45,9 +35,6 @@ use discovery::DiscoveryBuffer;
use io::{BleIo, BleScanner, BleStream};
use pool::{BleConnection, ConnectionPool};
use stats::BleStats;
use stream_read::BleStreamRead;
use crate::transport::tcp::stream::{StreamError, read_fmp_packet};
use secp256k1::XOnlyPublicKey;
use std::collections::HashMap;
@@ -68,12 +55,7 @@ pub const DEFAULT_PSM: u16 = 0x0085;
#[cfg(all(bluer_available, not(test)))]
pub type DefaultBleTransport = BleTransport<io::BluerIo>;
// Android: the Kotlin-radio backend over the byte-bridge.
#[cfg(all(target_os = "android", not(test)))]
pub type DefaultBleTransport = BleTransport<android_io::AndroidIo>;
// Everything else (macOS/musl/host) and all test builds: the in-memory mock.
#[cfg(any(all(not(bluer_available), not(target_os = "android")), test))]
#[cfg(any(not(bluer_available), test))]
pub type DefaultBleTransport = BleTransport<io::MockBleIo>;
// ============================================================================
@@ -382,15 +364,9 @@ impl<I: BleIo> BleTransport<I> {
}
};
// One buffering reader per connection, shared by the pubkey exchange
// and the receive loop so coalesced bytes are never dropped.
let recv_mtu = stream.recv_mtu();
let stream = Arc::new(stream);
let mut reader = BleStreamRead::new(Arc::clone(&stream), recv_mtu);
// Pre-handshake pubkey exchange (temporary, pre-XX)
if let Some(ref our_pubkey) = self.local_pubkey {
match pubkey_exchange(&mut reader, our_pubkey).await {
match pubkey_exchange(&stream, our_pubkey).await {
Ok(peer_pubkey) => {
debug!(addr = %addr, "BLE outbound pubkey exchange complete");
self.discovery_buffer
@@ -403,26 +379,24 @@ impl<I: BleIo> BleTransport<I> {
}
}
self.promote_connection(addr, &ble_addr, stream, reader)
.await
self.promote_connection(addr, &ble_addr, stream).await
}
/// Promote a newly established stream into the connection pool.
///
/// Spawns the receive loop (driven by `reader`) and inserts into the pool
/// with eviction.
/// Spawns the receive loop and inserts into the pool with eviction.
async fn promote_connection(
&self,
addr: &TransportAddr,
ble_addr: &BleAddr,
stream: Arc<I::Stream>,
reader: BleStreamRead<I::Stream>,
stream: I::Stream,
) -> Result<(), TransportError> {
let send_mtu = stream.send_mtu();
let recv_mtu = stream.recv_mtu();
let stream = Arc::new(stream);
let recv_task = tokio::spawn(receive_loop(
reader,
Arc::clone(&stream),
addr.clone(),
Arc::clone(&self.pool),
self.packet_tx.clone(),
@@ -510,16 +484,9 @@ impl<I: BleIo> BleTransport<I> {
match result {
Ok(Ok(stream)) => {
let send_mtu = stream.send_mtu();
let recv_mtu = stream.recv_mtu();
let stream = Arc::new(stream);
// Shared reader: pubkey exchange + receive loop read the
// same buffer so coalesced bytes survive the handoff.
let mut reader = BleStreamRead::new(Arc::clone(&stream), recv_mtu);
// Pre-handshake pubkey exchange (temporary, pre-XX)
if let Some(ref our_pubkey) = local_pubkey {
match pubkey_exchange(&mut reader, our_pubkey).await {
match pubkey_exchange(&stream, our_pubkey).await {
Ok(peer_pubkey) => {
debug!(addr = %addr_clone, "BLE outbound pubkey exchange complete");
discovery_buffer.add_peer_with_pubkey(&ble_addr, peer_pubkey);
@@ -534,8 +501,12 @@ impl<I: BleIo> BleTransport<I> {
}
}
let send_mtu = stream.send_mtu();
let recv_mtu = stream.recv_mtu();
let stream = Arc::new(stream);
let recv_task = tokio::spawn(receive_loop(
reader,
Arc::clone(&stream),
addr_clone.clone(),
Arc::clone(&pool),
packet_tx,
@@ -706,38 +677,31 @@ const PUBKEY_EXCHANGE_TIMEOUT_SECS: u64 = 5;
/// Exchange public keys over a newly established L2CAP connection.
///
/// Both sides send `[0x00][our_pubkey:32]` and receive the peer's. Reads go
/// through the connection's [`BleStreamRead`] buffer so a peer that fragments
/// the 33-byte message (stream-oriented backends) is read correctly, and any
/// bytes it coalesced after the pubkey stay buffered for the receive loop.
/// Both sides send `[0x00][our_pubkey:32]` and receive the peer's.
/// Returns the peer's XOnlyPublicKey on success.
async fn pubkey_exchange<S: BleStream>(
reader: &mut BleStreamRead<S>,
stream: &S,
local_pubkey: &[u8; 32],
) -> Result<XOnlyPublicKey, TransportError> {
use tokio::io::AsyncReadExt;
// Send our pubkey
let mut msg = [0u8; PUBKEY_EXCHANGE_SIZE];
msg[0] = PUBKEY_EXCHANGE_PREFIX;
msg[1..].copy_from_slice(local_pubkey);
reader.stream().send(&msg).await?;
stream.send(&msg).await?;
// Receive peer's pubkey (with timeout to prevent indefinite blocking).
// read_exact reassembles across fragmented recvs and leaves any trailing
// bytes in the reader's buffer.
// Receive peer's pubkey (with timeout to prevent indefinite blocking)
let mut buf = [0u8; PUBKEY_EXCHANGE_SIZE];
let timeout = std::time::Duration::from_secs(PUBKEY_EXCHANGE_TIMEOUT_SECS);
match tokio::time::timeout(timeout, reader.read_exact(&mut buf)).await {
Ok(Ok(_)) => {}
Ok(Err(e)) => {
return Err(TransportError::RecvFailed(format!(
"pubkey exchange: {}",
e
)));
}
let n = match tokio::time::timeout(timeout, stream.recv(&mut buf)).await {
Ok(result) => result?,
Err(_) => return Err(TransportError::Timeout),
};
if n != PUBKEY_EXCHANGE_SIZE {
return Err(TransportError::RecvFailed(format!(
"pubkey exchange: expected {} bytes, got {}",
PUBKEY_EXCHANGE_SIZE, n
)));
}
if buf[0] != PUBKEY_EXCHANGE_PREFIX {
return Err(TransportError::RecvFailed(format!(
"pubkey exchange: bad prefix 0x{:02X}",
@@ -787,13 +751,10 @@ async fn accept_loop<A>(
let send_mtu = stream.send_mtu();
let recv_mtu = stream.recv_mtu();
let stream = Arc::new(stream);
// Shared reader for pubkey exchange + receive loop.
let mut reader = BleStreamRead::new(Arc::clone(&stream), recv_mtu);
// Pre-handshake pubkey exchange (temporary, pre-XX)
if let Some(ref our_pubkey) = local_pubkey {
match pubkey_exchange(&mut reader, our_pubkey).await {
match pubkey_exchange(&stream, our_pubkey).await {
Ok(peer_pubkey) => {
debug!(addr = %ta, "BLE inbound pubkey exchange complete");
discovery_buffer.add_peer_with_pubkey(&addr, peer_pubkey);
@@ -819,9 +780,11 @@ async fn accept_loop<A>(
}
}
let stream = Arc::new(stream);
// Spawn receive loop
let recv_task = tokio::spawn(receive_loop(
reader,
Arc::clone(&stream),
ta.clone(),
Arc::clone(&pool),
packet_tx.clone(),
@@ -866,14 +829,8 @@ async fn accept_loop<A>(
}
/// Receive loop: reads packets from a BLE stream and delivers to node.
///
/// Recovers FIPS packet boundaries from the byte stream via the shared FMP
/// framer ([`read_fmp_packet`]) rather than assuming one `recv` is one packet.
/// L2CAP only preserves SDU boundaries on BlueZ (SeqPacket); stream-oriented
/// backends (Android, CoreBluetooth) fragment and coalesce, so the framer's
/// length-prefixed reads are what make delivery reliable across platforms.
async fn receive_loop<S: BleStream + 'static>(
mut reader: BleStreamRead<S>,
async fn receive_loop<S: BleStream>(
stream: Arc<S>,
addr: TransportAddr,
pool: Arc<Mutex<ConnectionPool<Arc<S>>>>,
packet_tx: PacketTx,
@@ -881,21 +838,21 @@ async fn receive_loop<S: BleStream + 'static>(
stats: Arc<BleStats>,
recv_mtu: u16,
) {
let mut buf = vec![0u8; recv_mtu as usize];
loop {
match read_fmp_packet(&mut reader, recv_mtu).await {
Ok(packet) => {
stats.record_recv(packet.len());
let received = ReceivedPacket::new(transport_id, addr.clone(), packet);
if packet_tx.send(received).await.is_err() {
match stream.recv(&mut buf).await {
Ok(0) => {
debug!(addr = %addr, "BLE connection closed by peer");
break;
}
Ok(n) => {
stats.record_recv(n);
let packet = ReceivedPacket::new(transport_id, addr.clone(), buf[..n].to_vec());
if packet_tx.send(packet).await.is_err() {
trace!("BLE packet_tx closed, stopping receive loop");
break;
}
}
// Clean peer close (EOF at a packet boundary) is expected, not an error.
Err(StreamError::Io(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
debug!(addr = %addr, "BLE connection closed by peer");
break;
}
Err(e) => {
debug!(addr = %addr, error = %e, "BLE receive error");
stats.record_recv_error();
@@ -1029,12 +986,7 @@ async fn scan_probe_loop<I: io::BleIo>(
// Pubkey exchange, then promote connection to pool
let ta = addr.to_transport_addr();
let send_mtu = stream.send_mtu();
let recv_mtu = stream.recv_mtu();
let stream = Arc::new(stream);
// Shared reader for pubkey exchange + receive loop.
let mut reader = BleStreamRead::new(Arc::clone(&stream), recv_mtu);
match pubkey_exchange(&mut reader, &our_pubkey).await {
match pubkey_exchange(&stream, &our_pubkey).await {
Ok(peer_pubkey) => {
debug!(addr = %addr, "BLE probe complete");
@@ -1053,8 +1005,12 @@ async fn scan_probe_loop<I: io::BleIo>(
}
// Promote connection to pool — no second L2CAP connect needed
let send_mtu = stream.send_mtu();
let recv_mtu = stream.recv_mtu();
let stream = Arc::new(stream);
let recv_task = tokio::spawn(receive_loop(
reader,
Arc::clone(&stream),
ta.clone(),
Arc::clone(&pool),
packet_tx.clone(),
-192
View File
@@ -1,192 +0,0 @@
//! Per-peer PSM discovery (BLE v2).
//!
//! On the platforms that matter the L2CAP listener PSM is **OS-assigned**, not
//! chosen by the app (Android `listenUsingInsecureL2capChannel`, macOS
//! `CBPeripheralManager.publishL2CAPChannel`); only BlueZ can bind a fixed one.
//! So rather than relying on the fixed `DEFAULT_PSM` (0x0085), every node
//! **advertises its own listener PSM** as a 16-bit little-endian value in the
//! service-data field keyed on the FIPS service UUID, and every dialer **reads a
//! peer's advertised PSM before `connect()`**. The fixed-PSM assumption was a
//! BlueZ quirk; this makes discovery symmetric across all backends.
//!
//! This module holds the platform-agnostic pieces shared by every `BleIo`
//! backend (`BluerIo`, `BluestIo`, `AndroidIo`): the service-data **codec** and
//! the short-lived **`BleAddr → PSM` map**. The per-backend advertise/scan/dial
//! wiring lives in the backends. See
//! [`docs/reference/ble-wire.md`](../../../../docs/reference/ble-wire.md) and
//! [`docs/design/ble-interop.md`](../../../../docs/design/ble-interop.md).
use std::collections::HashMap;
use std::sync::Mutex;
use std::sync::atomic::{AtomicU16, Ordering};
use super::addr::BleAddr;
/// Encode a listener PSM as the 2-byte little-endian service-data payload
/// advertised under the FIPS service UUID.
///
/// The legacy advertising PDU caps at ~31 bytes, so a 128-bit UUID + this
/// 2-byte value is the tight, legacy-safe layout (see ble-wire.md).
pub fn encode_psm(psm: u16) -> [u8; 2] {
psm.to_le_bytes()
}
/// Decode a peer's advertised PSM from its FIPS service-data payload.
///
/// Returns `None` when fewer than 2 bytes are present — e.g. a legacy,
/// UUID-only advert that carries no PSM, for which the dialer falls back to
/// [`DEFAULT_PSM`](super::DEFAULT_PSM). Any bytes beyond the first two are
/// ignored, so the encoding can grow without breaking older readers.
pub fn decode_psm(data: &[u8]) -> Option<u16> {
match data {
[lo, hi, ..] => Some(u16::from_le_bytes([*lo, *hi])),
_ => None,
}
}
/// Short-lived map of discovered peer addresses to their advertised listener
/// PSM, populated by the scan loop and consulted by `connect()`.
///
/// Keyed on [`BleAddr`], which **rotates** with MAC randomization, so entries
/// are transient: they are re-learned each scan cycle rather than cached
/// durably. A stale entry merely causes one dial failure and a re-probe on the
/// next scan tick (see "Why MAC randomization is harmless" in ble-interop.md).
#[derive(Debug, Default)]
pub struct PsmMap {
inner: Mutex<HashMap<BleAddr, u16>>,
/// The most-recently-learned PSM across all peers. A node advertises exactly
/// one OS-assigned listener PSM, so when an exact-address lookup misses — the
/// common case, since the peer's RPA rotates between the scan that learned the
/// PSM and the dial — this is a far better guess than the fixed legacy default,
/// which no one listens on. 0 = unset.
last: AtomicU16,
}
impl PsmMap {
/// Create an empty map.
pub fn new() -> Self {
Self::default()
}
/// Record a peer's advertised PSM, learned from a scan advert. A later
/// advert for the same address overwrites the earlier value.
pub fn learn(&self, addr: &BleAddr, psm: u16) {
self.lock().insert(addr.clone(), psm);
if psm != 0 {
self.last.store(psm, Ordering::Relaxed);
}
}
/// Look up a peer's learned PSM, if one has been seen this scan cycle.
pub fn lookup(&self, addr: &BleAddr) -> Option<u16> {
self.lock().get(addr).copied()
}
/// Resolve the PSM to dial for `addr`: the learned per-peer PSM if known,
/// otherwise `fallback` (the configured PSM, else the legacy
/// [`DEFAULT_PSM`](super::DEFAULT_PSM)).
pub fn resolve(&self, addr: &BleAddr, fallback: u16) -> u16 {
if let Some(psm) = self.lookup(addr) {
return psm;
}
// Exact-address miss — almost always because the peer's MAC rotated
// between the scan that learned its PSM and this dial. Use the most
// recently learned PSM rather than the legacy default (never a real
// listener); a wrong guess only costs a dial-retry and a re-probe.
match self.last.load(Ordering::Relaxed) {
0 => fallback,
last => last,
}
}
/// Forget a single learned entry (e.g. after a dial failure).
pub fn forget(&self, addr: &BleAddr) {
self.lock().remove(addr);
}
/// Drop all learned entries. Called at the start of a scan cycle, since
/// addresses rotate and a dropped PSM only costs a dial-retry.
pub fn clear(&self) {
self.lock().clear();
}
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<BleAddr, u16>> {
self.inner.lock().unwrap_or_else(|e| e.into_inner())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::transport::ble::DEFAULT_PSM;
fn addr(n: u8) -> BleAddr {
BleAddr {
adapter: "ble0".to_string(),
device: [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, n],
}
}
#[test]
fn codec_roundtrips_little_endian() {
// 0x0085 -> [0x85, 0x00]; explicit LE byte order matches the wire spec.
assert_eq!(encode_psm(0x0085), [0x85, 0x00]);
assert_eq!(encode_psm(0x1234), [0x34, 0x12]);
for psm in [0u16, 1, 0x0085, 0x0080, 0x00FF, 0x1234, u16::MAX] {
assert_eq!(decode_psm(&encode_psm(psm)), Some(psm));
}
}
#[test]
fn decode_rejects_short_payload_but_ignores_trailing() {
assert_eq!(decode_psm(&[]), None); // legacy UUID-only advert
assert_eq!(decode_psm(&[0x85]), None); // truncated
// Forward-compatible: trailing bytes beyond the PSM are ignored.
assert_eq!(decode_psm(&[0x85, 0x00, 0xFF, 0xFF]), Some(0x0085));
}
#[test]
fn learn_lookup_and_overwrite() {
let map = PsmMap::new();
assert_eq!(map.lookup(&addr(1)), None);
map.learn(&addr(1), 0x0091);
assert_eq!(map.lookup(&addr(1)), Some(0x0091));
// A later advert for the same address overwrites.
map.learn(&addr(1), 0x00A0);
assert_eq!(map.lookup(&addr(1)), Some(0x00A0));
// Distinct addresses are independent.
map.learn(&addr(2), 0x00B0);
assert_eq!(map.lookup(&addr(1)), Some(0x00A0));
assert_eq!(map.lookup(&addr(2)), Some(0x00B0));
}
#[test]
fn resolve_prefers_learned_then_falls_back() {
let map = PsmMap::new();
// No learned PSM yet -> legacy default.
assert_eq!(map.resolve(&addr(1), DEFAULT_PSM), DEFAULT_PSM);
map.learn(&addr(1), 0x0091);
assert_eq!(map.resolve(&addr(1), DEFAULT_PSM), 0x0091);
// An unseen address (e.g. the peer's MAC rotated since we learned its PSM)
// dials the most-recently-learned PSM, not the legacy default.
assert_eq!(map.resolve(&addr(9), DEFAULT_PSM), 0x0091);
}
#[test]
fn forget_and_clear_drop_entries() {
let map = PsmMap::new();
map.learn(&addr(1), 0x0091);
map.learn(&addr(2), 0x0092);
map.forget(&addr(1));
assert_eq!(map.lookup(&addr(1)), None);
assert_eq!(map.lookup(&addr(2)), Some(0x0092));
map.clear();
assert_eq!(map.lookup(&addr(2)), None);
}
}
-175
View File
@@ -1,175 +0,0 @@
//! `AsyncRead` adapter over a datagram-shaped [`BleStream`].
//!
//! L2CAP delivers different boundary guarantees per platform:
//!
//! - **BlueZ** (`SOCK_SEQPACKET`) preserves SDU boundaries — one `recv` is
//! exactly one FIPS packet.
//! - **Android** (`BluetoothSocket` input stream) and **CoreBluetooth** are
//! byte-stream oriented — a `recv` may return a fragment of a packet, or
//! several packets coalesced.
//!
//! FIPS packets are self-delimiting via the 4-byte FMP common prefix, so the
//! shared framer [`crate::transport::tcp::stream::read_fmp_packet`] can recover
//! boundaries from any byte stream. This adapter turns a [`BleStream`] (whose
//! `recv` fills a `&mut [u8]`) into the [`AsyncRead`] that framer expects. On a
//! SeqPacket backend it's a no-op pass-through; on a stream backend it
//! reassembles. Either way the layer above sees one whole packet per read.
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use tokio::io::{AsyncRead, ReadBuf};
use super::io::BleStream;
/// An in-flight `recv`: owns its scratch buffer and yields an owned `Vec`, so
/// the future is `'static` and can live across `poll_read` calls.
type PendingRecv = Pin<Box<dyn Future<Output = std::io::Result<Vec<u8>>> + Send>>;
/// Buffers a [`BleStream`] into an [`AsyncRead`] byte stream.
pub struct BleStreamRead<S: BleStream + 'static> {
stream: Arc<S>,
/// Per-`recv` scratch size; also the framer's MTU bound.
mtu: u16,
/// Bytes from the last `recv` not yet consumed by the framer.
leftover: Vec<u8>,
/// Read cursor into `leftover`.
pos: usize,
/// In-flight `recv`, if one is underway.
pending: Option<PendingRecv>,
}
impl<S: BleStream + 'static> BleStreamRead<S> {
/// Wrap a stream. `mtu` is the per-`recv` scratch size (use the channel's
/// recv MTU).
pub fn new(stream: Arc<S>, mtu: u16) -> Self {
Self {
stream,
mtu: mtu.max(1),
leftover: Vec::new(),
pos: 0,
pending: None,
}
}
/// The wrapped stream, for sending on the same channel (e.g. the pubkey
/// exchange writes here while reads come through the buffer).
pub fn stream(&self) -> &S {
&self.stream
}
}
impl<S: BleStream + 'static> AsyncRead for BleStreamRead<S> {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
dst: &mut ReadBuf<'_>,
) -> Poll<std::io::Result<()>> {
// BleStreamRead is Unpin (all fields are), so get_mut is sound.
let this = self.get_mut();
loop {
// Serve buffered bytes first.
if this.pos < this.leftover.len() {
let avail = &this.leftover[this.pos..];
let n = avail.len().min(dst.remaining());
dst.put_slice(&avail[..n]);
this.pos += n;
return Poll::Ready(Ok(()));
}
// Buffer drained: pull the next datagram. The future owns its
// scratch and returns it truncated, so it captures only `Arc<S>`.
if this.pending.is_none() {
let stream = Arc::clone(&this.stream);
let mtu = this.mtu as usize;
this.pending = Some(Box::pin(async move {
let mut scratch = vec![0u8; mtu];
let n = stream
.recv(&mut scratch)
.await
.map_err(|e| std::io::Error::other(e.to_string()))?;
scratch.truncate(n);
Ok(scratch)
}));
}
match this.pending.as_mut().unwrap().as_mut().poll(cx) {
Poll::Ready(Ok(buf)) => {
this.pending = None;
// A zero-length recv is the BleStream peer-closed signal;
// leaving `dst` unfilled surfaces as EOF to the framer.
if buf.is_empty() {
return Poll::Ready(Ok(()));
}
this.leftover = buf;
this.pos = 0;
// loop to copy out
}
Poll::Ready(Err(e)) => {
this.pending = None;
return Poll::Ready(Err(e));
}
Poll::Pending => return Poll::Pending,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::transport::ble::addr::BleAddr;
use crate::transport::ble::io::MockBleStream;
use tokio::io::AsyncReadExt;
fn addr(n: u8) -> BleAddr {
BleAddr {
adapter: "hci0".to_string(),
device: [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, n],
}
}
/// Several small recvs reassemble into one read_exact.
#[tokio::test]
async fn reassembles_fragmented_recv() {
let (a, b) = MockBleStream::pair(addr(1), addr(2), 2048);
// Peer sends three fragments that together form one 10-byte message.
a.send(b"abc").await.unwrap();
a.send(b"defg").await.unwrap();
a.send(b"hij").await.unwrap();
let mut reader = BleStreamRead::new(Arc::new(b), 2048);
let mut out = [0u8; 10];
reader.read_exact(&mut out).await.unwrap();
assert_eq!(&out, b"abcdefghij");
}
/// A recv carrying several packets is served across multiple reads without
/// dropping the tail (the coalescing case).
#[tokio::test]
async fn serves_coalesced_recv_in_pieces() {
let (a, b) = MockBleStream::pair(addr(1), addr(2), 2048);
a.send(b"0123456789").await.unwrap();
let mut reader = BleStreamRead::new(Arc::new(b), 2048);
let mut first = [0u8; 4];
reader.read_exact(&mut first).await.unwrap();
assert_eq!(&first, b"0123");
let mut rest = [0u8; 6];
reader.read_exact(&mut rest).await.unwrap();
assert_eq!(&rest, b"456789");
}
/// A closed stream surfaces as EOF (read_exact errors with UnexpectedEof).
#[tokio::test]
async fn closed_stream_is_eof() {
let (a, b) = MockBleStream::pair(addr(1), addr(2), 2048);
drop(a); // peer closes
let mut reader = BleStreamRead::new(Arc::new(b), 2048);
let mut out = [0u8; 4];
let err = reader.read_exact(&mut out).await.unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::UnexpectedEof);
}
}
+44 -83
View File
@@ -11,15 +11,15 @@ pub mod tcp;
pub mod tor;
pub mod udp;
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[cfg(unix)]
pub mod ethernet;
#[cfg(ble_available)]
#[cfg(target_os = "linux")]
pub mod ble;
#[cfg(ble_available)]
#[cfg(target_os = "linux")]
use ble::DefaultBleTransport;
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[cfg(unix)]
use ethernet::EthernetTransport;
#[cfg(test)]
use loopback::LoopbackTransport;
@@ -280,27 +280,6 @@ impl fmt::Display for TransportType {
}
}
/// Link preference for a transport type — higher is preferred for carrying a
/// peer's traffic when that peer is reachable over more than one transport at
/// once. Used by the roaming cutover (`ActivePeer::roam_current_addr`) so a
/// peer on a fast transport (Wi-Fi Aware / UDP) is not dragged onto a slow
/// one (BLE) by a stray packet. Equal preferences reduce to plain
/// last-authenticated-packet-wins roaming, so single-transport deployments are
/// unaffected.
pub fn transport_link_preference(name: &str) -> u8 {
match name {
// High-bandwidth IP transports (incl. the Wi-Fi Aware data path, which
// rides the UDP transport).
"wifi" | "udp" | "tcp" => 100,
"ethernet" => 90,
// Always-on but low-bandwidth control link.
"ble" => 50,
// Anonymity overlays: high latency.
"tor" | "nym" => 40,
_ => 80,
}
}
// ============================================================================
// Transport State
// ============================================================================
@@ -915,7 +894,7 @@ pub enum TransportHandle {
/// UDP/IP transport.
Udp(UdpTransport),
/// Raw Ethernet transport.
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[cfg(unix)]
Ethernet(EthernetTransport),
/// TCP/IP transport.
Tcp(TcpTransport),
@@ -924,7 +903,7 @@ pub enum TransportHandle {
/// Nym mixnet transport (via SOCKS5).
Nym(NymTransport),
/// BLE L2CAP transport.
#[cfg(ble_available)]
#[cfg(target_os = "linux")]
Ble(DefaultBleTransport),
/// In-process loopback transport (test harness only).
#[cfg(test)]
@@ -936,12 +915,12 @@ impl TransportHandle {
pub async fn start(&mut self) -> Result<(), TransportError> {
match self {
TransportHandle::Udp(t) => t.start_async().await,
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[cfg(unix)]
TransportHandle::Ethernet(t) => t.start_async().await,
TransportHandle::Tcp(t) => t.start_async().await,
TransportHandle::Tor(t) => t.start_async().await,
TransportHandle::Nym(t) => t.start_async().await,
#[cfg(ble_available)]
#[cfg(target_os = "linux")]
TransportHandle::Ble(t) => t.start_async().await,
#[cfg(test)]
TransportHandle::Loopback(t) => t.start_async().await,
@@ -952,12 +931,12 @@ impl TransportHandle {
pub async fn stop(&mut self) -> Result<(), TransportError> {
match self {
TransportHandle::Udp(t) => t.stop_async().await,
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[cfg(unix)]
TransportHandle::Ethernet(t) => t.stop_async().await,
TransportHandle::Tcp(t) => t.stop_async().await,
TransportHandle::Tor(t) => t.stop_async().await,
TransportHandle::Nym(t) => t.stop_async().await,
#[cfg(ble_available)]
#[cfg(target_os = "linux")]
TransportHandle::Ble(t) => t.stop_async().await,
#[cfg(test)]
TransportHandle::Loopback(t) => t.stop_async().await,
@@ -968,12 +947,12 @@ impl TransportHandle {
pub async fn send(&self, addr: &TransportAddr, data: &[u8]) -> Result<usize, TransportError> {
match self {
TransportHandle::Udp(t) => t.send_async(addr, data).await,
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[cfg(unix)]
TransportHandle::Ethernet(t) => t.send_async(addr, data).await,
TransportHandle::Tcp(t) => t.send_async(addr, data).await,
TransportHandle::Tor(t) => t.send_async(addr, data).await,
TransportHandle::Nym(t) => t.send_async(addr, data).await,
#[cfg(ble_available)]
#[cfg(target_os = "linux")]
TransportHandle::Ble(t) => t.send_async(addr, data).await,
#[cfg(test)]
TransportHandle::Loopback(t) => t.send_async(addr, data).await,
@@ -984,12 +963,12 @@ impl TransportHandle {
pub fn transport_id(&self) -> TransportId {
match self {
TransportHandle::Udp(t) => t.transport_id(),
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[cfg(unix)]
TransportHandle::Ethernet(t) => t.transport_id(),
TransportHandle::Tcp(t) => t.transport_id(),
TransportHandle::Tor(t) => t.transport_id(),
TransportHandle::Nym(t) => t.transport_id(),
#[cfg(ble_available)]
#[cfg(target_os = "linux")]
TransportHandle::Ble(t) => t.transport_id(),
#[cfg(test)]
TransportHandle::Loopback(t) => t.transport_id(),
@@ -1000,12 +979,12 @@ impl TransportHandle {
pub fn name(&self) -> Option<&str> {
match self {
TransportHandle::Udp(t) => t.name(),
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[cfg(unix)]
TransportHandle::Ethernet(t) => t.name(),
TransportHandle::Tcp(t) => t.name(),
TransportHandle::Tor(t) => t.name(),
TransportHandle::Nym(t) => t.name(),
#[cfg(ble_available)]
#[cfg(target_os = "linux")]
TransportHandle::Ble(t) => t.name(),
#[cfg(test)]
TransportHandle::Loopback(_) => None,
@@ -1016,12 +995,12 @@ impl TransportHandle {
pub fn transport_type(&self) -> &TransportType {
match self {
TransportHandle::Udp(t) => t.transport_type(),
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[cfg(unix)]
TransportHandle::Ethernet(t) => t.transport_type(),
TransportHandle::Tcp(t) => t.transport_type(),
TransportHandle::Tor(t) => t.transport_type(),
TransportHandle::Nym(t) => t.transport_type(),
#[cfg(ble_available)]
#[cfg(target_os = "linux")]
TransportHandle::Ble(t) => t.transport_type(),
#[cfg(test)]
TransportHandle::Loopback(t) => t.transport_type(),
@@ -1032,12 +1011,12 @@ impl TransportHandle {
pub fn state(&self) -> TransportState {
match self {
TransportHandle::Udp(t) => t.state(),
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[cfg(unix)]
TransportHandle::Ethernet(t) => t.state(),
TransportHandle::Tcp(t) => t.state(),
TransportHandle::Tor(t) => t.state(),
TransportHandle::Nym(t) => t.state(),
#[cfg(ble_available)]
#[cfg(target_os = "linux")]
TransportHandle::Ble(t) => t.state(),
#[cfg(test)]
TransportHandle::Loopback(t) => t.state(),
@@ -1048,12 +1027,12 @@ impl TransportHandle {
pub fn mtu(&self) -> u16 {
match self {
TransportHandle::Udp(t) => t.mtu(),
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[cfg(unix)]
TransportHandle::Ethernet(t) => t.mtu(),
TransportHandle::Tcp(t) => t.mtu(),
TransportHandle::Tor(t) => t.mtu(),
TransportHandle::Nym(t) => t.mtu(),
#[cfg(ble_available)]
#[cfg(target_os = "linux")]
TransportHandle::Ble(t) => t.mtu(),
#[cfg(test)]
TransportHandle::Loopback(t) => t.mtu(),
@@ -1067,12 +1046,12 @@ impl TransportHandle {
pub fn link_mtu(&self, addr: &TransportAddr) -> u16 {
match self {
TransportHandle::Udp(t) => t.link_mtu(addr),
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[cfg(unix)]
TransportHandle::Ethernet(t) => t.link_mtu(addr),
TransportHandle::Tcp(t) => t.link_mtu(addr),
TransportHandle::Tor(t) => t.link_mtu(addr),
TransportHandle::Nym(t) => t.link_mtu(addr),
#[cfg(ble_available)]
#[cfg(target_os = "linux")]
TransportHandle::Ble(t) => t.link_mtu(addr),
#[cfg(test)]
TransportHandle::Loopback(t) => t.link_mtu(addr),
@@ -1083,30 +1062,12 @@ impl TransportHandle {
pub fn local_addr(&self) -> Option<std::net::SocketAddr> {
match self {
TransportHandle::Udp(t) => t.local_addr(),
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[cfg(unix)]
TransportHandle::Ethernet(_) => None,
TransportHandle::Tcp(t) => t.local_addr(),
TransportHandle::Tor(_) => None,
TransportHandle::Nym(_) => None,
#[cfg(ble_available)]
TransportHandle::Ble(_) => None,
#[cfg(test)]
TransportHandle::Loopback(_) => None,
}
}
/// Raw fd of the bound socket (UDP only, returns None for other
/// transports). See [`crate::transport::udp::UdpTransport::raw_fd`].
#[cfg(unix)]
pub fn raw_fd(&self) -> Option<std::os::unix::io::RawFd> {
match self {
TransportHandle::Udp(t) => t.raw_fd(),
#[cfg(any(target_os = "linux", target_os = "macos"))]
TransportHandle::Ethernet(_) => None,
TransportHandle::Tcp(_) => None,
TransportHandle::Tor(_) => None,
TransportHandle::Nym(_) => None,
#[cfg(ble_available)]
#[cfg(target_os = "linux")]
TransportHandle::Ble(_) => None,
#[cfg(test)]
TransportHandle::Loopback(_) => None,
@@ -1117,12 +1078,12 @@ impl TransportHandle {
pub fn interface_name(&self) -> Option<&str> {
match self {
TransportHandle::Udp(_) => None,
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[cfg(unix)]
TransportHandle::Ethernet(t) => Some(t.interface_name()),
TransportHandle::Tcp(_) => None,
TransportHandle::Tor(_) => None,
TransportHandle::Nym(_) => None,
#[cfg(ble_available)]
#[cfg(target_os = "linux")]
TransportHandle::Ble(_) => None,
#[cfg(test)]
TransportHandle::Loopback(_) => None,
@@ -1157,12 +1118,12 @@ impl TransportHandle {
pub fn discover(&self) -> Result<Vec<DiscoveredPeer>, TransportError> {
match self {
TransportHandle::Udp(t) => t.discover(),
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[cfg(unix)]
TransportHandle::Ethernet(t) => t.discover(),
TransportHandle::Tcp(t) => t.discover(),
TransportHandle::Tor(t) => t.discover(),
TransportHandle::Nym(t) => t.discover(),
#[cfg(ble_available)]
#[cfg(target_os = "linux")]
TransportHandle::Ble(t) => t.discover(),
#[cfg(test)]
TransportHandle::Loopback(t) => t.discover(),
@@ -1173,12 +1134,12 @@ impl TransportHandle {
pub fn auto_connect(&self) -> bool {
match self {
TransportHandle::Udp(t) => t.auto_connect(),
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[cfg(unix)]
TransportHandle::Ethernet(t) => t.auto_connect(),
TransportHandle::Tcp(t) => t.auto_connect(),
TransportHandle::Tor(t) => t.auto_connect(),
TransportHandle::Nym(t) => t.auto_connect(),
#[cfg(ble_available)]
#[cfg(target_os = "linux")]
TransportHandle::Ble(t) => t.auto_connect(),
#[cfg(test)]
TransportHandle::Loopback(t) => t.auto_connect(),
@@ -1189,12 +1150,12 @@ impl TransportHandle {
pub fn accept_connections(&self) -> bool {
match self {
TransportHandle::Udp(t) => t.accept_connections(),
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[cfg(unix)]
TransportHandle::Ethernet(t) => t.accept_connections(),
TransportHandle::Tcp(t) => t.accept_connections(),
TransportHandle::Tor(t) => t.accept_connections(),
TransportHandle::Nym(t) => t.accept_connections(),
#[cfg(ble_available)]
#[cfg(target_os = "linux")]
TransportHandle::Ble(t) => t.accept_connections(),
#[cfg(test)]
TransportHandle::Loopback(t) => t.accept_connections(),
@@ -1211,12 +1172,12 @@ impl TransportHandle {
pub async fn connect(&self, addr: &TransportAddr) -> Result<(), TransportError> {
match self {
TransportHandle::Udp(_) => Ok(()), // connectionless
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[cfg(unix)]
TransportHandle::Ethernet(_) => Ok(()), // connectionless
TransportHandle::Tcp(t) => t.connect_async(addr).await,
TransportHandle::Tor(t) => t.connect_async(addr).await,
TransportHandle::Nym(t) => t.connect_async(addr).await,
#[cfg(ble_available)]
#[cfg(target_os = "linux")]
TransportHandle::Ble(t) => t.connect_async(addr).await,
#[cfg(test)]
TransportHandle::Loopback(_) => Ok(()), // connectionless
@@ -1231,12 +1192,12 @@ impl TransportHandle {
pub fn connection_state(&self, addr: &TransportAddr) -> ConnectionState {
match self {
TransportHandle::Udp(_) => ConnectionState::Connected,
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[cfg(unix)]
TransportHandle::Ethernet(_) => ConnectionState::Connected,
TransportHandle::Tcp(t) => t.connection_state_sync(addr),
TransportHandle::Tor(t) => t.connection_state_sync(addr),
TransportHandle::Nym(t) => t.connection_state_sync(addr),
#[cfg(ble_available)]
#[cfg(target_os = "linux")]
TransportHandle::Ble(t) => t.connection_state_sync(addr),
#[cfg(test)]
TransportHandle::Loopback(_) => ConnectionState::Connected,
@@ -1250,12 +1211,12 @@ impl TransportHandle {
pub async fn close_connection(&self, addr: &TransportAddr) {
match self {
TransportHandle::Udp(t) => t.close_connection(addr),
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[cfg(unix)]
TransportHandle::Ethernet(t) => t.close_connection(addr),
TransportHandle::Tcp(t) => t.close_connection_async(addr).await,
TransportHandle::Tor(t) => t.close_connection_async(addr).await,
TransportHandle::Nym(t) => t.close_connection_async(addr).await,
#[cfg(ble_available)]
#[cfg(target_os = "linux")]
TransportHandle::Ble(t) => t.close_connection_async(addr).await,
#[cfg(test)]
TransportHandle::Loopback(_) => {} // connectionless no-op
@@ -1275,12 +1236,12 @@ impl TransportHandle {
pub fn congestion(&self) -> TransportCongestion {
match self {
TransportHandle::Udp(t) => t.congestion(),
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[cfg(unix)]
TransportHandle::Ethernet(_) => TransportCongestion::default(),
TransportHandle::Tcp(_) => TransportCongestion::default(),
TransportHandle::Tor(_) => TransportCongestion::default(),
TransportHandle::Nym(_) => TransportCongestion::default(),
#[cfg(ble_available)]
#[cfg(target_os = "linux")]
TransportHandle::Ble(_) => TransportCongestion::default(),
#[cfg(test)]
TransportHandle::Loopback(_) => TransportCongestion::default(),
@@ -1295,7 +1256,7 @@ impl TransportHandle {
TransportHandle::Udp(t) => {
serde_json::to_value(t.stats().snapshot()).unwrap_or_default()
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[cfg(unix)]
TransportHandle::Ethernet(t) => {
let snap = t.stats().snapshot();
serde_json::json!({
@@ -1320,7 +1281,7 @@ impl TransportHandle {
TransportHandle::Nym(t) => {
serde_json::to_value(t.stats().snapshot()).unwrap_or_default()
}
#[cfg(ble_available)]
#[cfg(target_os = "linux")]
TransportHandle::Ble(t) => {
serde_json::to_value(t.stats().snapshot()).unwrap_or_default()
}
-11
View File
@@ -89,17 +89,6 @@ impl UdpTransport {
self.local_addr
}
/// Raw fd of the bound socket, so an embedder can apply socket options
/// this transport does not manage itself — notably binding it to one of
/// several networks a host OS offers, when destination-based routing alone
/// does not reach a peer (see [`crate::Node::enable_app_owned_udp_fd`]).
/// `None` before start.
#[cfg(unix)]
pub fn raw_fd(&self) -> Option<std::os::unix::io::RawFd> {
use std::os::unix::io::AsRawFd;
self.socket.as_ref().map(|s| s.as_raw_fd())
}
/// Configured recv buffer size — used when opening per-peer
/// `ConnectedPeerSocket`s so they get the same buffer ceiling as
/// the wildcard listen socket.
+1 -11
View File
@@ -606,17 +606,7 @@ mod platform {
unsafe { &*(storage as *const _ as *const libc::sockaddr_in6) };
let ip = std::net::Ipv6Addr::from(addr.sin6_addr.s6_addr);
let port = u16::from_be(addr.sin6_port);
// Preserve sin6_scope_id: a link-local source (fe80::/10) is
// only routable together with its interface scope, so dropping
// it here breaks replies to a link-local peer (e.g. a Wi-Fi
// Aware NDP interface). Non-link-local sources carry scope 0,
// for which SocketAddrV6 is identical to the unscoped form.
Ok(SocketAddr::V6(std::net::SocketAddrV6::new(
ip,
port,
0,
addr.sin6_scope_id,
)))
Ok(SocketAddr::from((ip, port)))
}
family => Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
-9
View File
@@ -92,15 +92,6 @@ impl TreeState {
&self.my_coords
}
/// Test-only override of this node's coordinates, bypassing the
/// parent/declaration state machine. Lets routing tests place the node at
/// an arbitrary tree position to exercise coordinate-based classification.
#[cfg(test)]
pub(crate) fn set_my_coords_for_test(&mut self, coords: TreeCoordinate) {
self.root = *coords.root_id();
self.my_coords = coords;
}
/// Get the current root.
pub fn root(&self) -> &NodeAddr {
&self.root
+1 -1
View File
@@ -301,7 +301,7 @@ fn extract_pktinfo_ifindex(msg: &libc::msghdr) -> Option<u32> {
if cmsg.cmsg_level == libc::IPPROTO_IPV6 && cmsg.cmsg_type == libc::IPV6_PKTINFO {
let data_ptr = unsafe { libc::CMSG_DATA(cmsg_ptr) } as *const libc::in6_pktinfo;
let pktinfo: libc::in6_pktinfo = unsafe { std::ptr::read_unaligned(data_ptr) };
return Some(pktinfo.ipi6_ifindex as u32);
return Some(pktinfo.ipi6_ifindex);
}
cmsg_ptr = unsafe { libc::CMSG_NXTHDR(msg, cmsg_ptr) };
}
+38 -159
View File
@@ -115,8 +115,8 @@ pub fn clamp_tcp_mss(ipv6_packet: &mut [u8], max_mss: u16) -> bool {
if current_mss > max_mss {
ipv6_packet[i + 2..i + 4].copy_from_slice(&max_mss.to_be_bytes());
// Recompute the now-stale checksum over the rewritten header.
recalculate_l4_checksum(ipv6_packet);
// Recalculate TCP checksum
recalculate_tcp_checksum(ipv6_packet, tcp_start);
modified = true;
}
@@ -129,39 +129,41 @@ pub fn clamp_tcp_mss(ipv6_packet: &mut [u8], max_mss: u16) -> bool {
modified
}
/// Recalculate the TCP or UDP checksum (IPv6 pseudo-header + segment) in place.
///
/// Completes the checksum macOS leaves offloaded on hairpinned self-traffic, and
/// is reused by MSS clamping. No-op for other next-headers (e.g. ICMPv6) and for
/// malformed packets. Assumes the L4 header follows the 40-byte IPv6 header, as
/// all FIPS packets do.
pub fn recalculate_l4_checksum(ipv6_packet: &mut [u8]) {
if ipv6_packet.len() < 40 || ipv6_packet[0] >> 4 != 6 {
return;
}
/// Recalculate TCP checksum after modifying the packet.
fn recalculate_tcp_checksum(ipv6_packet: &mut [u8], tcp_start: usize) {
// Zero out existing checksum
ipv6_packet[tcp_start + 16] = 0;
ipv6_packet[tcp_start + 17] = 0;
// Extract addresses
let src = &ipv6_packet[8..24];
let dst = &ipv6_packet[24..40];
// Get TCP segment length
let payload_len = u16::from_be_bytes([ipv6_packet[4], ipv6_packet[5]]) as usize;
if payload_len == 0 || 40 + payload_len > ipv6_packet.len() {
return;
}
let tcp_segment = &ipv6_packet[tcp_start..tcp_start + payload_len];
// Transport checksum field offset within the packet: TCP at 16, UDP at 6.
let proto = ipv6_packet[6];
let csum = match proto {
6 if payload_len >= TCP_HEADER_MIN_LEN => 40 + 16,
17 if payload_len >= 8 => 40 + 6,
_ => return,
};
ipv6_packet[csum] = 0;
ipv6_packet[csum + 1] = 0;
// Pseudo-header (src + dst are contiguous at 8..40), length, next header,
// Calculate checksum with pseudo-header
let mut sum: u32 = 0;
for chunk in ipv6_packet[8..40].chunks(2) {
// Pseudo-header: source address
for chunk in src.chunks(2) {
sum += u16::from_be_bytes([chunk[0], chunk[1]]) as u32;
}
sum += payload_len as u32 + proto as u32;
// then the transport segment itself (with the checksum field zeroed above).
for chunk in ipv6_packet[40..40 + payload_len].chunks(2) {
// Pseudo-header: destination address
for chunk in dst.chunks(2) {
sum += u16::from_be_bytes([chunk[0], chunk[1]]) as u32;
}
// Pseudo-header: TCP length
sum += payload_len as u32;
// Pseudo-header: next header (TCP = 6)
sum += 6;
// TCP segment
for chunk in tcp_segment.chunks(2) {
let value = if chunk.len() == 2 {
u16::from_be_bytes([chunk[0], chunk[1]])
} else {
@@ -169,16 +171,15 @@ pub fn recalculate_l4_checksum(ipv6_packet: &mut [u8]) {
};
sum += value as u32;
}
// Fold 32-bit sum to 16 bits
while sum >> 16 != 0 {
sum = (sum & 0xffff) + (sum >> 16);
}
// IPv6 forbids an all-zero UDP checksum; send 0xffff instead (TCP keeps 0).
let checksum = match (!sum as u16, proto) {
(0, 17) => 0xffff,
(c, _) => c,
};
ipv6_packet[csum..csum + 2].copy_from_slice(&checksum.to_be_bytes());
// One's complement
let checksum = !sum as u16;
ipv6_packet[tcp_start + 16..tcp_start + 18].copy_from_slice(&checksum.to_be_bytes());
}
#[cfg(test)]
@@ -215,7 +216,7 @@ mod tests {
packet[tcp_start + 24] = 0;
// Calculate checksum
recalculate_l4_checksum(&mut packet);
recalculate_tcp_checksum(&mut packet, tcp_start);
packet
}
@@ -276,126 +277,4 @@ mod tests {
assert!(!modified);
}
// ========================================================================
// recalculate_l4_checksum — finish macOS's offloaded self-traffic
// checksums (the bug that left ACK/data/FIN segments undeliverable).
// ========================================================================
/// True if the packet's TCP/UDP checksum verifies (folded ones-complement
/// sum over pseudo-header + segment, including the checksum field, == 0xffff).
fn l4_checksum_valid(pkt: &[u8]) -> bool {
let payload_len = u16::from_be_bytes([pkt[4], pkt[5]]) as usize;
let mut sum: u32 = 0;
for chunk in pkt[8..40].chunks(2) {
sum += u16::from_be_bytes([chunk[0], chunk[1]]) as u32;
}
sum += payload_len as u32;
sum += pkt[6] as u32; // next header
for chunk in pkt[40..40 + payload_len].chunks(2) {
let v = if chunk.len() == 2 {
u16::from_be_bytes([chunk[0], chunk[1]])
} else {
u16::from_be_bytes([chunk[0], 0])
};
sum += v as u32;
}
while sum >> 16 != 0 {
sum = (sum & 0xffff) + (sum >> 16);
}
sum as u16 == 0xffff
}
const SELF_ADDR: [u8; 16] = [0xfd, 0x12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x55];
/// A self-addressed 20-byte TCP ACK (no options) carrying a deliberately
/// wrong checksum — the shape macOS hands us for hairpinned non-SYN traffic.
fn make_tcp_ack_packet() -> Vec<u8> {
let mut p = vec![0u8; 40 + 20];
p[0] = 0x60;
p[4..6].copy_from_slice(&20u16.to_be_bytes()); // payload length
p[6] = 6; // TCP
p[7] = 64;
p[8..24].copy_from_slice(&SELF_ADDR);
p[24..40].copy_from_slice(&SELF_ADDR);
let t = 40;
p[t..t + 2].copy_from_slice(&52097u16.to_be_bytes());
p[t + 2..t + 4].copy_from_slice(&9999u16.to_be_bytes());
p[t + 4..t + 8].copy_from_slice(&1000u32.to_be_bytes()); // seq
p[t + 8..t + 12].copy_from_slice(&2000u32.to_be_bytes()); // ack
p[t + 12] = 0x50; // data offset = 5 (20-byte header)
p[t + 13] = 0x10; // ACK
p[t + 14..t + 16].copy_from_slice(&2049u16.to_be_bytes()); // window
p[t + 16] = 0x8e; // bogus checksum (macOS pseudo-header partial)
p[t + 17] = 0xce;
p
}
#[test]
fn recompute_fixes_tcp_non_syn_checksum() {
let mut pkt = make_tcp_ack_packet();
assert!(
!l4_checksum_valid(&pkt),
"fixture should start with a bad checksum"
);
recalculate_l4_checksum(&mut pkt);
assert!(
l4_checksum_valid(&pkt),
"TCP checksum must verify after recompute"
);
}
#[test]
fn recompute_fixes_udp_checksum() {
let mut p = vec![0u8; 40 + 12]; // 8-byte UDP header + 4-byte payload
p[0] = 0x60;
p[4..6].copy_from_slice(&12u16.to_be_bytes());
p[6] = 17; // UDP
p[7] = 64;
p[8..24].copy_from_slice(&SELF_ADDR);
p[24..40].copy_from_slice(&SELF_ADDR);
let u = 40;
p[u..u + 2].copy_from_slice(&40000u16.to_be_bytes()); // src port
p[u + 2..u + 4].copy_from_slice(&5354u16.to_be_bytes()); // dst port
p[u + 4..u + 6].copy_from_slice(&12u16.to_be_bytes()); // UDP length
p[u + 6] = 0x8e; // bogus checksum
p[u + 7] = 0xce;
p[u + 8..u + 12].copy_from_slice(&[0xde, 0xad, 0xbe, 0xef]); // payload
assert!(!l4_checksum_valid(&p), "fixture should start invalid");
recalculate_l4_checksum(&mut p);
assert!(
l4_checksum_valid(&p),
"UDP checksum must verify after recompute"
);
assert_ne!(
&p[u + 6..u + 8],
&[0, 0],
"IPv6 UDP checksum must not be zero"
);
}
#[test]
fn recompute_is_noop_for_non_transport() {
// Next-header 59 (No Next Header): nothing to checksum.
let mut pkt = vec![0u8; 60];
pkt[0] = 0x60;
pkt[4..6].copy_from_slice(&20u16.to_be_bytes());
pkt[6] = 59;
let before = pkt.clone();
recalculate_l4_checksum(&mut pkt);
assert_eq!(pkt, before, "non-transport packet must be left untouched");
}
#[test]
fn recompute_ignores_truncated_packet() {
// payload_len claims 20 bytes of TCP but only 10 are present.
let mut pkt = vec![0u8; 40 + 10];
pkt[0] = 0x60;
pkt[4..6].copy_from_slice(&20u16.to_be_bytes());
pkt[6] = 6;
let before = pkt.clone();
recalculate_l4_checksum(&mut pkt); // must not panic
assert_eq!(pkt, before, "truncated packet must be left untouched");
}
}
+1 -133
View File
@@ -693,7 +693,7 @@ fn handle_tun_packet(
path_mtu_lookup: &PathMtuLookup,
) -> bool {
use super::icmp::{DestUnreachableCode, build_dest_unreachable, should_send_icmp_error};
use super::tcp_mss::{clamp_tcp_mss, recalculate_l4_checksum};
use super::tcp_mss::clamp_tcp_mss;
log_ipv6_packet(packet);
@@ -704,30 +704,6 @@ fn handle_tun_packet(
// Check if destination is a FIPS address (fd::/8 prefix)
if packet[24] == crate::identity::FIPS_ADDRESS_PREFIX {
// Loopback: a packet to our own mesh address must be delivered
// locally, not pushed into the mesh (we have no session/route to
// ourselves, so it would just be dropped). Hairpin it back to the TUN
// writer for inbound delivery.
//
// Platform note: in practice this branch is reached only on macOS.
// macOS point-to-point `utun` interfaces egress self-addressed traffic
// down the tunnel into this reader, so the daemon has to loop it back
// itself. On Linux the kernel routes traffic to our own bound
// addresses via `lo` before it ever reaches the TUN, so this branch
// never fires there. The check is kept unconditional anyway, both as a
// platform-independent self-delivery invariant and so the path stays
// exercised by the Linux-only CI unit tests.
if packet[24..40] == *our_addr.as_bytes() {
trace!(name = %name, "Hairpinning self-addressed packet back to TUN (loopback)");
// Finish the checksum macOS leaves offloaded on self-traffic, else the
// local stack drops every non-SYN segment. See recalculate_l4_checksum.
recalculate_l4_checksum(packet);
if tun_tx.send(packet.to_vec()).is_err() {
return false; // Channel closed, shutdown
}
return true;
}
// Per-destination clamp: if discovery has learned a smaller path
// MTU for this destination, tighten the ceiling for this flow.
let effective_max_mss = per_flow_max_mss(path_mtu_lookup, &packet[24..40], max_mss);
@@ -1224,32 +1200,6 @@ mod windows_tun {
#[cfg(windows)]
pub use windows_tun::{TunDevice, TunWriter, run_tun_reader, shutdown_tun_interface};
// Android uses an app-owned TUN (the embedder owns the fd, e.g. an Android
// VpnService); FIPS never creates or configures a system TUN here. These no-op
// stubs stand in for the platform ops so the shared TunDevice code compiles.
#[cfg(target_os = "android")]
mod platform {
use super::TunError;
use std::net::Ipv6Addr;
pub fn is_ipv6_disabled() -> bool {
false
}
pub async fn interface_exists(_name: &str) -> bool {
false
}
pub async fn delete_interface(_name: &str) -> Result<(), TunError> {
Ok(())
}
pub async fn configure_interface(
_name: &str,
_addr: Ipv6Addr,
_mtu: u16,
) -> Result<(), TunError> {
Ok(())
}
}
#[cfg(target_os = "linux")]
mod platform {
use super::TunError;
@@ -1570,88 +1520,6 @@ mod tests {
assert_eq!(per_flow_max_mss(&lookup, b.as_bytes(), 1360), 1315);
}
// ========================================================================
// handle_tun_packet — self-addressed loopback hairpin
//
// A packet destined for our own mesh address must be delivered back to
// the local stack via the TUN writer, never pushed into the mesh (there
// is no session/route to ourselves). On macOS the kernel egresses such
// self-traffic down the utun into the reader, so the daemon has to loop
// it back itself.
// ========================================================================
/// Build a minimal 40-byte IPv6 packet (no upper-layer payload) addressed
/// to `dst`, sourced from a distinct fips address.
fn ipv6_packet_to(dst: &FipsAddress) -> Vec<u8> {
let mut pkt = vec![0u8; 40];
pkt[0] = 0x60; // version 6
pkt[6] = 59; // next header = No Next Header (skips MSS clamp)
pkt[7] = 64; // hop limit
pkt[8] = crate::identity::FIPS_ADDRESS_PREFIX; // src in fd::/8
pkt[24..40].copy_from_slice(dst.as_bytes()); // dst
pkt
}
#[test]
fn self_addressed_packet_is_hairpinned_to_tun() {
let our_addr = fips_addr_with_node_byte(0x55);
let (tun_tx, tun_rx) = mpsc::channel::<Vec<u8>>();
let (outbound_tx, mut outbound_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(4);
let lookup = empty_lookup();
let mut pkt = ipv6_packet_to(&our_addr);
assert!(handle_tun_packet(
&mut pkt,
1360,
"test0",
our_addr,
&tun_tx,
&outbound_tx,
&lookup,
));
// Delivered locally via the TUN writer...
let looped = tun_rx
.try_recv()
.expect("self-addressed packet should be hairpinned to the TUN");
assert_eq!(&looped[24..40], our_addr.as_bytes());
// ...and never handed to the mesh.
assert!(
outbound_rx.try_recv().is_err(),
"self-addressed packet must not be pushed into the mesh"
);
}
#[test]
fn other_fips_packet_goes_to_mesh() {
let our_addr = fips_addr_with_node_byte(0x55);
let peer = fips_addr_with_node_byte(0x66);
let (tun_tx, tun_rx) = mpsc::channel::<Vec<u8>>();
let (outbound_tx, mut outbound_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(4);
let lookup = empty_lookup();
let mut pkt = ipv6_packet_to(&peer);
assert!(handle_tun_packet(
&mut pkt,
1360,
"test0",
our_addr,
&tun_tx,
&outbound_tx,
&lookup,
));
// A non-self fips destination is routed into the mesh, not looped back.
assert!(
outbound_rx.try_recv().is_ok(),
"non-self fips destination should be sent to the mesh"
);
assert!(
tun_rx.try_recv().is_err(),
"non-self fips destination must not be hairpinned"
);
}
// ========================================================================
// macOS utun packet-info header (AF_INET6 4-byte big-endian prefix)
//
-72
View File
@@ -72,75 +72,3 @@ and optional trace-level RUST_LOG, capturing per-rep diagnostics and a
mechanism-match summary across the run. Used for statistical reliability
characterization of known flake classes under calibrated stress, not as
a per-commit gate; not part of `ci-local.sh`.
## Running CI locally (`ci-local.sh`)
[`ci-local.sh`](ci-local.sh) runs the full local CI pipeline — build,
clippy, unit tests, and the integration suites (including the chaos
scenarios) — mirroring the GitHub `ci.yml` integration matrix. Run
`./ci-local.sh --help` for the full option list and `--list` for the
available suites. `--check-parity` verifies the local suite set matches
the GitHub matrix (see [check-ci-parity.sh](check-ci-parity.sh)).
### Per-run isolation and the `FIPS_CI_RUN_ID` override
Every invocation derives a **run id** and scopes all of its Docker
resources to it, so two simultaneous runs on the same host (for example,
one per git worktree, or an operator testing by hand while CI is in
flight) never collide:
- **Compose projects** are named `fipsci_<run-id>_<suite>`, so
container, network, and volume names are all prefixed per run.
- **Build images** are tagged `fips-test:<run-id>` and
`fips-test-app:<run-id>` (exported as `FIPS_TEST_IMAGE` /
`FIPS_TEST_APP_IMAGE` for the compose consumers).
- Each parallel chaos child gets a unique, non-overlapping `/24` in
`10.30.x` (via the sim `--subnet` override). `10.30.x` sits outside
Docker's default address pool and the fixed-subnet suites' `172.x`
ranges, so neither a sibling chaos child nor an auto-assigned network
can swallow a pinned subnet.
By default the run id is `<short-git-sha>-<random>` — the SHA portion
records *what code* a container is testing, the random suffix keeps
simultaneous runs of the same SHA disjoint. Override it for a
reproducible, attach-by-name debug session:
```sh
FIPS_CI_RUN_ID=mydebug ./ci-local.sh --only static-mesh
# containers are named fipsci_mydebug_static_fips-node-a, etc.
```
### Preemption-safety and exit codes
`ci-local.sh` is safe to cancel mid-run. A signal trap tears down *every*
compose project the run started (not just the current suite) and reaps
any in-flight parallel chaos children, bounded by a `timeout` so a stuck
`compose down` cannot wedge the trap. Exit codes distinguish a cancelled
run from a failing one:
| Code | Meaning |
| ---- | ------- |
| `0` | all stages passed |
| `1` | one or more stages failed |
| `130` | interrupted by SIGINT — cancelled, not a failure |
| `143` | terminated by SIGTERM — cancelled, not a failure |
A preempting CI worker (the push-triggered, CI-gated build pipeline that
kills an in-flight run when a newer same-branch tip arrives) maps
`130`/`143`*cancelled* (discard, do not record a failing commit), `0`
→ green, any other non-zero → red.
### Cleaning up leftover resources
Every CI-created container, network, and volume carries the label
`com.corganlabs.fips-ci=1`. If a run is hard-killed (SIGKILL, OOM, crash)
and leaves resources behind, reap them with:
```sh
./ci-local.sh --reap # or: ./ci-cleanup.sh
```
[`ci-cleanup.sh`](ci-cleanup.sh) force-removes everything bearing the CI
label or a `fipsci_` compose-project prefix; it is safe to run when there
is nothing to reap and safe to run repeatedly. Pass `--project-prefix` to
scope the sweep to a single run.
-2
View File
@@ -1,8 +1,6 @@
networks:
acl-net:
driver: bridge
labels:
- "com.corganlabs.fips-ci=1"
ipam:
config:
- subnet: 172.31.0.0/24
-2
View File
@@ -6,8 +6,6 @@
networks:
bt-net:
driver: bridge
labels:
- "com.corganlabs.fips-ci=1"
ipam:
config:
- subnet: 172.99.0.0/24
-4
View File
@@ -65,7 +65,6 @@ VERBOSE=""
SEED=""
DURATION=""
NODES=""
SUBNET=""
while [ $# -gt 0 ]; do
case "$1" in
@@ -73,7 +72,6 @@ while [ $# -gt 0 ]; do
--seed) SEED="$2"; shift 2 ;;
--duration) DURATION="$2"; shift 2 ;;
--nodes) NODES="$2"; shift 2 ;;
--subnet) SUBNET="$2"; shift 2 ;;
--list) list_scenarios ;;
-*) echo "Error: Unknown option '$1'" >&2; usage ;;
*)
@@ -137,7 +135,6 @@ PYTHON_ARGS=("$SCENARIO_FILE")
[ -n "$VERBOSE" ] && PYTHON_ARGS+=("$VERBOSE")
[ -n "$SEED" ] && PYTHON_ARGS+=("--seed" "$SEED")
[ -n "$DURATION" ] && PYTHON_ARGS+=("--duration" "$DURATION")
[ -n "$SUBNET" ] && PYTHON_ARGS+=("--subnet" "$SUBNET")
echo "=== FIPS Stochastic Simulation ==="
echo ""
@@ -146,7 +143,6 @@ echo " File: $SCENARIO_FILE"
[ -n "$SEED" ] && echo " Seed: $SEED (override)"
[ -n "$DURATION" ] && echo " Duration: ${DURATION}s (override)"
[ -n "$NODES" ] && echo " Nodes: $NODES (override)"
[ -n "$SUBNET" ] && echo " Subnet: $SUBNET (override)"
echo ""
# If --nodes is specified, create a patched copy of the scenario file
-8
View File
@@ -25,12 +25,6 @@ def main():
"--duration", type=int, default=None,
help="Override scenario duration in seconds",
)
parser.add_argument(
"--subnet", type=str, default=None,
help="Override topology subnet CIDR (e.g. 10.30.0.0/24); node IPs "
"derive from it. Used by CI to give each parallel run a "
"non-overlapping network.",
)
args = parser.parse_args()
level = logging.DEBUG if args.verbose else logging.INFO
@@ -54,8 +48,6 @@ def main():
print("Error: --duration must be >= 1", file=sys.stderr)
sys.exit(1)
scenario.duration_secs = args.duration
if args.subnet is not None:
scenario.topology.subnet = args.subnet
runner = SimRunner(scenario)
result = runner.run()
-2
View File
@@ -20,8 +20,6 @@ _COMPOSE_TEMPLATE = Template(
networks:
fips-net:
driver: bridge
labels:
- "com.corganlabs.fips-ci=1"
ipam:
config:
- subnet: {{ subnet }}
-106
View File
@@ -1,106 +0,0 @@
#!/bin/bash
# Reap FIPS CI docker resources: containers, networks, volumes, images.
#
# Force-removes everything created by ci-local.sh that is still around —
# whether a run finished cleanly, was preempted (SIGTERM/SIGKILL), OOM-killed,
# or crashed. Two complementary selectors make this robust no matter how a
# prior run died:
#
# 1. The CI label com.corganlabs.fips-ci=1 (attached to every direct
# `docker run`/network/volume ci-local drives).
# 2. The compose project-name prefix fipsci_ (every compose project ci-local
# starts is named fipsci_<run-id>_<suite>, so its containers/networks/
# volumes all carry com.docker.compose.project=fipsci_... and are named
# with that prefix).
#
# Usage:
# ci-cleanup.sh Reap ALL fips-ci resources (any run)
# ci-cleanup.sh --project-prefix P Restrict the compose-project sweep to
# names starting with P (scopes the reap
# to a single run; the label sweep still
# runs)
# ci-cleanup.sh --label L Override the CI label (default above)
# ci-cleanup.sh --images "a b,c" Also `docker rmi -f` these image tags
# (space- or comma-separated)
#
# Safe to run when there is nothing to reap, and safe to run repeatedly.
# Also reachable as `ci-local.sh --reap`.
set -uo pipefail
LABEL="com.corganlabs.fips-ci=1"
PROJECT_PREFIX="fipsci_" # broad default: every CI run
IMAGES=""
while [[ $# -gt 0 ]]; do
case "$1" in
--label) LABEL="$2"; shift 2 ;;
--project-prefix) PROJECT_PREFIX="$2"; shift 2 ;;
--images) IMAGES="$2"; shift 2 ;;
-h|--help) sed -n '2,/^set /{ /^set /d; s/^# \?//; p }' "$0"; exit 0 ;;
*) echo "Unknown option: $1" >&2; exit 2 ;;
esac
done
if ! command -v docker >/dev/null 2>&1; then
# No docker, nothing to reap.
exit 0
fi
if ! docker info >/dev/null 2>&1; then
# Daemon unreachable; treat as nothing to reap rather than wedging a caller.
exit 0
fi
# Each docker mutation is wrapped in `timeout` so a stuck daemon/resource can
# never wedge a caller (ci-local's signal trap relies on this being bounded).
TMO=30
# Distinct compose project names (read off container labels) that start with
# the configured prefix.
ci_projects() {
docker ps -a --format '{{.Label "com.docker.compose.project"}}' 2>/dev/null \
| grep -E "^${PROJECT_PREFIX}" | sort -u
}
reap_containers() {
# By CI label.
docker ps -aq --filter "label=${LABEL}" 2>/dev/null \
| xargs -r timeout "$TMO" docker rm -f >/dev/null 2>&1 || true
# By compose project (carried even when container_name is explicit).
local p
for p in $(ci_projects); do
docker ps -aq --filter "label=com.docker.compose.project=${p}" 2>/dev/null \
| xargs -r timeout "$TMO" docker rm -f >/dev/null 2>&1 || true
done
}
reap_networks() {
docker network ls -q --filter "label=${LABEL}" 2>/dev/null \
| xargs -r timeout "$TMO" docker network rm >/dev/null 2>&1 || true
# Compose networks are named <project>_<net> → match by name prefix so
# orphaned networks (whose containers are already gone) are still caught.
docker network ls --format '{{.Name}}' 2>/dev/null | grep -E "^${PROJECT_PREFIX}" \
| xargs -r timeout "$TMO" docker network rm >/dev/null 2>&1 || true
}
reap_volumes() {
docker volume ls -q --filter "label=${LABEL}" 2>/dev/null \
| xargs -r timeout "$TMO" docker volume rm >/dev/null 2>&1 || true
docker volume ls --format '{{.Name}}' 2>/dev/null | grep -E "^${PROJECT_PREFIX}" \
| xargs -r timeout "$TMO" docker volume rm >/dev/null 2>&1 || true
}
reap_images() {
[[ -z "$IMAGES" ]] && return 0
local imgs
read -ra imgs <<< "${IMAGES//,/ }"
[[ ${#imgs[@]} -eq 0 ]] && return 0
timeout "$TMO" docker rmi -f "${imgs[@]}" >/dev/null 2>&1 || true
}
# Order matters: containers reference networks/volumes, so drop them first.
reap_containers
reap_networks
reap_volumes
reap_images
exit 0
+6 -154
View File
@@ -14,9 +14,6 @@
# --list List available integration suites
# --check-parity Verify this suite set matches ci.yml's integration
# matrix (see testing/check-ci-parity.sh), then exit
# --reap Force-remove all leftover FIPS CI docker resources
# (containers/networks/volumes carrying the CI label or a
# fipsci_ compose project), then exit. See ci-cleanup.sh.
# -h, --help Show this help
#
# Integration suites (default coverage):
@@ -35,13 +32,8 @@
# tor-socks5, tor-directory
#
# Exit codes:
# 0 — all stages passed
# 1 — one or more stages failed
# 130 — interrupted by SIGINT (128 + 2; run was cancelled, not a failure)
# 143 — terminated by SIGTERM (128 + 15; run was cancelled, not a failure)
#
# A preempting CI worker maps 130/143 → "cancelled" (discard, do not record a
# failing commit), 0 → green, any other non-zero → red.
# 0 — all stages passed
# 1 — one or more stages failed
#
# ── CI parity invariant ─────────────────────────────────────────────────────
# This local default suite set and the GitHub integration matrix
@@ -201,7 +193,6 @@ while [[ $# -gt 0 ]]; do
-j|--jobs) PARALLEL_JOBS="$2"; shift 2 ;;
--list) list_suites ;;
--check-parity) exec "$SCRIPT_DIR/check-ci-parity.sh" ;;
--reap) exec "$SCRIPT_DIR/ci-cleanup.sh" ;;
-h|--help) usage ;;
*) echo "Unknown option: $1"; usage ;;
esac
@@ -223,104 +214,6 @@ record() {
fi
}
# ── Per-run isolation + signal-safe teardown ───────────────────────────────
#
# This script may be preempted (a CI worker sends SIGTERM, waits ~30s, then
# SIGKILL) so it can restart on a newer tip. To make that safe:
# * every docker resource is namespaced to THIS run (compose project prefix
# + per-run image tags) so a restart never collides with a dying run;
# * a trap tears down everything this run created on signal/exit, bounded by
# `timeout` so a stuck `down` cannot wedge the trap (SIGKILL is the backstop).
# Derive a run id: honor the worker's $FIPS_CI_RUN_ID, else <short-sha>-<rand>,
# else $$-<timestamp>. Sanitize to a valid compose project / image-tag token.
if [[ -n "${FIPS_CI_RUN_ID:-}" ]]; then
CI_RUN_ID="$FIPS_CI_RUN_ID"
else
_ci_sha="$(git -C "$PROJECT_ROOT" rev-parse --short HEAD 2>/dev/null || true)"
if [[ -n "$_ci_sha" ]]; then
CI_RUN_ID="${_ci_sha}-${RANDOM}${RANDOM}"
else
CI_RUN_ID="$$-$(date +%s)"
fi
fi
CI_RUN_ID="$(printf '%s' "$CI_RUN_ID" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9_-' '-')"
# A docker image tag and a compose project name must both START with an
# alphanumeric, so strip any leading -/_ left by sanitization.
CI_RUN_ID="$(printf '%s' "$CI_RUN_ID" | sed -E 's/^[^a-z0-9]+//')"
[[ -z "$CI_RUN_ID" ]] && CI_RUN_ID="r$$"
CI_PROJECT_PREFIX="fipsci_${CI_RUN_ID}"
CI_IMAGE_TEST="fips-test:${CI_RUN_ID}"
CI_IMAGE_APP="fips-test-app:${CI_RUN_ID}"
CI_LABEL="com.corganlabs.fips-ci=1"
# Exported so child suite scripts + their compose/`docker run` invocations
# inherit the run identity. Compose files read FIPS_TEST_IMAGE/FIPS_TEST_APP_IMAGE
# (default :latest when unset, preserving manual `docker compose` use).
export FIPS_CI_RUN_ID="$CI_RUN_ID"
export FIPS_TEST_IMAGE="$CI_IMAGE_TEST"
export FIPS_TEST_APP_IMAGE="$CI_IMAGE_APP"
# Per-suite compose project name: ${prefix}_<suite-or-compose-basename>. Keeps
# today's intra-run distinctness (one project per compose file / chaos child)
# while adding the cross-run prefix that scopes the reap.
ci_project() { printf '%s_%s' "$CI_PROJECT_PREFIX" "$1"; }
# PIDs of in-flight parallel chaos children (subshells). The trap signals these.
CI_CHAOS_PIDS=()
CI_CLEANED=0
# Best-effort, BOUNDED teardown of every docker resource THIS run may have
# created. Idempotent (guarded), so the signal and EXIT paths don't double-run.
ci_teardown() {
[[ $CI_CLEANED -eq 1 ]] && return 0
CI_CLEANED=1
# 1. Propagate to parallel chaos children and reap them (bounded).
if [[ ${#CI_CHAOS_PIDS[@]} -gt 0 ]]; then
kill -TERM "${CI_CHAOS_PIDS[@]}" 2>/dev/null || true
local _end=$(( SECONDS + 10 )) _p
for _p in "${CI_CHAOS_PIDS[@]}"; do
while kill -0 "$_p" 2>/dev/null && (( SECONDS < _end )); do
sleep 0.3
done
done
kill -KILL "${CI_CHAOS_PIDS[@]}" 2>/dev/null || true
wait "${CI_CHAOS_PIDS[@]}" 2>/dev/null || true
fi
# 2. Remove all compose projects + direct-run resources + per-run images
# for this run. ci-cleanup.sh wraps each docker op in `timeout`; bound
# the whole sweep too so the trap can never wedge.
timeout 150 bash "$SCRIPT_DIR/ci-cleanup.sh" \
--label "$CI_LABEL" \
--project-prefix "$CI_PROJECT_PREFIX" \
--images "$CI_IMAGE_TEST $CI_IMAGE_APP" >/dev/null 2>&1 || true
}
on_signal() {
local sig="$1"
# Block re-entry and stop the EXIT trap from overriding the signal code.
trap '' TERM INT EXIT
echo "" >&2
fail "Received SIG$sig — cancelling run, tearing down ${CI_PROJECT_PREFIX}"
ci_teardown
# 128 + signal number: distinct from 0 (green) / 1 (stage failed).
if [[ "$sig" == "TERM" ]]; then exit 143; else exit 130; fi
}
on_exit() {
local code=$?
trap '' TERM INT EXIT
ci_teardown
exit "$code"
}
trap 'on_signal TERM' TERM
trap 'on_signal INT' INT
trap 'on_exit' EXIT
# ── Stage 1: Build ─────────────────────────────────────────────────────────
run_build() {
@@ -424,7 +317,6 @@ run_static() {
local topology="$1"
local compose="testing/static/docker-compose.yml"
local rc=0
export COMPOSE_PROJECT_NAME="$(ci_project static)"
info "[$topology] Generating configs"
bash testing/static/scripts/generate-configs.sh "$topology" || { record "static-$topology" 1; return; }
@@ -449,7 +341,6 @@ run_static() {
run_rekey() {
local compose="testing/static/docker-compose.yml"
local rc=0
export COMPOSE_PROJECT_NAME="$(ci_project static)"
info "[rekey] Generating configs"
bash testing/static/scripts/generate-configs.sh rekey || { record "rekey" 1; return; }
@@ -478,7 +369,6 @@ run_rekey() {
run_admission_cap() {
local compose="testing/static/docker-compose.yml"
local rc=0
export COMPOSE_PROJECT_NAME="$(ci_project static)"
info "[admission-cap] Generating configs"
bash testing/static/scripts/generate-configs.sh mesh || { record "admission-cap" 1; return; }
@@ -505,9 +395,6 @@ run_chaos() {
local name="$1"
shift
local rc=0
# Distinct project per scenario (chaos children run in parallel). When
# invoked from a background subshell this export is local to that child.
export COMPOSE_PROJECT_NAME="$(ci_project "chaos-$name")"
info "[chaos/$name] Running simulation"
if bash testing/chaos/scripts/chaos.sh "$@" 2>&1; then
@@ -523,7 +410,6 @@ run_chaos() {
run_gateway() {
local compose="testing/static/docker-compose.yml"
local rc=0
export COMPOSE_PROJECT_NAME="$(ci_project static)"
info "[gateway] Generating configs"
bash testing/static/scripts/generate-configs.sh gateway gateway-test || { record "gateway" 1; return; }
@@ -548,7 +434,6 @@ run_gateway() {
# Run sidecar test
run_sidecar() {
local rc=0
export COMPOSE_PROJECT_NAME="$(ci_project sidecar)"
info "[sidecar] Running integration test"
if bash testing/sidecar/scripts/test-sidecar.sh --skip-build 2>&1; then
@@ -565,7 +450,6 @@ run_sidecar() {
run_rekey_accept_off() {
local compose="testing/static/docker-compose.yml"
local rc=0
export COMPOSE_PROJECT_NAME="$(ci_project static)"
info "[rekey-accept-off] Generating configs"
bash testing/static/scripts/generate-configs.sh rekey-accept-off || \
@@ -600,7 +484,6 @@ run_rekey_accept_off() {
run_rekey_outbound_only() {
local compose="testing/static/docker-compose.yml"
local rc=0
export COMPOSE_PROJECT_NAME="$(ci_project static)"
info "[rekey-outbound-only] Generating configs"
bash testing/static/scripts/generate-configs.sh rekey-outbound-only || \
@@ -629,7 +512,6 @@ run_rekey_outbound_only() {
# Run ACL allowlist integration test
run_acl_allowlist() {
export COMPOSE_PROJECT_NAME="$(ci_project acl)"
info "[acl-allowlist] Running integration test"
if bash testing/acl-allowlist/test.sh --skip-build 2>&1; then
record "acl-allowlist" 0
@@ -640,7 +522,6 @@ run_acl_allowlist() {
# Run firewall baseline integration test
run_firewall() {
export COMPOSE_PROJECT_NAME="$(ci_project firewall)"
info "[firewall] Running integration test"
if bash testing/firewall/test.sh --skip-build 2>&1; then
record "firewall" 0
@@ -652,7 +533,6 @@ run_firewall() {
# Run a NAT scenario (cone, symmetric, lan)
run_nat() {
local scenario="$1"
export COMPOSE_PROJECT_NAME="$(ci_project nat)"
info "[nat-$scenario] Running NAT lab"
if bash testing/nat/scripts/nat-test.sh "$scenario" 2>&1; then
record "nat-$scenario" 0
@@ -666,7 +546,6 @@ run_nat() {
# (A→B publish/consume), Phase 2 (B→A reverse), and Phase 3 (malformed
# advert injected directly to the relay; consumer-liveness assertion).
run_nostr_publish_consume() {
export COMPOSE_PROJECT_NAME="$(ci_project nat)"
info "[nostr-publish-consume] Running Nostr publish/consume test"
if bash testing/nat/scripts/nostr-relay-test.sh 2>&1; then
record "nostr-publish-consume" 0
@@ -681,7 +560,6 @@ run_nostr_publish_consume() {
# kill. Asserts the daemon detects each fault, recovers from delay, and
# never panics.
run_stun_faults() {
export COMPOSE_PROJECT_NAME="$(ci_project nat)"
info "[stun-faults] Running STUN fault-injection test"
if bash testing/nat/scripts/stun-faults-test.sh 2>&1; then
record "stun-faults" 0
@@ -712,7 +590,6 @@ run_deb_install() {
# Run Tor SOCKS5 outbound test (live Tor network)
run_tor_socks5() {
export COMPOSE_PROJECT_NAME="$(ci_project tor-socks5)"
info "[tor-socks5] Running Tor SOCKS5 outbound test (live Tor)"
if bash testing/tor/socks5-outbound/scripts/tor-test.sh 2>&1; then
record "tor-socks5" 0
@@ -723,7 +600,6 @@ run_tor_socks5() {
# Run Tor directory-mode test (live Tor network)
run_tor_directory() {
export COMPOSE_PROJECT_NAME="$(ci_project tor-directory)"
info "[tor-directory] Running Tor directory-mode test (live Tor)"
if bash testing/tor/directory-mode/scripts/directory-test.sh 2>&1; then
record "tor-directory" 0
@@ -740,20 +616,10 @@ run_integration() {
info "Installing release binaries"
install_binaries testing/docker
# Build unified test image once (used by all harnesses). Tag per-run
# (fips-test:${run}) so a build killed mid-flight never wedges the next
# run's rebuild, and concurrent runs never clobber each other's image.
# Then retag :latest for the compose files / harness scripts that still
# reference fips-test:latest directly; the retag happens only after BOTH
# builds succeed, so :latest never points at a half-built image.
info "Building $CI_IMAGE_TEST Docker image"
docker build -t "$CI_IMAGE_TEST" --label "$CI_LABEL" testing/docker --quiet \
|| { record "docker-build" 1; return; }
docker build -t "$CI_IMAGE_APP" --label "$CI_LABEL" \
-f testing/docker/Dockerfile.app testing/docker --quiet \
|| { record "docker-build-app" 1; return; }
docker tag "$CI_IMAGE_TEST" fips-test:latest
docker tag "$CI_IMAGE_APP" fips-test-app:latest
# Build unified test image once (used by all harnesses)
info "Building fips-test Docker image"
docker build -t fips-test:latest testing/docker --quiet || { record "docker-build" 1; return; }
docker build -t fips-test-app:latest -f testing/docker/Dockerfile.app testing/docker --quiet || { record "docker-build-app" 1; return; }
# Single suite mode
if [[ -n "$ONLY_SUITE" ]]; then
@@ -807,7 +673,6 @@ run_integration() {
local pids=()
local suite_names=()
local running=0
local chaos_idx=0
for entry in "${CHAOS_SUITES[@]}"; do
# Parse: "display-name scenario [flags...]"
@@ -815,15 +680,6 @@ run_integration() {
local name="${parts[0]}"
local args=("${parts[@]:1}")
# Give each chaos child a unique, non-overlapping /24 in 10.30.x so
# parallel children never collide with each other, and so a chaos
# net can never swallow a fixed-subnet suite (sidecar/static/nat in
# 172.x). 10.30.x sits outside docker's default-address-pool range
# (172.17-31 / 192.168), so auto-assigned nets can't land on it
# either. Node IPs derive from this subnet inside the sim.
args+=("--subnet" "10.30.${chaos_idx}.0/24")
chaos_idx=$((chaos_idx + 1))
# Throttle: wait for a slot
while [[ $running -ge $PARALLEL_JOBS ]]; do
wait -n -p done_pid 2>/dev/null || true
@@ -837,7 +693,6 @@ run_integration() {
run_chaos "$name" "${args[@]}" >"$logfile" 2>&1
) &
pids+=($!)
CI_CHAOS_PIDS+=("$!")
suite_names+=("$name:$logfile")
running=$((running + 1))
done
@@ -860,9 +715,6 @@ run_integration() {
fi
rm -f "$logfile"
done
# All chaos children have been waited on; clear so a later signal does
# not try to kill already-reaped PIDs.
CI_CHAOS_PIDS=()
fi
# Sidecar
-1
View File
@@ -65,7 +65,6 @@ start_systemd_container_with_tun() {
local name="$1" image="$2"
cleanup_container "$name"
docker run -d --name "$name" \
--label com.corganlabs.fips-ci=1 \
--privileged \
--cgroupns=host \
--device /dev/net/tun \
-2
View File
@@ -67,7 +67,6 @@ start_systemd_container() {
local name="$1" image="$2"
cleanup_container "$name"
docker run -d --name "$name" \
--label com.corganlabs.fips-ci=1 \
--privileged \
--cgroupns=host \
-v /sys/fs/cgroup:/sys/fs/cgroup:rw \
@@ -80,7 +79,6 @@ start_systemd_container_with_tun() {
local name="$1" image="$2"
cleanup_container "$name"
docker run -d --name "$name" \
--label com.corganlabs.fips-ci=1 \
--privileged \
--cgroupns=host \
--device /dev/net/tun \
-2
View File
@@ -1,8 +1,6 @@
networks:
fw-net:
driver: bridge
labels:
- "com.corganlabs.fips-ci=1"
ipam:
config:
- subnet: 172.32.0.0/24
+14 -30
View File
@@ -162,13 +162,6 @@ REKEY_SETTLE=12 # FSP-cutover settle budget (Phase 6)
# strict assertion sweep runs. A genuinely stuck pair still fails — the
# poll times out and the recording sweep captures it.
POST_REKEY_TIMEOUT=45
# Progress-aware stall budget for the convergence detector
# (wait_for_full_baseline → wait_until_connected). If no additional pair
# becomes reachable for this long while more than the near-converged
# slack of pairs is still down, the detector gives up early instead of
# burning the whole convergence/post-rekey window; any progress resets
# the clock, so a slow-but-converging mesh under netem keeps polling.
RECONVERGE_STALL=15
LOG_POLL_INTERVAL=2
# Data-plane continuity stream (control-differential). Streams run a
@@ -441,27 +434,25 @@ ping_all_pairs() {
done
}
# Convergence detector probe: one full all-pairs ping sweep in the
# "convergence" context (which ping_all_pairs deliberately does NOT
# record as a failure), setting PASSED/FAILED for wait_until_connected.
_baseline_probe() {
ping_all_pairs quiet 1 "convergence"
}
# Poll until every directed pair pings clean, or until timeout. This is a
# convergence DETECTOR — used at establishment (Phase 1) and after each
# rekey (Phases 3/5). It pings with the "convergence" context, which
# ping_all_pairs deliberately does not record as a failure; the caller
# runs a separate strict assertion sweep afterwards.
#
# Delegates to the shared progress-aware wait_until_connected so the
# deadline extends while more pairs are still coming up and gives up fast
# on a genuine stall, instead of the prior fixed-deadline poll that could
# false-time-out under heavy CI contention even while still converging.
# Returns 0 once every pair is reachable, 1 on stall/timeout.
wait_for_full_baseline() {
local timeout="$1"
wait_until_connected _baseline_probe "$timeout" "$RECONVERGE_STALL"
local start=$SECONDS
local best_passed=0 best_failed="$NUM_DIRECTED"
while (( SECONDS - start < timeout )); do
ping_all_pairs quiet 1 "convergence"
if [ "$PASSED" -gt "$best_passed" ]; then
best_passed="$PASSED"; best_failed="$FAILED"
fi
[ "$FAILED" -eq 0 ] && return 0
sleep 1
done
PASSED="$best_passed"; FAILED="$best_failed"
return 1
}
phase_result() {
@@ -768,15 +759,8 @@ echo ""
# ── Phase 4: second rekey cycle ──────────────────────────────────────
echo "Phase 4: Second rekey cycle (waiting up to ${SECOND_REKEY_WAIT}s for the next cutover)"
# Poll for the next FMP cutover beyond what Phases 2/3 already saw, using
# the same pre/post cutover-count delta convention as the control window
# (Phase 1b), instead of a blind sleep. Bounded by SECOND_REKEY_WAIT so a
# stalled rekey falls through to the strict Phase 5/6 assertions.
fmp_cutovers_before="$(count_log_pattern 'Rekey cutover complete \(initiator\), K-bit flipped')"
wait_for_log_pattern_count \
"Rekey cutover complete \(initiator\), K-bit flipped" \
"$((fmp_cutovers_before + 1))" "$SECOND_REKEY_WAIT" || true
echo "Phase 4: Second rekey cycle (waiting ${SECOND_REKEY_WAIT}s)"
sleep "$SECOND_REKEY_WAIT"
echo ""
echo "Phase 5: Post-second-rekey connectivity (reconverge within ${POST_REKEY_TIMEOUT}s)"
-4
View File
@@ -1,15 +1,11 @@
networks:
wan:
driver: bridge
labels:
- "com.corganlabs.fips-ci=1"
ipam:
config:
- subnet: 172.31.254.0/24
shared-lan:
driver: bridge
labels:
- "com.corganlabs.fips-ci=1"
ipam:
config:
- subnet: 172.31.10.0/24
+1 -1
View File
@@ -202,7 +202,7 @@ dump_stun_udp_probe() {
local capture_file
capture_file="$(mktemp)"
docker run --rm --label com.corganlabs.fips-ci=1 --net=container:fips-nat-stun --cap-add NET_ADMIN --cap-add NET_RAW \
docker run --rm --net=container:fips-nat-stun --cap-add NET_ADMIN --cap-add NET_RAW \
--entrypoint sh "$helper_image" \
-lc "timeout 8 tcpdump -ni any 'udp and not port 53' -c 80" \
>"$capture_file" 2>&1 &
-1
View File
@@ -57,7 +57,6 @@ run_host_ip() {
local image="$1"
shift
docker run --rm \
--label com.corganlabs.fips-ci=1 \
--privileged \
--net=host \
--pid=host \
-10
View File
@@ -2,12 +2,6 @@ networks:
fips-net:
name: ${FIPS_NETWORK:-fips-sidecar-net}
driver: bridge
# Reap label: this harness uses fixed -p sidecar-{a,b,c} project names
# (the test execs containers by those derived names), so its resources are
# NOT covered by ci-local's fipsci_ project prefix. Label them directly so
# ci-cleanup.sh can still reap them after a preemption/SIGKILL.
labels:
- "com.corganlabs.fips-ci=1"
ipam:
config:
- subnet: ${FIPS_SUBNET:-172.20.1.0/24}
@@ -16,8 +10,6 @@ services:
fips:
image: fips-test:latest
hostname: fips-sidecar
labels:
- "com.corganlabs.fips-ci=1"
cap_add:
- NET_ADMIN
devices:
@@ -41,8 +33,6 @@ services:
app:
image: fips-test-app:latest
labels:
- "com.corganlabs.fips-ci=1"
network_mode: "service:fips"
depends_on:
- fips
+2 -6
View File
@@ -1,15 +1,11 @@
networks:
fips-net:
driver: bridge
labels:
- "com.corganlabs.fips-ci=1"
ipam:
config:
- subnet: 172.20.0.0/24
gateway-lan:
driver: bridge
labels:
- "com.corganlabs.fips-ci=1"
enable_ipv6: true
ipam:
config:
@@ -538,7 +534,7 @@ services:
ipv4_address: 172.20.0.12
gw-client:
image: ${FIPS_TEST_APP_IMAGE:-fips-test-app:latest}
image: fips-test-app:latest
profiles: ["gateway"]
container_name: fips-gw-client
hostname: gw-client
@@ -560,7 +556,7 @@ services:
# Same image and gateway-lan attachment as
# gw-client; the gateway must allocate a distinct virtual IP for it.
gw-client-2:
image: ${FIPS_TEST_APP_IMAGE:-fips-test-app:latest}
image: fips-test-app:latest
profiles: ["gateway"]
container_name: fips-gw-client-2
hostname: gw-client-2
+1 -1
View File
@@ -149,7 +149,7 @@ HELPER_IMAGE=$(docker inspect -f '{{.Config.Image}}' fips-node-$CAP_NODE 2>/dev/
LOAD_PID=$!
# Foreground: tcpdump capture for CAPTURE_SECS
docker run --rm --label com.corganlabs.fips-ci=1 --net=container:fips-node-$CAP_NODE \
docker run --rm --net=container:fips-node-$CAP_NODE \
--cap-add NET_ADMIN --cap-add NET_RAW \
--entrypoint sh "$HELPER_IMAGE" \
-c "timeout $CAPTURE_SECS tcpdump -nn -i any 'udp port 2121' -l 2>&1 || true" \
+9 -38
View File
@@ -158,20 +158,9 @@ REKEY_SETTLE=12 # > DRAIN_WINDOW_SECS (10) so post-rekey samples are off
# fully converged. Keep this bounded to preserve a meaningful scheduling check
# while still allowing for log visibility at the timeout edge.
FIRST_REKEY_TIMEOUT=$((REKEY_AFTER_SECS + 15))
SECOND_REKEY_WAIT=40 # upper bound for observing the second rekey cutover
SECOND_REKEY_WAIT=40 # wait for second cycle
LOG_EVENT_POLL_INTERVAL=1
# Post-rekey data-plane reconvergence is polled, not fixed-slept.
# wait_until_connected drives the same all-pairs ping sweep the strict
# per-phase assert depends on: it fails fast when reachability stops
# improving (no new reachable pair for RECONVERGE_STALL seconds while
# more than the near-converged slack of pairs is still down) and extends
# its deadline up to RECONVERGE_TIMEOUT only while pairs keep coming up,
# so a slow-but-converging post-rekey mesh under CI load passes instead
# of false-failing on a fixed settle window.
RECONVERGE_TIMEOUT=45
RECONVERGE_STALL=10
TIMEOUT=5
CONVERGENCE_PING_TIMEOUT=1
# Strict-ping retry policy for the per-phase ping_all asserts. Under 1%
@@ -403,38 +392,20 @@ assert_min_count "Rekey cutover complete (initiator), K-bit flipped" 1 \
phase_result "FMP rekey events"
echo ""
# Verify connectivity after first rekey (strict — no failures allowed).
# Wait for the data plane to reconverge with a progress-aware deadline
# (the same all-pairs ping sweep the strict assert below depends on)
# instead of a fixed settle: fail fast on a genuinely stuck pair, extend
# only while reachability is still climbing. The strict ping_all is the
# actual assertion, run only after the reconverge poll.
echo "Phase 3: Post-rekey connectivity (reconverging up to ${RECONVERGE_TIMEOUT}s)"
wait_until_connected _baseline_ping "$RECONVERGE_TIMEOUT" "$RECONVERGE_STALL" || true
# Verify connectivity after first rekey (strict — no failures allowed)
echo "Phase 3: Post-rekey connectivity (settling ${REKEY_SETTLE}s)"
sleep "$REKEY_SETTLE"
ping_all "" "$TIMEOUT" "$MAX_PING_ATTEMPTS"
phase_result "Post-first-rekey (all 20 pairs)"
echo ""
# ── Phase 4: Wait for second rekey cycle ──────────────────────────────
# Poll for the next FMP rekey cutover instead of blind-sleeping: capture
# the cutover count reached so far, then wait until at least one more
# cutover lands — that increment is the second rekey cycle firing.
# Bounded by SECOND_REKEY_WAIT so a stalled rekey still falls through to
# the strict Phase 5/6 assertions rather than hanging.
echo "Phase 4: Second rekey cycle (waiting up to ${SECOND_REKEY_WAIT}s for the next cutover)"
fmp_cutovers_before=$(count_log_pattern "Rekey cutover complete (initiator), K-bit flipped")
wait_for_log_pattern_count \
"Rekey cutover complete (initiator), K-bit flipped" \
"$((fmp_cutovers_before + 1))" "$SECOND_REKEY_WAIT" || true
echo "Phase 4: Second rekey cycle (waiting ${SECOND_REKEY_WAIT}s)"
sleep "$SECOND_REKEY_WAIT"
# Verify connectivity after second rekey (back-to-back). This is the
# site of the recurring post-second-rekey straggler-pair flake: wait for
# the data plane to reconverge with a progress-aware deadline (the same
# all-pairs ping sweep the strict assert below depends on) rather than a
# fixed settle window — fail fast if reachability stops improving, extend
# only while pairs are still coming up.
echo "Phase 5: Post-second-rekey connectivity (reconverging up to ${RECONVERGE_TIMEOUT}s)"
wait_until_connected _baseline_ping "$RECONVERGE_TIMEOUT" "$RECONVERGE_STALL" || true
# Verify connectivity after second rekey (back-to-back)
echo "Phase 5: Post-second-rekey connectivity (settling ${REKEY_SETTLE}s)"
sleep "$REKEY_SETTLE"
ping_all "" "$TIMEOUT" "$MAX_PING_ATTEMPTS"
phase_result "Post-second-rekey (all 20 pairs)"
echo ""
@@ -11,8 +11,6 @@
networks:
dir-test:
driver: bridge
labels:
- "com.corganlabs.fips-ci=1"
services:
fips-a:
@@ -10,8 +10,6 @@
networks:
tor-test:
driver: bridge
labels:
- "com.corganlabs.fips-ci=1"
services:
tor-daemon: